We closed the previous module with the Spring container completely taken apart: we know how beans are born, how they are injected, where their properties come from and why they show up on their own. All of that happens underneath, where nobody sees it. From now on we work in the layer that is visible: the HTTP API that will be consumed by the CicloUrbana mobile app, the control panel used by Ribalta's operators and, eventually, the city council's open data portal. This first lesson does not write controllers —that starts in the next one—; it sets the frame instead: what REST really means, how resources and URLs are designed, which verb and which status code belong to each operation, how the response format is negotiated and how a public contract is versioned. The decisions we take here shape the six lessons that follow and, in a real system, are very expensive to reverse: a published URL is a promise.
Contents
- What REST is and where it comes from
- The six REST constraints and what they mean in practice
- Resources, identifiers and representations
- The Richardson maturity model
- Designing CicloUrbana's URLs
- The complete API contract for the module
- HTTP verbs: safety and idempotence
- HTTP status codes
- Content negotiation
- API versioning
- REST versus SOAP, GraphQL and gRPC
- Where Spring MVC fits: the
DispatcherServlet - Common Mistakes and Tips
- Exercises
- What REST is and where it comes from
REST —REpresentational State Transfer— is not a protocol, nor a library, nor a format. It is an architectural style described by Roy Fielding in chapter 5 of his 2000 doctoral dissertation, Architectural Styles and the Design of Network-based Software Architectures. Fielding did not invent REST by observing how the APIs of his day were built: he described it by analysing why the Web had worked at a scale no previous distributed system had reached.
That origin explains a great deal. REST is not "JSON over HTTP with pretty URLs". It is a set of constraints that, applied to a distributed system, give it certain desirable properties: scalability, independence between client and server, tolerance to evolution and the ability to slot in intermediaries (caches, proxies, load balancers) that understand the traffic without knowing the application.
The distinction that matters to a developer is this:
| Claim | True? |
|---|---|
| REST requires JSON | No. REST mentions no format at all. JSON is the usual choice, not a requirement. |
| REST requires HTTP | Not formally, but HTTP is the only relevant implementation and the one we will always assume. |
| REST requires URLs made of nouns | Not literally, but it is the natural consequence of the uniform interface constraint. |
| An API with clean URLs and JSON is already REST | Not necessarily. It usually stops at maturity level 2 (section 4). |
| REST is faster than SOAP | Not by definition. It is simpler and more cacheable, which often translates into speed. |
In everyday industry usage, "REST API" is a synonym for "resource-oriented HTTP API with JSON". CicloUrbana will be exactly that, with full awareness of which constraints we meet and which we do not.
- The six REST constraints and what they mean in practice
Fielding defines six constraints. Five are mandatory and one is optional. The interesting part is not memorising them but seeing what each one forces upon the code we are about to write.
Client-server
Separation of concerns: the client takes care of the user interface, the server of the data and the business rules. They communicate only through the API contract.
What it means for CicloUrbana: the backend never generates HTML and never knows whether the client is an Android app or a web panel. If tomorrow the council wants a touch kiosk at every station, the server is untouched. It also means that business logic (can you rent a bike with 15% battery?) lives on the server, not in the app: a client may be out of date, or malicious.
Stateless
Every request carries all the information needed to process it. The server keeps no session context between requests.
What it means: no HttpSession holding the user's basket, the wizard step or the id of the rental in progress. If the client needs to authenticate, it sends the credential —a token— on every request (module 5). If it needs to paginate, it sends the page number every time.
The benefit is horizontal scalability: if CicloUrbana grows and we deploy three instances behind a load balancer, any of them can serve any request. With state held in memory we would need session affinity or a replicated session, and both complicate deployment (we will see this when we containerise in 07-04).
The cost is that requests are bigger and work is sometimes repeated (validating the token on every call). It is a trade-off that is almost always worth taking.
Cacheable
Every response must state, explicitly or implicitly, whether it can be cached and for how long.
What it means: GET /api/v1/stations returns data that barely changes —a new station once a month— and can carry Cache-Control: public, max-age=300. GET /api/v1/stations/1/bikes, by contrast, changes every minute and must carry Cache-Control: no-store or a very short max-age. Getting this decision right takes more load off the server than any code optimisation (we will come back to it in 09-02).
Uniform interface
This is the central constraint and the one that most distinguishes REST. It breaks down into four sub-constraints:
| Sub-constraint | Meaning | In CicloUrbana |
|---|---|---|
| Resource identification | Every thing has its URI | /api/v1/stations/1 identifies Main Square |
| Manipulation through representations | The client modifies by sending a representation, not by invoking methods | PUT with the station's complete JSON |
| Self-descriptive messages | Every message carries what is needed to interpret it | Content-Type: application/json, status codes |
| HATEOAS | The response includes links to the possible transitions | Optional; see level 3 in section 4 |
Layered system
The client does not know whether it is talking to the final server or to an intermediary. Between the app and our Tomcat there may be a CDN, a load balancer, an API gateway and a security proxy, and nothing changes.
What it means: never assume the client's IP without looking at X-Forwarded-For, never trust that the port the application sees is the one the client used, and build absolute URLs carefully (we will see this with the Location header in 03-03).
Code on demand (optional)
The server may send executable code to the client. It is the only optional constraint and it is practically unused in data APIs. CicloUrbana will not apply it.
- Resources, identifiers and representations
Three concepts that get confused constantly and are worth separating precisely.
- Resource: any concept in the domain worth naming. "The Main Square station", "the bikes available at station 3", "rental number 4471". It is an abstract notion, not a row in a table.
- Identifier (URI): the resource's stable name.
/api/v1/stations/1. - Representation: a concrete way of showing the resource's state at a given moment. The same resource can have several representations: JSON, XML, CSV, a summary version and a detailed one.
graph LR
R["Resource<br/>«The Main Square station»"] -->|is identified by| U["/api/v1/stations/1"]
R -->|is represented as| J["application/json<br/>{ id, name, capacity... }"]
R -->|is represented as| C["text/csv<br/>1,Main Square,24"]
The practical consequence matters: the resource is not the Java class. Our record Station is an internal representation; what the API exposes is something else, which may omit fields, add computed fields or aggregate data from several sources. That separation is exactly the subject of lesson 03-05 (DTOs).
A concrete CicloUrbana example: the "station" resource in the list view will include availableBikes, a computed number that is not in the record Station. And it will not include the exact coordinates of the maintenance dock, which are in the internal model. Resource and class are different things.
- The Richardson maturity model
Leonard Richardson proposed a four-level scale in 2008 to measure how close an API is to REST. It is the most useful tool for diagnosing an existing API.
| Level | Name | What it uses | Example in CicloUrbana |
|---|---|---|---|
| 0 | The swamp of POX | One URL, one verb | POST /api/service with {"operation":"listStations"} |
| 1 | Resources | Several URLs, one verb | POST /api/stations/list, POST /api/stations/1/delete |
| 2 | HTTP verbs | URLs + verbs + status codes | GET /api/v1/stations, DELETE /api/v1/stations/1 → 204 |
| 3 | HATEOAS | The above + links in the responses | The station returns _links.bikes and _links.rent |
Let us look at them with concrete requests.
Level 0 — everything goes through a single entry point and the HTTP verb means nothing:
POST /api/service HTTP/1.1
Content-Type: application/json
{ "operation": "getStation", "parameters": { "id": 1 } }The response will be 200 OK even if the station does not exist, with a {"error": "not found"} in the body. HTTP is used as a plain tunnel. It is the SOAP model and that of many legacy internal APIs.
Level 1 — there are resources with their own URL, but the operations are still verbs in the path:
Readability improves, but an intermediary still cannot cache anything or know which requests are safe.
Level 2 — the HTTP verb expresses the operation and the status code expresses the outcome:
GET /api/v1/stations/1 → 200 OK
DELETE /api/v1/stations/1 → 204 No Content
DELETE /api/v1/stations/999 → 404 Not FoundNow a CDN knows it can cache the GET, a proxy knows it can retry the DELETE without side effects and a generic client understands the outcome without reading the body. This is the level at which the vast majority of professional APIs operate, and the one CicloUrbana will implement.
Level 3 — responses describe what can be done next:
{
"id": 1,
"name": "Main Square",
"availableBikes": 7,
"_links": {
"self": { "href": "/api/v1/stations/1" },
"bikes": { "href": "/api/v1/stations/1/bikes" },
"rent": { "href": "/api/v1/rentals", "method": "POST" }
}
}The idea is powerful: if the station is empty, the rent link simply does not appear and the client does not need to replicate that business rule. Only level 3 fulfils the complete uniform interface constraint, and only it deserves the name REST according to Fielding.
Why CicloUrbana stops at level 2. Level 3 requires clients that navigate links instead of building URLs, and in practice almost no client does: mobile apps carry the routes hard-coded. The cost of maintaining the links does not pay for itself. It is a conscious, informed decision, which is exactly what is expected of an API designer: know level 3, apply it where it adds value —flows with a complex state machine— and not out of dogma. Spring offers spring-boot-starter-hateoas if it is ever needed.
- Designing CicloUrbana's URLs
The rules the project will follow, with their rationale:
Plural nouns, never verbs. /stations, not /getStations or /station. The plural is coherent: /stations is the collection and /stations/1 one element of it. Using the singular forces a case-by-case decision and produces inconsistencies.
Lowercase and hyphens to separate words. /api/v1/user-types, not /userTypes or /user_types. Domain names are case-insensitive but paths are not, and the hyphen is the convention of the web.
Hierarchy to express ownership. /api/v1/stations/1/bikes are the bikes of station 1. The practical rule: never nest more than two levels. /stations/1/bikes/42/rentals/7 is unreadable; if you need rental 7, ask for it by its own URL, /rentals/7.
No extensions or format suffixes. No /stations.json. The format is negotiated with headers (section 9).
No trailing slash. /api/v1/stations and /api/v1/stations/ must be the same thing; we choose the first form.
Filters go in the query string, not in the path. /api/v1/stations?minimumCapacity=20&city=ribalta, not /api/v1/stations/minimum-capacity/20. The reason: the path identifies which resource; the parameters modify how it is returned.
| Good | Bad | Why |
|---|---|---|
GET /api/v1/stations |
GET /api/v1/getStations |
The verb is already in the HTTP method |
GET /api/v1/stations/1 |
GET /api/v1/station?id=1 |
An element has its own URI |
GET /api/v1/stations?active=true |
GET /api/v1/stations/active |
active is not a resource, it is a filter |
GET /api/v1/stations/1/bikes |
GET /api/v1/bikes?station=1 |
Both are valid; the first expresses ownership better |
DELETE /api/v1/stations/1 |
POST /api/v1/stations/1/delete |
The HTTP verb is the operation |
About the /api prefix: it separates the API from any static content or web page the application might serve on the same domain, and it makes reverse proxy and CORS rules easier. It is such a widespread convention that its absence is surprising.
- The complete API contract for the module
This is the goal of module 3. By the end of lesson 03-07, CicloUrbana will expose exactly this:
| Verb | Path | Description | Success | Lesson |
|---|---|---|---|---|
GET |
/api/v1/stations |
Listing with filters and pagination | 200 | 03-02 |
GET |
/api/v1/stations/{id} |
Detail of one station | 200 | 03-02 |
POST |
/api/v1/stations |
Create a station | 201 + Location |
03-03 |
PUT |
/api/v1/stations/{id} |
Full replacement | 200 | 03-03 |
PATCH |
/api/v1/stations/{id} |
Partial update | 200 | 03-03 |
DELETE |
/api/v1/stations/{id} |
Remove a station | 204 | 03-03 |
GET |
/api/v1/stations/{id}/bikes |
Bikes docked at the station | 200 | 03-03 |
GET |
/api/v1/bikes |
Listing of the network's bikes | 200 | 03-03 |
GET |
/api/v1/bikes/{id} |
Detail of one bike | 200 | 03-03 |
POST |
/api/v1/bikes |
Create a bike | 201 + Location |
03-03 |
POST |
/api/v1/rentals |
Start a rental | 201 + Location |
03-03 |
POST |
/api/v1/rentals/{id}/finish |
Finish a rental | 200 | 03-03 |
GET |
/api/v1/rentals/{id} |
Detail of one rental | 200 | 03-03 |
Thirteen endpoints. None carries a verb in the path except finish, which is a state transition and not a CRUD operation; the corresponding section of 03-03 justifies that exception in detail.
Notice something that will become obvious in 03-05: GET /api/v1/stations and GET /api/v1/stations/{id} return different representations of the same resource. The listing returns a summary; the detail additionally includes the list of docked bikes. This is legitimate and very common: one resource, two representations with different levels of detail.
- HTTP verbs: safety and idempotence
Two properties defined in RFC 9110 govern what an intermediary may do with each verb.
- Safe: it does not modify server state. A crawler can walk every
GETin an API without breaking anything. - Idempotent: executing it N times leaves the system in the same state as executing it once. This allows retrying without fear when the network fails.
| Verb | Safe | Idempotent | Request body | Use in CicloUrbana |
|---|---|---|---|---|
GET |
Yes | Yes | No | Query stations, bikes, rentals |
HEAD |
Yes | Yes | No | Check existence without downloading the body |
OPTIONS |
Yes | Yes | No | Discover allowed verbs; used by CORS |
POST |
No | No | Yes | Create a station, start a rental |
PUT |
No | Yes | Yes | Replace a whole station |
PATCH |
No | Not guaranteed | Yes | Modify individual fields of a station |
DELETE |
No | Yes | Optional | Remove a station |
Idempotence is more practical than it sounds. Picture the CicloUrbana app on the Ribalta underground: the user taps "rent", the POST goes out, the server processes it, and the response is lost because the phone enters a tunnel. The app retries. If the POST is not idempotent, the citizen ends up with two rentals and two charges.
That POST is not idempotent is a feature, not a flaw: each POST /api/v1/rentals creates a new rental, and that is correct. To avoid duplicates caused by retries there is the idempotency key pattern —an Idempotency-Key header carrying a client-generated UUID— that the server remembers so as not to process the same request twice. It is the mechanism payment gateways use. In CicloUrbana we mention it here and do not implement it: it requires persistent storage, which arrives in module 4.
That DELETE is idempotent has a concrete design consequence: DELETE /api/v1/stations/1 on an already deleted station should return 204 or 404, but the final state is the same —the station does not exist—, and that is what defines idempotence. We will come back to it in 03-03.
PATCH is not idempotent in general because its body may describe a relative operation: "increase the capacity by 4" gives a different result every time. If the body describes absolute values —"the capacity becomes 28"— then it is. It is the responsibility of whoever designs the API to decide and document it.
- HTTP status codes
The status code is the most ignored and most valuable part of an HTTP response. Returning 200 OK with {"success": false} inside forces every client to read the body to find out whether something worked, and it breaks every intermediary.
The five families:
| Family | Meaning | Whose problem it is |
|---|---|---|
1xx |
Informational | Nobody's; rarely used |
2xx |
Success | Nobody's |
3xx |
Redirection | The client must go elsewhere |
4xx |
Client error | The client's: malformed request, unauthorised, non-existent resource |
5xx |
Server error | Ours: a bug, the database down, a dependency unavailable |
The 4xx/5xx distinction is not cosmetic. Monitoring systems (module 9) alert on 5xx and not on 4xx, because a 404 is normal operation and a 500 is a phone call at three in the morning. Returning 500 when the client has sent invalid JSON generates noise and erodes trust in the alerts.
The codes CicloUrbana will use:
| Code | Name | When CicloUrbana returns it |
|---|---|---|
200 |
OK | Successful query, PUT/PATCH returning the updated resource |
201 |
Created | Station, bike or rental created; always with a Location header |
204 |
No Content | Successful DELETE; PUT that returns no body |
304 |
Not Modified | Response to a conditional GET with a matching ETag (03-03) |
400 |
Bad Request | Malformed JSON or failed validation (03-04) |
401 |
Unauthorized | The token is missing or invalid (module 5) |
403 |
Forbidden | Authenticated but without permission (module 5) |
404 |
Not Found | Station 999 does not exist |
405 |
Method Not Allowed | DELETE /api/v1/stations on the collection |
409 |
Conflict | Duplicate plate, station full when returning a bike |
412 |
Precondition Failed | If-Match with a stale ETag (03-03) |
415 |
Unsupported Media Type | XML is sent where JSON is expected |
422 |
Unprocessable Entity | Correct syntax but a business rule violated (03-04) |
500 |
Internal Server Error | Unhandled exception; must never leak details (03-06) |
503 |
Service Unavailable | Dependency down, startup in progress (module 7) |
Two frequent confusions worth settling right now:
- 401 versus 403. 401 means "I don't know who you are" (authentication missing or invalid); 403 means "I know who you are and you may not" (authorisation missing). The standard's names are unfortunate:
Unauthorizedactually means unauthenticated. - 400 versus 422. 400 is "I don't understand your request" (broken JSON, missing mandatory field, wrong type); 422 is "I understand you perfectly, but what you are asking for is not acceptable" (you want to rent a bike that is under maintenance). Lesson 03-04 sets the project's policy.
- Content negotiation
HTTP allows the same resource to be served in several formats and lets client and server agree on which one. The mechanism is a pair of headers:
Content-Type: describes the format of the body being sent. It is set by whoever sends the body, whether the client in aPOSTor the server in the response.Accept: is the list of formats the client can interpret, in order of preference. It is always set by the client.
POST /api/v1/stations HTTP/1.1
Host: api.ciclourbana.ribalta.example
Content-Type: application/json
Accept: application/json
{ "name": "Central Market", "capacity": 20 }Here the client says: "I'm sending you JSON and I want JSON back". The server responds:
The MIME types relevant to the course:
| MIME type | Use |
|---|---|
application/json |
The default format of the whole CicloUrbana API |
application/problem+json |
Error responses with RFC 7807 Problem Details (03-06) |
application/x-www-form-urlencoded |
Classic HTML forms; not used by this API |
multipart/form-data |
File upload (incident photos of the bikes) |
text/csv |
Data export for the city council |
application/vnd.ciclourbana.v2+json |
Versioning by media type (section 10) |
The Accept header supports preference weights:
The client prefers JSON, accepts CSV and, as a last resort, anything. If the server cannot satisfy any of the options, it responds 406 Not Acceptable. If the client sends a Content-Type the server cannot read, the response is 415 Unsupported Media Type. These are two symmetrical codes that are often confused: 406 looks at Accept, 415 looks at Content-Type.
Spring implements all of this automatically from the produces and consumes attributes of @RequestMapping, which we will see in the next lesson.
- API versioning
A public API is a contract. As soon as the CicloUrbana mobile app is published in the stores, there will be citizens of Ribalta with old versions installed for months. Renaming a field breaks their phones.
The basic rule is to distinguish compatible from incompatible changes:
| Compatible (no new version required) | Incompatible (new version required) |
|---|---|
| Adding an optional field to a response | Removing or renaming a field |
| Adding a new endpoint | Changing the type of a field |
| Adding an optional query parameter | Making a previously optional field mandatory |
| Adding a value to a response enum | Changing the meaning of a field |
| Relaxing a validation | Changing a success status code |
The three versioning strategies:
By URL — /api/v1/stations, /api/v2/stations.
Advantages: visible at a glance, trivial to test in a browser or with curl, easy to route in a proxy or to deploy as separate applications, obvious in the logs. Theoretical drawback: two different URLs for the same resource, which breaks the idea of a unique identifier.
By custom header — the client sends X-API-Version: 1.
Advantage: the resource's URI is a single one. Drawbacks: invisible in the access log without extra configuration, impossible to test by pasting a URL in the browser, and the HTTP cache needs Vary: X-API-Version so it does not serve the wrong version.
By media type (version content negotiation) — the purist variant.
Advantage: theoretically the most correct; the version is part of the representation, not of the identity. Drawbacks: the hardest to explain to a client team, the most awkward to test and the one tooling supports worst.
| Strategy | Visibility | Ease of testing | REST purity | Real adoption |
|---|---|---|---|---|
URL (/api/v1) |
High | High | Low | Very high |
| Header | Low | Medium | Medium | Low |
| Media type | Low | Low | High | Low |
CicloUrbana uses /api/v1. The reasoning: the theoretical advantage of the other two does not offset the operational cost. With the version in the path, an operator looking at Ribalta's logs sees instantly which version each client uses, a new developer understands the scheme in three seconds, and the day v2 arrives we will be able to deploy /api/v2 on another instance and migrate clients gradually. It is the choice of GitHub, Stripe and practically every large-scale public API.
A warning about the number: the API version is not the software version. We can ship CicloUrbana 3.7.2 and keep serving /api/v1. The API version only changes when the contract breaks backwards, and that should happen very few times in a product's life.
- REST versus SOAP, GraphQL and gRPC
REST is not the only option. Knowing the alternatives helps you tell when REST is not the answer.
| Criterion | REST/HTTP | SOAP | GraphQL | gRPC |
|---|---|---|---|---|
| Format | JSON (free) | XML mandatory | JSON with a query language | Binary Protobuf |
| Contract | OpenAPI (optional) | WSDL (mandatory) | Schema (mandatory) | .proto (mandatory) |
| Transport | HTTP | HTTP, JMS, SMTP | HTTP (usually a single POST) | HTTP/2 |
| HTTP caching | Native | No | Hard (everything is POST) | No |
| Over-fetching | Frequent | Frequent | Solved by design | Controlled |
| Human-readable | Yes | Barely | Yes | No (binary) |
| Performance | Good | Low | Good | Very high |
| Bidirectional streaming | No | No | Subscriptions | Yes, native |
| Learning curve | Low | High | Medium | Medium |
When to choose each, in one sentence:
- REST: public API, many heterogeneous clients, caching matters, third-party integrations. This is CicloUrbana's case.
- SOAP: integration with corporate or public-administration systems that already demand it. Chosen out of obligation, not preference.
- GraphQL: very diverse clients with very different data needs —a mobile app that wants little and a control panel that wants everything— where over-fetching is a real problem.
- gRPC: communication between internal microservices, where performance matters and you control both ends. We will return to this scenario in 07-06.
They are not mutually exclusive. A mature architecture can expose REST to the outside world and use gRPC between internal services. In module 7, when CicloUrbana is split into services, we will see that contrast live.
- Where Spring MVC fits: the
DispatcherServlet
DispatcherServletAll the theory above translates, in Spring Boot, into a single central component. When we added spring-boot-starter-web in module 1, autoconfiguration —which we now know how to read— registered a DispatcherServlet mapped to /. It is the front controller: every request goes through it.
sequenceDiagram
participant C as Client (Ribalta app)
participant T as Tomcat + Filters
participant D as DispatcherServlet
participant HM as HandlerMapping
participant HA as HandlerAdapter
participant CT as StationController
participant MC as HttpMessageConverter (Jackson)
C->>T: GET /api/v1/stations/1<br/>Accept: application/json
T->>D: filter chain and servlet
D->>HM: which method serves this path?
HM-->>D: StationController#getById
D->>HA: invoke it
HA->>HA: resolves arguments (@PathVariable id = 1)
HA->>CT: getById(1L)
CT-->>HA: Station
HA->>MC: serialise according to Accept
MC-->>D: {"id":1,"name":"Main Square",...}
D-->>T: 200 OK + Content-Type: application/json
T-->>C: HTTP response
The pieces and their roles:
| Component | Responsibility |
|---|---|
DispatcherServlet |
Orchestrates the whole flow; it is the single entry point |
HandlerMapping |
Decides which method of which controller serves the path (RequestMappingHandlerMapping reads the @GetMappings) |
HandlerAdapter |
Invokes the method, resolving its arguments |
HandlerMethodArgumentResolver |
Turns parts of the request into Java parameters (@PathVariable, @RequestParam, @RequestBody) |
HttpMessageConverter |
Converts between the HTTP body and Java objects; MappingJackson2HttpMessageConverter does the JSON |
HandlerExceptionResolver |
Translates exceptions into HTTP responses (the central topic of 03-06) |
One idea is worth nailing down: a Spring controller does not see HTTP. It receives a Long and returns a Station. All the translation —parsing the path, choosing the format, serialising, setting headers— is done by the components in the table. That is why Spring controllers are so compact and so easy to test. When something does not work as you expect, the culprit is almost always one of those intermediate components, and knowing they exist is half of the debugging.
Common Mistakes and Tips
Putting verbs in the URL. POST /api/v1/stations/create gives away a level 1 design. The verb is already in the HTTP method; repeating it in the path is redundant and blocks caching and automatic retries.
Always returning 200. A 200 OK with {"error": "station not found"} forces every client to inspect the body and misleads monitoring, proxies and browsers. The status code is the first line of the response for a reason.
Confusing 401 and 403. 401 = I don't know who you are. 403 = I know who you are and you may not. This gets consolidated in module 5.
Returning 500 because of the client. If malformed JSON arrives, that is a 400. A 5xx means "we failed" and it pollutes production alerts.
Versioning too soon or too late. Publishing /api/v2 because an optional field was added multiplies maintenance cost for no reason: adding fields is compatible. Conversely, renaming a field in /api/v1 without warning breaks clients in production. The table in section 10 is the reference.
Half-hearted pluralisation. /api/v1/stations and /api/v1/bike/1 in the same API. The inconsistency forces you to check the documentation for every endpoint. Pick a convention and never break it.
Tip: write the contract before the code. The table in section 6 was written before a single controller. Arguing over a table takes minutes; renegotiating a deployed API takes weeks.
Tip: think about the client when in doubt. /stations/1/bikes or /bikes?station=1? Ask yourself which one whoever builds the app will write more naturally. If both are useful, expose both: it is not a sin.
Exercises
Exercise 1: Diagnose the maturity level
Ribalta city council hands over the API of its previous bike system. These are four real calls:
POST /bicing/api HTTP/1.1
Content-Type: application/json
{ "action": "listStations", "city": "ribalta" }
POST /bicing/api HTTP/1.1
{ "action": "deleteStation", "id": 3 }
POST /bicing/api HTTP/1.1
{ "action": "getStation", "id": 999 }
→ 200 OK { "ok": false, "message": "does not exist" }Determine the Richardson level, list which REST constraints it breaks and rewrite the three calls as a level 2 API with their status codes.
Exercise 2: Design the incident resources
CicloUrbana needs to manage incidents: a citizen reports that bike RB-0142 has a flat tyre; an operator inspects it and closes the report. The data of an incident are: identifier, bike plate, description, opening date, status (OPEN, IN_REVIEW, CLOSED) and assigned operator.
Design the contract: URLs, verbs, success and error status codes, and justify how you model the "close an incident" state transition.
Exercise 3: Decide the versioning policy
The CicloUrbana team proposes four changes for the next release. For each one, decide whether it is compatible or incompatible and what to do:
- Add the field
availableElectricBikesto the response ofGET /api/v1/stations. - Rename
capacitytototalCapacityin every station response. - Change
latitudeandlongitudefrom two loose fields to a nested object{"location": {"lat":..., "lon":...}}. - Accept a new optional parameter
?sortBy=namein the station listing.
Solutions
Solution 1.
Level: 0 (the swamp of POX). There is a single URL (/bicing/api), a single verb (POST) and the operation travels in the body. HTTP is used as a mere tunnel.
Constraints broken:
- Uniform interface / resource identification: station 3 has no URI. It cannot be linked, bookmarked or cached individually.
- Uniform interface / self-descriptive messages:
200 OKfor a non-existent resource is a lie. No intermediary can interpret the response without knowing the proprietary format. - Cacheable: listing stations is a query, but because it travels by
POSTno cache can store it. All read traffic reaches the server.
Rewrite at level 2:
GET /api/v1/stations?city=ribalta HTTP/1.1
Accept: application/json
→ 200 OK, body: [ {...}, {...} ]
→ 200 OK with an empty array if there are none (that is not an error)
DELETE /api/v1/stations/3 HTTP/1.1
→ 204 No Content (no body)
→ 404 Not Found if station 3 did not exist
→ 409 Conflict if it has active rentals
GET /api/v1/stations/999 HTTP/1.1
→ 404 Not Found
Content-Type: application/problem+json
{ "type": "https://api.ciclourbana.example/errors/resource-not-found",
"title": "Station not found", "status": 404, "detail": "Station 999 does not exist" }The error format is RFC 7807, implemented in lesson 03-06. Note that an empty listing is not a 404: the /stations collection exists even when it is empty. A 404 on a listing is only appropriate if the path itself does not exist.
Solution 2.
Incidents are a first-class resource: they have identity, a life cycle and are queried in their own right.
| Verb | Path | Description | Success | Errors |
|---|---|---|---|---|
GET |
/api/v1/incidents |
Listing, filterable by ?status=OPEN&bike=RB-0142 |
200 | — |
GET |
/api/v1/incidents/{id} |
Detail | 200 | 404 |
POST |
/api/v1/incidents |
Open an incident | 201 + Location |
400, 404 (non-existent bike) |
PATCH |
/api/v1/incidents/{id} |
Change the description or assign an operator | 200 | 400, 404 |
DELETE |
/api/v1/incidents/{id} |
Delete (administration only) | 204 | 404, 409 |
GET |
/api/v1/bikes/{id}/incidents |
Incidents of one specific bike | 200 | 404 |
About the opening date and the initial status: they are not accepted in the POST. The date is set by the server with the Clock bean from CommonConfig and the initial status is always OPEN. Accepting data from the client that the server controls is an avenue for manipulation.
About closing an incident, there are two defensible designs:
# Option A: state transition as an action sub-resource
POST /api/v1/incidents/7/close
{ "operatorId": 12, "resolution": "Inner tube replaced" }
→ 200 OK
# Option B: modify the status field
PATCH /api/v1/incidents/7
{ "status": "CLOSED", "resolution": "Inner tube replaced" }
→ 200 OKOption B is purer —only the resource's state is modified— but it leaves the server the job of detecting that this particular PATCH triggers side effects (notify the citizen, write an audit record, return the bike to service) and it does not validate illegal transitions well. Option A explicitly names a transition of the state machine, is self-documenting, allows different fields to be required for each transition and is authorised separately in module 5.
Recommendation: option A, the same one CicloUrbana will use in POST /api/v1/rentals/{id}/finish. The general rule: if a modification has a name in the language of the business —"close", "finish", "cancel"— and triggers effects beyond changing a field, it deserves its own action endpoint.
Solution 3.
| # | Change | Verdict | Action |
|---|---|---|---|
| 1 | Add availableElectricBikes |
Compatible | Added to /api/v1. An old client ignores it; Jackson discards unknown fields by default |
| 2 | Rename capacity to totalCapacity |
Incompatible | Breaks every client that reads capacity |
| 3 | Nest the coordinates in location |
Incompatible | Changes the structure; clients fail when reading latitude |
| 4 | Optional parameter ?sortBy=name |
Compatible | Added to /api/v1 with a default value that preserves the current ordering |
Strategy for changes 2 and 3. Publishing /api/v2 over a rename is disproportionate. The correct pattern is a gradual transition with duplicated fields:
- In
/api/v1, addtotalCapacityandlocationwhile keepingcapacity,latitudeandlongitude. The three new ones and the three old ones coexist; this is compatible. - Mark the old ones as deprecated in the OpenAPI documentation (
@Schema(deprecated = true), lesson 03-07) and announce the removal date. - Instrument with Actuator (module 7) which clients still read the old fields.
- When usage reaches zero or the deadline expires, remove them in
/api/v2.
The underlying lesson: most "incompatible" changes can be turned into compatible ones if you accept a few months of duplication. It is almost always cheaper than maintaining two complete versions of the API in parallel. The major version is reserved for genuine redesigns, not for naming changes.
Conclusion
We now have the frame. You know that REST is an architectural style with six constraints —client-server, stateless, cacheable, uniform interface, layered system and code on demand— and, more importantly, what each one forces upon the code you are about to write: no session in memory, explicit Cache-Control, business logic always on the server. You know the Richardson maturity model and why CicloUrbana deliberately settles at level 2. You have separated resource, identifier and representation, the distinction that will justify the DTOs of lesson 03-05. You have the project's URL design rules, the complete table of the thirteen endpoints we will build and the exact meaning of each verb in terms of safety and idempotence, with the example of the phone in the Ribalta underground tunnel so you never forget why it matters. You handle status codes by family and the two classic confusions —401 versus 403, 400 versus 422. You know how to negotiate content with Accept and Content-Type, and why 406 and 415 are not the same thing. You have compared the three versioning strategies and understand why the course chooses /api/v1 despite it not being the purest. And you have placed REST alongside SOAP, GraphQL and gRPC so you know when it is not the answer.
Finally, you have seen the complete journey of a request inside Spring MVC: Tomcat, filters, DispatcherServlet, HandlerMapping, HandlerAdapter, argument resolvers, HttpMessageConverter. That sequence is the map we will use for debugging throughout the module, because when a request does not reach the expected method, or the JSON does not come out as you thought, the culprit is always one of those pieces.
Lesson 03-02, Creating REST Controllers, goes down to the code. We will take @RestController apart, see how routes are mapped and how Spring extracts template variables, query parameters, headers and bodies to turn them into Java arguments; how Jackson transforms a record into JSON and how to control that transformation field by field and globally from application.yml; and when to return the object directly and when to wrap it in a ResponseEntity. By the end we will have a real StationController, with filtered listing, simple pagination and lookup by identifier, tested with curl and with a .http file. That GET /api/v1/stations which used to return a fixed list finally starts behaving like an API.
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
