feat: initial nutri-api — Spring Boot REST API for supplement catalog

- Supplement and Ingredient entities with UUID primary keys
- CRUD REST API with pagination, filtering, and full-text search
- Liquibase migrations with PostgreSQL tsvector full-text index
- SpringDoc OpenAPI documentation
- RFC 7807 Problem Details error handling
- Unit tests for service, mapper, and controller layers (23 tests)
- Dockerfile (multi-stage) and docker-compose.yaml for local dev
- Application profiles: default, dev, prod
This commit is contained in:
Backend Agent 2026-04-10 17:03:09 +00:00
commit 506d1ba761
32 changed files with 1625 additions and 0 deletions

10
.gitignore vendored Normal file
View File

@ -0,0 +1,10 @@
target/
*.class
*.jar
*.war
*.iml
.idea/
.vscode/
*.log
.env
.DS_Store

12
Dockerfile Normal file
View File

@ -0,0 +1,12 @@
FROM eclipse-temurin:21-jdk-alpine AS build
WORKDIR /app
COPY pom.xml .
COPY src ./src
RUN apk add --no-cache maven && \
mvn clean package -DskipTests -q
FROM eclipse-temurin:21-jre-alpine
WORKDIR /app
COPY --from=build /app/target/*.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]

36
docker-compose.yaml Normal file
View File

@ -0,0 +1,36 @@
version: "3.9"
services:
app:
build: .
ports:
- "8080:8080"
environment:
DB_HOST: postgres
DB_PORT: 5432
DB_NAME: nutri
DB_USER: nutri
DB_PASSWORD: nutri
SPRING_PROFILES_ACTIVE: dev
depends_on:
postgres:
condition: service_healthy
postgres:
image: postgres:16-alpine
environment:
POSTGRES_DB: nutri
POSTGRES_USER: nutri
POSTGRES_PASSWORD: nutri
ports:
- "5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U nutri -d nutri"]
interval: 5s
timeout: 3s
retries: 5
volumes:
pgdata:

131
pom.xml Normal file
View File

@ -0,0 +1,131 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.4.4</version>
<relativePath/>
</parent>
<groupId>ru.oa2.mvp</groupId>
<artifactId>nutri-api</artifactId>
<version>0.1.0-SNAPSHOT</version>
<name>nutri-api</name>
<description>REST API service for supplement (BAD) catalog</description>
<properties>
<java.version>21</java.version>
<springdoc.version>2.8.5</springdoc.version>
</properties>
<dependencies>
<!-- Web -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- Data JPA -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<!-- Validation -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<!-- PostgreSQL -->
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
<!-- Liquibase -->
<dependency>
<groupId>org.liquibase</groupId>
<artifactId>liquibase-core</artifactId>
</dependency>
<!-- OpenAPI / Swagger -->
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<version>${springdoc.version}</version>
</dependency>
<!-- Lombok -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<!-- Actuator -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<!-- Test -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<!-- Testcontainers -->
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>postgresql</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-testcontainers</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers-bom</artifactId>
<version>1.20.4</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<excludes>
<exclude>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</exclude>
</excludes>
</configuration>
</plugin>
</plugins>
</build>
</project>

View File

@ -0,0 +1,12 @@
package ru.oa2.mvp.nutriapi;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class NutriApiApplication {
public static void main(String[] args) {
SpringApplication.run(NutriApiApplication.class, args);
}
}

View File

@ -0,0 +1,19 @@
package ru.oa2.mvp.nutriapi.config;
import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.info.Info;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class OpenApiConfig {
@Bean
public OpenAPI customOpenAPI() {
return new OpenAPI()
.info(new Info()
.title("Nutri API")
.version("0.1.0")
.description("REST API for supplement (BAD) catalog — search, browse, and manage supplements"));
}
}

View File

@ -0,0 +1,87 @@
package ru.oa2.mvp.nutriapi.controller;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import ru.oa2.mvp.nutriapi.dto.*;
import ru.oa2.mvp.nutriapi.service.SupplementService;
import java.util.UUID;
@RestController
@RequestMapping("/api/v1/supplements")
@RequiredArgsConstructor
@Tag(name = "Supplements", description = "Supplement catalog API")
public class SupplementController {
private final SupplementService supplementService;
@GetMapping
@Operation(summary = "List supplements with pagination and optional filtering")
public PageResponse<SupplementResponse> list(
@Parameter(description = "Page number (0-based)") @RequestParam(defaultValue = "0") int page,
@Parameter(description = "Page size") @RequestParam(defaultValue = "20") int size,
@Parameter(description = "Filter by category") @RequestParam(required = false) String category,
@Parameter(description = "Filter by brand") @RequestParam(required = false) String brand,
@Parameter(description = "Sort field") @RequestParam(defaultValue = "name") String sortBy,
@Parameter(description = "Sort direction") @RequestParam(defaultValue = "asc") String sortDir) {
Sort sort = sortDir.equalsIgnoreCase("desc")
? Sort.by(sortBy).descending()
: Sort.by(sortBy).ascending();
PageRequest pageable = PageRequest.of(page, size, sort);
if (category != null) {
return supplementService.findByCategory(category, pageable);
}
if (brand != null) {
return supplementService.findByBrand(brand, pageable);
}
return supplementService.findAll(pageable);
}
@GetMapping("/{id}")
@Operation(summary = "Get supplement by ID")
public SupplementResponse getById(@PathVariable UUID id) {
return supplementService.findById(id);
}
@GetMapping("/search")
@Operation(summary = "Full-text search across supplements")
public PageResponse<SupplementResponse> search(
@Parameter(description = "Search query") @RequestParam String q,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size) {
return supplementService.search(q, PageRequest.of(page, size));
}
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
@Operation(summary = "Create a new supplement")
public SupplementResponse create(@Valid @RequestBody SupplementCreateRequest request) {
return supplementService.create(request);
}
@PutMapping("/{id}")
@Operation(summary = "Update an existing supplement")
public SupplementResponse update(
@PathVariable UUID id,
@Valid @RequestBody SupplementUpdateRequest request) {
return supplementService.update(id, request);
}
@DeleteMapping("/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT)
@Operation(summary = "Delete a supplement")
public ResponseEntity<Void> delete(@PathVariable UUID id) {
supplementService.delete(id);
return ResponseEntity.noContent().build();
}
}

View File

@ -0,0 +1,19 @@
package ru.oa2.mvp.nutriapi.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import java.math.BigDecimal;
public record IngredientRequest(
@NotBlank(message = "Ingredient name is required")
@Size(max = 255)
String name,
BigDecimal amount,
@Size(max = 50)
String unit,
Integer dailyValuePercent
) {}

View File

@ -0,0 +1,12 @@
package ru.oa2.mvp.nutriapi.dto;
import java.math.BigDecimal;
import java.util.UUID;
public record IngredientResponse(
UUID id,
String name,
BigDecimal amount,
String unit,
Integer dailyValuePercent
) {}

View File

@ -0,0 +1,12 @@
package ru.oa2.mvp.nutriapi.dto;
import java.util.List;
public record PageResponse<T>(
List<T> content,
int page,
int size,
long totalElements,
int totalPages,
boolean last
) {}

View File

@ -0,0 +1,49 @@
package ru.oa2.mvp.nutriapi.dto;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import java.math.BigDecimal;
import java.util.List;
public record SupplementCreateRequest(
@NotBlank(message = "Name is required")
@Size(max = 500, message = "Name must be at most 500 characters")
String name,
@Size(max = 255)
String brand,
@Size(max = 255)
String category,
@Size(max = 100)
String form,
@Size(max = 100)
String servingSize,
Integer servingsPerContainer,
String description,
String contraindications,
@Size(max = 100)
String country,
@Size(max = 1024)
String imageUrl,
@Size(max = 1024)
String sourceUrl,
BigDecimal rating,
@Size(max = 50)
String priceRange,
@Valid
List<IngredientRequest> ingredients
) {}

View File

@ -0,0 +1,26 @@
package ru.oa2.mvp.nutriapi.dto;
import java.math.BigDecimal;
import java.time.Instant;
import java.util.List;
import java.util.UUID;
public record SupplementResponse(
UUID id,
String name,
String brand,
String category,
String form,
String servingSize,
Integer servingsPerContainer,
String description,
String contraindications,
String country,
String imageUrl,
String sourceUrl,
BigDecimal rating,
String priceRange,
List<IngredientResponse> ingredients,
Instant createdAt,
Instant updatedAt
) {}

View File

@ -0,0 +1,47 @@
package ru.oa2.mvp.nutriapi.dto;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Size;
import java.math.BigDecimal;
import java.util.List;
public record SupplementUpdateRequest(
@Size(max = 500, message = "Name must be at most 500 characters")
String name,
@Size(max = 255)
String brand,
@Size(max = 255)
String category,
@Size(max = 100)
String form,
@Size(max = 100)
String servingSize,
Integer servingsPerContainer,
String description,
String contraindications,
@Size(max = 100)
String country,
@Size(max = 1024)
String imageUrl,
@Size(max = 1024)
String sourceUrl,
BigDecimal rating,
@Size(max = 50)
String priceRange,
@Valid
List<IngredientRequest> ingredients
) {}

View File

@ -0,0 +1,43 @@
package ru.oa2.mvp.nutriapi.entity;
import jakarta.persistence.*;
import lombok.*;
import java.math.BigDecimal;
import java.util.UUID;
@Entity
@Table(name = "ingredients")
@Getter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class Ingredient {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
@Column(name = "id", updatable = false, nullable = false)
private UUID id;
@Column(name = "name", nullable = false)
private String name;
@Column(name = "amount", precision = 10, scale = 4)
private BigDecimal amount;
@Column(name = "unit")
private String unit;
@Column(name = "daily_value_percent")
private Integer dailyValuePercent;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "supplement_id", nullable = false)
@Setter
private Supplement supplement;
public void setName(String name) { this.name = name; }
public void setAmount(BigDecimal amount) { this.amount = amount; }
public void setUnit(String unit) { this.unit = unit; }
public void setDailyValuePercent(Integer dailyValuePercent) { this.dailyValuePercent = dailyValuePercent; }
}

View File

@ -0,0 +1,101 @@
package ru.oa2.mvp.nutriapi.entity;
import jakarta.persistence.*;
import lombok.*;
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.UpdateTimestamp;
import java.math.BigDecimal;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
@Entity
@Table(name = "supplements")
@Getter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class Supplement {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
@Column(name = "id", updatable = false, nullable = false)
private UUID id;
@Column(name = "name", nullable = false)
private String name;
@Column(name = "brand")
private String brand;
@Column(name = "category")
private String category;
@Column(name = "form")
private String form;
@Column(name = "serving_size")
private String servingSize;
@Column(name = "servings_per_container")
private Integer servingsPerContainer;
@Column(name = "description", columnDefinition = "TEXT")
private String description;
@Column(name = "contraindications", columnDefinition = "TEXT")
private String contraindications;
@Column(name = "country")
private String country;
@Column(name = "image_url")
private String imageUrl;
@Column(name = "source_url")
private String sourceUrl;
@Column(name = "rating", precision = 3, scale = 2)
private BigDecimal rating;
@Column(name = "price_range")
private String priceRange;
@OneToMany(mappedBy = "supplement", cascade = CascadeType.ALL, orphanRemoval = true)
@Builder.Default
private List<Ingredient> ingredients = new ArrayList<>();
@CreationTimestamp
@Column(name = "created_at", updatable = false)
private Instant createdAt;
@UpdateTimestamp
@Column(name = "updated_at")
private Instant updatedAt;
public void setName(String name) { this.name = name; }
public void setBrand(String brand) { this.brand = brand; }
public void setCategory(String category) { this.category = category; }
public void setForm(String form) { this.form = form; }
public void setServingSize(String servingSize) { this.servingSize = servingSize; }
public void setServingsPerContainer(Integer servingsPerContainer) { this.servingsPerContainer = servingsPerContainer; }
public void setDescription(String description) { this.description = description; }
public void setContraindications(String contraindications) { this.contraindications = contraindications; }
public void setCountry(String country) { this.country = country; }
public void setImageUrl(String imageUrl) { this.imageUrl = imageUrl; }
public void setSourceUrl(String sourceUrl) { this.sourceUrl = sourceUrl; }
public void setRating(BigDecimal rating) { this.rating = rating; }
public void setPriceRange(String priceRange) { this.priceRange = priceRange; }
public void addIngredient(Ingredient ingredient) {
ingredients.add(ingredient);
ingredient.setSupplement(this);
}
public void removeIngredient(Ingredient ingredient) {
ingredients.remove(ingredient);
ingredient.setSupplement(null);
}
}

View File

@ -0,0 +1,67 @@
package ru.oa2.mvp.nutriapi.exception;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import java.net.URI;
import java.time.Instant;
import java.util.Map;
import java.util.stream.Collectors;
@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {
@ExceptionHandler(ResourceNotFoundException.class)
public ProblemDetail handleNotFound(ResourceNotFoundException ex) {
ProblemDetail problem = ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
problem.setTitle("Resource Not Found");
problem.setType(URI.create("https://api.nutri.oa2.ru/errors/not-found"));
problem.setProperty("timestamp", Instant.now());
return problem;
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ProblemDetail handleValidation(MethodArgumentNotValidException ex) {
Map<String, String> errors = ex.getBindingResult().getFieldErrors().stream()
.collect(Collectors.toMap(
FieldError::getField,
fe -> fe.getDefaultMessage() != null ? fe.getDefaultMessage() : "Invalid value",
(a, b) -> a
));
ProblemDetail problem = ProblemDetail.forStatusAndDetail(
HttpStatus.BAD_REQUEST, "Validation failed");
problem.setTitle("Validation Error");
problem.setType(URI.create("https://api.nutri.oa2.ru/errors/validation"));
problem.setProperty("timestamp", Instant.now());
problem.setProperty("errors", errors);
return problem;
}
@ExceptionHandler(IllegalArgumentException.class)
public ProblemDetail handleBadRequest(IllegalArgumentException ex) {
ProblemDetail problem = ProblemDetail.forStatusAndDetail(
HttpStatus.BAD_REQUEST, ex.getMessage());
problem.setTitle("Bad Request");
problem.setType(URI.create("https://api.nutri.oa2.ru/errors/bad-request"));
problem.setProperty("timestamp", Instant.now());
return problem;
}
@ExceptionHandler(Exception.class)
public ProblemDetail handleGeneric(Exception ex) {
log.error("Unhandled exception", ex);
ProblemDetail problem = ProblemDetail.forStatusAndDetail(
HttpStatus.INTERNAL_SERVER_ERROR, "An unexpected error occurred");
problem.setTitle("Internal Server Error");
problem.setType(URI.create("https://api.nutri.oa2.ru/errors/internal"));
problem.setProperty("timestamp", Instant.now());
return problem;
}
}

View File

@ -0,0 +1,10 @@
package ru.oa2.mvp.nutriapi.exception;
import java.util.UUID;
public class ResourceNotFoundException extends RuntimeException {
public ResourceNotFoundException(String resource, UUID id) {
super(resource + " not found with id: " + id);
}
}

View File

@ -0,0 +1,14 @@
package ru.oa2.mvp.nutriapi.repository;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import ru.oa2.mvp.nutriapi.entity.Ingredient;
import java.util.List;
import java.util.UUID;
@Repository
public interface IngredientRepository extends JpaRepository<Ingredient, UUID> {
List<Ingredient> findBySupplementId(UUID supplementId);
}

View File

@ -0,0 +1,31 @@
package ru.oa2.mvp.nutriapi.repository;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import ru.oa2.mvp.nutriapi.entity.Supplement;
import java.util.UUID;
@Repository
public interface SupplementRepository extends JpaRepository<Supplement, UUID> {
Page<Supplement> findByCategory(String category, Pageable pageable);
Page<Supplement> findByBrand(String brand, Pageable pageable);
@Query(value = """
SELECT s.* FROM supplements s
WHERE s.search_vector @@ plainto_tsquery('russian', :query)
ORDER BY ts_rank(s.search_vector, plainto_tsquery('russian', :query)) DESC
""",
countQuery = """
SELECT count(*) FROM supplements s
WHERE s.search_vector @@ plainto_tsquery('russian', :query)
""",
nativeQuery = true)
Page<Supplement> fullTextSearch(@Param("query") String query, Pageable pageable);
}

View File

@ -0,0 +1,101 @@
package ru.oa2.mvp.nutriapi.service;
import org.springframework.stereotype.Component;
import ru.oa2.mvp.nutriapi.dto.*;
import ru.oa2.mvp.nutriapi.entity.Ingredient;
import ru.oa2.mvp.nutriapi.entity.Supplement;
import java.util.Collections;
import java.util.List;
@Component
public class SupplementMapper {
public SupplementResponse toResponse(Supplement entity) {
return new SupplementResponse(
entity.getId(),
entity.getName(),
entity.getBrand(),
entity.getCategory(),
entity.getForm(),
entity.getServingSize(),
entity.getServingsPerContainer(),
entity.getDescription(),
entity.getContraindications(),
entity.getCountry(),
entity.getImageUrl(),
entity.getSourceUrl(),
entity.getRating(),
entity.getPriceRange(),
entity.getIngredients() != null
? entity.getIngredients().stream().map(this::toIngredientResponse).toList()
: Collections.emptyList(),
entity.getCreatedAt(),
entity.getUpdatedAt()
);
}
public Supplement toEntity(SupplementCreateRequest request) {
Supplement supplement = Supplement.builder()
.name(request.name())
.brand(request.brand())
.category(request.category())
.form(request.form())
.servingSize(request.servingSize())
.servingsPerContainer(request.servingsPerContainer())
.description(request.description())
.contraindications(request.contraindications())
.country(request.country())
.imageUrl(request.imageUrl())
.sourceUrl(request.sourceUrl())
.rating(request.rating())
.priceRange(request.priceRange())
.build();
if (request.ingredients() != null) {
request.ingredients().forEach(ir -> supplement.addIngredient(toIngredientEntity(ir)));
}
return supplement;
}
public void updateEntity(Supplement entity, SupplementUpdateRequest request) {
if (request.name() != null) entity.setName(request.name());
if (request.brand() != null) entity.setBrand(request.brand());
if (request.category() != null) entity.setCategory(request.category());
if (request.form() != null) entity.setForm(request.form());
if (request.servingSize() != null) entity.setServingSize(request.servingSize());
if (request.servingsPerContainer() != null) entity.setServingsPerContainer(request.servingsPerContainer());
if (request.description() != null) entity.setDescription(request.description());
if (request.contraindications() != null) entity.setContraindications(request.contraindications());
if (request.country() != null) entity.setCountry(request.country());
if (request.imageUrl() != null) entity.setImageUrl(request.imageUrl());
if (request.sourceUrl() != null) entity.setSourceUrl(request.sourceUrl());
if (request.rating() != null) entity.setRating(request.rating());
if (request.priceRange() != null) entity.setPriceRange(request.priceRange());
if (request.ingredients() != null) {
entity.getIngredients().clear();
request.ingredients().forEach(ir -> entity.addIngredient(toIngredientEntity(ir)));
}
}
public IngredientResponse toIngredientResponse(Ingredient entity) {
return new IngredientResponse(
entity.getId(),
entity.getName(),
entity.getAmount(),
entity.getUnit(),
entity.getDailyValuePercent()
);
}
private Ingredient toIngredientEntity(IngredientRequest request) {
return Ingredient.builder()
.name(request.name())
.amount(request.amount())
.unit(request.unit())
.dailyValuePercent(request.dailyValuePercent())
.build();
}
}

View File

@ -0,0 +1,92 @@
package ru.oa2.mvp.nutriapi.service;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import ru.oa2.mvp.nutriapi.dto.*;
import ru.oa2.mvp.nutriapi.entity.Supplement;
import ru.oa2.mvp.nutriapi.exception.ResourceNotFoundException;
import ru.oa2.mvp.nutriapi.repository.SupplementRepository;
import java.util.UUID;
@Service
@RequiredArgsConstructor
@Slf4j
public class SupplementService {
private final SupplementRepository supplementRepository;
private final SupplementMapper supplementMapper;
@Transactional(readOnly = true)
public PageResponse<SupplementResponse> findAll(Pageable pageable) {
Page<Supplement> page = supplementRepository.findAll(pageable);
return toPageResponse(page);
}
@Transactional(readOnly = true)
public SupplementResponse findById(UUID id) {
Supplement supplement = supplementRepository.findById(id)
.orElseThrow(() -> new ResourceNotFoundException("Supplement", id));
return supplementMapper.toResponse(supplement);
}
@Transactional(readOnly = true)
public PageResponse<SupplementResponse> findByCategory(String category, Pageable pageable) {
Page<Supplement> page = supplementRepository.findByCategory(category, pageable);
return toPageResponse(page);
}
@Transactional(readOnly = true)
public PageResponse<SupplementResponse> findByBrand(String brand, Pageable pageable) {
Page<Supplement> page = supplementRepository.findByBrand(brand, pageable);
return toPageResponse(page);
}
@Transactional(readOnly = true)
public PageResponse<SupplementResponse> search(String query, Pageable pageable) {
Page<Supplement> page = supplementRepository.fullTextSearch(query, pageable);
return toPageResponse(page);
}
@Transactional
public SupplementResponse create(SupplementCreateRequest request) {
Supplement supplement = supplementMapper.toEntity(request);
supplement = supplementRepository.save(supplement);
log.info("Created supplement: id={}, name={}", supplement.getId(), supplement.getName());
return supplementMapper.toResponse(supplement);
}
@Transactional
public SupplementResponse update(UUID id, SupplementUpdateRequest request) {
Supplement supplement = supplementRepository.findById(id)
.orElseThrow(() -> new ResourceNotFoundException("Supplement", id));
supplementMapper.updateEntity(supplement, request);
supplement = supplementRepository.save(supplement);
log.info("Updated supplement: id={}", id);
return supplementMapper.toResponse(supplement);
}
@Transactional
public void delete(UUID id) {
if (!supplementRepository.existsById(id)) {
throw new ResourceNotFoundException("Supplement", id);
}
supplementRepository.deleteById(id);
log.info("Deleted supplement: id={}", id);
}
private PageResponse<SupplementResponse> toPageResponse(Page<Supplement> page) {
return new PageResponse<>(
page.getContent().stream().map(supplementMapper::toResponse).toList(),
page.getNumber(),
page.getSize(),
page.getTotalElements(),
page.getTotalPages(),
page.isLast()
);
}
}

View File

@ -0,0 +1,10 @@
spring:
datasource:
url: jdbc:postgresql://localhost:5432/nutri
username: nutri
password: nutri
logging:
level:
ru.oa2.mvp.nutriapi: DEBUG
org.hibernate.SQL: DEBUG

View File

@ -0,0 +1,10 @@
spring:
datasource:
hikari:
maximum-pool-size: 20
minimum-idle: 5
logging:
level:
ru.oa2.mvp.nutriapi: INFO
org.hibernate.SQL: WARN

View File

@ -0,0 +1,49 @@
spring:
application:
name: nutri-api
datasource:
url: jdbc:postgresql://${DB_HOST:localhost}:${DB_PORT:5432}/${DB_NAME:nutri}
username: ${DB_USER:nutri}
password: ${DB_PASSWORD:nutri}
driver-class-name: org.postgresql.Driver
hikari:
maximum-pool-size: 10
minimum-idle: 2
jpa:
open-in-view: false
hibernate:
ddl-auto: validate
properties:
hibernate:
dialect: org.hibernate.dialect.PostgreSQLDialect
default_schema: public
format_sql: true
liquibase:
change-log: classpath:db/changelog/db.changelog-master.yaml
server:
port: ${SERVER_PORT:8080}
springdoc:
api-docs:
path: /api-docs
swagger-ui:
path: /swagger-ui.html
operationsSorter: method
management:
endpoints:
web:
exposure:
include: health,info,metrics
endpoint:
health:
show-details: when_authorized
logging:
level:
ru.oa2.mvp.nutriapi: INFO
org.hibernate.SQL: WARN

View File

@ -0,0 +1,76 @@
databaseChangeLog:
- changeSet:
id: 001-create-supplements-table
author: backend-agent
changes:
- createTable:
tableName: supplements
columns:
- column:
name: id
type: uuid
defaultValueComputed: gen_random_uuid()
constraints:
primaryKey: true
nullable: false
- column:
name: name
type: varchar(500)
constraints:
nullable: false
- column:
name: brand
type: varchar(255)
- column:
name: category
type: varchar(255)
- column:
name: form
type: varchar(100)
- column:
name: serving_size
type: varchar(100)
- column:
name: servings_per_container
type: int
- column:
name: description
type: text
- column:
name: contraindications
type: text
- column:
name: country
type: varchar(100)
- column:
name: image_url
type: varchar(1024)
- column:
name: source_url
type: varchar(1024)
- column:
name: rating
type: decimal(3,2)
- column:
name: price_range
type: varchar(50)
- column:
name: created_at
type: timestamp with time zone
defaultValueComputed: now()
- column:
name: updated_at
type: timestamp with time zone
defaultValueComputed: now()
- createIndex:
indexName: idx_supplements_category
tableName: supplements
columns:
- column:
name: category
- createIndex:
indexName: idx_supplements_brand
tableName: supplements
columns:
- column:
name: brand

View File

@ -0,0 +1,49 @@
databaseChangeLog:
- changeSet:
id: 002-create-ingredients-table
author: backend-agent
changes:
- createTable:
tableName: ingredients
columns:
- column:
name: id
type: uuid
defaultValueComputed: gen_random_uuid()
constraints:
primaryKey: true
nullable: false
- column:
name: name
type: varchar(255)
constraints:
nullable: false
- column:
name: amount
type: decimal(10,4)
- column:
name: unit
type: varchar(50)
- column:
name: daily_value_percent
type: int
- column:
name: supplement_id
type: uuid
constraints:
nullable: false
foreignKeyName: fk_ingredients_supplement
references: supplements(id)
deleteCascade: true
- createIndex:
indexName: idx_ingredients_supplement_id
tableName: ingredients
columns:
- column:
name: supplement_id
- createIndex:
indexName: idx_ingredients_name
tableName: ingredients
columns:
- column:
name: name

View File

@ -0,0 +1,19 @@
databaseChangeLog:
- changeSet:
id: 003-add-fulltext-search-index
author: backend-agent
changes:
- sql:
sql: >
ALTER TABLE supplements
ADD COLUMN IF NOT EXISTS search_vector tsvector
GENERATED ALWAYS AS (
setweight(to_tsvector('russian', coalesce(name, '')), 'A') ||
setweight(to_tsvector('russian', coalesce(brand, '')), 'B') ||
setweight(to_tsvector('russian', coalesce(category, '')), 'B') ||
setweight(to_tsvector('russian', coalesce(description, '')), 'C')
) STORED;
- sql:
sql: >
CREATE INDEX IF NOT EXISTS idx_supplements_search_vector
ON supplements USING gin(search_vector);

View File

@ -0,0 +1,7 @@
databaseChangeLog:
- include:
file: db/changelog/V001_create_supplements_table.yaml
- include:
file: db/changelog/V002_create_ingredients_table.yaml
- include:
file: db/changelog/V003_add_fulltext_search_index.yaml

View File

@ -0,0 +1,31 @@
package ru.oa2.mvp.nutriapi;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
@SpringBootTest
@Testcontainers
class NutriApiApplicationTests {
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine")
.withDatabaseName("nutri_test")
.withUsername("test")
.withPassword("test");
@DynamicPropertySource
static void configureProperties(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", postgres::getJdbcUrl);
registry.add("spring.datasource.username", postgres::getUsername);
registry.add("spring.datasource.password", postgres::getPassword);
}
@Test
void contextLoads() {
}
}

View File

@ -0,0 +1,162 @@
package ru.oa2.mvp.nutriapi.controller;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import ru.oa2.mvp.nutriapi.dto.*;
import ru.oa2.mvp.nutriapi.exception.ResourceNotFoundException;
import ru.oa2.mvp.nutriapi.service.SupplementService;
import java.time.Instant;
import java.util.Collections;
import java.util.List;
import java.util.UUID;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@WebMvcTest(SupplementController.class)
class SupplementControllerTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private ObjectMapper objectMapper;
@MockitoBean
private SupplementService supplementService;
private final UUID testId = UUID.randomUUID();
private SupplementResponse sampleResponse() {
return new SupplementResponse(
testId, "Vitamin C", "TestBrand", "Vitamins",
"Tablet", "1 tablet", 90, "Vitamin C supplement", null,
"USA", null, null, null, "$",
Collections.emptyList(), Instant.now(), Instant.now()
);
}
@Test
void list_returnsPageOfSupplements() throws Exception {
PageResponse<SupplementResponse> page = new PageResponse<>(
List.of(sampleResponse()), 0, 20, 1, 1, true
);
when(supplementService.findAll(any())).thenReturn(page);
mockMvc.perform(get("/api/v1/supplements"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.content[0].name").value("Vitamin C"))
.andExpect(jsonPath("$.totalElements").value(1));
}
@Test
void getById_existingSupplement_returnsOk() throws Exception {
when(supplementService.findById(testId)).thenReturn(sampleResponse());
mockMvc.perform(get("/api/v1/supplements/{id}", testId))
.andExpect(status().isOk())
.andExpect(jsonPath("$.name").value("Vitamin C"))
.andExpect(jsonPath("$.brand").value("TestBrand"));
}
@Test
void getById_nonExisting_returns404() throws Exception {
when(supplementService.findById(testId))
.thenThrow(new ResourceNotFoundException("Supplement", testId));
mockMvc.perform(get("/api/v1/supplements/{id}", testId))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.title").value("Resource Not Found"));
}
@Test
void create_validRequest_returns201() throws Exception {
SupplementCreateRequest request = new SupplementCreateRequest(
"Omega-3", "FishOil", "Fatty Acids", null,
null, null, null, null, null, null, null, null, null, null
);
when(supplementService.create(any())).thenReturn(sampleResponse());
mockMvc.perform(post("/api/v1/supplements")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isCreated());
}
@Test
void create_invalidRequest_returns400() throws Exception {
String invalidJson = """
{"name": ""}
""";
mockMvc.perform(post("/api/v1/supplements")
.contentType(MediaType.APPLICATION_JSON)
.content(invalidJson))
.andExpect(status().isBadRequest());
}
@Test
void update_existingSupplement_returnsOk() throws Exception {
SupplementUpdateRequest request = new SupplementUpdateRequest(
"Updated Name", null, null, null, null, null,
null, null, null, null, null, null, null, null
);
when(supplementService.update(eq(testId), any())).thenReturn(sampleResponse());
mockMvc.perform(put("/api/v1/supplements/{id}", testId)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isOk());
}
@Test
void delete_existingSupplement_returns204() throws Exception {
mockMvc.perform(delete("/api/v1/supplements/{id}", testId))
.andExpect(status().isNoContent());
}
@Test
void delete_nonExisting_returns404() throws Exception {
doThrow(new ResourceNotFoundException("Supplement", testId))
.when(supplementService).delete(testId);
mockMvc.perform(delete("/api/v1/supplements/{id}", testId))
.andExpect(status().isNotFound());
}
@Test
void search_returnsResults() throws Exception {
PageResponse<SupplementResponse> page = new PageResponse<>(
List.of(sampleResponse()), 0, 20, 1, 1, true
);
when(supplementService.search(eq("vitamin"), any())).thenReturn(page);
mockMvc.perform(get("/api/v1/supplements/search")
.param("q", "vitamin"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.content[0].name").value("Vitamin C"));
}
@Test
void list_withCategoryFilter_returnsFiltered() throws Exception {
PageResponse<SupplementResponse> page = new PageResponse<>(
List.of(sampleResponse()), 0, 20, 1, 1, true
);
when(supplementService.findByCategory(eq("Vitamins"), any())).thenReturn(page);
mockMvc.perform(get("/api/v1/supplements")
.param("category", "Vitamins"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.content").isArray());
}
}

View File

@ -0,0 +1,101 @@
package ru.oa2.mvp.nutriapi.service;
import org.junit.jupiter.api.Test;
import ru.oa2.mvp.nutriapi.dto.*;
import ru.oa2.mvp.nutriapi.entity.Ingredient;
import ru.oa2.mvp.nutriapi.entity.Supplement;
import java.math.BigDecimal;
import java.util.List;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
class SupplementMapperTest {
private final SupplementMapper mapper = new SupplementMapper();
@Test
void toResponse_mapsAllFields() {
Supplement supplement = Supplement.builder()
.id(UUID.randomUUID())
.name("Vitamin D3")
.brand("SunBrand")
.category("Vitamins")
.form("Capsule")
.servingSize("1 capsule")
.servingsPerContainer(120)
.description("Vitamin D3 supplement")
.country("USA")
.rating(new BigDecimal("4.80"))
.priceRange("$$")
.build();
Ingredient ingredient = Ingredient.builder()
.id(UUID.randomUUID())
.name("Vitamin D3")
.amount(new BigDecimal("5000"))
.unit("IU")
.dailyValuePercent(1250)
.build();
supplement.addIngredient(ingredient);
SupplementResponse response = mapper.toResponse(supplement);
assertThat(response.name()).isEqualTo("Vitamin D3");
assertThat(response.brand()).isEqualTo("SunBrand");
assertThat(response.ingredients()).hasSize(1);
assertThat(response.ingredients().get(0).name()).isEqualTo("Vitamin D3");
assertThat(response.ingredients().get(0).unit()).isEqualTo("IU");
}
@Test
void toEntity_mapsFromCreateRequest() {
SupplementCreateRequest request = new SupplementCreateRequest(
"Magnesium", "MagBrand", "Minerals", "Tablet",
"2 tablets", 30, "Magnesium citrate", null,
"Germany", null, null, new BigDecimal("4.20"), "$",
List.of(new IngredientRequest("Magnesium Citrate", new BigDecimal("400"), "mg", 95))
);
Supplement entity = mapper.toEntity(request);
assertThat(entity.getName()).isEqualTo("Magnesium");
assertThat(entity.getBrand()).isEqualTo("MagBrand");
assertThat(entity.getIngredients()).hasSize(1);
assertThat(entity.getIngredients().get(0).getName()).isEqualTo("Magnesium Citrate");
assertThat(entity.getIngredients().get(0).getSupplement()).isEqualTo(entity);
}
@Test
void updateEntity_updatesOnlyNonNullFields() {
Supplement entity = Supplement.builder()
.name("Old Name")
.brand("Old Brand")
.category("Old Category")
.build();
SupplementUpdateRequest request = new SupplementUpdateRequest(
"New Name", null, "New Category", null, null, null,
null, null, null, null, null, null, null, null
);
mapper.updateEntity(entity, request);
assertThat(entity.getName()).isEqualTo("New Name");
assertThat(entity.getBrand()).isEqualTo("Old Brand"); // not updated since request.brand() is null
assertThat(entity.getCategory()).isEqualTo("New Category");
}
@Test
void toEntity_withNullIngredients_createsEmptyList() {
SupplementCreateRequest request = new SupplementCreateRequest(
"Simple Supp", null, null, null, null, null,
null, null, null, null, null, null, null, null
);
Supplement entity = mapper.toEntity(request);
assertThat(entity.getIngredients()).isEmpty();
}
}

View File

@ -0,0 +1,180 @@
package ru.oa2.mvp.nutriapi.service;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.PageRequest;
import ru.oa2.mvp.nutriapi.dto.*;
import ru.oa2.mvp.nutriapi.entity.Supplement;
import ru.oa2.mvp.nutriapi.exception.ResourceNotFoundException;
import ru.oa2.mvp.nutriapi.repository.SupplementRepository;
import java.math.BigDecimal;
import java.time.Instant;
import java.util.Collections;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class SupplementServiceTest {
@Mock
private SupplementRepository supplementRepository;
@Mock
private SupplementMapper supplementMapper;
@InjectMocks
private SupplementService supplementService;
private UUID testId;
private Supplement testSupplement;
private SupplementResponse testResponse;
@BeforeEach
void setUp() {
testId = UUID.randomUUID();
testSupplement = Supplement.builder()
.id(testId)
.name("Vitamin C")
.brand("TestBrand")
.category("Vitamins")
.build();
testResponse = new SupplementResponse(
testId, "Vitamin C", "TestBrand", "Vitamins",
null, null, null, null, null, null, null, null,
null, null, Collections.emptyList(), Instant.now(), Instant.now()
);
}
@Test
void findById_existingSupplement_returnsResponse() {
when(supplementRepository.findById(testId)).thenReturn(Optional.of(testSupplement));
when(supplementMapper.toResponse(testSupplement)).thenReturn(testResponse);
SupplementResponse result = supplementService.findById(testId);
assertThat(result.id()).isEqualTo(testId);
assertThat(result.name()).isEqualTo("Vitamin C");
verify(supplementRepository).findById(testId);
}
@Test
void findById_nonExistingSupplement_throwsNotFound() {
when(supplementRepository.findById(testId)).thenReturn(Optional.empty());
assertThatThrownBy(() -> supplementService.findById(testId))
.isInstanceOf(ResourceNotFoundException.class)
.hasMessageContaining(testId.toString());
}
@Test
void findAll_returnsPageResponse() {
PageRequest pageable = PageRequest.of(0, 20);
Page<Supplement> page = new PageImpl<>(List.of(testSupplement), pageable, 1);
when(supplementRepository.findAll(pageable)).thenReturn(page);
when(supplementMapper.toResponse(testSupplement)).thenReturn(testResponse);
PageResponse<SupplementResponse> result = supplementService.findAll(pageable);
assertThat(result.content()).hasSize(1);
assertThat(result.totalElements()).isEqualTo(1);
assertThat(result.page()).isZero();
}
@Test
void create_validRequest_returnsSavedSupplement() {
SupplementCreateRequest request = new SupplementCreateRequest(
"Omega-3", "FishOil", "Fatty Acids", "Capsule",
"1 capsule", 60, "Fish oil supplement", null,
"Norway", null, null, new BigDecimal("4.50"), "$$",
List.of(new IngredientRequest("EPA", new BigDecimal("360"), "mg", null))
);
Supplement newSupplement = Supplement.builder().name("Omega-3").build();
Supplement saved = Supplement.builder().id(UUID.randomUUID()).name("Omega-3").build();
SupplementResponse savedResponse = new SupplementResponse(
saved.getId(), "Omega-3", "FishOil", "Fatty Acids",
"Capsule", "1 capsule", 60, "Fish oil supplement", null,
"Norway", null, null, new BigDecimal("4.50"), "$$",
Collections.emptyList(), Instant.now(), Instant.now()
);
when(supplementMapper.toEntity(request)).thenReturn(newSupplement);
when(supplementRepository.save(newSupplement)).thenReturn(saved);
when(supplementMapper.toResponse(saved)).thenReturn(savedResponse);
SupplementResponse result = supplementService.create(request);
assertThat(result.name()).isEqualTo("Omega-3");
verify(supplementRepository).save(any(Supplement.class));
}
@Test
void update_existingSupplement_returnsUpdated() {
SupplementUpdateRequest request = new SupplementUpdateRequest(
"Vitamin C Updated", null, null, null, null, null,
null, null, null, null, null, null, null, null
);
when(supplementRepository.findById(testId)).thenReturn(Optional.of(testSupplement));
when(supplementRepository.save(testSupplement)).thenReturn(testSupplement);
when(supplementMapper.toResponse(testSupplement)).thenReturn(testResponse);
SupplementResponse result = supplementService.update(testId, request);
assertThat(result).isNotNull();
verify(supplementMapper).updateEntity(testSupplement, request);
verify(supplementRepository).save(testSupplement);
}
@Test
void update_nonExistingSupplement_throwsNotFound() {
when(supplementRepository.findById(testId)).thenReturn(Optional.empty());
assertThatThrownBy(() -> supplementService.update(testId, new SupplementUpdateRequest(
null, null, null, null, null, null,
null, null, null, null, null, null, null, null)))
.isInstanceOf(ResourceNotFoundException.class);
}
@Test
void delete_existingSupplement_deletes() {
when(supplementRepository.existsById(testId)).thenReturn(true);
supplementService.delete(testId);
verify(supplementRepository).deleteById(testId);
}
@Test
void delete_nonExistingSupplement_throwsNotFound() {
when(supplementRepository.existsById(testId)).thenReturn(false);
assertThatThrownBy(() -> supplementService.delete(testId))
.isInstanceOf(ResourceNotFoundException.class);
}
@Test
void findByCategory_returnsFiltered() {
PageRequest pageable = PageRequest.of(0, 20);
Page<Supplement> page = new PageImpl<>(List.of(testSupplement), pageable, 1);
when(supplementRepository.findByCategory("Vitamins", pageable)).thenReturn(page);
when(supplementMapper.toResponse(testSupplement)).thenReturn(testResponse);
PageResponse<SupplementResponse> result = supplementService.findByCategory("Vitamins", pageable);
assertThat(result.content()).hasSize(1);
}
}