diff --git a/pom.xml b/pom.xml index 0c754f19..d28f66d5 100644 --- a/pom.xml +++ b/pom.xml @@ -15,6 +15,7 @@ jv-rick-and-morty 17 + 1.5.5.Final 3.1.1 https://raw.githubusercontent.com/mate-academy/style-guides/master/java/checkstyle.xml @@ -41,6 +42,41 @@ com.h2database h2 + + + org.mapstruct + mapstruct + ${mapstruct.version} + + + + org.mapstruct + mapstruct-processor + ${mapstruct.version} + + + org.springframework.boot + spring-boot-starter-web + + + org.projectlombok + lombok + annotationProcessor + + + com.mysql + mysql-connector-j + runtime + + + org.springframework.boot + spring-boot-starter-data-jdbc + + + org.springdoc + springdoc-openapi-starter-webmvc-ui + 2.3.0 + @@ -66,6 +102,7 @@ true true false + src diff --git a/src/main/java/mate/academy/rickandmorty/config/AppConfig.java b/src/main/java/mate/academy/rickandmorty/config/AppConfig.java new file mode 100644 index 00000000..ca447333 --- /dev/null +++ b/src/main/java/mate/academy/rickandmorty/config/AppConfig.java @@ -0,0 +1,25 @@ +package mate.academy.rickandmorty.config; + +import com.fasterxml.jackson.databind.ObjectMapper; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class AppConfig { + @Bean + public HttpClient httpClient() { + return HttpClient.newHttpClient(); + } + + @Bean + ObjectMapper objectMapper() { + return new ObjectMapper(); + } + + @Bean + HttpRequest.Builder httpRequest() { + return HttpRequest.newBuilder(); + } +} diff --git a/src/main/java/mate/academy/rickandmorty/config/MapperConfig.java b/src/main/java/mate/academy/rickandmorty/config/MapperConfig.java new file mode 100644 index 00000000..5830191e --- /dev/null +++ b/src/main/java/mate/academy/rickandmorty/config/MapperConfig.java @@ -0,0 +1,15 @@ +package mate.academy.rickandmorty.config; + +import org.mapstruct.InjectionStrategy; +import org.mapstruct.NullValueCheckStrategy; + +@org.mapstruct.MapperConfig( + componentModel = "spring", + injectionStrategy = InjectionStrategy.CONSTRUCTOR, + nullValueCheckStrategy = NullValueCheckStrategy.ALWAYS, + implementationPackage = ".impl" +) +public class MapperConfig { + +} + diff --git a/src/main/java/mate/academy/rickandmorty/controller/CharacterController.java b/src/main/java/mate/academy/rickandmorty/controller/CharacterController.java new file mode 100644 index 00000000..9e01ab0f --- /dev/null +++ b/src/main/java/mate/academy/rickandmorty/controller/CharacterController.java @@ -0,0 +1,45 @@ +package mate.academy.rickandmorty.controller; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import java.util.List; +import lombok.RequiredArgsConstructor; +import mate.academy.rickandmorty.dto.internal.InternalCharacterDto; +import mate.academy.rickandmorty.service.CharacterService; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "Rick&Morty Controller", description = """ + Controller for managing Rick and Morty characters. + Provides endpoints to fetch a random + character or search characters by name. + """) +@RequiredArgsConstructor +@RestController +@RequestMapping("/characters") +public class CharacterController { + private final CharacterService characterService; + + @Operation(summary = "Fetch a random character", description = """ + Fetches a random character. If the character is not found in the local database, + it is fetched from the external API and saved locally. + If the character already exists in the database, it is retrieved from there. + """) + @GetMapping("/random") + InternalCharacterDto getRandomCharacter() { + return characterService.getRandomCharacter(); + } + + @Operation(summary = "Search characters by name", description = """ + Searches for characters by their name. If a character + is not found in the local database, it is fetched from the external + API and saved locally. + If characters already exist in the database, they are retrieved from there. + """) + @GetMapping("/search") + List getCharactersByName(@RequestParam String name) { + return characterService.getCharactersByName(name); + } +} diff --git a/src/main/java/mate/academy/rickandmorty/dto/external/ExternalCharacterDto.java b/src/main/java/mate/academy/rickandmorty/dto/external/ExternalCharacterDto.java new file mode 100644 index 00000000..13244afa --- /dev/null +++ b/src/main/java/mate/academy/rickandmorty/dto/external/ExternalCharacterDto.java @@ -0,0 +1,16 @@ +package mate.academy.rickandmorty.dto.external; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; +import mate.academy.rickandmorty.model.Character; + +@Data +@JsonIgnoreProperties(ignoreUnknown = true) +public class ExternalCharacterDto { + @JsonProperty("id") + private Long externalId; + private String name; + private Character.Status status; + private Character.Gender gender; +} diff --git a/src/main/java/mate/academy/rickandmorty/dto/external/ExternalListCharacterDto.java b/src/main/java/mate/academy/rickandmorty/dto/external/ExternalListCharacterDto.java new file mode 100644 index 00000000..559cce7a --- /dev/null +++ b/src/main/java/mate/academy/rickandmorty/dto/external/ExternalListCharacterDto.java @@ -0,0 +1,12 @@ +package mate.academy.rickandmorty.dto.external; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.List; + +@JsonIgnoreProperties(ignoreUnknown = true) +public record ExternalListCharacterDto( + @JsonProperty("results") + List characters +) { +} diff --git a/src/main/java/mate/academy/rickandmorty/dto/internal/InternalCharacterDto.java b/src/main/java/mate/academy/rickandmorty/dto/internal/InternalCharacterDto.java new file mode 100644 index 00000000..f406de47 --- /dev/null +++ b/src/main/java/mate/academy/rickandmorty/dto/internal/InternalCharacterDto.java @@ -0,0 +1,13 @@ +package mate.academy.rickandmorty.dto.internal; + +import lombok.Data; +import mate.academy.rickandmorty.model.Character; + +@Data +public class InternalCharacterDto { + private Long id; + private Long externalId; + private String name; + private Character.Status status; + private Character.Gender gender; +} diff --git a/src/main/java/mate/academy/rickandmorty/mapper/CharacterMapper.java b/src/main/java/mate/academy/rickandmorty/mapper/CharacterMapper.java new file mode 100644 index 00000000..c4129a1b --- /dev/null +++ b/src/main/java/mate/academy/rickandmorty/mapper/CharacterMapper.java @@ -0,0 +1,16 @@ +package mate.academy.rickandmorty.mapper; + +import mate.academy.rickandmorty.config.MapperConfig; +import mate.academy.rickandmorty.dto.external.ExternalCharacterDto; +import mate.academy.rickandmorty.dto.internal.InternalCharacterDto; +import mate.academy.rickandmorty.model.Character; +import org.mapstruct.Mapper; + +@Mapper(config = MapperConfig.class) +public interface CharacterMapper { + Character fromExternalToEntity(ExternalCharacterDto externalCharacterDto); + + InternalCharacterDto fromEntityToInternalDto(Character character); + + InternalCharacterDto fromEntityToInternal(Character character); +} diff --git a/src/main/java/mate/academy/rickandmorty/model/Character.java b/src/main/java/mate/academy/rickandmorty/model/Character.java new file mode 100644 index 00000000..23f0cb21 --- /dev/null +++ b/src/main/java/mate/academy/rickandmorty/model/Character.java @@ -0,0 +1,73 @@ +package mate.academy.rickandmorty.model; + +import com.fasterxml.jackson.annotation.JsonProperty; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +@Entity +@Table(name = "rick_and_morty_characters") +public class Character { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + private Long externalId; + private String name; + @Enumerated(EnumType.STRING) + private Status status; + @Enumerated(EnumType.STRING) + private Gender gender; + + public enum Gender { + @JsonProperty("Male") + MALE("Male"), + + @JsonProperty("Female") + FEMALE("Female"), + + @JsonProperty("Genderless") + GENDERLESS("Genderless"), + + @JsonProperty("unknown") + UNKNOWN("unknown"); + + private String value; + + Gender(String value) { + this.value = value; + } + + public String getValue() { + return value; + } + } + + public enum Status { + @JsonProperty("Alive") + ALIVE("Alive"), + + @JsonProperty("Dead") + DEAD("Dead"), + + @JsonProperty("unknown") + UNKNOWN("unknown"); + + private String value; + + Status(String value) { + this.value = value; + } + + public String getValue() { + return value; + } + } +} diff --git a/src/main/java/mate/academy/rickandmorty/repository/CharacterRepository.java b/src/main/java/mate/academy/rickandmorty/repository/CharacterRepository.java new file mode 100644 index 00000000..45ef38ee --- /dev/null +++ b/src/main/java/mate/academy/rickandmorty/repository/CharacterRepository.java @@ -0,0 +1,14 @@ +package mate.academy.rickandmorty.repository; + +import java.util.List; +import java.util.Optional; +import mate.academy.rickandmorty.model.Character; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface CharacterRepository extends JpaRepository { + Optional findByExternalId(Long externalId); + + List findAllByName(String name); +} diff --git a/src/main/java/mate/academy/rickandmorty/service/CharacterService.java b/src/main/java/mate/academy/rickandmorty/service/CharacterService.java new file mode 100644 index 00000000..8948c4c6 --- /dev/null +++ b/src/main/java/mate/academy/rickandmorty/service/CharacterService.java @@ -0,0 +1,10 @@ +package mate.academy.rickandmorty.service; + +import java.util.List; +import mate.academy.rickandmorty.dto.internal.InternalCharacterDto; + +public interface CharacterService { + InternalCharacterDto getRandomCharacter(); + + List getCharactersByName(String name); +} diff --git a/src/main/java/mate/academy/rickandmorty/service/CharacterServiceImpl.java b/src/main/java/mate/academy/rickandmorty/service/CharacterServiceImpl.java new file mode 100644 index 00000000..95378224 --- /dev/null +++ b/src/main/java/mate/academy/rickandmorty/service/CharacterServiceImpl.java @@ -0,0 +1,63 @@ +package mate.academy.rickandmorty.service; + +import java.util.ArrayList; +import java.util.List; +import java.util.Random; +import lombok.RequiredArgsConstructor; +import mate.academy.rickandmorty.dto.external.ExternalCharacterDto; +import mate.academy.rickandmorty.dto.internal.InternalCharacterDto; +import mate.academy.rickandmorty.mapper.CharacterMapper; +import mate.academy.rickandmorty.model.Character; +import mate.academy.rickandmorty.repository.CharacterRepository; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class CharacterServiceImpl implements CharacterService { + private static final Random random = new Random(); + private static final int CHARACTER_COUNT = 800; + + private final RickAndMortyClientApi rickAndMortyClientApi; + private final CharacterRepository characterRepository; + private final CharacterMapper characterMapper; + + @Override + public InternalCharacterDto getRandomCharacter() { + Long randomNumber = random.nextLong(CHARACTER_COUNT); + return characterRepository.findByExternalId(randomNumber) + .map(characterMapper::fromEntityToInternal) + .orElseGet(() -> { + ExternalCharacterDto response = rickAndMortyClientApi + .getRandomCharacter(randomNumber); + Character character = characterMapper + .fromExternalToEntity(response); + + return characterMapper.fromEntityToInternal( + characterRepository.save(character) + ); + }); + } + + @Override + public List getCharactersByName(String name) { + List characterList = characterRepository.findAllByName(name); + + List characterDtos = new ArrayList<>( + characterList.stream() + .map(characterMapper::fromEntityToInternal) + .toList() + ); + + if (characterList.isEmpty()) { + List response = rickAndMortyClientApi.getCharactersByName(name); + List newCharacters = response.stream() + .map(characterMapper::fromExternalToEntity) + .toList(); + characterRepository.saveAll(newCharacters); + characterDtos.addAll(newCharacters.stream() + .map(characterMapper::fromEntityToInternalDto).toList()); + } + return characterDtos; + } +} + diff --git a/src/main/java/mate/academy/rickandmorty/service/RickAndMortyClientApi.java b/src/main/java/mate/academy/rickandmorty/service/RickAndMortyClientApi.java new file mode 100644 index 00000000..f487401d --- /dev/null +++ b/src/main/java/mate/academy/rickandmorty/service/RickAndMortyClientApi.java @@ -0,0 +1,65 @@ +package mate.academy.rickandmorty.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.IOException; +import java.net.URI; +import java.net.URLEncoder; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.util.List; +import lombok.RequiredArgsConstructor; +import mate.academy.rickandmorty.dto.external.ExternalCharacterDto; +import mate.academy.rickandmorty.dto.external.ExternalListCharacterDto; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class RickAndMortyClientApi { + private static final String ERROR_MESSAGE = "Error occurred while " + + "sending request or processing response: "; + private static final String BASE_URL = "https://rickandmortyapi.com/api/character"; + private static final String BY_ID = "%s/%d"; + private static final String BY_NAME = "%s?name=%s"; + + private final HttpClient httpClient; + private final HttpRequest.Builder requestBuilder; + private final ObjectMapper objectMapper; + + public ExternalCharacterDto getRandomCharacter(Long randomNumber) { + String url = BY_ID.formatted(BASE_URL, randomNumber); + + HttpRequest httpRequest = requestBuilder + .GET() + .uri(URI.create(url)) + .build(); + try { + HttpResponse response = httpClient.send( + httpRequest, + HttpResponse.BodyHandlers.ofString()); + return objectMapper.readValue(response.body(), ExternalCharacterDto.class); + } catch (IOException | InterruptedException e) { + throw new RuntimeException(ERROR_MESSAGE + e.getMessage(), e); + } + } + + public List getCharactersByName(String name) { + String encodedName = URLEncoder.encode(name, StandardCharsets.UTF_8); + String url = String.format(BY_NAME, BASE_URL, encodedName); + HttpRequest httpRequest = requestBuilder + .GET() + .uri(URI.create(url)) + .build(); + try { + HttpResponse response = httpClient.send( + httpRequest, + HttpResponse.BodyHandlers.ofString()); + return objectMapper + .readValue(response.body(), ExternalListCharacterDto.class) + .characters(); + } catch (IOException | InterruptedException e) { + throw new RuntimeException(ERROR_MESSAGE + e.getMessage(), e); + } + } +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 8b137891..70d8ff74 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -1 +1,8 @@ +spring.datasource.url=jdbc:mysql://localhost:3306/homework +spring.datasource.username=root +spring.datasource.password=databasepractice1! +spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver +spring.jpa.hibernate.ddl-auto=create-drop +spring.jpa.show-sql=true +spring.jpa.open-in-view=false diff --git a/src/test/java/mate/academy/rickandmorty/ApplicationTests.java b/src/test/java/mate/academy/rickandmorty/ApplicationTests.java index 8fec6af0..4fe47c0d 100644 --- a/src/test/java/mate/academy/rickandmorty/ApplicationTests.java +++ b/src/test/java/mate/academy/rickandmorty/ApplicationTests.java @@ -5,9 +5,7 @@ @SpringBootTest class ApplicationTests { - - @Test - void contextLoads() { - } - + @Test + void contextLoads() { + } } diff --git a/test.json b/test.json new file mode 100644 index 00000000..78389a89 --- /dev/null +++ b/test.json @@ -0,0 +1,68 @@ +{"id":2, + "name":"Morty Smith", + "status":"Alive", + "species":"Human", + "type":"", + "gender":"Male", + "origin":{ + "name":"unknown", + "url":"" + }, + "location": { + "name":"Citadel of Ricks","url":"https://rickandmortyapi.com/api/location/3" +}, + "image":"https://rickandmortyapi.com/api/character/avatar/2.jpeg", + "episode":["https://rickandmortyapi.com/api/episode/1", + "https://rickandmortyapi.com/api/episode/2", + "https://rickandmortyapi.com/api/episode/3", + "https://rickandmortyapi.com/api/episode/4", + "https://rickandmortyapi.com/api/episode/5", + "https://rickandmortyapi.com/api/episode/6", + "https://rickandmortyapi.com/api/episode/7", + "https://rickandmortyapi.com/api/episode/8", + "https://rickandmortyapi.com/api/episode/9", + "https://rickandmortyapi.com/api/episode/10", + "https://rickandmortyapi.com/api/episode/11", + "https://rickandmortyapi.com/api/episode/12", + "https://rickandmortyapi.com/api/episode/13", + "https://rickandmortyapi.com/api/episode/14", + "https://rickandmortyapi.com/api/episode/15", + "https://rickandmortyapi.com/api/episode/16", + "https://rickandmortyapi.com/api/episode/17", + "https://rickandmortyapi.com/api/episode/18", + "https://rickandmortyapi.com/api/episode/19", + "https://rickandmortyapi.com/api/episode/20", + "https://rickandmortyapi.com/api/episode/21", + "https://rickandmortyapi.com/api/episode/22", + "https://rickandmortyapi.com/api/episode/23", + "https://rickandmortyapi.com/api/episode/24", + "https://rickandmortyapi.com/api/episode/25", + "https://rickandmortyapi.com/api/episode/26", + "https://rickandmortyapi.com/api/episode/27", + "https://rickandmortyapi.com/api/episode/28", + "https://rickandmortyapi.com/api/episode/29", + "https://rickandmortyapi.com/api/episode/30", + "https://rickandmortyapi.com/api/episode/31", + "https://rickandmortyapi.com/api/episode/32", + "https://rickandmortyapi.com/api/episode/33", + "https://rickandmortyapi.com/api/episode/34", + "https://rickandmortyapi.com/api/episode/35", + "https://rickandmortyapi.com/api/episode/36", + "https://rickandmortyapi.com/api/episode/37", + "https://rickandmortyapi.com/api/episode/38", + "https://rickandmortyapi.com/api/episode/39", + "https://rickandmortyapi.com/api/episode/40", + "https://rickandmortyapi.com/api/episode/41", + "https://rickandmortyapi.com/api/episode/42", + "https://rickandmortyapi.com/api/episode/43", + "https://rickandmortyapi.com/api/episode/44", + "https://rickandmortyapi.com/api/episode/45", + "https://rickandmortyapi.com/api/episode/46", + "https://rickandmortyapi.com/api/episode/47", + "https://rickandmortyapi.com/api/episode/48", + "https://rickandmortyapi.com/api/episode/49", + "https://rickandmortyapi.com/api/episode/50", + "https://rickandmortyapi.com/api/episode/51"], + "url":"https://rickandmortyapi.com/api/character/2", + "created":"2017-11-04T18:50:21.651Z"} +