Skip to content
SparkTime, home
Java From Zero to Job-Ready
Lessons
1 · Java Foundations
2 · Object-Oriented Java
3 · Core Java
4 · Collections and Generics
5 · Functional Java
6 · The JVM and Concurrency
7 · Tools and Craft
8 · Databases and JDBC
9 · Web and Spring
10 · Persistence with JPA
11 · Secure, Tested, Production-Ready
12 · Interviews and Readiness
Lesson 30 of 41

Lesson 30 · Web and Spring

HTTP, JSON and REST APIs: How Web Backends Talk

Understand what actually travels between a client and a Java backend: the HTTP request and response, methods and their guarantees, status codes, headers, JSON and how Java maps to it, and how to design a clean REST API, with real captured traffic and real JSON mapping.

On this page

Key takeaways

  • HTTP is a text protocol of requests and responses. A request has a method, a path, headers and an optional body. A response has a status code, headers and an optional body.
  • GET is safe and idempotent, PUT and DELETE are idempotent, POST is neither. Idempotent means that repeating the request leaves the server in the same state as sending it once, which matters when a client retries after a timeout.
  • Status codes tell the client what happened: 2xx success, 4xx the client's mistake, 5xx the server's. Choose them deliberately, for example 201 with a Location header for a created resource and 404 for a missing one.
  • JSON is the usual body format. Libraries such as Jackson convert between JSON and Java objects, and the conversion silently accepts missing fields, so a real API must validate its input.
  • A REST API is organised around resources named by nouns in URLs, manipulated with the standard methods, with consistent errors, filtering and paging.

Almost every backend job involves building or calling web APIs: a mobile app, a browser front end or another service sends a request over the network, and your Java code sends back a response. Frameworks such as Spring hide the plumbing, but you cannot debug, design or secure an API without knowing what actually travels over the wire. This article shows it, with real captured traffic.

What you will learn

#
  • What an HTTP request and response contain, shown as real bytes.
  • The methods, their guarantees (safe, idempotent) and the status codes.
  • What JSON is and how Java objects map to it.
  • How to design a clean REST API, with errors, filtering and paging.

Clients, servers and URLs

#

A client (a browser, a phone app, a Java program) sends a request to a server, which sends back a response. Each request is independent. The server does not remember the previous one unless you add a mechanism to do so, which is why HTTP is called stateless. Statelessness is a major reason web servers scale: any server can answer any request.

A URL says where a resource is:

Code
https://api.example.com:8443/api/expenses/42?month=2026-09#details\___/   \_____________/\___/\_____________/ \__________/ \_____/scheme       host      port      path          query     fragment

Part

Meaning

Scheme

The protocol: http or https

Host and port

Which machine and which service on it (the defaults are 80 for http and 443 for https)

Path

Which resource on the server

Query

Extra parameters as name=value pairs joined with &

Fragment

Used by the browser only. It is never sent to the server

HTTPS is HTTP inside an encrypted TLS connection. It protects against eavesdropping and tampering, and lets the client verify the server through a certificate. Credentials, tokens and personal data must never travel over plain HTTP. (HTTP/2 and HTTP/3 change how the bytes are carried, but the ideas on this page are the same.)

An HTTP exchange, as bytes

#

HTTP/1.1 is a text protocol, and you can read it. The program below starts a tiny HTTP server inside the JVM, using only classes in the JDK, and then does two things. First it connects with a plain socket and sends a handwritten request, so that you see the raw text going out and coming back. Then it uses the JDK's HttpClient to call the API with several methods and prints the status codes. The server is a toy: it stores the JSON text it is given and validates nothing.

