add controllers. add integration test for freight controller.

numerous changes to services, including using Jspecify
This commit is contained in:
2025-11-27 03:59:34 +02:00
parent b161556e93
commit 45200d394d
15 changed files with 418 additions and 70 deletions

View File

@@ -18,10 +18,15 @@ dependencies {
implementation(libs.spring.boot.starter.hateoas)
developmentOnly(libs.spring.boot.devtools)
// xml
implementation(libs.jackson.dataformat.xml)
// testing
testImplementation(platform(libs.junit.bom))
testImplementation(libs.junit.jupiter)
testRuntimeOnly(libs.junit.platform.launcher)
implementation(libs.jspecify)
}
tasks.test {

View File

@@ -1,6 +1,7 @@
[versions]
junit = "6.0.1"
spring-boot-plugin = "4.0.0"
jspecify = "1.0.0"
[plugins]
spring-boot = { id = "org.springframework.boot", version.ref = "spring-boot-plugin" }
@@ -12,7 +13,12 @@ spring-boot-starter-web-test = { group = "org.springframework.boot", name = "spr
spring-boot-starter-hateoas = { group = "org.springframework.boot", name = "spring-boot-starter-hateoas" }
spring-boot-devtools = { group = "org.springframework.boot", name = "spring-boot-devtools" }
# xml
jackson-dataformat-xml = { group = "tools.jackson.dataformat", name = "jackson-dataformat-xml" }
# testing
junit-bom = { group = "org.junit", name = "junit-bom", version.ref = "junit" }
junit-jupiter = { group = "org.junit.jupiter", name = "junit-jupiter" }
junit-platform-launcher = { group = "org.junit.platform", name = "junit-platform-launcher" }
jspecify = { group = "org.jspecify", name = "jspecify", version.ref = "jspecify" }

View File

@@ -1,15 +1,21 @@
package ua.com.dxrkness.controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.jspecify.annotations.NullMarked;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import ua.com.dxrkness.model.Freight;
import ua.com.dxrkness.service.FreightService;
import java.util.List;
@RestController
@RequestMapping("/freight")
@RequestMapping(
value = "/freights",
produces = {MediaType.APPLICATION_JSON_VALUE, MediaType.APPLICATION_XML_VALUE},
consumes = {MediaType.APPLICATION_JSON_VALUE, MediaType.APPLICATION_XML_VALUE}
)
@NullMarked
public class FreightController {
private final FreightService service;
@@ -17,8 +23,28 @@ public class FreightController {
this.service = service;
}
@GetMapping
@GetMapping(consumes = MediaType.ALL_VALUE)
public List<Freight> getAll() {
return service.getAll();
}
@GetMapping(value = "/{id}", consumes = MediaType.ALL_VALUE)
public ResponseEntity<Freight> getById(@PathVariable("id") long id) {
return ResponseEntity.ofNullable(service.getById(id));
}
@PostMapping
public ResponseEntity<Freight> add(@RequestBody Freight newFreight) {
return ResponseEntity.ok(service.add(newFreight));
}
@PutMapping("/{id}")
public ResponseEntity<Freight> update(@PathVariable("id") long id, @RequestBody Freight newFreight) {
return ResponseEntity.ok(service.update(id, newFreight));
}
@DeleteMapping(value = "/{id}", consumes = MediaType.ALL_VALUE)
public ResponseEntity<Freight> delete(@PathVariable("id") long id) {
return ResponseEntity.ofNullable(service.delete(id));
}
}

View File

@@ -0,0 +1,61 @@
package ua.com.dxrkness.controller;
import org.jspecify.annotations.NullMarked;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import ua.com.dxrkness.model.Freight;
import ua.com.dxrkness.model.Route;
import ua.com.dxrkness.model.Vehicle;
import ua.com.dxrkness.repository.FreightRepository;
import ua.com.dxrkness.repository.RouteRepository;
import ua.com.dxrkness.repository.VehicleRepository;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;
@RestController
@RequestMapping("/populate")
@NullMarked
public class PopulateController {
private final FreightRepository freightRepository;
private final RouteRepository routeRepository;
private final VehicleRepository vehicleRepository;
public PopulateController(FreightRepository freightRepository, RouteRepository routeRepository, VehicleRepository vehicleRepository) {
this.freightRepository = freightRepository;
this.routeRepository = routeRepository;
this.vehicleRepository = vehicleRepository;
}
@GetMapping
public ResponseEntity<String> populate(@RequestParam("vals") int vals) {
var allFreights = new ArrayList<Freight>(vals);
for (int i = 0; i < vals; i++) {
var f = new Freight(i);
allFreights.add(f);
freightRepository.add(f);
}
var allVehicles = new ArrayList<Vehicle>(vals);
for (int i = 0; i < vals; i++) {
var v = new Vehicle(i);
allVehicles.add(v);
vehicleRepository.add(v);
}
for (int i = 0; i < vals; i++)
routeRepository.add(new Route(i, allVehicles.get(i), List.of(allFreights.get(i))));
return ResponseEntity.ok("success");
}
@DeleteMapping
public ResponseEntity<String> delete() {
routeRepository.deleteAll();
freightRepository.deleteAll();
vehicleRepository.deleteAll();
return ResponseEntity.ok("success");
}
}

View File

@@ -1,15 +1,22 @@
package ua.com.dxrkness.controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.jspecify.annotations.NullMarked;
import org.jspecify.annotations.Nullable;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import ua.com.dxrkness.model.Route;
import ua.com.dxrkness.service.RouteService;
import java.util.List;
@RestController
@RequestMapping("/route")
@RequestMapping(
value = "/routes",
produces = {MediaType.APPLICATION_JSON_VALUE, MediaType.APPLICATION_XML_VALUE},
consumes = {MediaType.APPLICATION_JSON_VALUE, MediaType.APPLICATION_XML_VALUE}
)
@NullMarked
public class RouteController {
private final RouteService service;
@@ -17,8 +24,39 @@ public class RouteController {
this.service = service;
}
@GetMapping
public List<Route> getAll() {
@GetMapping(consumes = MediaType.ALL_VALUE)
public List<Route> getAll(@RequestParam(name = "freightId", required = false) @Nullable Long freightId,
@RequestParam(name = "vehicleId", required = false) @Nullable Long vehicleId) {
if (vehicleId != null) {
return service.getByVehicleId(vehicleId);
}
if (freightId != null) {
var route = service.getByFreightId(freightId);
if (route == null) {
return List.of();
}
return List.of(route);
}
return service.getAll();
}
@PostMapping
public ResponseEntity<Route> add(@RequestBody Route newRoute) {
return ResponseEntity.ok(service.add(newRoute));
}
@PutMapping("/{id}")
public ResponseEntity<Route> update(@PathVariable("id") long id, @RequestBody Route newRoute) {
return ResponseEntity.ok(service.update(id, newRoute));
}
@GetMapping(value = "/{id}", consumes = MediaType.ALL_VALUE)
public ResponseEntity<Route> getById(@PathVariable("id") long id) {
return ResponseEntity.ofNullable(service.getById(id));
}
@DeleteMapping(value = "/{id}", consumes = MediaType.ALL_VALUE)
public ResponseEntity<Route> delete(@PathVariable("id") long id) {
return ResponseEntity.ofNullable(service.delete(id));
}
}

View File

@@ -1,15 +1,21 @@
package ua.com.dxrkness.controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.jspecify.annotations.NullMarked;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import ua.com.dxrkness.model.Vehicle;
import ua.com.dxrkness.service.VehicleService;
import java.util.List;
@RestController
@RequestMapping("/vehicle")
@RequestMapping(
value = "/vehicles",
produces = {MediaType.APPLICATION_JSON_VALUE, MediaType.APPLICATION_XML_VALUE},
consumes = {MediaType.APPLICATION_JSON_VALUE, MediaType.APPLICATION_XML_VALUE}
)
@NullMarked
public class VehicleController {
private final VehicleService service;
@@ -17,8 +23,28 @@ public class VehicleController {
this.service = service;
}
@GetMapping
@GetMapping(consumes = MediaType.ALL_VALUE)
public List<Vehicle> getAll() {
return service.getAll();
}
@GetMapping(value = "/{id}", consumes = MediaType.ALL_VALUE)
public ResponseEntity<Vehicle> getById(@PathVariable("id") long id) {
return ResponseEntity.ofNullable(service.getById(id));
}
@PostMapping
public ResponseEntity<Vehicle> add(@RequestBody Vehicle newVehicle) {
return ResponseEntity.ok(service.add(newVehicle));
}
@PutMapping("/{id}")
public ResponseEntity<Vehicle> update(@PathVariable("id") long id, @RequestBody Vehicle newVehicle) {
return ResponseEntity.ok(service.update(id, newVehicle));
}
@DeleteMapping(value = "/{id}", consumes = MediaType.ALL_VALUE)
public ResponseEntity<Vehicle> delete(@PathVariable("id") long id) {
return ResponseEntity.ofNullable(service.delete(id));
}
}

View File

@@ -0,0 +1,4 @@
@NullMarked
package ua.com.dxrkness;
import org.jspecify.annotations.NullMarked;

View File

@@ -1,10 +1,10 @@
package ua.com.dxrkness.repository;
import org.jspecify.annotations.Nullable;
import ua.com.dxrkness.model.Identifiable;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
abstract class CrudRepository<T extends Identifiable> {
protected final List<T> STORAGE = new ArrayList<>();
@@ -13,13 +13,20 @@ abstract class CrudRepository<T extends Identifiable> {
return new ArrayList<>(STORAGE); // defensive copy
}
public Optional<T> getById(long id) {
return STORAGE.stream().filter(el -> el.id() == id).findFirst();
public @Nullable T getById(long id) {
T found = null;
for (var el : STORAGE) {
if (el.id() == id) {
found = el;
break;
}
}
return found;
}
public T add(T toAdd) {
public int add(T toAdd) {
STORAGE.add(toAdd);
return toAdd;
return STORAGE.size();
}
public T update(long id, T newT) {
@@ -32,14 +39,20 @@ abstract class CrudRepository<T extends Identifiable> {
return newT;
}
public Optional<T> delete(long id) {
public @Nullable T delete(long id) {
T deleted = null;
for (int i = 0; i < STORAGE.size(); i++) {
if (STORAGE.get(i).id() == id) {
deleted = STORAGE.remove(i);
for (var itr = STORAGE.iterator(); itr.hasNext(); ) {
T next = itr.next();
if (next.id() == id) {
deleted = next;
itr.remove();
break;
}
}
return Optional.ofNullable(deleted);
return deleted;
}
public void deleteAll() {
STORAGE.clear();
}
}

View File

@@ -1,14 +1,8 @@
package ua.com.dxrkness.repository;
import jakarta.annotation.PostConstruct;
import org.springframework.stereotype.Repository;
import ua.com.dxrkness.model.Freight;
@Repository
public final class FreightRepository extends CrudRepository<Freight> {
@PostConstruct
private void initData() {
for (int i = 0; i < 100; i++)
add(new Freight(i));
}
}

View File

@@ -1,31 +1,36 @@
package ua.com.dxrkness.repository;
import jakarta.annotation.PostConstruct;
import org.jspecify.annotations.Nullable;
import org.springframework.stereotype.Repository;
import ua.com.dxrkness.model.Freight;
import ua.com.dxrkness.model.Route;
import ua.com.dxrkness.model.Vehicle;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
@Repository
public final class RouteRepository extends CrudRepository<Route> {
@PostConstruct
private void initData() {
for (int i = 0; i < 100; i++)
add(new Route(i, new Vehicle(i), List.of(new Freight(i))));
public @Nullable Route getByFreightId(long freightId) {
Route found = null;
out: for (var route : STORAGE) {
var freights = route.freights();
for (var freight : freights) {
if (freight.id() == freightId) {
found = route;
break out;
}
}
}
return found;
}
public Optional<Route> getByFreightId(long freightId) {
return STORAGE.stream()
.filter(route -> route.freights().stream().anyMatch(freight -> freight.id() == freightId))
.findAny();
}
public Optional<Route> getByVehicleId(long vehicleId) {
return STORAGE.stream()
.filter(route -> route.vehicle().id() == vehicleId)
.findAny();
public List<Route> getByVehicleId(long vehicleId) {
List<Route> found = new ArrayList<>();
for (var route : STORAGE) {
if (route.vehicle().id() == vehicleId) {
found.add(route);
break;
}
}
return found;
}
}

View File

@@ -1,14 +1,8 @@
package ua.com.dxrkness.repository;
import jakarta.annotation.PostConstruct;
import org.springframework.stereotype.Repository;
import ua.com.dxrkness.model.Vehicle;
@Repository
public final class VehicleRepository extends CrudRepository<Vehicle> {
@PostConstruct
private void initData() {
for (int i = 0; i < 100; i++)
add(new Vehicle(i));
}
}

View File

@@ -1,11 +1,11 @@
package ua.com.dxrkness.service;
import org.jspecify.annotations.Nullable;
import org.springframework.stereotype.Service;
import ua.com.dxrkness.model.Freight;
import ua.com.dxrkness.repository.FreightRepository;
import java.util.List;
import java.util.Optional;
@Service
public final class FreightService {
@@ -20,18 +20,20 @@ public final class FreightService {
}
public Freight add(Freight freight) {
return repo.add(freight);
int id = repo.add(freight);
return new Freight(id);
}
public Optional<Freight> getById(long id) {
public @Nullable Freight getById(long id) {
return repo.getById(id);
}
public Freight update(long id, Freight newFreight) {
newFreight = new Freight(id);
return repo.update(id, newFreight);
}
public Optional<Freight> delete(long id) {
public @Nullable Freight delete(long id) {
return repo.delete(id);
}
}

View File

@@ -1,11 +1,11 @@
package ua.com.dxrkness.service;
import org.jspecify.annotations.Nullable;
import org.springframework.stereotype.Service;
import ua.com.dxrkness.model.Route;
import ua.com.dxrkness.repository.RouteRepository;
import java.util.List;
import java.util.Optional;
@Service
public final class RouteService {
@@ -19,27 +19,29 @@ public final class RouteService {
return repo.getAll();
}
public Optional<Route> getById(long id) {
public @Nullable Route getById(long id) {
return repo.getById(id);
}
public Route add(Route route) {
return repo.add(route);
int id = repo.add(route);
return new Route(id, route.vehicle(), route.freights());
}
public Optional<Route> getByFreightId(long freightId) {
public @Nullable Route getByFreightId(long freightId) {
return repo.getByFreightId(freightId);
}
public Optional<Route> getByVehicleId(long vehicleId) {
public List<Route> getByVehicleId(long vehicleId) {
return repo.getByVehicleId(vehicleId);
}
public Route update(long id, Route newRoute) {
newRoute = new Route(id, newRoute.vehicle(), newRoute.freights());
return repo.update(id, newRoute);
}
public Optional<Route> delete(long id) {
public @Nullable Route delete(long id) {
return repo.delete(id);
}
}

View File

@@ -1,11 +1,11 @@
package ua.com.dxrkness.service;
import org.jspecify.annotations.Nullable;
import org.springframework.stereotype.Service;
import ua.com.dxrkness.model.Vehicle;
import ua.com.dxrkness.repository.VehicleRepository;
import java.util.List;
import java.util.Optional;
@Service
public final class VehicleService {
@@ -20,18 +20,20 @@ public final class VehicleService {
}
public Vehicle add(Vehicle veh) {
return repo.add(veh);
int id = repo.add(veh);
return new Vehicle(id);
}
public Optional<Vehicle> getById(long id) {
public @Nullable Vehicle getById(long id) {
return repo.getById(id);
}
public Vehicle update(long id, Vehicle newVehicle) {
newVehicle = new Vehicle(id);
return repo.update(id, newVehicle);
}
public Optional<Vehicle> delete(long id) {
public @Nullable Vehicle delete(long id) {
return repo.delete(id);
}
}

View File

@@ -0,0 +1,170 @@
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.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 java.util.List;
import java.util.stream.Stream;
import static org.assertj.core.api.BDDAssertions.then;
@SpringBootTest
@NullMarked
class FreightIntegrationTest {
private RestTestClient testClient;
private RestTestClient populateClient;
private static final int AMOUNT = 100;
@BeforeEach
void setUp(WebApplicationContext context) {
populateClient = RestTestClient.bindToApplicationContext(context).baseUrl("/populate").build();
populateClient.get().uri("?vals=%d".formatted(AMOUNT)).exchange();
testClient = RestTestClient.bindToApplicationContext(context).baseUrl("/freights").build();
}
@AfterEach
void teardown() {
populateClient.delete().exchange();
}
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<Freight>>() {
})
.consumeWith(res -> then(res.getResponseBody()).hasSizeGreaterThan(0));
}
@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(Freight.class)
.isEqualTo(new Freight(1));
}
@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 Freight(444);
// when
// then
testClient.post()
.accept(clientConsumes)
.contentType(clientProduces)
.body(newEntity)
.exchange()
.expectStatus()
.isOk()
.expectBody(Freight.class)
.isEqualTo(new Freight(AMOUNT + 1));
}
@ParameterizedTest
@MethodSource("mediaTypesClientConsumesProduces")
void update(MediaType clientConsumes, MediaType clientProduces) {
// given
final var existingEntityId = AMOUNT-1;
final var newEntity = new Freight(432);
// when
// then
testClient.put().uri("/{id}", existingEntityId)
.accept(clientConsumes)
.contentType(clientProduces)
.body(newEntity)
.exchange()
.expectStatus()
.isOk()
.expectBody(Freight.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(Freight.class)
.value(val -> {
then(val).isNotNull();
then(val.id()).isEqualTo(existingEntityId);
});
testClient.get().uri("/{id}", existingEntityId)
.accept(mediaType)
.exchange()
.expectStatus()
.isNotFound();
}
}