RESTful API Design and a Unified Response Envelope

bee2026-10-0851 min read0 views
What actually counts as RESTful? URL design, status-code semantics, idempotency and versioning — plus a production-grade response envelope and business exception system.
1 / 123
Section
1. REST's six constraints, in plain words
2 / 123

REST has been mythologized for too long. It is not "an HTTP API that sends JSON" — that is an HTTP API, not REST. The six constraints Roy Fielding laid out really answer one question: how do you let a system keep evolving as it grows? We only need to care about the few we can actually apply to business endpoints.

3 / 123
Table
ConstraintPlain meaningWhat we can realistically do
Client–server separationThe front end shows, the back end holds data; neither depends on the other's internalsDecouple them with an API contract
StatelessnessEvery request carries all it needs; the server keeps no per-client contextAuthenticate with tokens, not server sessions
CacheabilityResponses must declare whether they may be cachedUse Cache-Control and ETag correctly
Uniform interfaceUniform resource identifiers, representations, self-descriptive messages, hypermediaNoun-based URLs + standard methods + standard status codes
Layered systemIntermediaries such as gateways, proxies and load balancers are allowedCentralize auth and rate limiting at a gateway
Code on demand (optional)The server may ship executable codeRarely used; ignore it
4 / 123
Key point

only "statelessness" and "uniform interface" carry real value in practice. The first decides whether you can scale horizontally, the second whether a stranger can guess how to call your API without reading docs. The other four are architectural ideals; business projects need about seventy percent of them.

5 / 123
Section
2. URL design rules
6 / 123

A URL is a resource's name, not a function-call command. The gap between the two is one or two words on paper, but "understand it at a glance" versus "dig through the docs" in practice.

7 / 123
Table
GoodBadWhy
GET /users/42GET /getUserById?id=42Resources are nouns; the action lives in the method
GET /usersGET /userListCollections are plural and consistent
POST /usersPOST /createUserCreate a new member of the collection
PUT /users/42POST /updateUserUpdating is a method semantic; keep it out of the path
DELETE /users/42GET /deleteUser?id=42Deleting must never use GET (prefetch, caches and crawlers will trigger it)
GET /users/42/ordersGET /getOrdersByUser?uid=42Express ownership through hierarchy
GET /users?status=ACTIVE&page=1GET /activeUsersPage1Filtering, paging and sorting belong in the query string
8 / 123

Three iron rules: use plural nouns, express ownership through hierarchy, push filtering/paging/sorting into the query string. A verb in the path (get / create / update / delete) means you still think in procedures, not resources.

9 / 123
Trap

exposing a destructive action as GET /users/42/delete is the most dangerous variant. GET is a "safe method" that browsers, proxies and crawlers all assume may be repeated freely — prefetching will delete data, and that is a real incident, not a hypothetical.

10 / 123

The rules above mention a "uniform prefix", and in code there are two ways to get it: hard-code /api/v1 into every @RequestMapping, or delegate it to server.servlet.context-path. Choose wrong and gateway routing plus health checks trip over it together. Tick the handful of properties a public API actually needs — server for prefix and port, logging for access logs and slow requests, actuator for probe endpoints, profile so dev and prod can use different prefixes:

11 / 123
Generator
GeneratorThe lines a public API actually configuresapplication.yml2 / 4
Generate with only server ticked and look at how context-path relates to the /api/v1 prefix in the URL rules: whether the prefix lives in the annotations or on the server changes the gateway's routing rules and its probe path. Then tick actuator and note that health endpoints must never share a path space with business routes; the logging group decides whether you can reconstruct a 4xx after the fact
Output
server:
  port: 8080
  servlet:
    encoding: { charset: UTF-8, enabled: true, force: true }
  compression: { enabled: true, min-response-size: 2048 }

spring:
  application:
    name: demo-service

management:
  server:
    port: 9090                            # 管理端口与业务端口隔离
  endpoints:
    web:
      exposure:
        include: health,info,metrics,prometheus   # 白名单,绝不写 *
  endpoint:
    health:
      show-details: when_authorized
      group:
        liveness: { include: ping }
        readiness: { include: db,redis,diskSpace }
Why each choice matters
serverserver.port loses to --server.port=8081 on the command line and to the SERVER_PORT env var.
managementA whitelist plus a separate management port is the floor; * is the number-one leak incident cause.
12 / 123
Section
3. HTTP methods and idempotency
13 / 123

Methods are not decoration; they carry semantic contracts:

14 / 123
Table
MethodSemanticsIdempotentSafeTypical status
GETRead a resourceYesYes200 / 404
POSTCreate a resource / trigger processingNoNo201 / 400
PUTReplace a resource entirelyYesNo200 / 204
PATCHPartially update a resourceUsually implementation-dependentNo200 / 204
DELETERemove a resourceYesNo204 / 404
15 / 123

