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