Java
// File: Main.javaimport com.sun.net.httpserver.HttpExchange;import com.sun.net.httpserver.HttpServer;import java.io.BufferedReader;import java.io.IOException;import java.io.InputStreamReader;import java.io.OutputStream;import java.net.InetSocketAddress;import java.net.Socket;import java.net.URI;import java.net.http.HttpClient;import java.net.http.HttpRequest;import java.net.http.HttpResponse;import java.nio.charset.StandardCharsets;import java.util.Map;import java.util.TreeMap;public class Main {    // A tiny in-memory "expenses" resource. It stores whatever JSON text it is given and does no validation.    static final Map<Integer, String> STORE = new TreeMap<>();    static int nextId = 2;    static void handle(HttpExchange exchange) throws IOException {        String method = exchange.getRequestMethod();        String[] parts = exchange.getRequestURI().getPath().split("/");   // "", "expenses", maybe "1"        Integer id = parts.length > 2 ? Integer.valueOf(parts[2]) : null;        String body = new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8);        String contentType = exchange.getRequestHeaders().getFirst("Content-Type");        if (id == null) {                                                    // the collection: /expenses            switch (method) {                case "GET" -> send(exchange, 200, "[" + String.join(",", STORE.values()) + "]");                case "POST" -> {                    if (contentType == null || !contentType.startsWith("application/json")) {                        send(exchange, 415, "{\"error\":\"send application/json\"}");                        return;                    }                    int newId = nextId++;                    STORE.put(newId, body);                    exchange.getResponseHeaders().add("Location", "/expenses/" + newId);                    send(exchange, 201, body);                }                default -> {                    exchange.getResponseHeaders().add("Allow", "GET, POST");                    send(exchange, 405, "{\"error\":\"method not allowed\"}");                }            }            return;        }        switch (method) {                                                    // one item: /expenses/{id}            case "GET" -> {                String found = STORE.get(id);                send(exchange, found == null ? 404 : 200, found == null ? "{\"error\":\"not found\"}" : found);            }            case "PUT" -> {                STORE.put(id, body);                                         // replace, or create at this id                send(exchange, 200, body);            }            case "DELETE" -> {                if (STORE.remove(id) == null) {                    send(exchange, 404, "{\"error\":\"not found\"}");                } else {                    send(exchange, 204, null);                }            }            default -> {                exchange.getResponseHeaders().add("Allow", "GET, PUT, DELETE");                send(exchange, 405, "{\"error\":\"method not allowed\"}");            }        }    }    static void send(HttpExchange exchange, int status, String body) throws IOException {        if (body == null) {            exchange.sendResponseHeaders(status, -1);                        // no body        } else {            byte[] bytes = body.getBytes(StandardCharsets.UTF_8);            exchange.getResponseHeaders().add("Content-Type", "application/json");            exchange.sendResponseHeaders(status, bytes.length);            try (OutputStream out = exchange.getResponseBody()) {                out.write(bytes);            }        }        exchange.close();    }    static void rawExchange(int port) throws IOException {        String request = "GET /expenses/1 HTTP/1.1\r\n"                + "Host: localhost\r\n"                + "Accept: application/json\r\n"                + "Connection: close\r\n"                + "\r\n";        System.out.println("--- bytes sent by the client ---");        System.out.print(request.replace("\r\n", "\n"));        System.out.println("--- bytes received ---");        try (Socket socket = new Socket("127.0.0.1", port)) {            socket.getOutputStream().write(request.getBytes(StandardCharsets.US_ASCII));            BufferedReader in = new BufferedReader(new InputStreamReader(socket.getInputStream(), StandardCharsets.UTF_8));            String line;            while ((line = in.readLine()) != null) {                if (!line.startsWith("Date:")) {                             // the time changes on every run                    System.out.println(line);                }            }        }        System.out.println();    }    static void call(HttpClient client, String method, String url, String contentType, String body) throws Exception {        HttpRequest.Builder builder = HttpRequest.newBuilder(URI.create(url));        HttpRequest.BodyPublisher publisher = body == null                ? HttpRequest.BodyPublishers.noBody() : HttpRequest.BodyPublishers.ofString(body);        builder.method(method, publisher);        if (contentType != null) {            builder.header("Content-Type", contentType);        }        HttpResponse<String> response = client.send(builder.build(), HttpResponse.BodyHandlers.ofString());        String path = URI.create(url).getPath();        StringBuilder line = new StringBuilder(String.format("%-6s %-14s -> %d", method, path, response.statusCode()));        response.headers().firstValue("Location").ifPresent(v -> line.append("  Location: ").append(v));        response.headers().firstValue("Allow").ifPresent(v -> line.append("  Allow: ").append(v));        System.out.println(line);    }    public static void main(String[] args) throws Exception {        STORE.put(1, "{\"id\":1,\"amountCents\":1250,\"description\":\"lunch with Sam\"}");        HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);        server.createContext("/expenses", exchange -> {            try {                handle(exchange);            } catch (Exception e) {                e.printStackTrace();                exchange.sendResponseHeaders(500, -1);                exchange.close();            }        });        server.start();        int port = server.getAddress().getPort();        String base = "http://127.0.0.1:" + port;        try {            rawExchange(port);            HttpClient client = HttpClient.newHttpClient();            String json = "application/json";            String item = "{\"amountCents\":450,\"description\":\"bus\"}";            call(client, "GET", base + "/expenses/1", null, null);            call(client, "GET", base + "/expenses/99", null, null);            call(client, "POST", base + "/expenses", json, item);            call(client, "POST", base + "/expenses", json, item);            call(client, "POST", base + "/expenses", "text/plain", "450 bus");            call(client, "PUT", base + "/expenses/1", json, item);            call(client, "PUT", base + "/expenses/1", json, item);            call(client, "DELETE", base + "/expenses/2", null, null);            call(client, "DELETE", base + "/expenses/2", null, null);            call(client, "PATCH", base + "/expenses/1", json, item);            call(client, "DELETE", base + "/expenses", null, null);            System.out.println("stored now: " + STORE.keySet());        } finally {            server.stop(0);        }    }}
Code
--- bytes sent by the client ---GET /expenses/1 HTTP/1.1Host: localhostAccept: application/jsonConnection: close--- bytes received ---HTTP/1.1 200 OKContent-type: application/jsonContent-length: 58{"id":1,"amountCents":1250,"description":"lunch with Sam"}GET    /expenses/1    -> 200GET    /expenses/99   -> 404POST   /expenses      -> 201  Location: /expenses/2POST   /expenses      -> 201  Location: /expenses/3POST   /expenses      -> 415PUT    /expenses/1    -> 200PUT    /expenses/1    -> 200DELETE /expenses/2    -> 204DELETE /expenses/2    -> 404PATCH  /expenses/1    -> 405  Allow: GET, PUT, DELETEDELETE /expenses      -> 405  Allow: GET, POSTstored now: [1, 3]

