JavaRush /Courses /Go SELF /API documentation: contract-first, curl examples

API documentation: contract-first, curl examples

Go SELF
Level 56, Lesson 4
Available

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
POST /api/v1/tasks
Purpose what the endpoint does “Create a task”
Headers what is required by the contract
Content-Type: application/json, Accept: application/json
Request JSON fields, types, required/optional
{ "title": "buy milk" }
Success status + body/no body
201 + task JSON
Errors status + error envelope
400 validation, 500 internal
Example
curl
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.

1
Task
Go SELF, level 56, lesson 4
Locked
cURL cheat sheet
cURL cheat sheet
1
Task
Go SELF, level 56, lesson 4
Locked
Delete endpoint card
Delete endpoint card
1
Task
Go SELF, level 56, lesson 4
Locked
JSON examples
JSON examples
1
Task
Go SELF, level 56, lesson 4
Locked
README Generator
README Generator
1
Survey/quiz
HTTP and API design, 56 level, 4 lesson
Unavailable
HTTP and API design
HTTP and API design
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION