Command Palette

Search for a command to run...

[Spring Boot Basics] HTTP and REST Fundamentals: Methods, Status Codes and RESTful URL Design

Article 3 ended with a HelloController answering curl, and every article since has been about what happens inside the application: beans, injection, configuration, logging. Chapter 3 turns outward and builds a REST API. Before the first annotation, it is worth seeing what actually crosses the socket, because every design decision in an API — which method, which status code, which URL — is a decision about that text.

This article is about HTTP itself, captured on the wire against a small Spring Boot application: the structure of a request and a response, what each method promises according to RFC 9110, why idempotency decides whether a retry is safe, the status codes and headers an API really uses, what REST means once the buzzword is set aside, and the rules for designing URLs. It ends with the endpoint table that the rest of the chapter implements.

Five HTTP methods acting on one resource URL, which answers with status codes

Every exchange below was captured from one run of a Spring Boot 4.1.1 application (Spring Framework 7.0.9, embedded Tomcat 11.0.24) on OpenJDK 21.0.6, using curl 8.7.1 and the nc that ships with macOS. Quotations from the standards come from RFC 9110 (HTTP Semantics), RFC 9112 (HTTP/1.1) and RFC 5789 (PATCH).

A small product API to test against

Generate the project the same way as in article 3:

Bash
curl -s "https://start.spring.io/starter.zip?type=gradle-project&language=java&bootVersion=4.1.1&javaVersion=21&groupId=com.example&artifactId=demo&name=demo&packageName=com.example.demo&dependencies=web" -o demo.zip
unzip demo.zip -d demo

Then add one class. It keeps products in a ConcurrentHashMap with an AtomicLong for the ids, because databases arrive in Chapter 4:

src/main/java/com/example/demo/product/ProductController.java
package com.example.demo.product;
 
import java.math.BigDecimal;
import java.net.URI;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong;
 
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
 
@RestController
@RequestMapping("/api/products")
public class ProductController {
 
    public record Product(Long id, String name, BigDecimal price, String category) {}
 
    private final Map<Long, Product> store = new ConcurrentHashMap<>();
    private final AtomicLong nextId = new AtomicLong();
 
    public ProductController() {
        insert(new Product(null, "Clean Code", new BigDecimal("32.50"), "books"));
        insert(new Product(null, "Mechanical Keyboard", new BigDecimal("89.00"), "electronics"));
    }
 
    @GetMapping
    public List<Product> list(@RequestParam(required = false) String category) {
        return store.values().stream()
                .filter(p -> category == null || category.equals(p.category()))
                .toList();
    }
 
    @GetMapping("/{id}")
    public Product get(@PathVariable Long id) {
        return find(id);
    }
 
    @PostMapping
    public ResponseEntity<Product> create(@RequestBody Product body) {
        Product created = insert(body);
        return ResponseEntity.created(URI.create("/api/products/" + created.id())).body(created);
    }
 
    @PutMapping("/{id}")
    public Product replace(@PathVariable Long id, @RequestBody Product body) {
        find(id);
        Product replaced = new Product(id, body.name(), body.price(), body.category());
        store.put(id, replaced);
        return replaced;
    }
 
    @PatchMapping("/{id}")
    public Product update(@PathVariable Long id, @RequestBody Product body) {
        Product current = find(id);
        Product updated = new Product(id,
                body.name() != null ? body.name() : current.name(),
                body.price() != null ? body.price() : current.price(),
                body.category() != null ? body.category() : current.category());
        store.put(id, updated);
        return updated;
    }
 
    @DeleteMapping("/{id}")
    public ResponseEntity<Void> delete(@PathVariable Long id) {
        if (store.remove(id) == null) {
            throw new ResponseStatusException(HttpStatus.NOT_FOUND);
        }
        return ResponseEntity.noContent().build();
    }
 
    private Product find(Long id) {
        Product product = store.get(id);
        if (product == null) {
            throw new ResponseStatusException(HttpStatus.NOT_FOUND);
        }
        return product;
    }
 
    private Product insert(Product body) {
        long id = nextId.incrementAndGet();
        Product product = new Product(id, body.name(), body.price(), body.category());
        store.put(id, product);
        return product;
    }
}

The annotations in this class are the subject of articles 16 and 17. Here it is only something to send requests to. The store starts with two products, Clean Code with id 1 and Mechanical Keyboard with id 2. Build the jar and start it:

Bash
./gradlew bootJar
java -jar build/libs/demo-0.0.1-SNAPSHOT.jar --server.port=8115

The runs below use port 8115; any free port works. All the exchanges in this article come from a single run of the application, in the order they appear, so a product created in one section is still there in the next.

HTTP is text: requests and responses on the wire

HTTP/1.1 is a text protocol. Speaking it needs no client library, only a way to write bytes to a TCP connection. printf writes the request, and nc sends it to port 8115 and prints whatever comes back:

Bash
printf 'GET /api/products/1 HTTP/1.1\r\nHost: localhost\r\n\r\n' | nc localhost 8115
Text
HTTP/1.1 200 
Content-Type: application/json
Content-Length: 61
Date: Sat, 12 Sep 2026 07:03:15 GMT
 
{"id":1,"name":"Clean Code","price":32.50,"category":"books"}

A request line and one header went out; a status line, three headers and a JSON body came back. That is the complete exchange, and everything a web framework does with HTTP is built on text like this. The \r\n in the printf string is not decoration: RFC 9112 ends every line of an HTTP/1.1 message with CRLF, the two bytes carriage return and line feed, and marks the end of the headers with a line that contains nothing else.

On macOS, nc exited as soon as the response arrived. If yours sits waiting instead, press Ctrl+C. RFC 9112 makes persistent connections the default in HTTP/1.1, so the server keeps the connection open for another request, and not every nc closes its side when its input ends; the OpenBSD variant has the -N flag for exactly that.