The raw exchange

#

The first part of the output is the whole conversation:

  • The request line GET /expenses/1 HTTP/1.1 holds the method (what to do), the path (which resource) and the protocol version.
  • Headers are Name: value lines. Host says which site is wanted (one server can host many), Accept says which formats the client can handle, and Connection: close asks the server to close the connection after answering.
  • A blank line ends the headers. A request may then have a body, which this one does not, because a GET normally carries no body.
  • The response status line HTTP/1.1 200 OK holds the status code and its reason phrase.
  • Response headers: Content-type says that the body is JSON, and Content-length says how many bytes follow. Header names are case-insensitive, which is why this server's Content-type is the same header as the Content-Type your code sets. (A real server also sends a Date header, which the program filters out so that the output is repeatable.)
  • After another blank line comes the body: the JSON.

That is all HTTP is: a request in this shape, a response in that shape. A browser, curl, Postman and Spring all produce and parse exactly this.

Methods and what they promise

#

The method says what the client wants to do. Two properties from the HTTP standard (RFC 9110) matter for API design:

  • A method is safe if it is meant only to read, so the client does not ask for any change. GET is safe.
  • A method is idempotent if making the same request several times has the same effect on the server's state as making it once. GET, PUT and DELETE are idempotent. POST is not.

Method

Typical use

Safe

Idempotent

GET

Read a resource or a list

Yes

Yes

POST

Create a resource, or run an action

No

No

PUT

Replace a resource entirely at a known URL

No

Yes

PATCH

Change part of a resource

No

Not guaranteed

DELETE

Remove a resource

No

Yes

The second part of the program's output demonstrates this on a real server. Read it top to bottom:

  • POST /expenses twice gave two 201 Created responses with different Location headers (/expenses/2 and /expenses/3): sending a POST twice created two resources. This is why a client that is unsure whether its POST arrived cannot safely just repeat it.
  • PUT /expenses/1 twice gave 200 both times and left the same state: replacing a resource with the same content again changes nothing.
  • DELETE /expenses/2 returned 204 No Content, and the second identical DELETE returned 404. The server state after the second call is the same (the resource is gone), which is what idempotent means, even though the response differed.
  • PATCH on a resource that does not allow it, and DELETE on the collection, returned 405 Method Not Allowed with an Allow header listing what is permitted.
  • POST with Content-Type: text/plain returned 415 Unsupported Media Type, because the server accepts only JSON.
  • At the end, stored now: [1, 3]: resource 1 exists (replaced), 2 was deleted, 3 was created by the second POST.

