1. Public API and the goal of documentation
If you approach REST Docs with the enthusiasm of “let’s document absolutely everything the server returns,” you will very quickly turn documentation into a museum of accidents. Yesterday you added the debugInfo field “for a minute,” today you forgot to remove it, tomorrow the client happily started relying on it, and then you deleted it — and now you have a new season of the series “Breaking Changes: The Reckoning”.
That is why in ContentHub we start with the public surface: what external clients actually consume (or at least other parts of your system). This is first and foremost GET /api/public/articles and GET /api/public/articles/{slug}, plus the essential companions of any public API: pagination and errors. Internal service details, “how we pick articles from the database on the inside,” should not be restated by REST Docs. Documentation is a contract at the HTTP boundary, not the autobiography of your @Service.
To avoid drifting into “document everything in sight,” it helps to keep a simple (and very unromantic) priority table in mind:
| What we document | Why it matters to the client | Typical consequences if it is not documented |
|---|---|---|
| Public list/details endpoints | This is the main entry point to the system | The client writes code “by guesswork” and starts guessing the contract |
| Pagination/sorting/filtering query parameters | The client needs to know the format and defaults | Endless “why does page start at 0?”, “and how does sort work?” |
| Page wrapper (page metadata) | The client builds the pagination UI | The UI breaks because totalPages suddenly disappears |
| Error contract (ApiProblem) | Errors are part of the API too | The client cannot distinguish 404 from 409 and does not show violations |
The idea of this lecture: we document economically, but strictly by meaning. In other words, we describe important fields, parameters, and structures, rather than taking a complete JSON snapshot “however serialization happens to look today.”
2. Query parameters for GET /api/public/articles
Pagination and filters are often seen as “well, these are just minor things, the client will figure it out.” And then you see in the team chat: “Guys, is page zero-based or one-based?”, “Is size limited?”, “Is sorting comma-separated or space-separated?” And now you are discussing the API not from documentation, but from people’s memory. Human memory is a creative thing, so it is better not to trust it.
REST Docs is good here because it forces us to honestly describe the input contract: which query params exist, what they mean, which are optional, and what their defaults are. And most importantly — we describe this in the test, which already checks that the endpoint returns 200 OK and returns JSON of the correct shape.
The test skeleton for REST Docs usually looks like this (we stay within the MVC slice because we are documenting the HTTP boundary, not the database):
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.restdocs.AutoConfigureRestDocs;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.test.web.servlet.assertj.MockMvcTester;
// MVC slice test: we start only the web layer to verify the HTTP contract
@WebMvcTest(PublicArticleController.class)
@AutoConfigureRestDocs // Enable snippet generation in build/generated-snippets
class PublicArticleRestDocsTest {
@Autowired
MockMvcTester mvc; // Simplified client for executing HTTP requests against MockMvc
}
Now let’s add the query parameters documentation to the test. Pay attention to the style: first we verify the status, and only then do we “attach” document(...). This matters psychologically: documentation is not the main goal of the test; it is secondary to contract verification.
import org.junit.jupiter.api.Test;
import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document;
import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName;
import static org.springframework.restdocs.request.RequestDocumentation.queryParameters;
import static org.springframework.test.web.servlet.assertj.MockMvcTester.assertThat;
@Test
void shouldDocumentPublicArticlesQueryParams() {
// Make a real (for the test) HTTP call to the controller
assertThat(mvc.get().uri("/api/public/articles?page=0&size=20&sort=publishedAt,desc"))
// 1) First verify that the happy path is indeed happy
.hasStatusOk()
// 2) Only after that generate documentation snippets
.apply(document("public-articles-list",
queryParameters(
// Describe the input contract: which parameters the endpoint accepts
parameterWithName("page").optional().description("Page number (0-based)"),
parameterWithName("size").optional().description("Page size"),
parameterWithName("sort").optional().description("Sorting in the format field,(asc|desc)")
)
));
}
A couple of important nuances usually appear not immediately, but after the first “battle with reality.”
First, optional() here is not just “well, it is kind of optional.” It is also about the fact that you may be running the test without this parameter (for example, the default page=0, size=20). If you forget optional(), REST Docs will expect the parameter every time and start complaining when you call the endpoint without sort. Documentation should reflect the real contract: if the parameter is optional, then it is optional.
Second, descriptions in description(...) are best written for a client seeing the API for the first time. The phrase “page param” is almost like writing int x; // x in code. Formally there is a comment; practically, it is useless.
To make the documentation more readable, it is often useful to keep an “internal table” for pagination parameters in mind (it is not inserted directly into REST Docs, but it helps to formulate descriptors correctly):
| Parameter | Example | What it means | Question it answers |
|---|---|---|---|
| page | 0 | page number | “zero-based or one-based numbering?” |
| size | 20 | page size | “what is the default? is there a limit?” |
| sort | publishedAt,desc | sorting | “how do you specify the sort direction?” |
If GET /api/public/articles has filters, for example category, that should also be documented — otherwise the client will look for a “magic parameter” the archaeological way:
import org.junit.jupiter.api.Test;
import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document;
import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName;
import static org.springframework.restdocs.request.RequestDocumentation.queryParameters;
import static org.springframework.test.web.servlet.assertj.MockMvcTester.assertThat;
@Test
void shouldDocumentCategoryFilter() {
// Scenario: the client filters the article list by category
assertThat(mvc.get().uri("/api/public/articles?category=java&page=0&size=20"))
.hasStatusOk() // Important: the filter should not "break" the successful response
.apply(document("public-articles-list-with-category",
queryParameters(
// Filters are also part of the public contract
parameterWithName("category").optional().description("Filter by category code"),
// Pagination stays the same
parameterWithName("page").optional().description("Page number (0-based)"),
parameterWithName("size").optional().description("Page size")
)
));
}
Yes, snippets will multiply. And that is fine, as long as they do not become “one per every sneeze.” The point is not the number of files, but that each snippet covers a separate stable scenario.
3. Pagination response: wrapper and content[]
A paginated response is the place where “everything seems clear” turns into “why is it impossible to implement pagination properly on the client.” The client usually needs two things: the list of elements, and the page metadata (where we are, how many pages there are, what the size is). If you do not document the metadata, every client starts interpreting your API in its own way.
In ContentHub we do not expose Page<Article> directly (and that is correct); instead we use a wrapper like PageResponse. Therefore REST Docs ideally should show the wrapper structure and the list item structure separately. And here it is important not to mix everything into one soup of fieldWithPath("content[].something") without understanding it.
Let’s start with a basic documentation description of PageResponse without going into the fields of the element itself. It is like labeling a box “this contains a results page” and specifying that inside there is content, page, size, totalElements, totalPages.
import org.junit.jupiter.api.Test;
import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document;
import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath;
import static org.springframework.restdocs.payload.PayloadDocumentation.responseFields;
import static org.springframework.test.web.servlet.assertj.MockMvcTester.assertThat;
@Test
void shouldDocumentPublicArticlesPageWrapper() {
assertThat(mvc.get().uri("/api/public/articles?page=0&size=20"))
.hasStatusOk() // Check that pagination works in principle
.apply(document("public-articles-list-page",
responseFields(
// Describe the page wrapper (metadata + list)
fieldWithPath("content").description("List of articles on the page"),
fieldWithPath("page").description("Page number (0-based)"),
fieldWithPath("size").description("Page size"),
fieldWithPath("totalElements").description("Total number of articles found"),
fieldWithPath("totalPages").description("Total number of pages")
)
));
}
Now let’s document the content[] items. And this is where many people start making a “full snapshot of the article card” with every field, including those the client does not need at all. In a public list, usually only what is actually shown in the list is necessary: slug, title, summary, publishedAt, something about the category.
import org.junit.jupiter.api.Test;
import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document;
import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath;
import static org.springframework.restdocs.payload.PayloadDocumentation.responseFields;
import static org.springframework.test.web.servlet.assertj.MockMvcTester.assertThat;
@Test
void shouldDocumentArticleCardInList() {
assertThat(mvc.get().uri("/api/public/articles?page=0&size=20"))
.hasStatusOk()
.apply(document("public-articles-list-content",
responseFields(
// Fields that the client usually shows in the list
fieldWithPath("content[].slug").description("Public article identifier"),
fieldWithPath("content[].title").description("Article title"),
fieldWithPath("content[].summary").description("Short description"),
fieldWithPath("content[].publishedAt").description("Publication time"),
// Minimum page metadata so the UI can build pagination
fieldWithPath("page").description("Page number (0-based)"),
fieldWithPath("size").description("Page size")
)
));
}
You may have noticed that in this snippet we document page and size again. It looks like duplication — and it really is. But in REST Docs there is a balance: sometimes it is simpler (and more readable) to duplicate a couple of fields a bit than to build overly clever frameworks and “universal snippets for every case.”
If you want to be neater, you can move common FieldDescriptors into a constant and reuse them. This is especially useful where the same pagination or ApiProblem fields repeat in several snippets.
Another practical point: if your API evolves and you add new fields to the response (for example, content[].readingTimeMinutes), strict responseFields(...) will require every field to be documented. That is good when you want tight control, but sometimes you only need to document the stable minimum. In that case, the “relaxed” version — relaxedResponseFields(...) — is useful; it does not fail if there are undocumented fields.
import org.junit.jupiter.api.Test;
import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document;
import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath;
import static org.springframework.restdocs.payload.PayloadDocumentation.relaxedResponseFields;
import static org.springframework.test.web.servlet.assertj.MockMvcTester.assertThat;
@Test
void shouldDocumentOnlyStableFields() {
assertThat(mvc.get().uri("/api/public/articles?page=0&size=20"))
.hasStatusOk()
.apply(document("public-articles-list-relaxed",
relaxedResponseFields(
// Document only the "stable core"; ignore everything else
fieldWithPath("content[].slug").description("Public article identifier"),
fieldWithPath("content[].title").description("Article title")
)
));
}
That is not “cheating.” It is a deliberate compromise: you choose whether what matters more to you is strict completeness of documentation or resilience to the addition of secondary fields. For a public API, it is usually still better to keep documentation close to the contract, and the contract stable. But when you are just starting to live with REST Docs, relaxed mode can save your sanity.
4. Details endpoint: GET /api/public/articles/{slug}
There is a temptation to document list and details with one snippet because “well, it is the same article, what is the difference.” At minimum, the difference is that details usually contains more data, while the list is more compact. And if you mix them into one “universal article documentation,” the client will try to use list as details or vice versa, and then the classic “why is our body always null?” begins.
So we make a separate snippet for the details endpoint. And immediately another contract detail appears: the path parameter slug. Even if you do not use pathParameters(...) (and it exists in REST Docs), the snippet itself should make it clear that slug is the request key and what exactly should be inserted there.
Let’s start with the details response documentation:
import org.junit.jupiter.api.Test;
import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document;
import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath;
import static org.springframework.restdocs.payload.PayloadDocumentation.responseFields;
import static org.springframework.test.web.servlet.assertj.MockMvcTester.assertThat;
@Test
void shouldDocumentPublicArticleDetails() {
// Scenario: the client opens the page for a specific article
assertThat(mvc.get().uri("/api/public/articles/java-testing-basics"))
.hasStatusOk() // We verified that the article is available and the endpoint is alive
.apply(document("public-article-details",
responseFields(
// Details are usually richer than the list card, but we still document by meaning
fieldWithPath("slug").description("Public article identifier"),
fieldWithPath("title").description("Article title"),
fieldWithPath("summary").description("Short description"),
fieldWithPath("body").description("Full article text"),
fieldWithPath("publishedAt").description("Publication time")
)
));
}
The principle of economy works here again: you do not need to describe “all fields of the domain model” if they are not part of the stable public contract. For example, version (optimistic lock) is not relevant to the public API client at all. What matters to you in the database and JPA is not necessarily important to the external consumer. Documentation helps you notice these mix-ups: if your hand reaches for documenting version and authorUsername in a public endpoint, that is a good moment to stop and ask, “does the client really need this?”
If you want to add path parameter documentation, which is usually useful, you can do it the same way “on top of” the verified scenario:
import org.junit.jupiter.api.Test;
import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document;
import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName;
import static org.springframework.restdocs.request.RequestDocumentation.pathParameters;
import static org.springframework.test.web.servlet.assertj.MockMvcTester.assertThat;
@Test
void shouldDocumentSlugPathParam() {
assertThat(mvc.get().uri("/api/public/articles/java-testing-basics"))
.hasStatusOk()
.apply(document("public-article-details-path",
pathParameters(
// Document the request "key": what exactly needs to be inserted into the URL
parameterWithName("slug").description("Slug of the published article")
)
));
}
From the documentation reader’s point of view, this removes a lot of questions: “is this an id?”, “is this a UUID?”, “is this a human-readable path?” We clearly say: this is a slug, a public identifier.
5. API errors: 404 and 400 for pagination
An error in an API is not something “embarrassing, let’s hide it,” but just as much of a contract as 200 OK. More than that, errors are often more important: the happy path client may “sort of handle,” but if it does not understand what errorCode means, it will not be able to show a proper message to the user and will simply write “Oops.”
In ContentHub we have a stable ApiProblem format (ProblemDetail-compatible style): fields like type, title, status, detail, errorCode, plus sometimes violations. That means we can and should document this payload. A convenient strategy is to make separate snippets for different classes of errors: not found (404), invalid pagination parameters (400), and, if desired, a general “problem payload overview.”
Let’s document 404 “article not found.” From the test’s point of view, it is important to call the endpoint with a missing slug and get status 404. How exactly the service arrived there is secondary for the REST Docs test; usually this is handled with a stub via @MockitoBean, but what we document is not the mock, but the HTTP-boundary response itself.
import org.junit.jupiter.api.Test;
import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document;
import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath;
import static org.springframework.restdocs.payload.PayloadDocumentation.responseFields;
import static org.springframework.test.web.servlet.assertj.MockMvcTester.assertThat;
@Test
void shouldDocumentArticleNotFound() {
// Scenario: the client requested an article that does not exist
assertThat(mvc.get().uri("/api/public/articles/missing-slug"))
.hasStatusNotFound() // Contract: return 404 for a missing article
.apply(document("public-article-not-found",
responseFields(
// Document the useful minimum the client needs to handle the error
fieldWithPath("title").description("Short error name"),
fieldWithPath("status").description("HTTP status"),
fieldWithPath("detail").description("Human-readable description"),
fieldWithPath("errorCode").description("Stable error code")
)
));
}
Notice: we do not document all fields of ApiProblem here. That is another deliberate economy. For example, instance and type can be useful, but if they are not yet settled or sometimes differ, it is better not to make them central. You can document them later or mark them optional. In REST Docs, fieldWithPath(...).optional() also works for response fields.
Now let’s document 400 Bad Request for pagination, if, for example, the client passed an invalid size. In an ideal world this will be a validation error with violations (for example, size must be > 0 and <= 100). And that is exactly what the client needs: a list of violations.
import org.junit.jupiter.api.Test;
import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document;
import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath;
import static org.springframework.restdocs.payload.PayloadDocumentation.responseFields;
import static org.springframework.test.web.servlet.assertj.MockMvcTester.assertThat;
@Test
void shouldDocumentInvalidPaginationError() {
// Scenario: the client passed an invalid page size
assertThat(mvc.get().uri("/api/public/articles?page=0&size=-1"))
.hasStatusBadRequest() // Contract: invalid parameters -> 400
.apply(document("public-articles-invalid-pagination",
responseFields(
// Basic error fields
fieldWithPath("status").description("HTTP status"),
fieldWithPath("errorCode").description("Stable error code"),
// Validation violations: the client can highlight the field and show the message
fieldWithPath("violations[].field").description("Field with a violation"),
fieldWithPath("violations[].message").description("Violation description")
)
));
}
If violations is not always present (for example, it is absent for 404), then in a “general” snippet you can mark it as optional. But I would not create one “universal error snippet for all cases” right away, because it becomes too vague. It is more practical to have 2–3 concrete documentation scenarios: one for not found, one for validation, and that’s it.
In the end, a very healthy idea emerges: it is normal for one endpoint to have two documentation snippets — happy path and error path. For clients this is often even more important than “one more field in the successful response.”
To reinforce the picture, here is a mini diagram of how it looks in your head (and in the project) in practice:
flowchart TD
A["MVC test (MockMvcTester)"] --> B["assertions: status, JSON, headers"]
B --> C["document(...): query params / response fields"]
C --> D["generated snippets"]
D --> E["API documentation (assembled from snippets)"]
Important emphasis: documentation stands on the shoulders of a verified scenario. We do not draw the contract by hand; we grow it from a test that has already proven the contract is really that way.
6. Living REST Docs: snippets and stable names
For the public API in ContentHub, it is more convenient to stick to explicit path-like snippet names: from the name itself you can immediately see the endpoint and scenario. {method-name} remains a workable alternative, but here it matters more that the generated-snippets tree itself tells you what exactly has been documented.
When REST Docs first appears in a project, the first emotion is often: “Oh cool, now we will have documentation!” The second emotion a week later: “Oh no, now we have documentation too…”. To keep REST Docs from becoming a “tax on life,” you need two things: choose what to document sensibly, and make snippets reusable, but without magic.
Let’s start with snippet names. The most practical scheme is to use names that read like a path: API area + endpoint + scenario. Then build/generated-snippets becomes almost a self-documenting tree. For example:
| Snippet name | What it is |
|---|---|
| public-articles-list | public list happy path |
| public-article-details | public details happy path |
| public-article-not-found | 404 on details |
| public-articles-invalid-pagination | 400 on list |
This is not the only correct approach, but it helps prevent snippets from turning into a set of “doc1”, “doc2”, “doc-final-final2”.
Now — reuse. The safest kind of reuse is not to hide the whole test in a DSL, but to extract only the repeated pieces: for example, the description of the basic ApiProblem fields. Then the documentation remains consistent, and the test remains readable.
import org.springframework.restdocs.payload.FieldDescriptor;
import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath;
// Small helper: keep common FieldDescriptor instances in one place
final class ApiProblemDoc {
static FieldDescriptor[] basic() {
return new FieldDescriptor[] {
// The minimal error contract that we consider stable
fieldWithPath("title").description("Short error name"),
fieldWithPath("status").description("HTTP status"),
fieldWithPath("detail").description("Error description"),
fieldWithPath("errorCode").description("Stable error code")
};
}
}
Then you use this in the test and add the scenario-specific fields, for example violations. Yes, you will need to concatenate arrays of descriptors (manually or with a small helper), but the idea is simple: the common minimum lives in one place, and the “specifics” remain in the test.
Another principle that saves you from “snippets for the sake of snippets”: document what is actually stable and useful. If a field is computed dynamically and the client does not need its meaning, do not make it central in the documentation. REST Docs is not a duty to describe every byte; it is a duty to describe the contract.
And finally: do not be afraid that one endpoint will have several snippets. For clients that is normal: the same URL has different scenarios and different responses. Documentation that honestly shows “successful response” and “error” is adult documentation. Documentation that pretends errors do not exist is like a kettle manual without a section on “what to do if it does not turn on.”
7. Typical mistakes in Spring REST Docs
Mistake #1: documenting before you have verified the contract with assertions.
It is very easy to get carried away with REST Docs and start writing document(...) as the main goal of the test. In the end, you document a scenario that is not actually checked at all. That turns documentation into a pretty theory. The cure is simple and boring: first status, headers, and meaningful response fields are verified by assertions, and only then document(...) is attached on top of that.
Mistake #2: trying to describe “the whole JSON as a whole” in the documentation, including random fields.
A snapshot of “the whole response as is” looks tempting, but it breaks very quickly from any refactoring and creates fear of changing the contract even where it is safe. In REST Docs it is better to document stable and useful fields rather than turning the documentation into a museum of serialization. If you want partial documentation, use a relaxed approach or document only the important pieces.
Mistake #3: documenting only the happy path and ignoring the error contract.
In practice, clients stumble over errors more often than over successful responses. If you documented 200 OK but did not document ApiProblem for 404 and 400, the client will still write error handling “by guesswork.” Then your team gets questions like: “How do we distinguish ARTICLE_NOT_FOUND from ACCESS_DENIED?” and “Where are the violations?” Errors are part of the API, and REST Docs is useful precisely because it forces that recognition.
Mistake #4: mixing pagination documentation and list item documentation so that nobody understands anything.
If in one snippet you try to describe both page/size/totalPages and content[].slug/title/summary, and also nested category fields, the documentation becomes unreadable. It is better to document the pagination wrapper as the page structure and separately document the item structure (article card). Even if that is two snippets, the reader will thank you.
Mistake #5: unstable snippet names and “doc-final3” in the repository.
At first it seems that the name is a minor detail. Then you have 40 snippets, and you spend more time finding “what was that snippet called for not found” than on real work. A stable naming scheme (public/list/details/error) is not bureaucracy; it is navigation through the project once tests and documentation become plentiful.
GO TO FULL VERSION