finish 3rd practical

This commit is contained in:
2025-12-17 12:37:39 +02:00
parent 1c162907fd
commit d203544a6f
53 changed files with 706 additions and 130 deletions
+38
View File
@@ -0,0 +1,38 @@
plugins {
id("java")
alias(libs.plugins.spring.boot)
}
group = "ua.com.dxrkness"
version = "1.0-SNAPSHOT"
repositories {
mavenCentral()
}
dependencies {
// spring
implementation(platform(org.springframework.boot.gradle.plugin.SpringBootPlugin.BOM_COORDINATES))
implementation(libs.spring.boot.starter.web)
implementation(libs.spring.boot.starter.web.test)
// http client
implementation(libs.apache.http.client)
// openapi docs
implementation(libs.springdoc.openapi.starter.webmvc.ui)
// testing
testImplementation(platform(libs.junit.bom))
testImplementation(libs.junit.jupiter)
testRuntimeOnly(libs.junit.platform.launcher)
implementation(libs.jspecify)
implementation(project(":models"))
implementation(project(":shared"))
}
tasks.test {
useJUnitPlatform()
}
@@ -0,0 +1,11 @@
package ua.com.dxrkness;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class RouteServiceApp {
static void main(String[] args) {
SpringApplication.run(RouteServiceApp.class, args);
}
}
@@ -0,0 +1,47 @@
package ua.com.dxrkness.client;
import com.networknt.schema.InputFormat;
import com.networknt.schema.Schema;
import org.springframework.http.HttpStatusCode;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import org.springframework.web.ErrorResponseException;
import org.springframework.web.client.RestClient;
import org.springframework.web.server.ResponseStatusException;
import tools.jackson.core.type.TypeReference;
import tools.jackson.databind.json.JsonMapper;
import ua.com.dxrkness.model.Freight;
import java.util.List;
@Component
public class FreightClient {
private final RestClient freightRestClient;
private final Schema freightSchema;
private final JsonMapper mapper = JsonMapper.shared();
public FreightClient(RestClient freightRestClient, Schema freightSchema) {
this.freightRestClient = freightRestClient;
this.freightSchema = freightSchema;
}
public Freight getById(long id) {
var resp = freightRestClient.get().uri("/{id}", id)
.accept(MediaType.APPLICATION_JSON)
.retrieve()
.onStatus(HttpStatusCode::is4xxClientError, (req, res) -> {
throw new ResponseStatusException(res.getStatusCode(), res.getStatusText());
})
.body(String.class);
System.out.println(resp);
List<com.networknt.schema.Error> errs = freightSchema.validate(resp, InputFormat.JSON);
if (!errs.isEmpty()) {
System.out.println("Some errors have occured!");
errs.forEach(err -> {
System.out.print(err);
});
}
return mapper.readValue(resp, new TypeReference<List<Freight>>() {
}).getFirst();
}
}
@@ -0,0 +1,47 @@
package ua.com.dxrkness.client;
import com.networknt.schema.Error;
import com.networknt.schema.InputFormat;
import com.networknt.schema.Schema;
import org.springframework.http.HttpStatusCode;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient;
import org.springframework.web.server.ResponseStatusException;
import tools.jackson.core.type.TypeReference;
import tools.jackson.databind.json.JsonMapper;
import ua.com.dxrkness.model.Vehicle;
import java.util.List;
@Component
public class VehicleClient {
private final RestClient vehicleRestClient;
private final Schema vehicleSchema;
private final JsonMapper mapper = JsonMapper.shared();
public VehicleClient(RestClient vehicleRestClient, Schema vehicleSchema) {
this.vehicleRestClient = vehicleRestClient;
this.vehicleSchema = vehicleSchema;
}
public Vehicle getById(long id) {
var resp = vehicleRestClient.get().uri("/{id}", id)
.accept(MediaType.APPLICATION_JSON)
.retrieve()
.onStatus(HttpStatusCode::is4xxClientError, (req, res) -> {
throw new ResponseStatusException(res.getStatusCode(), res.getStatusText());
})
.body(String.class);
System.out.println(resp);
List<Error> errs = vehicleSchema.validate(resp, InputFormat.JSON);
if (!errs.isEmpty()) {
System.out.println("Some errors have occured!");
errs.forEach(err -> {
System.out.print(err);
});
}
return mapper.readValue(resp, new TypeReference<List<Vehicle>>() {
}).getFirst();
}
}
@@ -0,0 +1,18 @@
package ua.com.dxrkness.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.client.RestClient;
@Configuration
public class ClientConfiguration {
@Bean
public RestClient vehicleRestClient() {
return RestClient.create("http://localhost:8081/vehicles");
}
@Bean
public RestClient freightRestClient() {
return RestClient.create("http://localhost:8080/freights");
}
}
@@ -0,0 +1,227 @@
package ua.com.dxrkness.controller;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.jspecify.annotations.NullMarked;
import org.jspecify.annotations.Nullable;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.*;
import ua.com.dxrkness.client.FreightClient;
import ua.com.dxrkness.client.VehicleClient;
import ua.com.dxrkness.model.Freight;
import ua.com.dxrkness.model.Route;
import ua.com.dxrkness.model.Vehicle;
import ua.com.dxrkness.service.RouteService;
import java.util.ArrayList;
import java.util.List;
@RestController
@RequestMapping(
value = "/routes",
produces = {MediaType.APPLICATION_JSON_VALUE, MediaType.APPLICATION_XML_VALUE},
consumes = {MediaType.APPLICATION_JSON_VALUE, MediaType.APPLICATION_XML_VALUE}
)
@Tag(name = "Route", description = "Routes management")
@NullMarked
public class RouteController {
private final RouteService routeService;
private final VehicleClient vehicleClient;
private final FreightClient freightClient;
public RouteController(RouteService routeService,
VehicleClient vehicleClient,
FreightClient freightClient) {
this.routeService = routeService;
this.vehicleClient = vehicleClient;
this.freightClient = freightClient;
}
@Operation(
summary = "Get routes",
description = "Get all routes or filter by freight ID/vehicle ID/status. May result in empty list!"
)
@ApiResponse(
responseCode = "200",
description = "Successfully retrieved routes"
)
@GetMapping(consumes = MediaType.ALL_VALUE)
public List<Route> getAll(
@Parameter(description = "Filter routes by freight ID")
@RequestParam(name = "freightId", required = false) @Nullable Long freightId,
@Parameter(description = "Filter routes by vehicle ID")
@RequestParam(name = "vehicleId", required = false) @Nullable Long vehicleId,
@Parameter(description = "Filter routes by status (PLANNED, IN_PROGRESS, COMPLETED, CANCELLED)")
@RequestParam(name = "status", required = false) Route.@Nullable Status status) {
if (status != null) {
return routeService.getByStatus(status);
}
if (vehicleId != null) {
return routeService.getByVehicleId(vehicleId);
}
if (freightId != null) {
var route = routeService.getByFreightId(freightId);
if (route == null) {
return List.of();
}
return List.of(route);
}
return routeService.getAll();
}
@Operation(
summary = "Retrieve a route by ID",
description = "Get a single route by its ID"
)
@ApiResponse(
responseCode = "200",
description = "Route found"
)
@ApiResponse(
responseCode = "404",
description = "Route not found"
)
@GetMapping(value = "/{id}", consumes = MediaType.ALL_VALUE)
public Route getById(
@Parameter(description = "ID of the route to retrieve", required = true)
@PathVariable("id") long id) {
return routeService.getById(id);
}
@Operation(
summary = "Retrieve a vehicle by route ID"
)
@ApiResponse(
responseCode = "200",
description = "Route and vehicle found"
)
@ApiResponse(
responseCode = "404",
description = "Route or vehicle not found"
)
@GetMapping(value = "/{id}/vehicle", consumes = MediaType.ALL_VALUE)
public Vehicle getVehicleById(
@Parameter(description = "ID of the route to retrieve", required = true)
@PathVariable("id") long id) {
return vehicleClient.getById(routeService.getById(id).vehicleId());
}
@Operation(
summary = "Retrieve freights by route ID"
)
@ApiResponse(
responseCode = "200",
description = "Route and freights found"
)
@ApiResponse(
responseCode = "404",
description = "Route or freight not found"
)
@GetMapping(value = "/{id}/freights", consumes = MediaType.ALL_VALUE)
public List<Freight> getFreightsById(
@Parameter(description = "ID of the route to retrieve", required = true)
@PathVariable("id") long id) {
var route = routeService.getById(id);
var freights = new ArrayList<Freight>();
for (var freightId : route.freightId()) {
freights.add(freightClient.getById(freightId));
}
return freights;
}
@Operation(
summary = "Create new route",
description = "Add new route"
)
@ApiResponse(
responseCode = "200",
description = "Route successfully created"
)
@ApiResponse(
responseCode = "400",
description = "Invalid route data: vehicle or freight not found"
)
@ApiResponse(
responseCode = "404",
description = "Referenced vehicle or freight not found"
)
@PostMapping
public Route add(
@io.swagger.v3.oas.annotations.parameters.RequestBody(
description = "Route object to be created. Must include valid vehicleId and freightId list.",
required = true
)
@RequestBody Route newRoute) {
return routeService.add(newRoute);
}
@Operation(
summary = "Update a route (full)",
description = "Update all fields of an existing route by ID"
)
@ApiResponse(
responseCode = "200",
description = "Route successfully updated"
)
@ApiResponse(
responseCode = "400",
description = "Invalid route data provided"
)
@PutMapping("/{id}")
public Route update(
@Parameter(description = "ID of the route to update", required = true)
@PathVariable("id") long id,
@io.swagger.v3.oas.annotations.parameters.RequestBody(
description = "Updated route object",
required = true
)
@RequestBody Route newRoute) {
return routeService.update(id, newRoute);
}
@Operation(
summary = "Update a route (partial)",
description = "Update specific fields of an existing route by ID"
)
@ApiResponse(
responseCode = "200",
description = "Route successfully updated"
)
@ApiResponse(
responseCode = "400",
description = "Invalid route data provided"
)
@PatchMapping("/{id}")
public Route updatePatch(
@Parameter(description = "ID of the route to update", required = true)
@PathVariable("id") long id,
@io.swagger.v3.oas.annotations.parameters.RequestBody(
description = "Route object with fields to update",
required = true
)
@RequestBody Route newRoute) {
return routeService.update(id, newRoute);
}
@Operation(
summary = "Delete a route",
description = "Delete a route by ID"
)
@ApiResponse(
responseCode = "200",
description = "Route successfully deleted"
)
@ApiResponse(
responseCode = "404",
description = "Route not found"
)
@DeleteMapping(value = "/{id}", consumes = MediaType.ALL_VALUE)
public Route delete(
@Parameter(description = "ID of the route to delete", required = true)
@PathVariable("id") long id) {
return routeService.delete(id);
}
}
@@ -0,0 +1,36 @@
package ua.com.dxrkness.repository;
import org.jspecify.annotations.Nullable;
import org.springframework.stereotype.Repository;
import ua.com.dxrkness.model.Route;
import java.util.ArrayList;
import java.util.List;
@Repository
public final class RouteRepository extends CrudRepository<Route> {
public @Nullable Route getByFreightId(long freightId) {
Route found = null;
out: for (var route : STORAGE) {
var freights = route.freightId();
for (var freight : freights) {
if (freight == freightId) {
found = route;
break out;
}
}
}
return found;
}
public List<Route> getByVehicleId(long vehicleId) {
List<Route> found = new ArrayList<>();
for (var route : STORAGE) {
if (route.vehicleId() == vehicleId) {
found.add(route);
break;
}
}
return found;
}
}
@@ -0,0 +1,119 @@
package ua.com.dxrkness.service;
import org.jspecify.annotations.Nullable;
import org.springframework.stereotype.Service;
import ua.com.dxrkness.client.FreightClient;
import ua.com.dxrkness.client.VehicleClient;
import ua.com.dxrkness.dto.FreightDto;
import ua.com.dxrkness.dto.VehicleDto;
import ua.com.dxrkness.exception.FreightNotFoundException;
import ua.com.dxrkness.exception.RouteNotFoundException;
import ua.com.dxrkness.exception.VehicleNotFoundException;
import ua.com.dxrkness.model.Route;
import ua.com.dxrkness.repository.RouteRepository;
import java.util.List;
@Service
public final class RouteService {
private final RouteRepository repo;
private final VehicleClient vehicleClient;
private final FreightClient freightClient;
public RouteService(RouteRepository repo,
VehicleClient vehicleClient,
FreightClient freightClient) {
this.repo = repo;
this.vehicleClient = vehicleClient;
this.freightClient = freightClient;
}
public List<Route> getAll() {
return repo.getAll();
}
public Route getById(long id) {
Route route = repo.getById(id);
if (route == null) {
throw new RouteNotFoundException("Route with ID " + id + " not found.");
}
return route;
}
public Route add(Route route) {
VehicleDto vehicleDto = validateAndGetVehicle(route.vehicleId());
List<FreightDto> freightDtos = validateAndGetFreights(route.freightId());
var newRoute = new Route(repo.lastId(),
route.vehicleId(),
route.freightId(),
route.startLocation(),
route.endLocation(),
route.distanceKm(),
route.estimatedDurationHours(),
route.status());
repo.add(route);
return newRoute;
}
private VehicleDto validateAndGetVehicle(long vehicleId) {
try {
var vehicle = vehicleClient.getById(vehicleId);
return VehicleDto.fromVehicle(vehicle);
} catch (VehicleNotFoundException e) {
throw new VehicleNotFoundException(
"Cannot create or update route: Referenced vehicle with ID " + vehicleId + " not found.");
}
}
private List<FreightDto> validateAndGetFreights(List<Long> freightIds) {
List<FreightDto> freightDtos = new java.util.ArrayList<>();
for (long freightId : freightIds) {
try {
var freight = freightClient.getById(freightId);
freightDtos.add(FreightDto.fromFreight(freight));
} catch (FreightNotFoundException e) {
throw new FreightNotFoundException(
"Cannot create or update route: Referenced freight with ID " + freightId + " not found.");
}
}
return freightDtos;
}
public @Nullable Route getByFreightId(long freightId) {
return repo.getByFreightId(freightId);
}
public List<Route> getByVehicleId(long vehicleId) {
return repo.getByVehicleId(vehicleId);
}
public List<Route> getByStatus(Route.Status status) {
return repo.getAll().stream()
.filter(r -> r.status() == status)
.toList();
}
public Route update(long id, Route newRoute) {
VehicleDto vehicleDto = validateAndGetVehicle(newRoute.vehicleId());
List<FreightDto> freightDtos = validateAndGetFreights(newRoute.freightId());
newRoute = new Route(id,
newRoute.vehicleId(),
newRoute.freightId(),
newRoute.startLocation(),
newRoute.endLocation(),
newRoute.distanceKm(),
newRoute.estimatedDurationHours(),
newRoute.status());
return repo.update(id, newRoute);
}
public Route delete(long id) {
var deleted = repo.delete(id);
if (deleted == null) {
throw new RouteNotFoundException("Route with ID " + id + " not found.");
}
return deleted;
}
}
@@ -0,0 +1,5 @@
spring:
application:
name: route-service
server:
port: 8082
@@ -0,0 +1,300 @@
package ua.com.dxrkness.integration;
import org.jspecify.annotations.NullMarked;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.client.RestTestClient;
import org.springframework.web.context.WebApplicationContext;
import ua.com.dxrkness.model.Freight;
import ua.com.dxrkness.model.Route;
import ua.com.dxrkness.model.Vehicle;
import ua.com.dxrkness.service.PopulateService;
import java.util.List;
import java.util.stream.Stream;
import static org.assertj.core.api.BDDAssertions.then;
@SpringBootTest
@NullMarked
class RouteIntegrationTest {
private static final int AMOUNT = 100;
@Autowired
private PopulateService populateService;
private RestTestClient testClient;
@BeforeEach
void setUp(WebApplicationContext context) {
populateService.populate(AMOUNT);
testClient = RestTestClient.bindToApplicationContext(context).baseUrl("/routes").build();
}
@AfterEach
void teardown() {
populateService.clear();
}
private static Stream<MediaType> mediaTypesClientConsumes() {
return Stream.of(MediaType.APPLICATION_JSON, MediaType.APPLICATION_XML);
}
private static Stream<Arguments> mediaTypesClientConsumesProduces() {
return Stream.of(
Arguments.of(MediaType.APPLICATION_JSON, MediaType.APPLICATION_JSON),
Arguments.of(MediaType.APPLICATION_XML, MediaType.APPLICATION_XML),
Arguments.of(MediaType.APPLICATION_JSON, MediaType.APPLICATION_XML),
Arguments.of(MediaType.APPLICATION_XML, MediaType.APPLICATION_JSON)
);
}
@ParameterizedTest
@MethodSource("mediaTypesClientConsumes")
void getAll(MediaType mediaType) {
// given
// when
// then
testClient.get()
.accept(mediaType)
.exchange()
.expectStatus()
.isOk()
.expectBody(new ParameterizedTypeReference<List<Route>>() {
})
.consumeWith(res -> then(res.getResponseBody()).hasSize(AMOUNT));
}
@ParameterizedTest
@MethodSource("mediaTypesClientConsumes")
void getByVehicleIdReturnsEntity(MediaType mediaType) {
// given
int vehicleId = 1;
// when
// then
testClient.get().uri(b -> b.queryParam("vehicleId", vehicleId).build())
.accept(mediaType)
.exchangeSuccessfully()
.expectStatus()
.isOk()
.expectBody(new ParameterizedTypeReference<List<Route>>() {
})
.value(val -> then(val).isNotNull()
.hasSize(1)
.allSatisfy(route -> then(route.vehicleId()).isEqualTo(vehicleId)));
}
@ParameterizedTest
@MethodSource("mediaTypesClientConsumes")
void getByVehicleIdReturns404(MediaType mediaType) {
// given
int vehicleId = AMOUNT + 1;
// when
// then
testClient.get().uri(b -> b.queryParam("vehicleId", vehicleId).build())
.accept(mediaType)
.exchangeSuccessfully()
.expectStatus()
.isOk()
.expectBody(List.class)
.isEqualTo(List.of());
}
@ParameterizedTest
@MethodSource("mediaTypesClientConsumes")
void getByFreightIdReturnsEntity(MediaType mediaType) {
// given
int freightId = 1;
// when
// then
testClient.get().uri(b -> b.queryParam("freightId", freightId).build())
.accept(mediaType)
.exchangeSuccessfully()
.expectStatus()
.isOk()
.expectBody(new ParameterizedTypeReference<List<Route>>() {
})
.value(val -> then(val).isNotNull()
.hasSize(1)
.allSatisfy(route -> then(route.freightId())
.isNotNull()
.isNotEmpty()
.anySatisfy(fre -> then(fre).isEqualTo(freightId))));
}
@ParameterizedTest
@MethodSource("mediaTypesClientConsumes")
void getByFreightIdReturns404(MediaType mediaType) {
// given
int freightId = AMOUNT + 1;
// when
// then
testClient.get().uri(b -> b.queryParam("freightId", freightId).build())
.accept(mediaType)
.exchangeSuccessfully()
.expectStatus()
.isOk()
.expectBody(List.class)
.isEqualTo(List.of());
}
@ParameterizedTest
@MethodSource("mediaTypesClientConsumes")
void getByIdReturnsEntity(MediaType mediaType) {
// given
final var id = 1;
// when
// then
testClient.get().uri("/{id}", id)
.accept(mediaType)
.exchange()
.expectStatus()
.isOk()
.expectBody(Route.class)
.value(val -> {
then(val).isNotNull();
then(val.id()).isEqualTo(id);
});
}
@ParameterizedTest
@MethodSource("mediaTypesClientConsumes")
void getByIdReturnsNotFound(MediaType mediaType) {
// given
final var id = -1;
// when
// then
testClient.get().uri("/{id}", id)
.accept(mediaType)
.exchange()
.expectStatus()
.isNotFound();
}
@ParameterizedTest
@MethodSource("mediaTypesClientConsumesProduces")
void add(MediaType clientConsumes, MediaType clientProduces) {
// given
final var newEntity = new Route(444, 1, List.of(1L), "123", "321", 1., 2., Route.Status.IN_PROGRESS);
// when
// then
testClient.post()
.accept(clientConsumes)
.contentType(clientProduces)
.body(newEntity)
.exchange()
.expectStatus()
.isOk()
.expectBody(Route.class)
.value(val -> {
then(val.id()).isEqualTo(AMOUNT+1);
then(val).usingRecursiveComparison()
.ignoringFields("id")
.isEqualTo(newEntity);
});
}
@ParameterizedTest
@MethodSource("mediaTypesClientConsumesProduces")
void update(MediaType clientConsumes, MediaType clientProduces) {
// given
final var existingEntityId = AMOUNT - 1;
final var newEntity = new Route(432, 1, List.of(1L), "123", "321", 22., 44., Route.Status.PLANNED);
// when
// then
testClient.put().uri("/{id}", existingEntityId)
.accept(clientConsumes)
.contentType(clientProduces)
.body(newEntity)
.exchange()
.expectStatus()
.isOk()
.expectBody(Route.class)
.value(val -> {
then(val.id()).isEqualTo(existingEntityId);
then(val).usingRecursiveComparison()
.ignoringFields("id")
.isEqualTo(newEntity);
});
}
@ParameterizedTest
@MethodSource("mediaTypesClientConsumesProduces")
void updatePatch(MediaType clientConsumes, MediaType clientProduces) {
// given
final var existingEntityId = AMOUNT - 1;
final var newEntity = new Route(432, 1, List.of(1L), "123", "321", 22., 44., Route.Status.CANCELLED);
// when
// then
testClient.patch().uri("/{id}", existingEntityId)
.accept(clientConsumes)
.contentType(clientProduces)
.body(newEntity)
.exchange()
.expectStatus()
.isOk()
.expectBody(Route.class)
.value(val -> {
then(val).isNotNull();
then(val.id()).isEqualTo(existingEntityId);
then(val).usingRecursiveComparison()
.ignoringFields("id")
.isEqualTo(newEntity);
});
}
@ParameterizedTest
@MethodSource("mediaTypesClientConsumes")
void delete(MediaType mediaType) {
// given
final var existingEntityId = AMOUNT - 1;
// when
// then
testClient.delete().uri("/{id}", existingEntityId)
.accept(mediaType)
.exchange()
.expectBody(Route.class)
.value(val -> {
then(val).isNotNull();
then(val.id()).isEqualTo(existingEntityId);
});
testClient.get().uri("/{id}", existingEntityId)
.accept(mediaType)
.exchange()
.expectStatus()
.isNotFound();
}
@ParameterizedTest
@MethodSource("mediaTypesClientConsumes")
void deleteIfEntityNotFound(MediaType mediaType) {
// given
final var existingEntityId = AMOUNT + 1;
// when
// then
testClient.delete().uri("/{id}", existingEntityId)
.accept(mediaType)
.exchange()
.expectStatus()
.isNotFound();
}
}