Why idempotency matters: networks fail. If a client sends a request and the connection drops before the response arrives, it does not know whether the server processed it. For an idempotent request it can safely retry. For a POST that creates an order or charges a card, a blind retry could do it twice. APIs solve this with an idempotency key: the client sends a unique key in a header, and the server remembers it and returns the original result for a repeated key.

Status codes

#

The status code is a three-digit number whose first digit gives the class. Use the code that is correct for the situation, because clients, proxies, monitoring tools and other developers rely on it.

Code

Name

Use it when

200

OK

A successful read or update with a body

201

Created

A resource was created. Add a Location header with its URL

202

Accepted

The request was accepted for later processing

204

No Content

Success with nothing to return (a delete)

301, 302

Moved

The resource lives at another URL

304

Not Modified

The client's cached copy is still valid

400

Bad Request

The request is malformed or invalid (bad JSON, a failed validation)

401

Unauthorized

The client is not authenticated (the name is historical: it means "who are you?")

403

Forbidden

The client is authenticated but not allowed to do this

404

Not Found

No such resource

405

Method Not Allowed

The resource does not support that method. Send an Allow header

409

Conflict

The request conflicts with the current state (a duplicate, an edit conflict)

415

Unsupported Media Type

The body is in a format the server does not accept

422

Unprocessable Content

The request is well-formed but semantically invalid (some APIs use this for validation failures, others use 400)

429

Too Many Requests

The client is being rate-limited

500

Internal Server Error

A bug or unexpected failure on the server

502, 503, 504

Bad Gateway, Service Unavailable, Gateway Timeout

A dependency or the service itself is unavailable

The most important distinction: 4xx means the client should change something, 5xx means the server failed. A validation error answered with 500 makes monitoring page you at night for a user's typo, and a server crash answered with 400 hides your bug. Another pair that is often mixed up is 401 and 403, covered in article 36. Never put stack traces, SQL or internal class names in an error response, because they help an attacker.

Headers you will meet

#

Header

Direction

Purpose

Content-Type

Both

The format of the body (application/json)

Accept

Request

The formats the client can handle

Content-Length

Both

The size of the body in bytes

Location

Response

The URL of a newly created resource

Authorization

Request

Credentials or a token

Cache-Control, ETag

Response

Rules for caching, and a version tag for conditional requests

Allow

Response

The methods a resource supports (with 405)

Origin and Access-Control-*

Both

Cross-origin browser rules, covered in the security article

JSON

#

JSON (JavaScript Object Notation, RFC 8259) is the usual format for API bodies: text, readable, and understood by every language. It has exactly six kinds of value:

JSON
{  "id": 42,  "description": "lunch with Sam",  "amountCents": 1250,  "paid": true,  "category": null,  "tags": ["work", "shared"],  "receipt": { "number": "R-17", "pages": 1 }}

JSON type

Example

Java type it usually maps to

Object

{ "a": 1 }

A class or record, or a Map

Array

[1, 2, 3]

A List or an array

String

"text" (double quotes only)

String, and also dates and enums

Number

12, 1.5, -3e2

int, long, double, BigDecimal

Boolean

true, false

boolean

null

null

null

The rules that catch people: strings and keys use double quotes only, there are no comments and no trailing commas, and every document is a single value (usually an object or an array). Mistakes in these cause parse errors.

Design conventions that avoid trouble:

  • Dates and times are strings in ISO 8601 format ("2026-09-03", "2026-09-03T10:15:00Z"), because JSON has no date type.
  • Money is an integer in the smallest unit (1250 cents) or a decimal string, never a floating-point number that you compute with (article 2).
  • Large integers: JSON itself does not limit number size, but JavaScript clients read numbers as double-precision floats, which are exact only up to 2^53 − 1. A 64-bit id above that can silently change in a browser, so APIs sometimes send such ids as strings.
  • null and "absent" are different in principle. Decide what each means in your API, particularly for partial updates.
  • Use consistent names, usually camelCase.

Java objects to JSON and back

#

Converting between JSON and Java objects is called serialisation and deserialisation (or marshalling). In the Spring world the library is Jackson. Spring Boot 4 uses Jackson 3, whose packages start with tools.jackson (the annotations still live in com.fasterxml.jackson.annotation). This program, built with Maven and run on JDK 21 and 25 with identical output, shows the mapping and what happens with bad input:

Java
// File: src/main/java/com/example/jsondemo/JsonDemo.javapackage com.example.jsondemo;import java.time.LocalDate;import java.util.List;import tools.jackson.core.JacksonException;import tools.jackson.core.type.TypeReference;import tools.jackson.databind.SerializationFeature;import tools.jackson.databind.json.JsonMapper;public class JsonDemo {    record Expense(long id, LocalDate spentOn, long amountCents, String category, String description) {    }    record NewExpense(LocalDate spentOn, long amountCents, String category, String description) {    }    static void tryRead(JsonMapper mapper, String label, String json) {        try {            System.out.println(label + ": " + mapper.readValue(json, NewExpense.class));        } catch (JacksonException e) {            String message = e.getOriginalMessage();            if (message.length() > 90) {                message = message.substring(0, 90) + "...";             // the full message is very long            }            System.out.println(label + ": " + e.getClass().getSimpleName() + " - " + message);        }    }    public static void main(String[] args) {        JsonMapper mapper = JsonMapper.builder().build();        JsonMapper pretty = JsonMapper.builder().enable(SerializationFeature.INDENT_OUTPUT).build();        Expense expense = new Expense(1, LocalDate.of(2026, 9, 3), 1250, "FOOD", "lunch with Sam");        System.out.println("Java object to JSON:");        System.out.println(pretty.writeValueAsString(expense));        String body = "{\"spentOn\":\"2026-09-05\",\"amountCents\":4500,\"category\":\"TRANSPORT\","                + "\"description\":\"metro pass\"}";        NewExpense received = mapper.readValue(body, NewExpense.class);        System.out.println("JSON to Java object:");        System.out.println(received);        String list = "[{\"id\":1,\"spentOn\":\"2026-09-03\",\"amountCents\":1250,\"category\":\"FOOD\",\"description\":\"a\"},"                + "{\"id\":2,\"spentOn\":\"2026-09-05\",\"amountCents\":4500,\"category\":\"TRANSPORT\",\"description\":null}]";        List<Expense> expenses = mapper.readValue(list, new TypeReference<List<Expense>>() { });        System.out.println("A JSON array becomes a list of " + expenses.size() + ", the second description is "                + expenses.get(1).description());        System.out.println("Bad input:");        tryRead(mapper, "missing fields    ", "{\"amountCents\":100}");        tryRead(mapper, "unknown field     ", "{\"amountCents\":100,\"colour\":\"red\"}");        tryRead(mapper, "wrong type        ", "{\"amountCents\":\"lots\"}");        tryRead(mapper, "invalid date      ", "{\"spentOn\":\"05/09/2026\",\"amountCents\":100}");        tryRead(mapper, "not JSON at all   ", "{\"amountCents\": }");    }}
Code
Java object to JSON:{  "id" : 1,  "spentOn" : "2026-09-03",  "amountCents" : 1250,  "category" : "FOOD",  "description" : "lunch with Sam"}JSON to Java object:NewExpense[spentOn=2026-09-05, amountCents=4500, category=TRANSPORT, description=metro pass]A JSON array becomes a list of 2, the second description is nullBad input:missing fields    : NewExpense[spentOn=null, amountCents=100, category=null, description=null]unknown field     : NewExpense[spentOn=null, amountCents=100, category=null, description=null]wrong type        : InvalidFormatException - Cannot deserialize value of type `long` from String "lots": not a valid `long` valueinvalid date      : InvalidFormatException - Cannot deserialize value of type `java.time.LocalDate` from String "05/09/2026": Failed to...not JSON at all   : StreamReadException - Unexpected character ('}' (code 125)): expected a value

What this teaches:

  • A record maps naturally to a JSON object. The date became an ISO string, and long became a number. Jackson 3 reads and writes java.time types by default.
  • Reading is just as direct, and TypeReference lets Jackson handle a generic type such as List<Expense>, a consequence of type erasure (article 14). A JSON null becomes a Java null.
  • Missing fields do not cause an error. {"amountCents":100} produced an object with spentOn, category and description all null. The mapper converts, it does not validate. If your API trusts it, an invalid request creates an invalid object. Article 33 adds Bean Validation for this.
  • Unknown fields were ignored by the default settings of this Jackson version. This is configurable, and the behaviour has differed between libraries and versions, so decide deliberately and test it.
  • Wrong types and malformed JSON throw exceptions (InvalidFormatException, StreamReadException). In a web application those must be turned into a 400 Bad Request with a clear message, not a 500.