Anatomy of an HTTP request

A GET carries no body, so a POST shows every part. The body is 55 bytes of JSON, and the request says so:

Bash
printf 'POST /api/products HTTP/1.1\r\nHost: localhost\r\nContent-Type: application/json\r\nContent-Length: 55\r\n\r\n{"name":"Refactoring","price":47.00,"category":"books"}' | nc localhost 8115

RFC 9112 gives every HTTP/1.1 message, request or response, the same shape:

Text
HTTP-message = start-line CRLF
               *( field-line CRLF )
               CRLF
               [ message-body ]

In the request above, those parts are:

  • Request linePOST /api/products HTTP/1.1: the method, the request target and the protocol version, separated by single spaces. RFC 9110 makes the method case-sensitive, so post is not POST.
  • Header fields — one Name: value per line. Field names are case-insensitive, so content-type and Content-Type are the same header. HTTP/1.1 requires Host, which lets one server on one address serve several host names.
  • Empty line — a CRLF on its own. It is the only signal that the headers are over.
  • Body — the content. Nothing marks where it ends: the server reads exactly as many bytes as Content-Length announced, which is why the printf string has no CRLF after the JSON.

Anatomy of an HTTP response

Tomcat answered the POST with:

Text
HTTP/1.1 201 
Location: /api/products/3
Content-Type: application/json
Transfer-Encoding: chunked
Date: Sat, 12 Sep 2026 07:03:16 GMT
 
3e
{"id":3,"name":"Refactoring","price":47.00,"category":"books"}
0
  • Status lineHTTP/1.1 201: the version and a three-digit status code. The grammar allows a reason phrase after the code, status-line = HTTP-version SP status-code SP [ reason-phrase ], and Tomcat 11 sends none: the line is HTTP/1.1 201, one space, then CRLF. RFC 9112 tells clients to ignore the phrase anyway, because it "is not a reliable channel for information".
  • Header fieldsLocation is the URL of the product that was just created, Content-Type says the body is JSON, and Date is when the response was generated.
  • Empty line — exactly as in the request.
  • Body — chunked this time. Transfer-Encoding: chunked is the alternative to Content-Length for content whose size is not announced up front: each chunk is its size in hexadecimal, CRLF, the data, CRLF, and a chunk of size 0 followed by one more CRLF ends the body. 3e is 62, the length of the JSON. The GET earlier used Content-Length: 61 instead. curl and every HTTP library reassemble chunks for you, so this framing rarely shows up outside a raw capture.

One real POST request and its 201 response, line by line, with the start line, header fields, empty line and body of each bracketed and labelled

What Tomcat does with a request that has no Host header

Leave Host out:

Bash
printf 'GET /api/products/1 HTTP/1.1\r\n\r\n' | nc localhost 8115
Text
HTTP/1.1 400 
Content-Type: text/html;charset=utf-8
Content-Language: en
Content-Length: 435
Date: Sat, 12 Sep 2026 07:03:15 GMT
Connection: close
 
