diff --git a/pom.xml b/pom.xml
index 0c754f19..8034947b 100644
--- a/pom.xml
+++ b/pom.xml
@@ -1,21 +1,22 @@
+ xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
4.0.0
org.springframework.boot
spring-boot-starter-parent
- 3.1.4
-
+ 3.2.4
+
mate.academy
- jv-rick-and-morty
+ jv-spring-boot-mapstruct
0.0.1-SNAPSHOT
- jv-rick-and-morty
- jv-rick-and-morty
+ jv-spring-boot-mapstruct
+ jv-spring-boot-mapstruct
17
- 3.1.1
+ 0.2.0
+ 1.5.5.Final
https://raw.githubusercontent.com/mate-academy/style-guides/master/java/checkstyle.xml
@@ -23,36 +24,78 @@
org.springframework.boot
- spring-boot-starter
+ spring-boot-starter-web
+
+
+ org.springframework.boot
+ spring-boot-starter-data-jpa
+
+
+ com.h2database
+ h2
+
+
+ com.mysql
+ mysql-connector-j
+ runtime
+
+
+ org.projectlombok
+ lombok
+ true
-
org.springframework.boot
spring-boot-starter-test
test
-
org.springframework.boot
- spring-boot-starter-data-jpa
+ spring-boot-starter-validation
-
- com.h2database
- h2
+ org.mapstruct
+ mapstruct
+ ${mapstruct.version}
+
+
+ org.springdoc
+ springdoc-openapi-starter-webmvc-ui
+ 2.5.0
- org.springframework.boot
- spring-boot-maven-plugin
+ org.apache.maven.plugins
+ maven-compiler-plugin
+
+ ${java.version}
+ ${java.version}
+
+
+ org.projectlombok
+ lombok
+ ${lombok.version}
+
+
+ org.projectlombok
+ lombok-mapstruct-binding
+ ${lombok.mapstruct.binding.version}
+
+
+ org.mapstruct
+ mapstruct-processor
+ ${mapstruct.version}
+
+
+
org.apache.maven.plugins
maven-checkstyle-plugin
- 3.3.0
+ 3.3.1
compile
@@ -63,12 +106,25 @@
${maven.checkstyle.plugin.configLocation}
+ false
+ true
true
true
false
+
+ org.springframework.boot
+ spring-boot-maven-plugin
+
+
+
+ org.projectlombok
+ lombok
+
+
+
+
-
diff --git a/src/main/java/mate/academy/rickandmorty/client/RickAndMortyClient.java b/src/main/java/mate/academy/rickandmorty/client/RickAndMortyClient.java
new file mode 100644
index 00000000..ff4d953c
--- /dev/null
+++ b/src/main/java/mate/academy/rickandmorty/client/RickAndMortyClient.java
@@ -0,0 +1,91 @@
+package mate.academy.rickandmorty.client;
+
+import com.fasterxml.jackson.databind.DeserializationFeature;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import java.io.IOException;
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.util.ArrayList;
+import java.util.List;
+import mate.academy.rickandmorty.dto.external.ExternalCharacterDto;
+import mate.academy.rickandmorty.dto.external.RickAndMortyResponseDto;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Component;
+
+@Component
+public class RickAndMortyClient {
+ private static final Logger logger =
+ LoggerFactory.getLogger(RickAndMortyClient.class);
+
+ private static final String BASE_URL = "https://rickandmortyapi.com/api/character";
+ private static final int MAX_RETRIES = 3;
+ private final ObjectMapper objectMapper;
+
+ public RickAndMortyClient(ObjectMapper objectMapper) {
+ this.objectMapper = objectMapper
+ .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
+
+ }
+
+ public List fetchAllCharacters() {
+
+ List allCharacters = new ArrayList<>();
+
+ HttpClient httpClient = HttpClient.newHttpClient();
+
+ String pageUrl = BASE_URL;
+
+ while (pageUrl != null) {
+ int attempt = 1;
+
+ while (attempt <= MAX_RETRIES) {
+ try {
+ HttpRequest httpRequest = HttpRequest.newBuilder()
+ .GET()
+ .uri(URI.create(BASE_URL))
+ .build();
+
+ HttpResponse response = httpClient
+ .send(httpRequest, HttpResponse.BodyHandlers.ofString());
+
+ logger.debug("Fetched URL: {}, Status code: {}",
+ BASE_URL, response.statusCode());
+
+ if (response.statusCode() != 200) {
+ throw new RuntimeException("Failed to fetch characters. URL: "
+ + " Status code: " + response.statusCode()
+ + " Body: " + response.body());
+ }
+
+ RickAndMortyResponseDto apiResponse = objectMapper
+ .readValue(response.body(), RickAndMortyResponseDto.class);
+
+ allCharacters.addAll(apiResponse.results());
+ pageUrl = apiResponse.info().next();
+ break;
+
+ } catch (IOException | InterruptedException e) {
+ logger.warn("Attempt {}/{} failed for URL: {}",
+ attempt, MAX_RETRIES, pageUrl, e);
+
+ if (attempt == MAX_RETRIES) {
+ throw new RuntimeException("Failed after " + MAX_RETRIES
+ + " attempts for URL: " + BASE_URL, e);
+ }
+ }
+ attempt++;
+
+ try {
+ Thread.sleep(1000);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ throw new RuntimeException("Thread interrupted during retry", e);
+ }
+ }
+ }
+ return allCharacters;
+ }
+}
diff --git a/src/main/java/mate/academy/rickandmorty/config/DataLoader.java b/src/main/java/mate/academy/rickandmorty/config/DataLoader.java
new file mode 100644
index 00000000..3297fd5b
--- /dev/null
+++ b/src/main/java/mate/academy/rickandmorty/config/DataLoader.java
@@ -0,0 +1,26 @@
+package mate.academy.rickandmorty.config;
+
+import lombok.RequiredArgsConstructor;
+import mate.academy.rickandmorty.service.CharacterService;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.boot.ApplicationArguments;
+import org.springframework.boot.ApplicationRunner;
+import org.springframework.stereotype.Component;
+
+@Component
+@RequiredArgsConstructor
+public class DataLoader implements ApplicationRunner {
+ private static final Logger logger = LoggerFactory.getLogger(DataLoader.class);
+ private final CharacterService characterService;
+
+ @Override
+ public void run(ApplicationArguments arguments) {
+
+ try {
+ characterService.loadInitialData();
+ } catch (Exception e) {
+ logger.error("Initial data load failed!: " + e);
+ }
+ }
+}
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..2c3306cb
--- /dev/null
+++ b/src/main/java/mate/academy/rickandmorty/controller/CharacterController.java
@@ -0,0 +1 @@
+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 java.util.Optional;
import lombok.RequiredArgsConstructor;
import mate.academy.rickandmorty.dto.CharacterResponseDto;
import mate.academy.rickandmorty.service.CharacterService;
import org.springframework.http.HttpStatus;
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.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/characters")
@RequiredArgsConstructor
@Tag(name = "Rick and Morty API", description = "Endpoints for Rick and Morty characters")
public class CharacterController {
private final CharacterService characterService;
@GetMapping("/random")
@ResponseStatus(HttpStatus.OK)
@Operation(summary = "Get random character",
description = "Returns one of the character from local database")
public Optional getRandomCharacter() {
return characterService.getRandomCharacter();
}
@GetMapping("/search")
@ResponseStatus(HttpStatus.OK)
@Operation(summary = "Search characters by name",
description = "Finds all characters for the specific name")
public List searchCharacters(
@RequestParam(required = false) String name) {
return characterService.searchByName(name);
}
}
\ No newline at end of file
diff --git a/src/main/java/mate/academy/rickandmorty/dto/CharacterResponseDto.java b/src/main/java/mate/academy/rickandmorty/dto/CharacterResponseDto.java
new file mode 100644
index 00000000..4b716f34
--- /dev/null
+++ b/src/main/java/mate/academy/rickandmorty/dto/CharacterResponseDto.java
@@ -0,0 +1,9 @@
+package mate.academy.rickandmorty.dto;
+
+public record CharacterResponseDto(
+ Long id,
+ String externalId,
+ String name,
+ String status,
+ String gender
+) {}
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..bbbfbc14
--- /dev/null
+++ b/src/main/java/mate/academy/rickandmorty/dto/external/ExternalCharacterDto.java
@@ -0,0 +1,10 @@
+package mate.academy.rickandmorty.dto.external;
+
+public record ExternalCharacterDto(
+ Integer id,
+ String name,
+ String status,
+ String species,
+ String type,
+ String gender
+) {}
diff --git a/src/main/java/mate/academy/rickandmorty/dto/external/InfoDto.java b/src/main/java/mate/academy/rickandmorty/dto/external/InfoDto.java
new file mode 100644
index 00000000..988d53ce
--- /dev/null
+++ b/src/main/java/mate/academy/rickandmorty/dto/external/InfoDto.java
@@ -0,0 +1,8 @@
+package mate.academy.rickandmorty.dto.external;
+
+public record InfoDto(
+ Integer count,
+ Integer pages,
+ String next,
+ String previous
+) {}
diff --git a/src/main/java/mate/academy/rickandmorty/dto/external/RickAndMortyResponseDto.java b/src/main/java/mate/academy/rickandmorty/dto/external/RickAndMortyResponseDto.java
new file mode 100644
index 00000000..2fcc183c
--- /dev/null
+++ b/src/main/java/mate/academy/rickandmorty/dto/external/RickAndMortyResponseDto.java
@@ -0,0 +1,8 @@
+package mate.academy.rickandmorty.dto.external;
+
+import java.util.List;
+
+public record RickAndMortyResponseDto(
+ InfoDto info,
+ List results
+) {}
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..ef63e6cf
--- /dev/null
+++ b/src/main/java/mate/academy/rickandmorty/mapper/CharacterMapper.java
@@ -0,0 +1,26 @@
+package mate.academy.rickandmorty.mapper;
+
+import mate.academy.rickandmorty.dto.CharacterResponseDto;
+import mate.academy.rickandmorty.dto.external.ExternalCharacterDto;
+import mate.academy.rickandmorty.model.Character;
+
+public class CharacterMapper {
+ public static CharacterResponseDto toDto(Character character) {
+ return new CharacterResponseDto(
+ character.getId(),
+ character.getExternalId(),
+ character.getName(),
+ character.getStatus(),
+ character.getGender()
+ );
+ }
+
+ public static Character toEntity(ExternalCharacterDto externalCharacterDto) {
+ Character character = new Character();
+ character.setExternalId(externalCharacterDto.id().toString());
+ character.setName(externalCharacterDto.name());
+ character.setStatus(externalCharacterDto.status());
+ character.setGender(externalCharacterDto.gender());
+ return 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..f56415a4
--- /dev/null
+++ b/src/main/java/mate/academy/rickandmorty/model/Character.java
@@ -0,0 +1,32 @@
+package mate.academy.rickandmorty.model;
+
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.GeneratedValue;
+import jakarta.persistence.GenerationType;
+import jakarta.persistence.Id;
+import jakarta.persistence.Table;
+import lombok.Data;
+import lombok.Getter;
+import lombok.Setter;
+
+@Entity
+@Table(name = "characters")
+@Data
+@Getter
+@Setter
+public class Character {
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ private Long id;
+
+ @Column(name = "external_id", nullable = false, unique = true)
+ private String externalId;
+
+ @Column(nullable = false)
+ private String name;
+
+ private String status;
+
+ private String gender;
+}
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..698094b0
--- /dev/null
+++ b/src/main/java/mate/academy/rickandmorty/repository/CharacterRepository.java
@@ -0,0 +1,16 @@
+package mate.academy.rickandmorty.repository;
+
+import java.util.List;
+import mate.academy.rickandmorty.model.Character;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Query;
+
+public interface CharacterRepository extends JpaRepository {
+
+ List findByNameContainingIgnoreCase(String name);
+
+ @Query("SELECT c.externalId FROM Character c")
+ List findByExternalId();
+
+ boolean existsByExternalId(String externalId);
+}
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..cff1dfa7
--- /dev/null
+++ b/src/main/java/mate/academy/rickandmorty/service/CharacterService.java
@@ -0,0 +1,13 @@
+package mate.academy.rickandmorty.service;
+
+import java.util.List;
+import java.util.Optional;
+import mate.academy.rickandmorty.dto.CharacterResponseDto;
+
+public interface CharacterService {
+ void loadInitialData();
+
+ List searchByName(String name);
+
+ Optional getRandomCharacter();
+}
diff --git a/src/main/java/mate/academy/rickandmorty/service/impl/CharacterServiceImpl.java b/src/main/java/mate/academy/rickandmorty/service/impl/CharacterServiceImpl.java
new file mode 100644
index 00000000..c107732c
--- /dev/null
+++ b/src/main/java/mate/academy/rickandmorty/service/impl/CharacterServiceImpl.java
@@ -0,0 +1,86 @@
+package mate.academy.rickandmorty.service.impl;
+
+import java.util.HashSet;
+import java.util.List;
+import java.util.Optional;
+import java.util.Random;
+import java.util.Set;
+import lombok.RequiredArgsConstructor;
+import mate.academy.rickandmorty.client.RickAndMortyClient;
+import mate.academy.rickandmorty.dto.CharacterResponseDto;
+import mate.academy.rickandmorty.dto.external.ExternalCharacterDto;
+import mate.academy.rickandmorty.mapper.CharacterMapper;
+import mate.academy.rickandmorty.model.Character;
+import mate.academy.rickandmorty.repository.CharacterRepository;
+import mate.academy.rickandmorty.service.CharacterService;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.PageRequest;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+@Service
+@RequiredArgsConstructor
+public class CharacterServiceImpl implements CharacterService {
+
+ private static final Logger logger = LoggerFactory.getLogger(CharacterServiceImpl.class);
+ private final CharacterRepository characterRepository;
+ private final RickAndMortyClient rickAndMortyClient;
+ private final Random random = new Random();
+
+ @Override
+ @Transactional
+ public void loadInitialData() {
+
+ logger.info("Starting initial data load from external API...");
+ List externalCharacterDto = rickAndMortyClient.fetchAllCharacters();
+ logger.info("Received {} characters", externalCharacterDto.size());
+
+ Set existingExternalIds = new HashSet<>(characterRepository.findByExternalId());
+
+ List toSaveInitialData = externalCharacterDto.stream()
+ .filter(dto -> !existingExternalIds.contains(dto.id().toString()))
+ .map(CharacterMapper::toEntity)
+ .toList();
+
+ if (toSaveInitialData.isEmpty()) {
+ logger.info("No new characters to save.");
+ return;
+ }
+
+ logger.info("Saving {} new characters", toSaveInitialData.size());
+
+ characterRepository.saveAll(toSaveInitialData);
+ logger.info("Initial data load finished.");
+ }
+
+ @Override
+ public List searchByName(String name) {
+ return (name == null || name.isBlank())
+ ? List.of() :
+ characterRepository.findByNameContainingIgnoreCase(name).stream()
+ .map(CharacterMapper::toDto)
+ .toList();
+ }
+
+ @Override
+ public Optional getRandomCharacter() {
+
+ long count = characterRepository.count();
+ if (count == 0) {
+ return Optional.empty();
+ }
+
+ int randomIndex = random.nextInt((int) count);
+ Page page = characterRepository.findAll(PageRequest.of(randomIndex, 1));
+
+ List existsContent = page.getContent();
+ if (existsContent.isEmpty()) {
+ return Optional.empty();
+ }
+
+ Character character = page.getContent().get(0);
+ return Optional.of(CharacterMapper.toDto(character));
+ }
+}
diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties
index 8b137891..e37b4380 100644
--- a/src/main/resources/application.properties
+++ b/src/main/resources/application.properties
@@ -1 +1,13 @@
-
+spring.application.name=bookstore
+spring.datasource.url=jdbc:mysql://localhost:3306/rick_and_morty_db
+spring.datasource.username=root
+spring.datasource.password=gRw100HDRas900/MySQL
+logging.level.org.springframework=DEBUG
+spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
+spring.liquibase.change-log=classpath:db/changelog/db.changelog-master.yaml
+spring.liquibase.enabled=true
+spring.jpa.hibernate.ddl-auto=update
+spring.jpa.show-sql=true
+spring.jpa.database-platform=org.hibernate.dialect.MySQL8Dialect
+springdoc.api-docs.path=/api-docs
+springdoc.swagger-ui.path=/swagger-ui.html
\ No newline at end of file
diff --git a/src/test/resources/application.properties b/src/test/resources/application.properties
index bc2fdde8..9c86aa6b 100644
--- a/src/test/resources/application.properties
+++ b/src/test/resources/application.properties
@@ -1,5 +1,8 @@
spring.datasource.url=jdbc:h2:mem:testdb
spring.datasource.driverClassName=org.h2.Driver
-spring.datasource.username=sa
-spring.datasource.password=password
+spring.datasource.username=root
+spring.datasource.password=gRw100HDRas900/MySQL
spring.jpa.database-platform=org.hibernate.dialect.H2Dialect
+spring.jpa.hibernate.ddl-auto=create-drop
+spring.jpa.show-sql=true
+spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.H2Dialect