What REST means

#

REST (Representational State Transfer) is an architectural style for web APIs, and "REST API" in practice means an HTTP API with these characteristics:

  1. Resources are the nouns of your domain (an expense, a job application), each with a URL. The client works with representations of them (usually JSON).
  2. The standard methods express the operation, not the URL. You do not write /getExpense or /deleteExpense.
  3. Stateless requests: each request carries what the server needs.
  4. Standard status codes and headers carry the outcome.
  5. A uniform, predictable structure so that a developer who has seen one endpoint can guess the others.

Designing the URLs

#
Code
GET    /api/expenses             list expenses (supports filtering and paging)POST   /api/expenses             create an expenseGET    /api/expenses/42          read onePUT    /api/expenses/42          replace onePATCH  /api/expenses/42          change some fieldsDELETE /api/expenses/42          delete oneGET    /api/expenses/summary     an aggregate view of the collection

Rules of thumb:

  • Use plural nouns for collections (/expenses), and the id for one item (/expenses/42).
  • Nest only when the child makes no sense without the parent: /applications/7/notes for the notes of application 7. Avoid nesting more than one or two levels.
  • Put filtering, sorting and paging in query parameters: /api/expenses?month=2026-09&category=FOOD&sort=spentOn,desc&page=0&size=20.
  • Do not put verbs or actions in the path when a method expresses it. For something that is truly an action, a sub-resource noun is acceptable (POST /applications/7/withdrawal), and do not use GET for anything that changes state.
  • Do not leak your database design: the URL structure serves the client's needs, not your tables.
  • Version the API when you must make a breaking change, for example with /api/v1/..., and avoid breaking changes whenever possible: adding a field is compatible, removing or renaming one is not.

What each operation should return

#

Operation

Success

Typical errors

List (GET /expenses)

200 with an array (empty is fine, not 404)

400 for an invalid filter

Read one

200

404

Create (POST)

201 with Location: /api/expenses/43 and the created resource

400 invalid, 409 duplicate, 415 wrong format

Replace (PUT)

200 with the resource, or 204

400, 404

Delete

204

404 (or 204 if you treat it as already gone)

Paging

#