Idempotency means "the same request executed once or N times leaves the resource in the same state". This property directly determines your retry strategy: GET / PUT / DELETE are safe to retry, POST is not — a retry may place one extra order or charge a payment twice.

16 / 123
Animation
Animation · One REST round trip
Animation · One REST round trip
17 / 123
Section
4. Using status codes correctly
18 / 123

"Everything is 200 and the error code hides in the body" is a legacy weight many teams carry. It lets the front end check a single field, but it blinds monitoring, gateways, caches and logs.

19 / 123

There are not many status codes worth memorizing, but the closely related pairs are exactly the ones people misuse — 401 versus 403, 404 versus 409, 400 versus 422. Reading a table three times teaches less than one round of this: click a code, then the situation it belongs to, and a wrong pair explains the gap immediately.

20 / 123
Match
MatchMatch the status code to its situationMatched 0/8 · Missed 0
The right column is deliberately phrased as a one-line contract — work out whose fault the sentence describes before you click
Pick a card on the left first
21 / 123
Diagram
Figure 1 · Four pillars of RESTful design
Figure 1 · Four pillars of RESTful design
22 / 123

Compare the two camps:

23 / 123
  • "Everything is 200": {"code":50001,"message":"user not found","data":null}. Easy on the front end, but a CDN will cache the 200 error response, a gateway cannot count error rates by status, and every dashboard looks green
  • Proper status codes: 404 plus {"code":50001,"message":"user not found"}. Infrastructure understands it, and code still preserves business granularity
24 / 123
Section
5. Unified response envelope: two camps and a recommendation
25 / 123
  • Purist status-code camp: 200 with the bare payload on success, 4xx/5xx with problem details on failure. The most "standard", but unfriendly to front ends — the decision point is split across the status line and the error shape
  • Envelope camp: wrap everything in { code, message, data }. Centralized handling for the front end, finer business errors — at the cost of drifting from the pure-REST "representation is the resource" ideal
26 / 123

My recommendation is to combine them: the HTTP status expresses "how the request was handled", while the envelope's code expresses "how the business logic went". Each has its job; neither replaces the other.

27 / 123
java
package com.example.common.api;import com.fasterxml.jackson.annotation.JsonInclude;import lombok.Getter;import java.io.Serializable;/** * Unified response envelope. * code is decoupled from the HTTP status: HTTP covers transport/handling, code covers business. */@Getter@JsonInclude(JsonInclude.Include.NON_NULL)public class Result<T> implements Serializable {    /** Business code: 0 means success, otherwise see the ErrorCode enum */    private final int code;    private final String message;    private final T data;    private Result(int code, String message, T data) {        this.code = code;        this.message = message;        this.data = data;    }    public static <T> Result<T> ok(T data) {        return new Result<>(0, "success", data);    }    public static <T> Result<T> ok() {        return new Result<>(0, "success", null);    }    public static <T> Result<T> fail(int code, String message) {        return new Result<>(code, message, null);    }    public static <T> Result<T> fail(ErrorCode errorCode) {        return new Result<>(errorCode.getCode(), errorCode.getMessage(), null);    }}
28 / 123
Section
6. Global wrapping with ResponseBodyAdvice
29 / 123

Hand-writing Result.ok(...) has a flaw: someone always forgets to wrap, and the API shape becomes inconsistent. ResponseBodyAdvice can wrap automatically before serialization, while excluding special returns such as file downloads.

