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"}
+