A list that can grow must be paged: never return a million rows. The common approach is page and size (or offset and limit) parameters, with the response saying how many items there are in total. It is simple, but a deep page is slow in the database (article 27's OFFSET) and results can shift while the client pages. Cursor-based paging (?after=<last id seen>) avoids both. Choose one, apply it to every collection, and set a maximum page size so a client cannot ask for everything.

Errors

#

Every failure should have the same shape, so a client can handle it generically. RFC 9457 defines a standard format called problem details, with the media type application/problem+json:

JSON
{  "type": "https://example.com/problems/validation-error",  "title": "Invalid expense",  "status": 400,  "detail": "amountCents must be greater than zero",  "instance": "/api/expenses"}

status repeats the HTTP status, title is a short summary, detail explains this occurrence, and type identifies the kind of problem. You may add fields, such as a list of the invalid fields. Spring supports this format directly, as article 33 shows. The rules: a stable machine-readable structure, a message the client developer can act on, and no internal details (stack traces, SQL, class names).

Looking ahead: the Job Application Tracker

#

Project 3 builds an API for tracking job applications. Sketching the resources now shows the method:

Resource

Endpoints

Applications

GET/POST /api/applications, GET/PUT/DELETE /api/applications/{id}

Status changes

POST /api/applications/{id}/status (an action with a body such as {"status": "INTERVIEW"})

Notes on an application

GET/POST /api/applications/{id}/notes

Filtering

GET /api/applications?status=INTERVIEW&company=Acme&page=0&size=20

Tools for trying APIs

#

The command-line tool curl, graphical clients such as Postman and Bruno, your browser's developer tools, and a Java HttpClient all send requests like the ones above. A typical curl call to create an expense is curl -i -X POST http://localhost:8080/api/expenses -H "Content-Type: application/json" -d '{"amountCents":450}', where -i prints the response headers and -d supplies the body. You will use it against the real Spring Boot API in the next articles.

Common mistakes

#
  • Using GET for actions that change data, which breaks caching, crawlers, prefetching and the guarantees clients rely on.
  • Returning 200 with an error message in the body instead of a proper status code.
  • Answering every failure with 500, or every client error with 400 without a useful message.
  • Verbs in URLs (/createExpense) instead of resources and methods.
  • Trusting incoming JSON because Jackson accepted it. Missing fields are null, so validate.
  • Returning database entities directly as the API shape, so a schema change breaks clients.
  • Unbounded lists with no paging.
  • Leaking stack traces or SQL errors in responses.
  • Sending secrets in URLs. URLs are logged by servers, proxies and browsers. Put credentials in headers or bodies, over HTTPS.
  • Retrying POST blindly after a timeout.

Exercises

#

Exercise 1

Choose method and status

#

For each, state the method, a suitable URL and the success status code: (a) list this month's expenses, (b) add an expense, (c) remove expense 7, (d) read expense 7 when it does not exist, (e) change only the description of expense 7.

Exercise 2: Fix the JSON

#

What is wrong with each, and what is the correct JSON?

Code
{ 'amount': 450, "description": "bus", }{ "amount": 450, // cents  "description": "bus" }{ "amount": 4.50 "description": "bus" }

Exercise 3: Which code?

#

Choose a status code and give a reason: (a) a request with the JSON {"amountCents": cut off, (b) a valid user asks for another user's data they may not see, (c) a user who sent no token, (d) the database is down, (e) creating an application that already exists with the same unique reference.

Exercise 4: The retry

#

A mobile app sends POST /api/payments, loses the connection, and does not know whether the payment was created. What can go wrong if it simply retries, and how does an API fix that?

Exercise 5: Design the endpoints

#

Design the endpoints for notes on a job application: add a note, list the notes of one application, delete a note, with status codes.

Show solutionHide solution

(a) GET /api/expenses?month=2026-09, 200. (b) POST /api/expenses, 201 with a Location header. (c) DELETE /api/expenses/7, 204. (d) GET /api/expenses/7 returns 404. (e) PATCH /api/expenses/7 with {"description":"new text"}, 200 with the updated resource (PUT would need the whole resource).

Exercise 2

Show solutionHide solution

First: JSON needs double quotes around keys and strings, and has no trailing comma. Second: JSON has no comments. Third: a comma is missing between the members. The correct versions are all the same document, which is valid JSON:

JSON
{ "amount": 450, "description": "bus" }

For a decimal amount, 4.50 is a valid JSON number, but money is better sent as integer cents.

Exercise 3

Show solutionHide solution

(a) 400, because the body is malformed JSON, so it is a client error. (b) 403, because the client is authenticated but not allowed. (Some APIs return 404 instead so as not to reveal that the data exists, which is a deliberate choice.) (c) 401, because the client has not authenticated. (d) 503 Service Unavailable (or 500 if you treat it as an unexpected failure): it is the server's problem, not the client's. (e) 409 Conflict, because the request conflicts with the existing state.

Exercise 4

Show solutionHide solution

If the first request actually succeeded, the retry creates a second payment, because POST is not idempotent, so the customer is charged twice. The usual fix is an idempotency key: the client generates a unique value for the payment and sends it in a header (such as Idempotency-Key) with every attempt. The server stores the key with the result, and when it sees the same key again, it returns the original result instead of creating another payment.

Exercise 5

Show solutionHide solution

POST /api/applications/{id}/notes returns 201 with a Location of the new note, 404 if the application does not exist, and 400 if the body is invalid. GET /api/applications/{id}/notes returns 200 with an array (empty if there are none), and 404 if the application does not exist. DELETE /api/applications/{id}/notes/{noteId} returns 204, and 404 if either does not exist. The note is nested because it has no meaning without its application. The server must also check that the note belongs to that application, so one cannot delete another application's note by guessing an id.

Where to go next

#

Sources & further reading

  1. rfc-editor.org
  2. rfc-editor.org
  3. rfc-editor.org
  4. developer.mozilla.org
  • Guide · Technology

    Heaps and Priority Queues

  • Guide · Technology

    Dynamic Programming Practice: From 1D to 2D

  • Course · Technology

    DSA From Zero to Job-Ready: A Practical Guide with Java