1. Documentation as part of the contract
To be honest, API documentation often lives somewhere between “we will definitely finish it later” and “does anyone remember why it’s 201 here?”. And that is exactly why, in a sane API, documentation is part of the contract, not decoration. A contract is not for looks: it makes behavior predictable for the client, the tester, future you, and even for that teammate who came “just for five minutes to fix one endpoint” and accidentally fixed everything.
The contract-first approach means a simple idea: first we formalize the interaction rules (methods, paths, JSON, statuses, errors, and examples), and only then implement. This reduces the chance of “we implemented one thing, documented another, and the client understood a third thing and got upset forever”.
There is one more useful idea: documentation should be as “verifiable” as possible. In Go, this is popular even at the package documentation level: code examples can be executable and checked by tests, so they do not become outdated over time. And although we are documenting HTTP now rather than a Go package, the principle is the same: it is better when examples and structure are as close as possible to real data and it is hard to “forget to update them”.
Endpoint card: a minimal template
When you start documenting an API, it is tempting to write “POST /tasks — creates a task” and stop there. But that is like writing “Kitchen” on the door and thinking you have explained a borscht recipe. The client needs to know not only where to knock, but also what exactly counts as success, what the request/response bodies look like, what errors are possible, and what they mean.
Below is an “endpoint card” template that is compact enough, but already locks in the contract so it can be used without telepathy:
| Field | What we lock in | Example |
|---|---|---|
| Endpoint | method + path | |
| Purpose | what the endpoint does | “Create a task” |
| Headers | what is required by the contract | |
| Request JSON | fields, types, required/optional | |
| Success | status + body/no body | |
| Errors | status + error envelope | |
| Example | command |
ready-to-run command |
Note: there is not a word here about “how we implement it on the server”. In a contract-first document, implementation is secondary. First the agreement, then the code.
A unified vocabulary and a base URL
Documentation often breaks not because of complex things, but because of small drift: here someone wrote /todos, there /tasks, in examples somewhere it is :8080, somewhere :3000, and somewhere it is https://prod… (and then someone accidentally hit prod). So it is useful to agree upfront on two things: the resource vocabulary and the base URL for examples.
In this module we keep a single vocabulary:
- the resource is tasks
- the version is /api/v1
- the completion state is done
And for examples we use one base URL, for instance:
- http://localhost:8080 as “local, poke it by hand”.
In documentation it is convenient to define an environment variable purely “on the human side”, so you do not copy the address into every example. In bash it usually looks like this:
export BASE_URL="http://localhost:8080"
And then in commands we use $BASE_URL. Even if the reader is not strong with shell, they will at least see one place where the address changes, not twenty.
2. curl examples for the tasks API
curl as “executable documentation”
curl is the Swiss Army knife for HTTP. The only problem is that the Swiss Army knife easily turns into Swiss chaos if you do not agree on a style. In good documentation, a curl example is not “roughly like this”, but a command you can copy-paste and get a predictable result.
Let’s agree on a micro-standard for formatting curl in documentation:
- We explicitly specify the method -X ..., even if GET is the default.
- We explicitly set the Accept and Content-Type headers where JSON is involved.
- We use one quoting style (usually single quotes '...' around JSON so we do not have to escape every double quote).
- We sometimes add -i to see the status code in the response (this is useful specifically for documenting statuses).
Here is a small Go snippet that we will add to our learning app (for now it is not a server, but a “documentation generator”), to store curl commands as constants and not retype them manually each time:
package main
const BaseURL = "http://localhost:8080"
const CurlListTasks = `curl -i -H "Accept: application/json" ` + BaseURL + `/api/v1/tasks`
Yes, it looks a bit “straight on”. But you will not lose the command, and it will live next to the rest of the contract.
5 core scenarios for tasks
Now we will assemble a set of examples for our tasks domain. Important: the examples below are the contract. We are not discussing server implementation and we are not getting into routing. We need the reader of the documentation to understand: what to send and what to expect.
Create a task — POST /api/v1/tasks
Let’s start with the most “alive” endpoint: creation. Headers and the request body matter here. curl example:
curl -i \
-X POST \
-H "Accept: application/json" \
-H "Content-Type: application/json" \
-d '{"title":"buy milk"}' \
http://localhost:8080/api/v1/tasks
In documentation, right next to it you should provide an example of a successful response: status 201 Created, and the task JSON. We agreed on the DTO fields: id, title, done.
Example response body (for documentation):
{
"id": 1,
"title": "buy milk",
"done": false
}
Get the task list — GET /api/v1/tasks
A list usually returns 200 OK and a JSON array (or an object with an array—this is a separate contract decision; here we use an array for simplicity).
curl -i \
-H "Accept: application/json" \
http://localhost:8080/api/v1/tasks
Example response:
[
{ "id": 1, "title": "buy milk", "done": false },
{ "id": 2, "title": "pay rent", "done": true }
]
Get a task by id — GET /api/v1/tasks/{id}
Here an important part of the contract shows up: what counts as “not found”. It is 404 with an error envelope, not “200 and empty” and not “500 (well, we didn’t find it...)”.
curl -i \
-H "Accept: application/json" \
http://localhost:8080/api/v1/tasks/1
If the task does not exist:
{
"error": {
"code": "not_found",
"message": "resource not found"
}
}
Mark a task as done — /done as a state action
We chose the “state segment” style: /api/v1/tasks/{id}/done. For the method there are options (PATCH/POST). Within our day-to-day agreement, we can show, for example, POST as “perform the action”, and a 200 response with the task body.
curl -i \
-X POST \
-H "Accept: application/json" \
http://localhost:8080/api/v1/tasks/1/done
Example response:
{
"id": 1,
"title": "buy milk",
"done": true
}
Delete a task — DELETE /api/v1/tasks/{id}
Deletion is a good moment to lock in 204 No Content and not try to “stuff” JSON like “successfully deleted” in there. If you chose 204, then the response body is absent.
curl -i \
-X DELETE \
-H "Accept: application/json" \
http://localhost:8080/api/v1/tasks/1
We expect: HTTP/1.1 204 No Content and an empty body.
Document errors: not only the happy path, but also the error envelope
When documentation contains only successful responses, it looks optimistic—like a resume saying “I can do everything”, but without examples. The real value of an API contract shows up in errors: that is exactly where the client needs to understand what to do next.
We locked in a unified error envelope:
{
"error": {
"code": "...",
"message": "...",
"fields": { "...": "..." }
}
}
The fields field appears only for validation errors. This is convenient to document with a concrete example: for instance, creating a task with an empty title.
curl -i \
-X POST \
-H "Accept: application/json" \
-H "Content-Type: application/json" \
-d '{"title":""}' \
http://localhost:8080/api/v1/tasks
Example 400 Bad Request response:
{
"error": {
"code": "validation",
"message": "invalid request",
"fields": {
"title": "must not be empty"
}
}
}
And be sure to document internal in a way that does not make the client expect err.Error() from your database. In the contract, internal usually looks consistent and safe:
{
"error": {
"code": "internal",
"message": "internal error"
}
}
The point of such documentation is: the client can reliably recognize code, show the user message, and use fields to highlight specific form fields.
3. How to keep documentation and DTOs in sync
Generating JSON examples from DTOs in Go
The most common cause of outdated documentation is hand-written JSON examples. Today your done field is boolean, tomorrow you renamed title to text, and now the README lies. So here is a useful trick: generate JSON examples from DTO structs via encoding/json. This is not “complex automation”, it is a simple way to reduce human error.
Let’s add a file apidoc.go to our learning app (or a separate cmd/apidoc/main.go—whatever is more convenient). Let’s start with DTOs:
package main
type CreateTaskRequest struct {
Title string `json:"title"`
}
type TaskDTO struct {
ID int `json:"id"`
Title string `json:"title"`
Done bool `json:"done"`
}
Now let’s add the error envelope:
package main
type APIError struct {
Code string `json:"code"`
Message string `json:"message"`
Fields map[string]string `json:"fields,omitempty"`
}
type ErrorEnvelope struct {
Error APIError `json:"error"`
}
And a small helper that prints JSON nicely (and yes, here we honestly handle the error, as we were taught earlier):
package main
import (
"encoding/json"
"fmt"
)
func printJSON(v any) {
b, err := json.MarshalIndent(v, "", " ")
if err != nil {
fmt.Println("json error:", err) // json error: ...
return
}
fmt.Println(string(b))
}
Now we can “pull” examples directly from the structs:
package main
func main() {
printJSON(CreateTaskRequest{Title: "buy milk"})
// {
// "title": "buy milk"
// }
printJSON(TaskDTO{ID: 1, Title: "buy milk", Done: false})
}
And the same for a validation error:
package main
func main() {
env := ErrorEnvelope{
Error: APIError{
Code: "validation",
Message: "invalid request",
Fields: map[string]string{"title": "must not be empty"},
},
}
printJSON(env)
}
This way you keep examples next to the real structs. If tags, fields, or names change, the examples change with them (or at least they will not let you “silently lie”).
Mini format for API.md: schema and example
When you have assembled everything above, the final document can usually live in API.md. It is important that it reads top-to-bottom like an interaction guide, not like a set of random notes. It is convenient to start with a short schema: what request/response means at the contract level.
flowchart LR C[Client] -->|HTTP request| S[Server] S -->|HTTP response| C C -->|reads status code| C C -->|parses JSON or error envelope| C
And then come the endpoint cards in a single style (same headings, same blocks).
Below is a small example of a “card” in Markdown (as text, without Go code):
### POST /api/v1/tasks
Create a task.
Headers:
- Accept: application/json
- Content-Type: application/json
Request JSON:
{ "title": "buy milk" }
Success:
- 201 + TaskDTO
Errors:
- 400 + ErrorEnvelope(code=validation, fields=...)
- 500 + ErrorEnvelope(code=internal)
curl:
curl -i -X POST -H "Accept: application/json" -H "Content-Type: application/json" \
-d '{"title":"buy milk"}' http://localhost:8080/api/v1/tasks
Yes, there are lists here—but this is exactly that rare case where a list is appropriate, because this is a format. In the main lecture text we avoid lists, but a “contract block” in documentation, on the contrary, benefits from structure.
4. Common mistakes when documenting an API: contract-first and curl
Mistake #1: documenting only the “happy path”.
When only success is described, the client later “hacks in” error handling with the rule “if it’s not 200, then everything is bad”. In a normal contract, errors are part of behavior, not an accident: for 400 you need to show an error envelope with fields, for 404—not_found, for 500—a fixed safe message.
Mistake #2: curl examples missing headers, and then suddenly “why is the server complaining?”.
Beginners often copy curl -d '{...}' and forget Content-Type: application/json. Then the server fairly assumes it was sent something unknown. In documentation it is better to be boring: if it is JSON, explicitly write Content-Type and Accept.
Mistake #3: drift of terms and paths.
Today you write /api/v1/tasks, tomorrow someone writes /api/v1/todos in an example, the day after tomorrow /task/1 appears “because it’s shorter”. As a result, the client does not understand which version is correct. The only cure is discipline: one vocabulary (tasks/done), one base URL, one example style.
Mistake #4: “204 No Content”, but the response shows JSON “ok”.
This breaks trust in the contract: the client expects an empty body and does not read it, but the server unexpectedly sends something else. If you chose 204, document it explicitly: there is no body, period. If you want a body, choose 200.
Mistake #5: using fields for any errors whatsoever.
Sometimes developers start stuffing fields even into not_found or internal, and the client side loses the meaning: “are fields about form input or about everything in the world?”. In the contract, it is better to keep discipline: fields only for validation, otherwise absent.
GO TO FULL VERSION