<!doctype html><html lang="en"><head><title>HTTP Status 400 – Bad Request</title><style type="text/css">body {font-family:Tahoma,Arial,sans-serif;} h1, h2, h3, b {color:white;background-color:#525D76;} h1 {font-size:22px;} h2 {font-size:16px;} h3 {font-size:14px;} p {font-size:12px;} a {color:black;} .line {height:1px;background-color:#525D76;border:none;}</style></head><body><h1>HTTP Status 400 – Bad Request</h1></body></html>

RFC 9112 leaves the server no choice: "A server MUST respond with a 400 (Bad Request) status code to any HTTP/1.1 request message that lacks a Host header field". The HTML page is Tomcat's own error report, not the JSON that Spring Boot produces, because the request never reached Spring. In the captured run DispatcherServlet was logging at DEBUG and printed a line for every other request in this article, but nothing for this one. Connection: close tells the client that Tomcat closes the connection after this response.

The same exchange through curl -v

Bash
curl -v http://localhost:8115/api/products/1
Text
* Host localhost:8115 was resolved.
* IPv6: ::1
* IPv4: 127.0.0.1
*   Trying [::1]:8115...
* Connected to localhost (::1) port 8115
> GET /api/products/1 HTTP/1.1
> Host: localhost:8115
> User-Agent: curl/8.7.1
> Accept: */*
> 
* Request completely sent off
< HTTP/1.1 200 
< Content-Type: application/json
< Content-Length: 61
< Date: Sat, 12 Sep 2026 07:03:16 GMT
< 
* Connection #0 to host localhost left intact
{"id":1,"name":"Clean Code","price":32.50,"category":"books"}

Lines starting with > are the request curl sent, < the response it received, and * curl's own notes about the connection. curl wrote the request line and three headers for you: Host including the port, since 8115 is not the default port 80; a User-Agent; and Accept: */*, meaning any media type will do. curl -i prints the status line and headers without the connection notes, and it is what the rest of this article uses.

Everything in this article is HTTP/1.1. HTTP/2 and HTTP/3 carry the same methods, status codes and headers in binary frames instead of text lines, which is why the semantics live in RFC 9110 and each version's wire format in its own RFC — 9112, 9113 and 9114.

HTTP methods: GET, POST, PUT, PATCH, DELETE, HEAD and OPTIONS

The method is the first word of the request line and says what the client wants done with the target resource. RFC 9110 gives methods three properties that matter far more than their names:

  • Safe — "the client does not request, and does not expect, any state change on the origin server". A server may still write an access log line for a GET; what counts is that the client asked for no change.
  • Idempotent — "the intended effect on the server of multiple identical requests with that method is the same as the effect for a single such request."
  • Cacheable — a response may be stored and reused to answer a later request.
MethodAsks the server toSafeIdempotentCacheableRequest body
GETsend a representation of the resourceyesyesyesnot normally; it "has no generally defined semantics"
HEADdo what GET does, without sending the contentyesyesyesno
POSTprocess the enclosed representation by the resource's own rulesnonoonly with explicit freshness information and a matching Content-Locationyes
PUTcreate or replace the resource's state with the enclosed onenoyesnoyes, the complete new state
PATCHapply a set of changes to the resourcenonoonly with explicit freshness information and a matching Content-Locationyes, the changes
DELETEremove the resourcenoyesnonot normally; no generally defined semantics
OPTIONSdescribe the communication options for the resourceyesyesnonot normally

Three details the table leaves out. PATCH is not part of RFC 9110 at all: RFC 5789 added it in 2010, calling it "neither safe nor idempotent" while noting that a PATCH "can be issued in such a way as to be idempotent". RFC 9110 defines caching for POST, yet adds that "the overwhelming majority of cache implementations only support GET and HEAD". And the specification also defines CONNECT and TRACE, which APIs do not use.

PUT vs PATCH vs POST

The three methods that send a body are the ones most often mixed up, and the RFCs are precise about each:

  • PUT replaces. It "requests that the state of the target resource be created or replaced with the state defined by the representation enclosed in the request message content." The body is the complete new state: whatever it leaves out is not kept.
  • PATCH modifies. Its body "contains a set of instructions describing how a resource currently residing on the origin server should be modified to produce a new version." Only what the instructions touch changes.
  • POST processes. It asks the target resource to "process the representation enclosed in the request according to the resource's own specific semantics." In an API that means creating a new member of a collection whose URL the server chooses, or running an operation no other method describes.

The difference between the first two shows on product 2. A PATCH that sends only a price:

Bash
curl -i -X PATCH -H 'Content-Type: application/json' -d '{"price":79.00}' http://localhost:8115/api/products/2
Text
HTTP/1.1 200 
Content-Type: application/json
Content-Length: 76
Date: Sat, 12 Sep 2026 07:03:16 GMT
 
{"id":2,"name":"Mechanical Keyboard","price":79.00,"category":"electronics"}

The name and the category survived. Now a PUT with the name and the same price, but no category:

Bash
curl -i -X PUT -H 'Content-Type: application/json' -d '{"name":"Mechanical Keyboard","price":79.00}' http://localhost:8115/api/products/2
Text
HTTP/1.1 200 
Content-Type: application/json
Content-Length: 67
Date: Sat, 12 Sep 2026 07:03:16 GMT
 
{"id":2,"name":"Mechanical Keyboard","price":79.00,"category":null}

category is now null. The PUT body was taken as the whole product, and a product without a category is what it described.

⚠️ A client that means "change one field" and sends PUT with a partial body erases every field it did not send. If clients update a few fields at a time, give them PATCH.

PUT can also create. RFC 9110 requires 201 (Created) when a PUT creates the resource, and 200 or 204 when it replaces an existing one. That only makes sense when the client chooses the URL, as with a file uploaded to a path of its choosing. In this API the server assigns ids, so a PUT to an id that does not exist is an error rather than a create:

Bash
curl -i -X PUT -H 'Content-Type: application/json' -d '{"name":"Mechanical Keyboard","price":79.00}' http://localhost:8115/api/products/77
Text
HTTP/1.1 404 
Content-Type: application/json
Transfer-Encoding: chunked
Date: Sat, 12 Sep 2026 07:03:16 GMT
 
{"timestamp":"2026-09-12T07:03:16.110Z","status":404,"error":"Not Found","path":"/api/products/77"}

The test target accepts a PATCH body as plain application/json and changes the fields that are present, which is the common simplification. The two standard patch formats are JSON Merge Patch (RFC 7396, application/merge-patch+json), where null removes a field, and JSON Patch (RFC 6902, application/json-patch+json), a list of operations such as add, replace and remove.

HEAD and OPTIONS on the wire

curl -I sends HEAD:

Bash
curl -I http://localhost:8115/api/products/1
Text
HTTP/1.1 200 
Content-Type: application/json
Content-Length: 61
Date: Sat, 12 Sep 2026 07:03:16 GMT

The same Content-Type and the same Content-Length: 61 as the GET, and no body. RFC 9110 says the server "MUST NOT send content in the response" to HEAD and "SHOULD send the same header fields" it would send for GET, which makes HEAD the cheap way to ask whether something exists and how large it is.

OPTIONS asks which methods a URL supports:

Bash
curl -i -X OPTIONS http://localhost:8115/api/products/1
curl -i -X OPTIONS http://localhost:8115/api/products
Text
HTTP/1.1 200 
Allow: PATCH,DELETE,PUT,GET,HEAD,OPTIONS
Accept-Patch: 
Content-Length: 0
Date: Sat, 12 Sep 2026 07:03:16 GMT
 
HTTP/1.1 200 
Allow: POST,GET,HEAD,OPTIONS
Accept-Patch: 
Content-Length: 0
Date: Sat, 12 Sep 2026 07:03:16 GMT

Allow lists the methods: a single product can be read, replaced, patched and deleted; the collection can be read and posted to. Three things in these responses are worth knowing:

  • The controller declares no HEAD or OPTIONS method, yet both are answered and listed. Spring does that on its own, and article 16 shows how.
  • The order inside Allow carries no meaning and is not even stable. An earlier run of the same jar printed DELETE,PATCH,GET,HEAD,PUT,OPTIONS for the same URL.
  • Accept-Patch is the header RFC 5789 defines for listing the patch document formats a resource accepts. It is empty because the controller declares none.

Browsers also send OPTIONS on their own, as the CORS preflight before certain cross-origin requests. That belongs to Chapter 5.

Idempotency and retries: why sending a request twice matters

Networks fail in the least convenient place: after the server has done the work and before the client has read the answer. The client's timeout fires, and it faces a question HTTP cannot answer for it — did the request happen? All it can do is send the request again, and whether that is harmless depends on the method.

RFC 9110 states the rule. Idempotent methods are singled out "because the request can be repeated automatically if a communication failure occurs before the client is able to read the server's response". For the others, "a client SHOULD NOT automatically retry a request with a non-idempotent method unless it has some means to know that the request semantics are actually idempotent", and "a proxy MUST NOT automatically retry non-idempotent requests".

Sending the same request twice is exactly what a retry does. The POST first:

Bash
curl -i -X POST -H 'Content-Type: application/json' -d '{"name":"Effective Java","price":45.00,"category":"books"}' http://localhost:8115/api/products

The first time:

Text
HTTP/1.1 201 
Location: /api/products/4
Content-Type: application/json
Transfer-Encoding: chunked
Date: Sat, 12 Sep 2026 07:03:16 GMT
 
{"id":4,"name":"Effective Java","price":45.00,"category":"books"}

The second time, with the identical command:

Text
HTTP/1.1 201 
Location: /api/products/5
Content-Type: application/json
Transfer-Encoding: chunked
Date: Sat, 12 Sep 2026 07:03:16 GMT
 
{"id":5,"name":"Effective Java","price":45.00,"category":"books"}

Two successes, two products: 4 and 5. A client that retried an order or a payment after a timeout would have created a duplicate, and received a perfectly normal 201 for it.

Now the PUT, twice:

Bash
curl -i -X PUT -H 'Content-Type: application/json' -d '{"name":"Clean Code","price":29.90,"category":"books"}' http://localhost:8115/api/products/1

Both runs printed exactly the same response:

Text
HTTP/1.1 200 
Content-Type: application/json
Content-Length: 61
Date: Sat, 12 Sep 2026 07:03:16 GMT
 
{"id":1,"name":"Clean Code","price":29.90,"category":"books"}

The whole collection afterwards:

Bash
curl -s http://localhost:8115/api/products
Text
[{"id":1,"name":"Clean Code","price":29.90,"category":"books"},{"id":2,"name":"Mechanical Keyboard","price":79.00,"category":null},{"id":3,"name":"Refactoring","price":47.00,"category":"books"},{"id":4,"name":"Effective Java","price":45.00,"category":"books"},{"id":5,"name":"Effective Java","price":45.00,"category":"books"}]

Product 1 exists once, at 29.90, exactly as it would after a single PUT. Products 4 and 5 are duplicates.

Idempotency is a statement about the effect on the server, not about the response. DELETE is idempotent, and deleting the duplicate twice shows the difference:

Bash
curl -i -X DELETE http://localhost:8115/api/products/5
curl -i -X DELETE http://localhost:8115/api/products/5
Text
HTTP/1.1 204 
Date: Sat, 12 Sep 2026 07:03:16 GMT
 
HTTP/1.1 404 
Content-Type: application/json
Transfer-Encoding: chunked
Date: Sat, 12 Sep 2026 07:03:16 GMT
 
{"timestamp":"2026-09-12T07:03:16.155Z","status":404,"error":"Not Found","path":"/api/products/5"}

204, then 404. After either request product 5 is gone, which is the effect the client asked for. RFC 9110 anticipates exactly this: repeating an idempotent request has the same intended effect "even if the original request succeeded, though the response might differ."

Two timelines in which the first response is lost and the client retries: the retried POST creates product 5 next to product 4, the retried PUT leaves product 1 at 29.90

The Idempotency-Key header

Retrying a POST safely needs the server's cooperation. The usual pattern: the client generates a unique key for each logical operation, typically a UUID, and sends it as a header such as Idempotency-Key: 8e03978e-40d5-43e8-bc93-6894a57f9324. The server stores the key together with the outcome of the first request, and when the same key arrives again it returns that stored outcome instead of processing the request a second time. The retry creates nothing new.

It is a convention, not a standard. The IETF HTTPAPI working group drafted it as draft-ietf-httpapi-idempotency-key-header, and the draft lists Stripe and Adyen among existing implementations, but its last revision, -07 of 15 October 2025, expired on 18 April 2026 without being published as an RFC. Implementing it needs somewhere durable to keep keys and outcomes, so it is not part of this chapter.

HTTP status codes for REST APIs

A status code is three digits, and only the first carries structure. In RFC 9110's words, "the first digit of the status code defines the class of response. The last two digits do not have any categorization role."

ClassRFC 9110 descriptionFor an API
1xxInformational: the request was received, continuing processhandled by servers and HTTP libraries; you do not return these
2xxSuccessful: the request was successfully received, understood, and acceptedthe operation worked
3xxRedirection: further action needs to be taken in order to complete the requestrare in JSON APIs
4xxClient Error: the request contains bad syntax or cannot be fulfilledthe client must change something, or for 429 wait, before trying again
5xxServer Error: the server failed to fulfill an apparently valid requestthe fault is on the server side; a later retry may succeed

A client that does not recognise a code must treat it as the x00 code of its class, one more reason to stick to registered codes rather than inventing a 299. The codes an API actually uses:

CodeNameReturn it when
200OKa GET succeeded, or a PUT, PATCH or POST succeeded and returns a body
201Createda new resource now exists; send its URL in Location
202Acceptedthe request was queued for later processing and is not done yet
204No Contentthe operation succeeded and there is nothing to send back, typically DELETE
304Not Modifieda conditional GET found that the client's cached copy is still current
400Bad Requestthe request cannot be read: malformed JSON, a value of the wrong type, a broken query parameter
401Unauthorizedcredentials are missing or invalid; the response must carry WWW-Authenticate
403Forbiddenthe caller is known but not allowed to do this
404Not Foundnothing exists at this URL, or the server chooses not to reveal that it does
405Method Not Allowedthe URL exists but does not support this method; the response must carry Allow
406Not Acceptablethe server cannot produce any media type the Accept header allows
409Conflictthe request conflicts with the current state: already shipped, already registered, edited by someone else
415Unsupported Media Typethe endpoint does not read the request body's Content-Type
422Unprocessable Contentthe body is well-formed but its content breaks the rules
429Too Many Requestsa rate limit was hit; Retry-After can say when to try again (RFC 6585)
500Internal Server Erroran unexpected failure on the server, usually a bug
502Bad Gatewaya gateway or proxy received an invalid response from the service behind it
503Service Unavailablethe service is temporarily overloaded or down for maintenance; Retry-After can say for how long
504Gateway Timeouta gateway or proxy did not get a response in time from the service behind it

Two of those headers are requirements rather than advice: a 401 "MUST send a WWW-Authenticate header field", and a 405 "MUST generate an Allow header field". 422 has a history too. WebDAV introduced it as Unprocessable Entity, and RFC 9110 brought it into core HTTP as Unprocessable Content, which is why Spring's HttpStatus has both UNPROCESSABLE_ENTITY and UNPROCESSABLE_CONTENT.

Status codes from the test target

The test target has already produced 201 with Location and 204. A product that does not exist:

Bash
curl -i http://localhost:8115/api/products/99
Text
HTTP/1.1 404 
Content-Type: application/json
Transfer-Encoding: chunked
Date: Sat, 12 Sep 2026 07:03:16 GMT
 
{"timestamp":"2026-09-12T07:03:16.162Z","status":404,"error":"Not Found","path":"/api/products/99"}

A method the URL does not support — deleting the entire collection:

Bash
curl -i -X DELETE http://localhost:8115/api/products
Text
HTTP/1.1 405 
Allow: POST, GET
Content-Type: application/json
Transfer-Encoding: chunked
Date: Sat, 12 Sep 2026 07:03:16 GMT
 
{"timestamp":"2026-09-12T07:03:16.168Z","status":405,"error":"Method Not Allowed","path":"/api/products"}

The 405 carries the Allow header the RFC requires. It lists only POST, GET, while the OPTIONS response for the same URL also listed HEAD and OPTIONS, the two methods Spring answers by itself; article 16 returns to that. These JSON bodies are Spring Boot's default error response, which article 20 replaces.

Common status code mistakes

200 with the error in the body. A 200 carrying {"success": false, "error": "not found"} tells every piece of software between the server and the caller — HTTP libraries, proxies, caches, monitoring, retry logic — that the request succeeded. Only code that parses that particular body knows otherwise. Put the outcome in the status code and the details in the body.

500 for a client mistake. A NumberFormatException from a bad parameter or a NullPointerException from a missing field, left unhandled, reaches the client as 500. That says the server is broken, invites a retry for input that will never become valid, and wakes whoever is on call. Check the input and answer with a 4xx.

400 versus 422. RFC 9110 separates them. 400 means the server "cannot or will not process the request due to something that is perceived to be a client error (e.g., malformed request syntax, invalid request message framing, or deceptive request routing)". 422 means the server "understands the content type of the request content (hence a 415 (Unsupported Media Type) status code is inappropriate), and the syntax of the request content is correct, but it was unable to process the contained instructions." Malformed JSON is a 400, and Spring already agrees:

Bash
curl -i -X POST -H 'Content-Type: application/json' -d '{"name":"Effective Java","price":}' http://localhost:8115/api/products
Text
HTTP/1.1 400 
Content-Type: application/json
Transfer-Encoding: chunked
Date: Sat, 12 Sep 2026 07:03:16 GMT
Connection: close
 
{"timestamp":"2026-09-12T07:03:16.198Z","status":400,"error":"Bad Request","path":"/api/products"}

The application log names the cause: HttpMessageNotReadableException: JSON parse error: Unexpected character ('}' (code 125)): expected a value. A well-formed product with a negative price is the kind of request 422 describes. Many APIs answer 400 for both kinds of problem, which is defensible as long as it is consistent; the design at the end of this article keeps them apart.

401 versus 403. 401 means the request "lacks valid authentication credentials": the server does not know who is calling, and it must say how to authenticate in WWW-Authenticate. 403 means the server "understood the request but refuses to fulfill it": it may know exactly who is calling, and the answer is still no. After a 403 the client "SHOULD NOT automatically repeat the request with the same credentials". Chapter 5 produces both.

404 to hide existence, applied inconsistently. Answering 404 instead of 403 for something the caller may not see is explicitly allowed: "An origin server that wishes to 'hide' the current existence of a forbidden target resource MAY instead respond with a status code of 404 (Not Found)." It stops a customer from discovering which order ids exist by trying them. It only works if every such resource gets the same treatment. An API that answers 403 for other customers' orders that exist and 404 for ids that do not has told the caller exactly what it meant to hide.

Headers that matter for an API

HeaderSent inSays
Content-Typerequests and responses with a bodythe media type of this message's body
Acceptrequests, and 406 or 415 responsesthe media types the client will take back, or the server will take in
Location201 and 3xx responsesthe URL of the created resource, or of the redirect target
Allow405 and OPTIONS responsesthe methods the resource supports
Cache-Controlmostly responseswhether a response may be stored, and for how long
ETag / If-None-Matchresponse / requesta version tag for a representation, and "only send it if it changed"
Authorization / WWW-Authenticaterequest / 401 responsethe caller's credentials, and the challenge that asks for them
Retry-After429 and 503 responseshow long to wait before trying again

Content-Type and Accept are the pair that gets confused. Content-Type describes the body of the message it appears in. Accept lists the media types the client is willing to receive. When the server cannot read the type of the request body, it answers 415; when it cannot produce any type that Accept allows, it answers 406. That choice of representation is content negotiation, and the easiest way to trigger it is to forget the Content-Type header in curl:

Bash
curl -i -X POST -d '{"name":"Effective Java","price":45.00,"category":"books"}' http://localhost:8115/api/products
Text
HTTP/1.1 415 
Accept: application/json, application/*+json
Content-Type: application/json
Transfer-Encoding: chunked
Date: Sat, 12 Sep 2026 07:03:16 GMT
 
{"timestamp":"2026-09-12T07:03:16.175Z","status":415,"error":"Unsupported Media Type","path":"/api/products"}

The request half of the same command's -v output shows why:

Text
> POST /api/products HTTP/1.1
> Host: localhost:8115
> User-Agent: curl/8.7.1
> Accept: */*
> Content-Length: 58
> Content-Type: application/x-www-form-urlencoded
> 

With -d and no Content-Type of your own, curl sends application/x-www-form-urlencoded, the format of an HTML form. The endpoint reads only JSON, so it answers 415, and the Accept header in that response lists what it would have taken. RFC 9110 gives Accept exactly that meaning in a response: it "provides information about which content types are preferred in the content of a subsequent request to the same resource."

Location holds a URI reference. The test target sends a relative one, /api/products/4, and RFC 9110 resolves a relative value against the request's target URI, which gives http://localhost:8115/api/products/4.

Cache-Control, ETag and If-None-Match work together. Cache-Control tells browsers and proxies whether a response may be stored and for how long, with directives such as no-store or max-age=60. ETag is an opaque version tag for a representation. A client that already holds a copy sends that tag back in If-None-Match, and if the representation has not changed the server answers 304 Not Modified with no body, so the client keeps using its copy. None of the responses in this article carry these headers, because the test target sends no caching information; HTTP caching is a topic for the Advanced course.

Authorization carries credentials on every request, for example Authorization: Bearer followed by a token, and it is the counterpart of WWW-Authenticate on a 401. Both arrive in Chapter 5.

What REST actually means

REST, Representational State Transfer, is an architectural style that Roy Fielding defined in his 2000 doctoral dissertation. It is not a protocol, a data format or a specification. It is a set of constraints, and HTTP was shaped by them: Fielding is also one of the editors of RFC 9110.

Resources and representations. A resource is anything that has an identifier: product 1, the collection of products, the orders of customer 42. A representation is a snapshot of a resource's state in a transferable format, such as the 61 bytes of JSON the first request in this article received. RFC 9110 uses the same words: a representation "is information that is intended to reflect a past, current, or desired state of a given resource". A client never receives the resource itself, only representations of it, and one product could have a JSON and an XML representation at the same URL.

The constraints, briefly:

  • Client-server — the user interface and the data storage are separate, so each side can change independently.
  • Stateless — each request carries everything the server needs to understand it. There is no server-side session that one request sets up and the next relies on, which is why an API sends credentials in Authorization on every call.
  • Cacheable — responses state whether they may be reused, so the work of answering the same request twice can be skipped.
  • Uniform interface — every resource is handled the same way: identified by a URL, manipulated through representations, with self-descriptive messages (method, status code, Content-Type), and with hypermedia in the responses telling the client what it can do next.
  • Layered system — a client cannot tell whether it is talking to the application or to a gateway, load balancer or cache in front of it. Status codes 502 and 504 exist because of those layers.
  • Code on demand — optional: the server may send executable code, as it sends JavaScript to a browser.

The Richardson Maturity Model, presented by Leonard Richardson at QCon and written up by Martin Fowler in 2010, grades how much of HTTP an API actually uses. Level 0 sends everything through one URL, usually as POST, with the operation named in the body. Level 1 gives each resource its own URL. Level 2 uses the HTTP methods and status codes for their defined meaning, so a PUT is idempotent and a 404 means not found; this chapter builds a Level 2 API. Level 3 adds hypermedia controls, known as HATEOAS (Hypermedia as the Engine of Application State): each response carries links to the actions available next, so a client can follow the API instead of hard-coding its URLs. HATEOAS is covered in the Advanced course.

REST is not "JSON over HTTP". JSON is a format and HTTP is a protocol; neither makes an API RESTful. POST /api/getProducts answering with JSON is JSON over HTTP at Level 0. Fielding goes further: in his 2008 post "REST APIs must be hypertext-driven" he wrote that an API whose application state is not driven by hypertext "cannot be RESTful and cannot be a REST API. Period." In everyday use, "REST API" means Level 2 — resources, methods and status codes used correctly — and that is the meaning this series uses.

RESTful URL design rules

A URL identifies a resource, and the method says what to do with it. Most of the rules below follow from keeping those two jobs apart.

Plural nouns, with the identifier in the path

DoDon't
GET /api/productsGET /api/product, GET /api/productList
GET /api/products/42GET /api/product/42, GET /api/products?id=42

Name a collection with a plural noun and address one member by appending its id. The collection and its members then share a prefix, and /api/products/42 reads as product 42 of the products. The id belongs in the path because it identifies the resource; the query string is for narrowing a collection.

Sub-resources for ownership, and how deep to nest

DoDon't
GET /api/customers/42/ordersGET /api/customerOrders/42
GET /api/orders/1001GET /api/customers/42/orders/1001/items/3

When a resource belongs to another, nest it: /api/customers/42/orders is the orders of customer 42, and a 404 for it can mean that customer does not exist. Filtering a top-level collection with GET /api/orders?customerId=42 is an equally valid choice when orders are often searched across customers; pick one per relationship.

Keep nesting to one level: collection, id, sub-collection. Once a child has an id of its own, give it a top-level URL. An order placed with POST /api/customers/42/orders is answered with Location: /api/orders/1001, and from then on /api/orders/1001 is its address. Deeper paths make every URL depend on the whole chain of parents, even when the client knows only the order id.

DoDon't
GET /api/products?category=booksGET /api/products/category/books, GET /api/books
GET /api/products?sort=price,desc&page=0&size=20GET /api/products/sorted-by-price/page/1
GET /api/products?q=keyboardGET /api/products/search/keyboard

The query string narrows or orders a collection without naming a new resource: the result is still products, only fewer of them or in a different order. The sort=price,desc&page=0&size=20 form, with a zero-based page, is the one Spring Data reads, and Chapter 4 implements it with the database. The test target already filters by category:

Bash
curl -i "http://localhost:8115/api/products?category=books"
Text
HTTP/1.1 200 
Content-Type: application/json
Content-Length: 192
Date: Sat, 12 Sep 2026 07:03:16 GMT
 
[{"id":1,"name":"Clean Code","price":29.90,"category":"books"},{"id":3,"name":"Refactoring","price":47.00,"category":"books"},{"id":4,"name":"Effective Java","price":45.00,"category":"books"}]

Products 1, 3 and 4. Product 2 lost its category to the PUT earlier, so it no longer matches. The quotes around the URL keep the shell from interpreting ? and &.

No verbs in URLs, and what to do with actions

DoDon't
GET /api/productsGET /api/getProducts
POST /api/productsPOST /api/createProduct
DELETE /api/products/42POST /api/products/42/delete, GET /api/deleteProduct?id=42

The method is the verb. A verb in the path repeats it at best and contradicts it at worst: GET /api/deleteProduct?id=42 is a safe method that deletes. RFC 9110 distinguishes safe methods precisely so that "automated retrieval processes (spiders) and cache performance optimization (pre-fetching)" can call them "without fear of causing harm", and such a URL gives them exactly that opportunity.

Some operations are not create, read, update or delete on any resource: cancelling an order, approving an invoice, resending an email. Three designs are common:

DesignExampleTrade-off
action sub-pathPOST /api/orders/1001/cancelexplicit, and easy to document, authorise and log as one operation; the path contains a verb and the result is not a resource you can GET
sub-resourcePOST /api/orders/1001/cancellationstays with nouns and gives the cancellation its own URL, so its reason and time can be read back; one more resource to model for a single state change
state fieldPATCH /api/orders/1001 with {"status":"CANCELLED"}no new URL; the business rules — which transitions are allowed, the refund and the stock that go with them — hide behind a generic field update

The worked design below uses POST /api/orders/{id}/cancel: cancelling is one operation with side effects, and nothing about the cancellation needs to be read back. If it did — a reason, a timestamp, who cancelled — the sub-resource would be the better fit. Either way the method is POST, never GET, because the operation changes state.

Casing, trailing slashes, file extensions and versions

DoDon't
/api/product-categories/api/productCategories, /api/Product_Categories
/api/products/api/products/ as a second spelling of the same URL
/api/products/42 with Accept: application/json/api/products/42.json

Lowercase, with hyphens. RFC 3986 makes the scheme and host of a URI case-insensitive, but "the other generic syntax components are assumed to be case-sensitive". Spring follows that for the path:

Bash
curl -i http://localhost:8115/API/Products
Text
HTTP/1.1 404 
Content-Type: application/json
Transfer-Encoding: chunked
Date: Sat, 12 Sep 2026 07:03:16 GMT
 
{"timestamp":"2026-09-12T07:03:16.212Z","status":404,"error":"Not Found","path":"/API/Products"}

The same request to /api/products returned 200. A URL that works in only one spelling is the reason mixed case is a bad idea: someone will type it the other way. Hyphens keep multi-word segments readable, where camelCase depends on capitals that are easy to get wrong and underscores disappear under a link underline.

One form of each URL, without a trailing slash. To HTTP, /api/products and /api/products/ are two different URLs. Pick the form without the slash and use it everywhere: in controllers, in Location headers, in documentation. What Spring does when a request arrives with the slash anyway is shown in article 16.

No file extensions. The format of a response is negotiated with Accept, not encoded in the path; /api/products/42 is one resource whatever format it is sent in.

Versioning, as /api/v1/products in the path or as a request header or media type parameter, is a decision to make before the first client exists. Spring Framework 7 supports these strategies natively through ApiVersionConfigurer; choosing between them belongs to the Advanced course.

Worked design: the product catalogue endpoints

Articles 16 to 20 implement this API. It applies every rule above: plural nouns, ids in the path, one level of nesting for a customer's orders, query strings for filters, one action URL, and status codes taken from the table.

MethodURLMeaningSuccessTypical errors
GET/api/productslist products; ?category=, ?q=, ?sort=, ?page=, ?size=200400
POST/api/productscreate a product201 + Location400, 415, 422
GET/api/products/{id}read one product200404
PUT/api/products/{id}replace a product200400, 404, 415, 422
PATCH/api/products/{id}change some fields of a product200400, 404, 415, 422
DELETE/api/products/{id}delete a product204404
GET/api/customerslist customers; ?page=, ?size=200400
POST/api/customersregister a customer201 + Location400, 409 email already registered, 415, 422
GET/api/customers/{id}read one customer200404
GET/api/customers/{id}/ordersthat customer's orders; ?status=200400, 404 no such customer
POST/api/customers/{id}/ordersplace an order for that customer201 + Location: /api/orders/{orderId}400, 404 no such customer, 409 not enough stock, 415, 422
GET/api/ordersall orders; ?status=, ?page=, ?size=200400
GET/api/orders/{id}read one order200404
POST/api/orders/{id}/cancelcancel an order200404, 409 already shipped

A few decisions in it deserve a sentence each:

  • Ids are assigned by the server. POST creates; PUT and PATCH only change what exists, so a PUT to an unknown id is 404, as the test target showed.
  • An order is created under its customer and lives on its own. POST /api/customers/{id}/orders answers 201 with Location: /api/orders/{orderId}. The nested URL means this customer's orders, the top-level one means this order.
  • Orders are cancelled, not deleted. An order is a business record, so there is no DELETE /api/orders/{id}.
  • 400 and 422 are kept apart. 400 for a body or parameter that cannot be read; 422 for one that reads correctly but breaks a rule, such as a negative price.
  • 409 is about current state: an email that is already registered, stock that has run out, an order that has already shipped.
  • Only the errors specific to an endpoint are listed. Any endpoint can also answer 500 when something breaks, and 401 or 403 once security is added in Chapter 5.
  • Pagination parameters are part of the contract from the start. page, size and sort are implemented together with the database in Chapter 4.

The worked design as a resource tree under /api, with the methods each URL accepts and the 201 Location arrow from a customer's orders to the order's own URL

FAQ

What is the difference between PUT and PATCH?

PUT sends the complete new state and replaces the resource, so anything missing from the body is gone, as "category":null showed above. PATCH sends only the changes. PUT is idempotent by definition; PATCH is not, although a patch that sets fields to fixed values happens to be. Use PATCH when clients update a few fields, and PUT when they own the whole representation.

Should a POST that creates a resource return 200 or 201?

201, with a Location header holding the new resource's URL. RFC 9110 identifies the created resource by that header "or, if no Location header field is received, by the target URI", and for POST /api/products the target URI is the collection, not the new product. Returning the created representation in the body as well saves the client a second request.

Can a GET request have a body?

The message format does not stop it, but RFC 9110 says such content "has no generally defined semantics, cannot alter the meaning or target of the request, and might lead some implementations to reject the request and close the connection". Put the parameters of a GET in the path and the query string.

Why does Spring Boot answer HTTP/1.1 200 without OK?

Because embedded Tomcat 11 sends no reason phrase, and HTTP/1.1 allows that: the phrase is optional in the status line, and RFC 9112 tells clients to ignore it. Clients act on the number.

Should an API return 403 or 404 when the caller may not see a resource?

RFC 9110 allows either. 403 is fine when the existence of the resource is not a secret. 404 hides whether it exists, which matters for ids a caller could enumerate, such as other customers' orders. Whichever you choose, apply it to every such resource, or the difference between the two answers reveals what you tried to hide.

Is every API that returns JSON a REST API?

No. JSON is a format, and REST is a set of architectural constraints. An API that funnels everything through POST /api with the operation named in the body is JSON over HTTP. In everyday use, "REST API" means resources with their own URLs and methods and status codes used for their defined meaning, Level 2 of the Richardson Maturity Model, while Roy Fielding reserves the term for APIs that are also driven by hypermedia.

Conclusion

HTTP is lines of text: a start line, header fields, an empty line and a body, and every part of an API maps onto one of them. Methods carry promises — GET, HEAD and OPTIONS are safe, PUT and DELETE are idempotent, POST and PATCH are neither — and the same POST sent twice made two products while the same PUT sent twice left one state. Status codes put the outcome where every client and intermediary can read it, with Location, Allow, WWW-Authenticate and Retry-After next to the codes that need them. REST is a set of constraints rather than a data format, and the URL rules follow from letting URLs name resources while methods say what to do with them. The worked design turns all of that into fourteen endpoints.

The next article starts implementing them: @RestController and request mapping with @GetMapping, @PostMapping, @PutMapping and @DeleteMapping.

Related Posts

[Spring Boot Basics] How Spring Boot Auto-Configuration Works: Conditions, Back-Off and the --debug Report

The mechanism behind Spring Boot 4.1.1 auto-configuration, opened up and measured: @EnableAutoConfiguration and AutoConfigurationImportSelector, the META-INF/spring/…AutoConfiguration.imports files that Boot 4 spreads across small modules, the @ConditionalOnClass / @ConditionalOnMissingBean family with a custom Condition of your own, a back-off demonstration with real before-and-after numbers, and how to read the --debug CONDITIONS EVALUATION REPORT.

[Spring Boot Basics] IoC and Dependency Injection in Spring: Why You Stop Calling new

The idea the whole framework rests on, demonstrated on Spring Boot 4.1.1 and Java 21: a four-class object graph built with new at every level and the three failures that follow, Inversion of Control and Dependency Injection named separately, the same graph hand-wired in main with no framework at all, then wired by the Spring container with the injected instance identities printed to prove it, plus a JUnit 5 test with a hand-made stub, an implementation swapped without touching its consumer, and an honest list of what the container costs you.

[Spring Boot Basics] Validation in Spring Boot: Bean Validation Annotations, @Valid and Custom Validators

Bean Validation in Spring Boot 4.1.1 with Hibernate Validator 9.1.3, checked against real runs: spring-boot-starter-validation, @NotNull vs @NotEmpty vs @NotBlank, @Size, @DecimalMin, @Digits, @Email and @Pattern on request DTO records, @Valid on @RequestBody and the default 400, nested objects and lists, @PathVariable and @RequestParam validation and the @Validated 500 trap, validation groups, ValidationMessages.properties and Accept-Language, custom ConstraintValidator and cross-field constraints, and validation in the service layer.

[Spring Boot Basics] Spring Beans and the ApplicationContext: @Component, Stereotypes and Component Scanning

What a Spring bean actually is and which of your objects should never be one, @Component proved to be the meta-annotation behind @Service, @Repository and @Controller, what each stereotype really adds at run time, how component scanning turns class files into BeanDefinitions, the bean-naming rule including the two-capitals case, the ApplicationContext API with the real bean count of a Spring Boot 4.1.1 app, and three registration failures reproduced with their actual messages.