30 / 123
java
package com.example.common.api;import org.springframework.core.MethodParameter;import org.springframework.http.MediaType;import org.springframework.http.converter.HttpMessageConverter;import org.springframework.http.server.ServerHttpRequest;import org.springframework.http.server.ServerHttpResponse;import org.springframework.web.bind.annotation.RestControllerAdvice;import org.springframework.web.servlet.mvc.method.annotation.ResponseBodyAdvice;/** * Automatically wraps controller return values in Result. * Only touches methods annotated @WrapResult, so downloads and third-party callbacks stay intact. */@RestControllerAdvicepublic class ResultWrapAdvice implements ResponseBodyAdvice<Object> {    @Override    public boolean supports(MethodParameter returnType,                            Class<? extends HttpMessageConverter<?>> converterType) {        // Already a Result: skip. Annotated @SkipWrap: always skip.        return returnType.getMethodAnnotation(SkipWrap.class) == null                && !Result.class.isAssignableFrom(returnType.getParameterType());    }    @Override    public Object beforeBodyWrite(Object body, MethodParameter returnType,                                  MediaType selectedContentType,                                  Class<? extends HttpMessageConverter<?>> converterType,                                  ServerHttpRequest request, ServerHttpResponse response) {        // Strings need special care or they clash with StringHttpMessageConverter        if (body instanceof String) {            return body; // let the caller return a Result itself        }        return Result.ok(body);    }}
31 / 123

Pair it with a marker annotation:

32 / 123
Code
Codejava
@Target(ElementType.METHOD)@Retention(RetentionPolicy.RUNTIME)public @interface SkipWrap {}
Notes

Warning: once global wrapping is on, every String return hits a snag — Spring handles strings with StringHttpMessageConverter, and wrapping a string in Result then serializing it collides with it, producing anything from a type-conversion exception to escaped JSON garbage. Either special-case String in beforeBodyWrite, or simply have handlers return objects instead of strings.

33 / 123

Every node on this chain has a named owner, and clicking through it beats reading the code. Node ⑤ is the crime scene of the warning above:

34 / 123
Diagram
FlowThe full path by which one return value gets wrapped1 / 6
Tap ① to ⑥; node ③ (the converter is already chosen) and node ⑤ (where String breaks) are the two that matter
→
→
→
→
→
① The handler returns a bare object
You write return userService.find(id) and never build a Result yourself. What exists now is a business object plus its declared type — no JSON has been produced yet. Putting a shell around it is everything that follows.
All clearThe Advice sits between "converter chosen" and "body written": it can rewrite the body, but not the status, and it cannot outvote the converter's type rules.
35 / 123

That String fork can be clicked in the kernel. One return, one annotation swapped, and the exit changes — and the missing scene is the whitelabel 404 from the error table:

36 / 123
Kernel lab
TeaVM@Controller or @RestController: four endings for one return statementidle
Start with json to watch an object get serialized, then string to see why a bare String comes back as text/plain rather than JSON; view and missing explain how an endpoint ends up returning a whole HTML page
Scenario
Click “Run demo” to execute the AOT-compiled Java kernel right in your browser, step by step.
37 / 123
Section
7. A business exception system
38 / 123

Exceptions are not an appendix to logs; they are the carrier of business error codes. A clean system has two layers: an error-code enum and a business exception.

39 / 123
java
package com.example.common.exception;import lombok.Getter;@Getterpublic enum ErrorCode {    SUCCESS(0, "success"),    INVALID_PARAM(40000, "invalid parameter"),    USER_NOT_FOUND(40401, "user not found"),    USERNAME_TAKEN(40901, "username already taken"),    ORDER_STATE_CONFLICT(40902, "order state does not allow this operation"),    PERMISSION_DENIED(40300, "no permission to perform this operation"),    UNAUTHORIZED(40100, "session expired, please sign in again"),    SYSTEM_ERROR(50000, "the service is busy, please retry later");    private final int code;    private final String message;    ErrorCode(int code, String message) {        this.code = code;        this.message = message;    }}
40 / 123
java
package com.example.common.exception;import lombok.Getter;/** A business exception: safe to show the user; never leak a 5xx stack trace */@Getterpublic class BizException extends RuntimeException {    private final ErrorCode errorCode;    public BizException(ErrorCode errorCode) {        super(errorCode.getMessage());        this.errorCode = errorCode;    }    public BizException(ErrorCode errorCode, String message) {        super(message);        this.errorCode = errorCode;    }    // Skip the stack: business exceptions do not need it, saving considerable overhead    @Override    public synchronized Throwable fillInStackTrace() {        return this;    }}
41 / 123

The mapping between business exceptions and HTTP status codes is the glue of the whole system:

42 / 123
Table
Business exceptionBusiness codeHTTP status
BizException(INVALID_PARAM)40000400
BizException(UNAUTHORIZED)40100401
BizException(PERMISSION_DENIED)40300403
BizException(USER_NOT_FOUND)40401404
BizException(USERNAME_TAKEN)40901409
BizException(ORDER_STATE_CONFLICT)40902409 / 422
An uncaught Exception50000500
43 / 123

That mapping table is the glue of the whole system: one business failure travels seven steps from throw to landing, and steps ④ and ⑤ decide respectively what code sits in the body and what the status line says. The animation's two readers — the gateway and the page — each read only their own field, and neither can substitute for the other:

44 / 123
Animation
Animation · How one business failure lands
Animation · How one business failure lands
45 / 123
Tip

the overridden fillInStackTrace() deserves a word of its own. A business exception is control flow, not an accident: a stack for "username already taken" proves only that you constructed it, and collecting frames on a hot endpoint costs real CPU. Drop it, and locate problems in the logs by code plus the key business identifier — that is what treating exceptions as return values properly looks like.

46 / 123
Section
8. API versioning strategies
47 / 123

Once an API is public, you must plan for "old clients are still calling it". The three mainstream strategies each trade off differently:

48 / 123
Table
StrategyFormProsCons
URL version/api/v1/usersObvious, easy to debug, easy to route at a gatewayThe URL changes; not strictly "pure"
Header versionX-API-Version: 1The URL stays stableHard to debug; invisible in the address bar
Media-type versionAccept: application/vnd.demo.v1+jsonThe most "RESTful"Verbose, weak tooling support
49 / 123
Note

the vast majority of teams should pick URL versioning. It is obvious, shareable, openable directly in a browser, and a gateway can route by path prefix. Media-type versioning is theoretically ideal and practically the worst — unless you maintain a public API that must adhere to REST to the letter.

50 / 123
Section
9. Standardizing pagination and sorting
51 / 123

Pagination parameters need a single convention, or every endpoint invents its own and the front end suffers. Recommended:

52 / 123
  • Request: ?page=1&size=20&sort=createdAt,desc
  • Response: return the total plus the current page
53 / 123
java
package com.example.common.api;import java.util.List;/** A page result: total lets the client compute page counts, list is the current page */public record PageResult<T>(long total, int page, int size, List<T> list) {    public static <T> PageResult<T> of(long total, int page, int size, List<T> list) {        return new PageResult<>(total, page, size, list);    }}
54 / 123
java
@GetMapping("/users")public PageResult<UserVO> page(        @RequestParam(defaultValue = "1") int page,        @RequestParam(defaultValue = "20") int size,        @RequestParam(defaultValue = "createdAt,desc") String sort) {    return userService.page(page, size, sort);}
55 / 123

Fix the names as page / size / sort, with sort in field,direction form, consistent across the whole site — then one pagination component on the front end serves every list.

56 / 123

Naming conventions tame the parameter names; they say nothing about the parameter values. How large size may get is a genuine capacity line, held by spring.data.web.pageable.max-page-size — and when it is crossed the consequence is a silent truncation, not an error. Drag the number and watch where the contract starts to deform:

57 / 123
Tuner
TunerRows per page: the capacity ceiling one URL parameter decides
Slide from 0 to a million and sit with band two: the truncation is silent — a caller asking for 5000 receives 2000 and only notices by comparing against total
spring.data.web.pageable.max-page-size
2000rowsNow 0 – 1000000
2000 rows: the Boot default
  • Boot's default ceiling for Pageable is exactly 2000, and above it the request is truncated rather than rejected
  • A caller asking for 5000 gets 2000 and only discovers it by checking total against the list length
  • Two thousand entities plus the ORM first-level cache can easily occupy tens of megabytes of heap per request
  • Fine for internal systems; a public endpoint should usually sit a notch lower — do not publish a default as a contract
Rows per query40%
Heap use45%
A ceiling is not a gate for honest callers, it is a fuse for accidents: exports go async, deep pages get a cursor, and size always has a top.
58 / 123
Section
10. Three "convention over correctness" traps
59 / 123
  • Mixing up PUT and PATCH: PUT replaces the whole resource (omitted fields are cleared), PATCH updates partially. Pick one and write it into the standard, or "update user" clears or preserves fields depending on the developer's mood
  • DELETE returning 200 or 204: both are valid. 204 is more semantically pure (no content); 200 is easier for a front end to parse uniformly. The point is consistency — do not have one endpoint return 204 and another 200
  • Paging starting at 0 or 1: Spring Data's Pageable starts at 0 by default while most front-end components start at 1. This is purely a "team convention" with no right answer, but it must be pinned down in the project standard
60 / 123
Kernel lab
TeaVMFrom request to response: dispatch in practiceidle
Compare the full handling path of /users/42 and /users
Scenario
Click “Run demo” to execute the AOT-compiled Java kernel right in your browser, step by step.
61 / 123
Decision
Decisiona new company project will expose an API and the PM says "third parties may integrate later". Should you adopt HATEOAS now (ship `_links` so clients navigate by following links)?
62 / 123
Section
11. Thirty seconds: REST is "house number + action"
63 / 123

The first ten sections are clauses. This one compresses them into a single picture.

64 / 123
Diagram
Figure 4 · The six elements of an API contract
Figure 4 · The six elements of an API contract
65 / 123
类比

REST boils down to one sentence — whom you are calling, and what you want to do. To visit Mr Zhang the correct phrasing is "Happy Road no. 42" (the house number = the URL) plus "visit" (the action = the HTTP method), not inventing a new place called "visit-Mr-Zhang-at-Happy-Road-42". The payoff: every action on "Happy Road no. 42" shares one address (GET/PUT/DELETE /users/42), so the courier (gateway), the guard (cache) and the property office (monitoring) can all decide from address plus action whether to let it through, retry it, or count it as a failure. With invented place names, every new action needs a new street and nobody can navigate without your map (docs). The URL says what is operated, the method says how, the status code says how it went — that is the whole idea.

66 / 123
Diagram
Figure 3 · Verbs in the URL vs resource URLs
Figure 3 · Verbs in the URL vs resource URLs
67 / 123
类比

a unified response envelope is like the same letterhead printed on every hospital slip. Lab report, receipt, discharge summary — the top-right corner always carries the same three fields: department code (code), a one-line note (message), and the actual content (data). Once the reception desk (the frontend) recognises that letterhead, one piece of code handles every document. Meanwhile the stamp on the envelope (the HTTP status code) is for the postman (gateway, CDN, monitoring) — two audiences, so neither may be dropped. That is the real reason Section 5 recommends the hybrid camp.

68 / 123

After this article you should answer:

69 / 123
  • "What is wrong with POST /createUser?" (A verb leaked into the house number; creating into a collection should be POST /users)
  • "Why must deletion never use GET?" (GET is assumed safe and repeatable — prefetchers and crawlers will delete for you)
  • "What is the relationship between status 404 and a body code of 40401?" (One speaks to infrastructure, the other to business branches)
70 / 123
Section
12. Hands-on: four cross-sections of one API interaction
71 / 123

REST is not memorized, it is looked at: one glance at the request, one at the response. The four demos below are four cross-sections — click them in order.

72 / 123

Cross-section one: content negotiation — why the same code returns JSON today and a 406 tomorrow.

73 / 123
Kernel lab
TeaVMContent negotiation and converters, liveidle
Step through json / accept / string / fail and see how the Accept header picks a converter and where the 406 appears
Scenario
Click “Run demo” to execute the AOT-compiled Java kernel right in your browser, step by step.
74 / 123

Linger on the fail scene: it produces HttpMediaTypeNotAcceptableException, the first row of Section 13's table. Note that a 406 is caused by the client's Accept, not by your return value being wrong — many beginners immediately go edit the controller, which is exactly backwards.

75 / 123

Cross-section two: how an error becomes a proper error response.

76 / 123
Kernel lab
TeaVMException resolver chain: how a business exception lands on a 4xxidle
Compare handler / status / none: with @ExceptionHandler, with @ControllerAdvice, with nothing — three different outputs
Scenario
Click “Run demo” to execute the AOT-compiled Java kernel right in your browser, step by step.
77 / 123

The none scene is the shape the "everything is 200" camp fears most: nobody catches it → 500 plus the Whitelabel page, the gateway's error rate suddenly doubles, when all you wanted to say was "username already taken".

78 / 123

Cross-section three: how a resource-style URL actually gets matched.

79 / 123
Kernel lab
TeaVMDispatching resource-style URLsidle
/users/42 and /users both hit the same controller, differing only in path template; /nope shows which stop kills a mistyped URL
Scenario
Click “Run demo” to execute the AOT-compiled Java kernel right in your browser, step by step.
80 / 123

Putting /users/42 (one resource) beside /users (the collection) on a single chain makes Section 2's iron rule concrete: hierarchy expresses ownership, the query string expresses filtering.

81 / 123

Cross-section four: the whole API pipeline — including failures and slowness.

82 / 123

The three above are local views. This one threads filters, dispatch, validation, the service layer and the database together; its five scenes are precisely this article's five sections bound into one:

83 / 123
Kernel lab
TeaVMOne request across the stack: happy / validation / business error / DB down / slowidle
Run happy first, then valid, biz, db and slow, and watch one contract behave under four kinds of failure
Scenario
Click “Run demo” to execute the AOT-compiled Java kernel right in your browser, step by step.
84 / 123

The last animation shows exactly at which step the Sections 5–6 envelope gets put on — once you see that, the String warning in Section 6 stops being a rule to memorize:

85 / 123
Animation
Animation · How the unified envelope gets applied
Animation · How the unified envelope gets applied
86 / 123

With the four cross-sections clicked through, switch to a command line. This console is wired to the same kernel inside your browser and every reply is computed — run boot, then type the five endings of this chapter with lab:

87 / 123
Console
88 / 123
Note

lab conv fail and lab err handler are both "the endpoint answered with JSON and something was wrong", but the first is a 406 caused by the caller's Accept, while the second is a 4xx produced by your own @RestControllerAdvice. Section 4's rule — status codes for infrastructure, code for business branches — reads most clearly in the difference between these two outputs.

89 / 123
Section
13. Common errors at a glance
90 / 123
Table
Error snippetReal causeThirty-second fixDig deeper in
406 Not Acceptable plus org.springframework.web.HttpMediaTypeNotAcceptableException: No acceptable representationNo converter can produce a type the client's Accept allows (e.g. it accepts only application/xml but Jackson XML is not on the classpath)Prove the endpoint itself is alive with curl -H "Accept: application/json"; then confirm the caller's header is too picky#22 Section 7
415 Unsupported Media Type plus HttpMediaTypeNotSupportedException: Content-Type 'application/x-www-form-urlencoded;charset=UTF-8' is not supportedNobody can read the media type @RequestBody receivedSend -H "Content-Type: application/json", or drop @RequestBody and bind with @ModelAttribute#23 Section 6
JSON parse error: Cannot deserialize value of type java.util.Date from String "2024-05-01 10:00:00"Jackson accepts ISO-8601 (2024-05-01T10:00:00.000+08:00) or a timestamp by defaultPut @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") on the field, or set spring.jackson.date-format globallyEnd of this section
InvalidDefinitionException: Java 8 date/time type ... not supported by default when serializing LocalDateTimeThe jackson-datatype-jsr310 module is not registeredspring-boot-starter-web already ships it; you only miss it after hand-constructing an ObjectMapperEnd of this section
Unrecognized field "user_name" (class UserCreateDTO), not marked as ignorable (3 known properties: ...)Names differ between client and DTO while strict mode is switched onAlign the naming or add @JsonProperty("user_name"); relaxation is discussed below#23 Section 11
Required request body is missing (HttpMessageNotReadableException)@RequestBody declared but no body arrived; also typical when an interceptor/filter already consumed the streamWrap the request in a ContentCachingRequestWrapper so the body stays re-readable#23 Section 8
After adding a ResponseBodyAdvice, endpoints returning String throw ClassCastException: Result cannot be cast to java.lang.StringThe wrapped object reaches StringHttpMessageConverter, which only eats stringsSpecial-case body instanceof String in beforeBodyWrite, or have that endpoint return an objectSection 6
A download endpoint receives escaped JSON instead of a fileGlobal wrapping swallowed a binary/text responseAdd a marker such as @SkipWrap and exclude it inside supportsSection 6
NoResourceFoundException / static resources 404 while APIs workResource-handler behaviour changed across versions (Boot 3.2+ no longer hands unknown paths to static handling by default)Set spring.web.resources.add-mappings=true explicitly and re-check the paths#22 Section 15
CORS reports Response to preflight request doesn't pass access control checkThe OPTIONS preflight was blocked by a login interceptorHandle CORS with CorsFilter / addCorsMappings and let OPTIONS through; never manage CORS in an MVC interceptor#26
91 / 123

The 406 in the first row is the one beginners investigate in the wrong place: it looks like a server fault, yet it is the caller's request header. Here is that stack for real — do not read the analysis first; click the frame you think is guilty:

92 / 123
Triage
Error triageHttpMediaTypeNotAcceptableException: No acceptable representation
Every endpoint returns 406, yet the same URL works in the address bar

During integration the front end reports 406 on every call. You paste the same URL into the browser and it returns data normally, so for a while you suspect something in the middle is rewriting the response.

org.springframework.web.HttpMediaTypeNotAcceptableException: No acceptable representation
at org.springframework.web.servlet.mvc.method.AbstractResponseBodyMethodProcessor.writeWithMessageConverters(AbstractResponseBodyMethodProcessor.java:272)
at org.springframework.web.servlet.mvc.method.annotation.RequestResponseBodyMethodProcessor.handleReturnValue(RequestResponseBodyMethodProcessor.java:195)
at org.springframework.web.method.support.HandlerMethodReturnValueHandlerComposite.handleValue(HandlerMethodReturnValueHandlerComposite.java:78)
at org.springframework.web.method.support.InvocableHandlerMethod.invokeForRequest(InvocableHandlerMethod.java:155)
at org.springframework.web.servlet.DispatcherServlet.processDispatchResult(DispatcherServlet.java:1165)
requestedMediaTypes = [application/xml]
Click the frame you blame — guessing is allowed
No pressure: guess the exception first, then which line actually made the call.
93 / 123
坑

once Jackson's date format is relaxed globally it becomes an implicit contract. The recommended move is to write the format into the DTO: @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8"), or standardise the API layer on ISO-8601 strings (which new Date() parses directly). spring.jackson.serialization.write-dates-as-timestamps=false is the laziest switch but affects only JSR-310 types; legacy java.util.Date still follows spring.jackson.date-format.

94 / 123
Section
14. Quick checks
95 / 123
Quiz
Check yourselfThe product asks for a "cancel many orders at once" operation. Which design is most RESTful?
Pick one — you get feedback right away
96 / 123
Quiz
Check yourselfA public API answers `200 OK` with `{"code":40401,"message":"user not found","data":null}`. What is the most direct consequence?
Pick one — you get feedback right away
97 / 123
Section
15. Sandbox: four contract knobs, pick a combination and see the bill
98 / 123

Unified envelope, status codes, pagination and versioning each carry a trade-off. Pick a combination and the output tells you what that contract looks like under real operations.

99 / 123
Sandbox
SandboxAPI contract knobs
Result
GET /api/v1/users?page=1&size=20 -> 200
{"code":0,"data":{"total":137,"list":[...]}}
gateway error-rate panel: truthful
#the sane default for most internal and small public APIs
The status states the transport verdict, code states the business verdict, and a URL version is shareable and routable — the cheapest option on all three
100 / 123
Section
16. Hands-on exercises
101 / 123
Section
Tier one · Follow along
102 / 123

Goal: assemble the minimum trio — resource-style API + unified envelope + global exception — and verify four status codes with curl.

103 / 123
java
package com.example.lab.rest;import org.springframework.http.HttpStatus;import org.springframework.http.ResponseEntity;import org.springframework.web.bind.annotation.*;import java.time.LocalDate;import java.util.List;import java.util.Map;import java.util.concurrent.ConcurrentHashMap;import java.util.concurrent.atomic.AtomicLong;@RestController@RequestMapping("/api/v1/books")              // resource URL + URL versionpublic class BookLabController {    public record Book(Long id, String title, LocalDate published) {}    public record CreateForm(@jakarta.validation.constraints.NotBlank String title, LocalDate published) {}    public record PageResult<T>(long total, int page, int size, List<T> list) {}    private static final Map<Long, Book> STORE = new ConcurrentHashMap<>();    private static final AtomicLong SEQ = new AtomicLong();    static {                                        // a little seed data        SEQ.set(2);        STORE.put(1L, new Book(1L, "Domain-Driven Design", LocalDate.of(2003, 8, 20)));        STORE.put(2L, new Book(2L, "Spring in Action", LocalDate.of(2020, 5, 1)));    }    // collection: paging and filtering all ride on the query string -> 200    @GetMapping    public Map<String, Object> list(@RequestParam(defaultValue = "1") int page,                                   @RequestParam(defaultValue = "20") int size) {        var list = STORE.values().stream().sorted((a, b) -> a.id().compareTo(b.id()))                .skip((long) (page - 1) * size).limit(size).toList();        return Map.of("code", 0, "message", "success",                "data", new PageResult<>(STORE.size(), page, size, list));    }    // one resource: present -> 200, absent -> a real 404 (never 200 + code)    @GetMapping("/{id}")    public ResponseEntity<Map<String, Object>> get(@PathVariable Long id) {        Book book = STORE.get(id);        if (book == null) {            return ResponseEntity.status(HttpStatus.NOT_FOUND)                    .body(Map.of("code", 40401, "message", "book not found"));        }        return ResponseEntity.ok(Map.of("code", 0, "message", "success", "data", book));    }    // creation -> 201 + Location    @PostMapping    public ResponseEntity<Map<String, Object>> create(@jakarta.validation.Valid @RequestBody CreateForm form) {        Long id = SEQ.incrementAndGet();        Book saved = new Book(id, form.title(), form.published() == null ? LocalDate.now() : form.published());        STORE.put(id, saved);        return ResponseEntity.created(java.net.URI.create("/api/v1/books/" + id))                .body(Map.of("code", 0, "message", "success", "data", saved));    }    // deletion -> 204, no body    @DeleteMapping("/{id}")    public ResponseEntity<Void> delete(@PathVariable Long id) {        return STORE.remove(id) == null                ? ResponseEntity.status(HttpStatus.NOT_FOUND).build()                : ResponseEntity.noContent().build();    }}
104 / 123

Five curls and their expected answers:

105 / 123
bash
# 1) collection -> 200curl -i "http://localhost:8080/api/v1/books?page=1&size=20"# 2) single resource -> 200curl -i http://localhost:8080/api/v1/books/1# 3) missing resource -> 404 (the point: the status really is 404)curl -i http://localhost:8080/api/v1/books/999# 4) creation -> 201 plus a Location headercurl -i -X POST http://localhost:8080/api/v1/books \     -H "Content-Type: application/json" -d '{"title":"Refactoring","published":"2018-11-19"}'# 5) required field missing -> 400 (@Valid raises MethodArgumentNotValidException)curl -i -X POST http://localhost:8080/api/v1/books \     -H "Content-Type: application/json" -d '{"title":""}'
106 / 123

Expected response for command 2:

107 / 123
json
{  "code": 0,  "message": "success",  "data": { "id": 1, "title": "Domain-Driven Design", "published": "2003-08-20" }}
108 / 123

For command 3 expect the status line HTTP/1.1 404 with body {"code":40401,"message":"book not found"}. For command 4 expect HTTP/1.1 201 and a Location: /api/v1/books/3 header. For command 5 expect HTTP/1.1 400 — if you get a 200 there, you forgot @Valid before the fourth parameter.

109 / 123
Section
Tier two · Variants
110 / 123
  1. Change command 3's status to HttpStatus.OK, leaving the body untouched → you observe curl -i reporting 200, and a follow-up curl -sI .../books/999 revealing nothing. That is Section 14's second question: you can now reproduce "all dashboards green" with one command
  2. Return a bare PageResult<Book> from list (PageResult is defined in Section 9) and add a ResponseBodyAdvice that wraps automatically → you observe an identical response shape while the controller no longer contains any Map.of("code", 0, ...). Then add @GetMapping("/ping") public String ping() → you observe a ClassCastException or a lump of escaped JSON, reproducing Section 6's warning exactly
  3. Swap Book.published from LocalDate to java.util.Date and rerun command 1 → you observe millisecond timestamps (e.g. "published": 1061308800000). Add @JsonFormat(pattern = "yyyy-MM-dd", timezone = "GMT+8") and rerun → you observe "2003-08-20" again. Both are legal, but they must be uniform site-wide or the frontend ends up with two parsers
  4. Drop the /api/v1 prefix and match on a header instead (@RequestMapping(value="/books", headers="X-API-Version=1")) → you observe that opening localhost:8080/books in a browser instantly gives a 404, because an address bar cannot send headers. That is the concrete shape of Section 8's "worst practical experience"
111 / 123
Section
Tier three · Build one
112 / 123

Build a publishable "book API" that turns every rule in this article into a deliverable contract.

113 / 123

Requirements:

114 / 123
  • Resource URLs: /api/v1/books, /api/v1/books/{id}, /api/v1/books/{id}/reviews (hierarchy for ownership)
  • Full method semantics: GET (200), POST (201 + Location), PUT (full replacement, 200), PATCH (partial update, 200), DELETE (204)
  • Unified Result<T> + ErrorCode + BizException + @RestControllerAdvice, with business codes mapped to HTTP statuses per Section 7's table
  • Fixed pagination parameters page/size/sort returning total; size above 100 yields a 400 rather than a slow query
  • Idempotency safety: calling PUT/DELETE twice on the same id must be predictable the second time (200/204 or 404) — never a 500
115 / 123

Acceptance checklist:

116 / 123
  • [ ] curl -i -X DELETE .../books/1 twice: first 204, second 404, neither a 500
  • [ ] curl -i -X POST .../books -H "Content-Type: application/json" -d '{"title":"x"}' returns 201 with a Location header
  • [ ] Trigger a BizException(USERNAME_TAKEN)-style conflict: expect 409 with a code in the body
  • [ ] curl -i -H "Accept: application/xml" .../books returns 406 and the log names HttpMediaTypeNotAcceptableException
  • [ ] Every list response has an identical data shape (one PageResult), so the frontend writes the pager once
  • [ ] You can state in one sentence why the status code and code must not be merged into one
117 / 123
Section
17. Self-check
118 / 123
自检

without looking up, can you give each of GET/POST/PUT/PATCH/DELETE's idempotency and typical status codes? (GET safe and idempotent 200/404; POST non-idempotent 201/400; PUT idempotent 200/204; PATCH implementation-dependent; DELETE idempotent 204/404)

119 / 123
自检

who is responsible for 404, 406 and 415 respectively? (404 = no mapping matched, or the resource is absent; 406 = the client's Accept is too picky; 415 = nobody can read the client's Content-Type)

120 / 123
自检

why must Result<T>'s code and the HTTP status coexist? (Different audiences: the status serves gateway/CDN/monitoring, the code serves business branching)

121 / 123
自检

why must global wrapping special-case String returns? (StringHttpMessageConverter only accepts strings, so handing it a Result collides)

122 / 123
口诀

the house number says WHO, the method says WHAT YOU DO, the status says WHETHER IT WORKED, the code says WHY; verbs never enter the URL.

123 / 123
Summary

RESTful is not mysticism; it asks you to get three things right — nouns in the URL express resources, methods express operations, status codes express results. On top of that, a unified Result<T> envelope, an ErrorCode + BizException system and a versioning strategy form an enterprise-ready API skeleton that can evolve for a long time.