So far the CicloUrbana API is read-only: two GET endpoints and little else. In this lesson we turn it into a complete API. We will implement the creation, replacement, partial modification and removal of stations and bikes, with the status code and the headers each operation deserves. Along the way, problems appear that only show up once an API starts writing: how to return the URL of the resource just created, how to distinguish "don't send me this field" from "set this field to null", what to do when two Ribalta operators edit the same station at once, and how to model business operations —starting and finishing a rental— that do not fit the CRUD mould without breaking the REST design.
Contents
POST: creating resources@ResponseStatusand theLocationheaderPUT: full replacement and idempotencePATCH: partial update and the absent-field problemDELETE: removal and idempotence- Summary table: verb, status and body
- The bike resource and the station sub-resource
- Action endpoints: starting and finishing a rental
HEADandOPTIONS- Concurrency:
ETag,If-Matchand conditional requests - Common Mistakes and Tips
- Exercises
POST: creating resources
POST: creating resourcesPOST on a collection means "add a new element to this collection". The server assigns the identifier and responds 201 Created with a Location header pointing to the freshly created resource.
We first need a class for the request body. We cannot accept Station directly, because the client must not send the id: the server assigns it. We use a simple record —it will become CreateStationRequest, with validation, in 03-04 and 03-05:
package com.ciclourbana.stations;
public record NewStation(String name, String address,
int capacity, double latitude, double longitude) {}We extend StationRepository with three new operations —boolean existsById(Long), boolean deleteById(Long) and boolean existsByName(String)— which InMemoryStationRepository implements on top of its ConcurrentHashMap:
@Override
public boolean existsById(Long id) {
return byId.containsKey(id);
}
@Override
public boolean deleteById(Long id) {
return byId.remove(id) != null; // remove returns the previous value or null
}
@Override
public boolean existsByName(String name) {
return byId.values().stream().anyMatch(s -> s.name().equalsIgnoreCase(name));
}In StationService, creation:
public Station create(NewStation newStation) {
// Ribalta business rule: no two stations share a name.
// Provisional: in 03-06 it becomes DuplicateStationException -> 409.
if (stationRepository.existsByName(newStation.name())) {
throw new IllegalStateException("A station named " + newStation.name() + " already exists");
}
return stationRepository.save(new Station(null, newStation.name(), // null id:
newStation.address(), newStation.capacity(), // assigned by
newStation.latitude(), newStation.longitude())); // the repository
}Notice where the duplicate-name check lives: in the service, not in the controller. It is a network business rule and it must apply whoever the caller is, not only over HTTP.
@ResponseStatus and the Location header
@ResponseStatus and the Location headerThe controller:
@PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<Station> create(@RequestBody NewStation newStation) {
Station created = stationService.create(newStation);
// Absolute URI of the freshly created resource, built from the current
// request: http://host/api/v1/stations + /{id}
URI location = ServletUriComponentsBuilder.fromCurrentRequest()
.path("/{id}").buildAndExpand(created.id()).toUri();
return ResponseEntity.created(location).body(created);
}ResponseEntity.created(uri) does two things at once: it sets the status to 201 and adds the Location header. The response:
HTTP/1.1 201 Created
Location: http://localhost:8080/api/v1/stations/5
Content-Type: application/json
{ "id": 5, "name": "Central Market", "capacity": 20, ... }Why the Location header matters. Without it, the client that has just created a station does not know where it is: it would have to read the id from the body and build the URL by hand, replicating the server's routing scheme. With Location, the client stores that URL and uses it. It is the only point at which CicloUrbana touches Richardson level 3, and it comes for free.
About ServletUriComponentsBuilder. It builds the URI from the request in progress, respecting the real host, port and scheme. Its variants: fromCurrentRequest() uses the full URL (the usual thing in a POST on the collection), fromCurrentContextPath() only the host and the context, and fromCurrentRequestUri() the URL without the query string. Behind a reverse proxy —the norm in production— the request Tomcat sees may be http://10.0.0.4:8080/... while the client used https://api.ciclourbana.example/.... For the Location to come out right you have to enable the handling of the X-Forwarded-* headers:
It is one of those lines nobody remembers until a client receives a Location with http:// and an internal IP. It is the layered system constraint from 03-01: the server must not assume it is talking directly to the client.
@ResponseStatus as an alternative. If you do not need the Location header, @ResponseStatus(HttpStatus.CREATED) on the method fixes the 201 and lets you return the object directly, without a ResponseEntity. It is more readable, but it loses the Location. CicloUrbana's policy: ResponseEntity.created() for creations —the Location is part of a properly done 201— and @ResponseStatus where the status is fixed and no header is needed.
PUT: full replacement and idempotence
PUT: full replacement and idempotencePUT /api/v1/stations/{id} means "the state of this resource becomes exactly what I am sending you". It is a complete replacement, not a merge: if the body omits the address, the address ends up empty.
// In StationService
public Optional<Station> replace(Long id, NewStation data) {
if (!stationRepository.existsById(id)) {
return Optional.empty();
}
// A new object with ALL the fields from the body: whatever the client
// does not send is lost, and that is exactly PUT's semantics.
return Optional.of(stationRepository.save(new Station(id, data.name(),
data.address(), data.capacity(), data.latitude(), data.longitude())));
}
// In StationController
@PutMapping(path = "/{id:\\d+}", consumes = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<Station> replace(@PathVariable("id") Long id,
@RequestBody NewStation data) {
return stationService.replace(id, data)
.map(ResponseEntity::ok)
.orElseGet(() -> ResponseEntity.notFound().build());
}Why PUT is idempotent. Sending the same body three times leaves the station exactly as sending it once, because it describes an absolute final state and not a relative operation. It is the property that allows retrying without fear when the network fails.
200 with a body or 204 without one. Both are valid: the 200 with the updated resource lets the client see the fields the server computes or normalises, at the cost of a heavier response; the 204 minimises traffic but forces a subsequent GET. CicloUrbana returns 200 with the resource.
PUT on a non-existent resource. The RFC allows PUT to create the resource if the client chooses the identifier (upsert). CicloUrbana does not do this: identifiers are generated by the server, so a PUT on id 999 responds 404. Creating with PUT only makes sense when the identifier is natural and known to the client, as an official code like RIB-001 would be.
PATCH: partial update and the absent-field problem
PATCH: partial update and the absent-field problemPATCH modifies only the fields that are sent. Ribalta's operator panel needs it to correct a station's capacity without resending its coordinates.
And here comes the subtlest problem of this lesson. Consider {"capacity": 28} ("don't touch the address") versus {"capacity": 28, "address": null} ("clear the address"). If the body is deserialised into a record with a String address field, both produce exactly the same thing: address == null. The absent field and the field set to null are indistinguishable, and yet they mean opposite things. The three solutions:
| Strategy | How it tells them apart | Advantages | Drawbacks |
|---|---|---|---|
Map<String, Object> |
By the presence of the key | No dependencies | No typing, no validation, no OpenAPI |
Optional<T> fields |
null = absent, Optional.empty() = set to null |
Plain Java only | Confusing double wrapper |
JsonNullable<T> |
isPresent() = sent |
Typed, validates, documents | Extra dependency |
Solution with Map, the most direct one and the one CicloUrbana uses for now:
// In StationService. It starts from the current state and only replaces
// what comes in the map.
public Optional<Station> partialUpdate(Long id, Map<String, Object> changes) {
return stationRepository.findById(id).map(current ->
stationRepository.save(new Station(id,
changes.containsKey("name")
? (String) changes.get("name") : current.name(),
changes.containsKey("address")
? (String) changes.get("address") : current.address(),
changes.containsKey("capacity")
? ((Number) changes.get("capacity")).intValue() : current.capacity(),
current.latitude(), current.longitude())));
}containsKey is literally the crux of the matter: it distinguishes "the key did not arrive" from "the key arrived with a null value". The Map is not free of problems —the casts are fragile and a misspelled key is silently ignored—, so it is worth validating that every key received is a known one and rejecting the rest with a 400.
Solution with JsonNullable, the recommended one for public APIs. It requires the org.openapitools:jackson-databind-nullable dependency and registering its module with the customizer from 03-02: builder.modulesToInstall(new JsonNullableModule()). The DTO ends up typed and expressive:
public record StationPatch(JsonNullable<String> name,
JsonNullable<String> address,
JsonNullable<Integer> capacity) {
public StationPatch { // compact constructor: never null fields
name = name == null ? JsonNullable.undefined() : name;
address = address == null ? JsonNullable.undefined() : address;
capacity = capacity == null ? JsonNullable.undefined() : capacity;
}
/** Applies the patch to the current state and returns a new Station. */
public Station applyTo(Station current) {
return new Station(current.id(), name.orElse(current.name()),
address.orElse(current.address()), capacity.orElse(current.capacity()),
current.latitude(), current.longitude());
}
}JsonNullable.undefined() means "the client did not send this key" and JsonNullable.of(null) means "it sent it with a null value". orElse returns the current value when it is not defined: exactly PATCH's semantics.
The controller, with the Map variant:
@PatchMapping(path = "/{id:\\d+}", consumes = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<Station> partialUpdate(@PathVariable("id") Long id,
@RequestBody Map<String, Object> changes) {
return stationService.partialUpdate(id, changes)
.map(ResponseEntity::ok)
.orElseGet(() -> ResponseEntity.notFound().build());
}A note on the standard: there is JSON Patch (RFC 6902), a format with explicit operations —[{"op":"replace","path":"/capacity","value":28}]— that solves the problem at the root, but it is rigorous and not very friendly for clients. The mainstream variant is JSON Merge Patch (RFC 7386): send the partial object, where null means "clear this field". CicloUrbana uses merge patch.
DELETE: removal and idempotence
DELETE: removal and idempotence// In StationController; the service simply delegates to deleteById
@DeleteMapping("/{id:\\d+}")
public ResponseEntity<Void> delete(@PathVariable("id") Long id) {
return stationService.delete(id)
? ResponseEntity.noContent().build() // 204: deleted
: ResponseEntity.notFound().build(); // 404: did not exist
}The 404 debate in DELETE. What should a DELETE on an already deleted station respond? There are two arguments: 204 No Content because the final state is the desired one —the station does not exist—, which is the strict reading of idempotence; or 404 Not Found, which tells the client its mental model is out of date. Both are correct and there are first-rate APIs in each camp. Idempotence does not require the response to be identical every time, only the server state to be; a 204 followed by a 404 is perfectly idempotent. CicloUrbana chooses 404, because an operator panel deleting a non-existent station probably has a stale list, and silencing that hides the problem. What is non-negotiable is that the second DELETE must not cause a server error: if a NoSuchElementException propagates up to a 500, the operation stops being retryable.
Soft versus hard delete. Deleting a station with historical rentals would destroy data the council needs. The usual approach in production is to mark it as inactive and exclude it from the listings: the API does not change —it is still DELETE with 204—, only the implementation. We will apply this with a database, in module 4.
- Summary table: verb, status and body
The complete reference for the CicloUrbana API:
| Verb | Path | Request body | Success | Response body | Errors |
|---|---|---|---|---|---|
GET |
/stations |
— | 200 |
Array | — |
GET |
/stations/{id} |
— | 200 |
Object | 404 |
POST |
/stations |
Object without id |
201 |
Object + Location |
400, 409 |
PUT |
/stations/{id} |
Complete object | 200 |
Updated object | 400, 404, 412 |
PATCH |
/stations/{id} |
Partial object | 200 |
Updated object | 400, 404, 412 |
DELETE |
/stations/{id} |
— | 204 |
Empty | 404, 409 |
HEAD |
/stations/{id} |
— | 200 |
Headers only | 404 |
OPTIONS |
/stations |
— | 200 |
Empty + Allow |
— |
And the .http file that exercises it end to end:
@base = http://localhost:8080/api/v1
### Create a station
POST {{base}}/stations
Content-Type: application/json
{ "name": "Central Market", "address": "Market Street 8",
"capacity": 20, "latitude": 41.3902, "longitude": 2.1655 }
### Full replacement: BEWARE, whatever is omitted is lost
PUT {{base}}/stations/5
Content-Type: application/json
{ "name": "Central Market", "address": "Market Street 8",
"capacity": 26, "latitude": 41.3902, "longitude": 2.1655 }
### Partial update (merge patch): the capacity only
PATCH {{base}}/stations/5
Content-Type: application/json
{ "capacity": 30 }
### Removal, then a second removal: idempotent, returns 404 but breaks nothing
DELETE {{base}}/stations/5
DELETE {{base}}/stations/5
- The bike resource and the station sub-resource
We add the com.ciclourbana.bikes package with its model:
package com.ciclourbana.bikes;
public enum BikeStatus { AVAILABLE, IN_USE, MAINTENANCE, RETIRED }
/**
* Electric bike of the Ribalta network.
* stationId is null when the bike is in use (out of its dock).
*/
public record Bike(Long id, String plate, int batteryLevel,
BikeStatus status, Long stationId) {}The service, with the queries the API needs (we omit the constructor, which injects BikeRepository, StationRepository and NetworkProperties):
@Service
public class BikeService {
/** Bikes docked at a station. Optional.empty() = the station does not exist. */
public Optional<List<Bike>> findByStation(Long stationId, boolean availableOnly) {
if (!stationRepository.existsById(stationId)) {
return Optional.empty();
}
List<Bike> bikes = bikeRepository.findByStation(stationId).stream()
.filter(b -> !availableOnly || isUsable(b))
.toList();
return Optional.of(bikes);
}
/** Available and with enough charge according to ciclourbana.network.battery-threshold. */
private boolean isUsable(Bike b) {
return b.status() == BikeStatus.AVAILABLE
&& b.batteryLevel() >= networkProperties.batteryThreshold();
}
}Here you can see why we invested module 2 in typed properties: the battery threshold is not written in the code but in ciclourbana.network.battery-threshold, and the council can raise it to 30% in winter without recompiling.
The sub-resource. /api/v1/stations/{id}/bikes is not the same as /api/v1/bikes?stationId={id}: the sub-resource expresses ownership and responds 404 if the station does not exist, whereas the filter over the global collection responds 200 with an empty array. Both forms can coexist.
// In StationController: the sub-resource hangs off the station
@GetMapping("/{id:\\d+}/bikes")
public ResponseEntity<List<Bike>> stationBikes(
@PathVariable("id") Long id,
@RequestParam(defaultValue = "false") boolean availableOnly) {
return bikeService.findByStation(id, availableOnly)
.map(ResponseEntity::ok)
.orElseGet(() -> ResponseEntity.notFound().build()); // the station does not exist
}BikeController replicates the StationController pattern for GET /api/v1/bikes, GET /api/v1/bikes/{id} and POST /api/v1/bikes. Creation requires the plate to have the RB-0142 format and not be repeated; both checks will become declarative in 03-04 with @BikePlate.
- Action endpoints: starting and finishing a rental
Here CRUD falls short. "Starting a rental" is not just creating a row: you have to check the bike is available and has charge, mark it as IN_USE, undock it, record the time with the Clock bean and publish the RentalStarted event. And "finishing" is not an ordinary modification either: it computes the amount with FareSelector, docks the bike at the destination and checks that it fits. The first part does fit REST effortlessly: starting a rental is creating a rental resource.
package com.ciclourbana.rentals;
public record StartRentalRequest(Long userId, Long bikeId) {}
public record Rental(Long id, Long userId, Long bikeId,
Long originStationId, Long destinationStationId,
LocalDateTime startedAt, LocalDateTime endedAt,
BigDecimal totalAmount, RentalStatus status) {}@RestController
@RequestMapping(path = "/api/v1/rentals", produces = MediaType.APPLICATION_JSON_VALUE)
public class RentalController {
private final RentalService rentalService;
public RentalController(RentalService rentalService) {
this.rentalService = rentalService;
}
/** POST /api/v1/rentals — creating a rental is ordinary CRUD. */
@PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<Rental> start(@RequestBody StartRentalRequest request) {
Rental rental = rentalService.start(request.userId(), request.bikeId());
URI location = ServletUriComponentsBuilder.fromCurrentRequest()
.path("/{id}").buildAndExpand(rental.id()).toUri();
return ResponseEntity.created(location).body(rental);
}
}GET /api/v1/rentals/{id} follows the same pattern as StationController#getById.
Finishing is what does not fit. The options on the table:
| Design | Request | Assessment |
|---|---|---|
PATCH of the status |
PATCH /rentals/7 with {"status":"FINISHED"} |
Pure, but the server has to guess that this change triggers the charge; the destination station does not fit |
PUT of the resource |
PUT /rentals/7 with the whole object |
The client would send the amount, which only the server computes |
| Action sub-resource | POST /rentals/7/finish |
Explicit, with its own body and errors |
| Status sub-resource | PUT /rentals/7/status |
Halfway; the destination still does not fit |
CicloUrbana chooses the action sub-resource:
public record FinishRentalRequest(Long destinationStationId) {}
/** POST /api/v1/rentals/{id}/finish — a state transition of the business
* machine, not CRUD. It is POST because it is not idempotent: finishing twice
* is an error that must respond 409, not a no-op. */
@PostMapping(path = "/{id:\\d+}/finish", consumes = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<Rental> finish(@PathVariable("id") Long id,
@RequestBody FinishRentalRequest request) {
return ResponseEntity.ok(rentalService.finish(id, request.destinationStationId()));
}When an action endpoint is justified. It is not a licence to go back to Richardson level 1. It must meet all three conditions: (1) it has a name in the language of the business —operators say "finish a rental", not "set the status to finished"—; (2) it has effects beyond changing a field —it computes the amount, docks the bike, checks the capacity, publishes an event—; and (3) it has its own input contract and errors —it requires destinationStationId and can fail with 409—. If it does not meet all three, it is almost certainly a PATCH in disguise: POST /api/v1/stations/5/rename meets none of them.
The service, where the real logic lives:
public Rental finish(Long rentalId, Long destinationStationId) {
Rental rental = rentalRepository.findById(rentalId)
.orElseThrow(() -> new IllegalArgumentException("Rental not found"));
if (rental.status() == RentalStatus.FINISHED) {
throw new IllegalStateException("The rental was already finished"); // 409 in 03-06
}
Station destination = stationRepository.findById(destinationStationId)
.orElseThrow(() -> new IllegalArgumentException("Station not found"));
if (bikeRepository.countByStation(destination.id()) >= destination.capacity()) {
throw new IllegalStateException("The station is full"); // 409 in 03-06
}
LocalDateTime endedAt = LocalDateTime.now(clock); // the Clock from CommonConfig
BigDecimal totalAmount = fareSelector.forUser(rental.userId())
.calculate(Duration.between(rental.startedAt(), endedAt));
bikeService.dockAt(rental.bikeId(), destination.id());
return rentalRepository.save(new Rental(rental.id(), rental.userId(),
rental.bikeId(), rental.originStationId(), destination.id(),
rental.startedAt(), endedAt, totalAmount, RentalStatus.FINISHED));
}The injected Clock is not a whim: thanks to it, in module 6 we will test the pricing of a two-hour rental without waiting two hours.
HEAD and OPTIONS
HEAD and OPTIONSHEAD is identical to GET but with no body: it returns only the headers. It is used to check whether a resource exists, or to learn its size or its ETag before downloading it. Spring implements it automatically for every @GetMapping: nothing has to be written. OPTIONS reports which verbs a path accepts, and Spring also generates it on its own from the declared mappings:
curl -I http://localhost:8080/api/v1/stations/1
# HTTP/1.1 200 · Content-Type: application/json · Content-Length: 142
curl -i -X OPTIONS http://localhost:8080/api/v1/stations/1
# HTTP/1.1 200 · Allow: GET,PUT,PATCH,DELETE,HEAD,OPTIONSThat second one is the response the browser uses in the CORS preflight we saw in 03-02. To switch off the automatic response —rarely needed— there is spring.mvc.dispatch-options-request.
- Concurrency:
ETag, If-Match and conditional requests
ETag, If-Match and conditional requestsThe scenario, with two operators from the Ribalta workshop:
sequenceDiagram
participant A as Operator Ana
participant S as CicloUrbana
participant B as Operator Bru
A->>S: GET /stations/1 (capacity 24)
B->>S: GET /stations/1 (capacity 24)
A->>S: PUT /stations/1 (capacity 28)
S-->>A: 200 OK
B->>S: PUT /stations/1 (capacity 24, address corrected)
S-->>B: 200 OK
Note over S: Ana's change has been lost<br/>and nobody notices
This is the lost update problem. HTTP's solution is optimistic locking with conditional requests: the server tags each version of the resource with an ETag and the client returns it in If-Match when modifying.
GET /api/v1/stations/1
→ 200 OK
ETag: "a3f5c9e1"
PUT /api/v1/stations/1
If-Match: "a3f5c9e1"
→ 200 OK if the ETag is still that one
→ 412 Precondition Failed if someone else changed it in the meantimeBru would receive a 412 and his panel could reload the data and show the conflict instead of trampling on Ana's work.
The cheapest way of getting ETags in Spring Boot is the ShallowEtagHeaderFilter, which computes an MD5 hash of the already serialised body:
package com.ciclourbana.common;
@Configuration
public class EtagConfig {
@Bean
FilterRegistrationBean<ShallowEtagHeaderFilter> etagFilter() {
var registration = new FilterRegistrationBean<>(new ShallowEtagHeaderFilter());
registration.addUrlPatterns("/api/v1/stations/*", "/api/v1/bikes/*");
registration.setName("etagFilter");
return registration;
}
}With the filter active, a repeated GET with If-None-Match: "0a1b2c3d4e..." responds 304 Not Modified with no body, saving the transfer.
| Header | Typical verb | What it checks | Response on failure |
|---|---|---|---|
If-None-Match |
GET |
Has the resource changed? | 304 Not Modified |
If-Match |
PUT, PATCH, DELETE |
Is it still the version I read? | 412 Precondition Failed |
If-Modified-Since |
GET |
The same, with a date | 304 Not Modified |
If-Unmodified-Since |
PUT, PATCH |
The same, with a date | 412 Precondition Failed |
For writes, the shallow filter is not enough: the If-Match has to be checked in the controller.
@PutMapping(path = "/{id:\\d+}", consumes = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<Station> replace(
@PathVariable("id") Long id,
@RequestHeader(value = HttpHeaders.IF_MATCH, required = false) String ifMatch,
@RequestBody NewStation data) {
Optional<Station> current = stationService.findById(id);
if (current.isEmpty()) {
return ResponseEntity.notFound().build();
}
// The quotation marks around the ETag are mandatory per RFC 9110
String currentEtag = "\"" + stationService.versionOf(current.get()) + "\"";
if (ifMatch != null && !ifMatch.equals(currentEtag)) {
return ResponseEntity.status(HttpStatus.PRECONDITION_FAILED).build();
}
Station replaced = stationService.replace(id, data).orElseThrow();
return ResponseEntity.ok()
.eTag("\"" + stationService.versionOf(replaced) + "\"")
.body(replaced);
}Limitations of the shallow ETag: the server generates the complete response and only then computes the hash, so it saves bandwidth but not server work; and since the hash depends on the exact JSON, a change in the order of the properties produces a different ETag even when the data is identical. The robust solution is a version field in the entity, which is exactly what JPA's @Version does: it arrives in module 4 and will replace this filter.
Common Mistakes and Tips
Returning 200 instead of 201 when creating, or forgetting the Location header. The 201 with Location is what distinguishes a creation from a query for any generic client, and without the header the client has to replicate the server's routing scheme.
Using PUT for partial updates. PUT replaces: if the client sends {"capacity": 28}, the station ends up with no name and no address. A classic source of silent data loss.
Not distinguishing the absent field from the null one in PATCH. Deserialising into a plain record turns what was not sent into null and clears fields the client did not want to touch. Use Map, JsonNullable or Optional.
Making the second DELETE blow up. A NoSuchElementException propagated up to a 500 breaks idempotence and makes automatic retries dangerous.
Putting verbs in the URL without justification. POST /stations/5/rename meets none of the three conditions from section 8: it is a PATCH. And returning a 404 from the controller with ResponseEntity works, but it scatters error logic across all the controllers; from 03-06 on it is centralised.
Tip: always test the second call. Run every PUT and every DELETE twice in a row; if the second gives a different result from the expected one, idempotence is broken. And writing lives in the service. The controller translates HTTP; the rules —duplicate name, full station, insufficient charge— belong to the service and must apply wherever the call comes from.
Exercises
Exercise 1: Action endpoint to put a bike into maintenance
Ribalta's operators need to withdraw a bike from service temporarily. Design and implement the endpoint, justifying the verb and the path. It must accept a reason, change the status to MAINTENANCE, and reject the operation if the bike is currently in use. Implement the inverse operation as well.
Exercise 2: Safe PATCH with key validation
The Map<String, Object> implementation silently accepts unknown keys: PATCH {"capacty": 30} (with the typo) responds 200 without changing anything, and the operator believes it worked. Modify partialUpdate to reject with a 400 any unrecognised key, and to prevent the id from being modified.
Exercise 3: DELETE with integrity check and If-Match
Deleting a station that still has bikes docked would leave orphan bikes. Implement a DELETE that responds 409 Conflict in that case, unless ?force=true is sent, in which case the bikes move to the RETIRED status. Add If-Match support as well.
Solutions
Solution 1.
Design. It meets the three conditions from section 8: its own name in the business ("put into maintenance"), effects beyond a single field (undock, notify the workshop) and its own contract and errors (the reason; 409 if it is in use). Therefore, an action endpoint with POST: POST /api/v1/bikes/{id}/maintenance and POST /api/v1/bikes/{id}/return-to-service. An equally defensible alternative: PUT and DELETE on /bikes/{id}/maintenance, modelling maintenance as a sub-resource that either exists or does not; more elegant and less readable.
public record MaintenanceRequest(String reason) {}
// In BikeService
public Bike sendToMaintenance(Long id, String reason) {
Bike bike = bikeRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Bike not found"));
if (bike.status() == BikeStatus.IN_USE) {
throw new IllegalStateException("A bike in use cannot be withdrawn"); // 409 in 03-06
}
if (bike.status() == BikeStatus.MAINTENANCE) {
return bike; // already is: not an error, we do nothing
}
log.info("Bike {} sent to maintenance. Reason: {}", bike.plate(), reason);
return bikeRepository.save(new Bike(bike.id(), bike.plate(),
bike.batteryLevel(), BikeStatus.MAINTENANCE, bike.stationId()));
}
// In BikeController
@PostMapping(path = "/{id:\\d+}/maintenance", consumes = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<Bike> toMaintenance(@PathVariable("id") Long id,
@RequestBody MaintenanceRequest request) {
return ResponseEntity.ok(bikeService.sendToMaintenance(id, request.reason()));
}Design detail. Sending to maintenance a bike that is already in maintenance is not an error: the current state is returned. This makes the operation idempotent in practice, even though the POST verb does not guarantee it, and it lets the operator panel retry without fear after a network failure.
Solution 2.
private static final Set<String> EDITABLE_FIELDS =
Set.of("name", "address", "capacity", "latitude", "longitude");
public Optional<Station> partialUpdate(Long id, Map<String, Object> changes) {
// 1. Reject unknown keys BEFORE touching anything. In 03-06 this
// becomes a custom exception -> 400 with the list of wrong fields.
Set<String> unknown = new LinkedHashSet<>(changes.keySet());
unknown.removeAll(EDITABLE_FIELDS);
if (!unknown.isEmpty()) {
throw new IllegalArgumentException("Unrecognised fields: " + unknown);
}
// 2. The id is never modified, not even when it arrives with the right value
if (changes.containsKey("id")) {
throw new IllegalArgumentException("The id field is not editable");
}
return stationRepository.findById(id).map(current -> stationRepository.save(
new Station(
id, // the path always owns the id
valueOrDefault(changes, "name", current.name()),
valueOrDefault(changes, "address", current.address()),
changes.containsKey("capacity")
? ((Number) changes.get("capacity")).intValue() : current.capacity(),
changes.containsKey("latitude")
? ((Number) changes.get("latitude")).doubleValue() : current.latitude(),
changes.containsKey("longitude")
? ((Number) changes.get("longitude")).doubleValue() : current.longitude())));
}
@SuppressWarnings("unchecked")
private <T> T valueOrDefault(Map<String, Object> changes, String key, T current) {
return changes.containsKey(key) ? (T) changes.get(key) : current;
}Why the id is rejected even when it matches. Accepting it in the body opens the door to a future implementation using it to reassign the resource. The general rule: the identifier in the path is the only source of truth.
About the casts. ((Number) value).intValue() is necessary because Jackson deserialises JSON numbers into Integer, Long or Double depending on their magnitude and shape; a direct (Integer) blows up with a ClassCastException if the client sends 28.0. That fragility is the best argument for moving to JsonNullable with declared types.
Solution 3.
// In StationService. The enum lets us distinguish three outcomes, not two.
public enum DeleteResult { DELETED, DID_NOT_EXIST, HAS_BIKES }
public DeleteResult delete(Long id, boolean force) {
if (!stationRepository.existsById(id)) {
return DeleteResult.DID_NOT_EXIST;
}
List<Bike> docked = bikeRepository.findByStation(id);
if (!docked.isEmpty() && !force) {
return DeleteResult.HAS_BIKES;
}
// With force=true, the bikes are withdrawn from service before deleting
for (Bike b : docked) {
bikeRepository.save(new Bike(b.id(), b.plate(),
b.batteryLevel(), BikeStatus.RETIRED, null));
log.warn("Bike {} retired by forced removal of station {}", b.plate(), id);
}
stationRepository.deleteById(id);
return DeleteResult.DELETED;
}
// In StationController
@DeleteMapping("/{id:\\d+}")
public ResponseEntity<Void> delete(
@PathVariable("id") Long id,
@RequestParam(defaultValue = "false") boolean force,
@RequestHeader(value = HttpHeaders.IF_MATCH, required = false) String ifMatch) {
Optional<Station> current = stationService.findById(id);
if (current.isEmpty()) {
return ResponseEntity.notFound().build();
}
// Optimistic check: if the client sends If-Match, it must match
if (ifMatch != null
&& !ifMatch.equals("\"" + stationService.versionOf(current.get()) + "\"")) {
return ResponseEntity.status(HttpStatus.PRECONDITION_FAILED).build();
}
return switch (stationService.delete(id, force)) {
case DELETED -> ResponseEntity.noContent().build(); // 204
case DID_NOT_EXIST -> ResponseEntity.notFound().build(); // 404
case HAS_BIKES -> ResponseEntity.status(HttpStatus.CONFLICT).build(); // 409
};
}Design notes. The switch over the enum, exhaustive thanks to Java 21, guarantees that if a value is added to DeleteResult tomorrow the compiler will force us to decide which HTTP code it deserves.
About the optional If-Match: if the client does not send it, the operation is carried out without a check. An API with strong guarantees can require it always and respond 428 Precondition Required when it is missing; for Ribalta's panel, optional is enough. And about ?force=true: it is a query parameter and not a different path because it modifies how the operation is carried out, not which resource is touched, honouring the URL design rule from 03-01. Note the log.warn too: a forced removal withdraws bikes from service and that must be recorded.
Conclusion
The CicloUrbana API now writes. You know how to create resources with POST, returning 201 Created and a Location header built with ServletUriComponentsBuilder, including the server.forward-headers-strategy line that makes that URL correct behind a proxy. You understand PUT as a full replacement, why that makes it idempotent and why CicloUrbana does not allow creation with PUT. You have taken apart PATCH's subtlest problem —telling the absent field from the field set to null— and you know the three solutions, with Map implemented and JsonNullable as the recommended path, plus the difference between JSON Patch and JSON Merge Patch. You know how to implement DELETE without breaking idempotence and you know the 404 versus 204 debate with arguments from both sides. You have the complete verb, status and body table for the API.
On top of that, the Ribalta network is now complete: the bike resource with its status and charge level, the /stations/{id}/bikes sub-resource with its semantic difference from the global filter, and rentals with creation by POST and finishing through an action endpoint, justified with three concrete conditions that stop that exception from becoming the back door to Richardson level 1. You know that HEAD and OPTIONS are generated by Spring on its own. And you have solved the lost-update problem with ETag, If-Match and ShallowEtagHeaderFilter, knowing their limitations and knowing that the definitive solution will arrive with @Version in module 4.
But there is a crack that has been widening lesson by lesson. Nothing stops anyone from creating a station with capacity -5, with an empty name, with a latitude of 200 degrees or with a plate that looks nothing like RB-0142. The checks we have written are scattered across the services, mixed in with the business rules, and none of them yet produces a useful message for the client.
Lesson 03-04, Input Data Validation, closes that crack. We will see why validation happens at the edge of the application and what layers of validation exist; we will use Jakarta Bean Validation with @NotBlank, @Positive, @Size, @Pattern and the rest of the catalogue; we will apply @Valid to bodies and @Validated to path and query parameters; we will distinguish the validation groups for creation and for modification; we will internationalise the messages; and we will build two custom constraints, @BikePlate and @ValidCoordinates, with their validators. We will also settle the project's policy on when a failure is a 400 and when it is a 409 or a 422.
Spring Boot Course
Module 1: Introduction to Spring Boot
- What Is Spring Boot?
- Setting Up Your Development Environment
- Building Your First Spring Boot Application
- Understanding the Project Structure
- Application Startup and Lifecycle
Module 2: Spring Boot Core Concepts
- Spring Boot Annotations
- Dependency Injection in Spring Boot
- Bean Scope and Lifecycle
- Spring Boot Configuration
- Spring Boot Properties
- Auto-Configuration and Starters from the Inside
Module 3: Building RESTful Web Services
- Introduction to RESTful Web Services
- Creating REST Controllers
- Handling HTTP Methods
- Validating Input Data
- DTOs and Mapping Between Layers
- Exception Handling in REST
- Documenting the API with OpenAPI
Module 4: Data Access with Spring Boot
- Introduction to Spring Data JPA
- Configuring Data Sources
- Creating JPA Entities
- Relationships Between Entities
- Using Spring Data Repositories
- Query Methods in Spring Data JPA
- Transactions and Persistence Management
- Schema Migrations with Flyway
Module 5: Security in Spring Boot
- Introduction to Spring Security
- Configuring Spring Security
- User Authentication and Authorization
- Implementing JWT Authentication
- Method-Level Security and API Hardening
Module 6: Testing in Spring Boot
- Introduction to Testing
- Unit Testing with JUnit
- Mocking with Mockito
- Integration Testing
- Testing with Testcontainers
Module 7: Advanced Spring Boot Features
- Spring Boot Actuator
- Spring Boot Profiles
- Scheduled Tasks and Asynchronous Execution
- Spring Boot with Docker
- Spring Boot and Microservices
- Service Communication and Fault Tolerance
Module 8: Deploying Spring Boot Applications
- Introduction to Deployment
- Deploying to Heroku
- Deploying to AWS
- Deploying to Kubernetes
- Continuous Integration and Delivery
Module 9: Performance and Monitoring
- Performance Tuning
- Caching with Spring Cache
- Monitoring with Spring Boot Actuator
- Using Prometheus and Grafana
- Logging and Log Management
- Distributed Tracing
