tinka alpha
tinkaalpha

Request bodies

This chapter lets clients create posts by sending them as JSON. You will learn how to:

Overview

c.Body[T]() reads the request body into a value of the type you name, picking the format from the request's Content-Type. When the type has a Valid method, c.Body calls it after decoding. Every failure, from a body that isn't JSON to a title that's missing, comes back as an error that's already the right response, so the handler returns it unchanged.

Creating posts also changes the store for the first time. Until now, requests only read it; from here on, two requests can write to it at the same moment.

Let the store create posts

The App runs handlers concurrently, one goroutine per request, so two POST /posts requests can append to the slice at the same time and lose one of the posts. A sync.RWMutex lets any number of requests read the posts together, and gives a writer the store to itself. Import sync:

app/posts/posts.go
import (
	"context"
	"errors"
	"slices"
	"sync"
)

Add the mutex to the store:

app/posts/posts.go
type Store struct {
	mu    sync.RWMutex // handlers run concurrently
	posts []Post
}

Lock it for reading in All and Find, and add Create, which locks it for writing and gives the post the next ID:

app/posts/posts.go
// All returns every post, oldest first.
func (s *Store) All(ctx context.Context) ([]Post, error) {
	s.mu.RLock()
	defer s.mu.RUnlock()
	return slices.Clone(s.posts), nil
}

// Find returns the post with the ID id, or ErrNotFound.
func (s *Store) Find(ctx context.Context, id int64) (Post, error) {
	s.mu.RLock()
	defer s.mu.RUnlock()
	for _, p := range s.posts {
		if p.ID == id {
			return p, nil
		}
	}
	return Post{}, ErrNotFound
}

// Create adds a post and returns it with its new ID.
func (s *Store) Create(ctx context.Context, title, body string) (Post, error) {
	s.mu.Lock()
	defer s.mu.Unlock()
	post := Post{ID: int64(len(s.posts)) + 1, Title: title, Body: body}
	s.posts = append(s.posts, post)
	return post, nil
}

Describe the body

The handler decodes the body into a type of its own, newPost, that holds only what a client may send. A client can't choose a post's ID, because newPost has no field for it. JSON members match the json tags exactly, including case, and members the type doesn't have are ignored.

Valid checks the decoded input and returns a problem for each invalid field, keyed by the field's name. An empty map means the input is valid. The title must hold something other than spaces and stay under 100 characters, counted with utf8.RuneCountInString so that "é" counts as one, and the body must not be empty. The switch reports at most one problem for the title, the one that applies.

Add the packages Valid and the handler use to the imports:

app/handlers/posts.go
import (
	"context"
	"errors"
	"net/http"
	"strings"
	"unicode/utf8"

	"github.com/mucolabs/tinka"

	"blog/app/posts"
)

Then add the type, after Show:

app/handlers/posts.go
// newPost is the body POST /posts accepts.
type newPost struct {
	Title string `json:"title"`
	Body  string `json:"body"`
}

// Valid reports each field the post can't be created with.
func (p newPost) Valid(ctx context.Context) map[string]string {
	problems := map[string]string{}
	switch {
	case strings.TrimSpace(p.Title) == "":
		problems["title"] = "Enter a title."
	case utf8.RuneCountInString(p.Title) > 100:
		problems["title"] = "Keep the title under 100 characters."
	}
	if strings.TrimSpace(p.Body) == "" {
		problems["body"] = "Write the post's body."
	}
	return problems
}

Create the post

Create asks for the body as a newPost. A body that can't be decoded or isn't valid returns an error, and the handler returns it. Otherwise the store creates the post, c.Status sets the status to 201 Created, and the handler returns the post with its new ID. Add it at the end of the file:

app/handlers/posts.go
// Create answers POST /posts with the new post and 201 Created.
func (h Posts) Create(c *tinka.Ctx) (posts.Post, error) {
	in, err := c.Body[newPost]()
	if err != nil {
		return posts.Post{}, err
	}
	post, err := h.Store.Create(c, in.Title, in.Body)
	if err != nil {
		return posts.Post{}, err
	}
	c.Status(http.StatusCreated)
	return post, nil
}

Register the route next to the others:

start/routes.go
	postHandlers := handlers.Posts{Store: posts.NewStore()}
	app.Get("/posts", postHandlers.List)
	app.Get("/posts/{id}", postHandlers.Show)
	app.Post("/posts", postHandlers.Create)

Try it

Save the files, then create a post, with -i to see the status:

Terminal
curl -i -X POST localhost:3000/posts -H 'Content-Type: application/json' -d '{"title": "Request bodies", "body": "Reading JSON with c.Body."}'
Output
HTTP/1.1 201 Created
Content-Length: 69
Content-Type: application/json
Date: Tue, 20 Oct 2026 23:11:17 GMT
Referrer-Policy: strict-origin-when-cross-origin
X-Content-Type-Options: nosniff
X-Request-Id: 4A4L6IVQEC65FB73P5CPCDGDKD

{"id":3,"title":"Request bodies","body":"Reading JSON with c.Body."}

The new post is in the store, at the ID it was given:

Terminal
curl localhost:3000/posts/3
Output
{"id":3,"title":"Request bodies","body":"Reading JSON with c.Body."}

Send a title of spaces and no body. Both problems come back in one 422, under fields, so a client can show every error at once:

Terminal
curl -X POST localhost:3000/posts -H 'Content-Type: application/json' -d '{"title": " "}'
Output
{"title":"Unprocessable Entity","status":422,"detail":"Some fields are invalid.","code":"invalid","fields":{"body":"Write the post's body.","title":"Enter a title."},"request_id":"IE6PQOK2R4WBPZWSQTZTEV4N6D"}

A body that isn't valid JSON never reaches Valid. It's a 400, with a message that says what's wrong:

Terminal
curl -X POST localhost:3000/posts -H 'Content-Type: application/json' -d '{"title": "Request bodies",'
Output
{"title":"Bad Request","status":400,"detail":"The request body ends in the middle of a JSON value.","code":"bad_json","request_id":"GSI3T3OETFCGGM7DIUEU6QXWPD"}

The same handler also accepts an HTML form, since c.Body decodes form fields by the same json tags. curl -d without a Content-Type header sends one:

Terminal
curl -X POST localhost:3000/posts -d 'title=From+a+form&body=Forms+work+too'
Output
{"id":4,"title":"From a form","body":"Forms work too"}

Recap

The store locks itself, so concurrent requests can create posts safely. Create decodes the body with c.Body[newPost](), whose Valid method reports every invalid field in one 422, and answers 201 Created with the new post. Next, Services and providers moves the store into the App's container, so more than the routes can use it.

See also: Body parser, Exception handling, Response