# Request bodies

> Add POST /posts to the blog App, decoding a JSON body with c.Body, validating every field at once, and answering 201 Created.

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

- Make the store safe for handlers that run at the same time
- Decode a request body into a typed value with `c.Body`
- Validate the body and report every invalid field in one response
- Answer `201 Created` with the new post

## 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`:

```go title="app/posts/posts.go" ins={5}
import (
	"context"
	"errors"
	"slices"
	"sync"
)
```

Add the mutex to the store:

```go title="app/posts/posts.go" ins={2}
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:

```go title="app/posts/posts.go" ins={3-4,10-11,20-27}
// 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:

```go title="app/handlers/posts.go" ins={2,4-6}
import (
	"context"
	"errors"
	"net/http"
	"strings"
	"unicode/utf8"

	"github.com/mucolabs/tinka"

	"blog/app/posts"
)
```

Then add the type, after `Show`:

```go title="app/handlers/posts.go" ins={1-20}
// 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
}
```

:::tip
Keep `Valid` to checks on the input itself: required fields, lengths, formats. A rule that needs the store, such as "no two posts share a title", belongs in the store's `Create`, which returns an error the handler maps to a response, as `Show` does with `ErrNotFound`.
:::

## 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:

```go title="app/handlers/posts.go" ins={1-13}
// 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:

```go title="start/routes.go" ins={4}
	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:

```sh
curl -i -X POST localhost:3000/posts -H 'Content-Type: application/json' -d '{"title": "Request bodies", "body": "Reading JSON with c.Body."}'
```

```text title="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:

```sh
curl localhost:3000/posts/3
```

```text title="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:

```sh
curl -X POST localhost:3000/posts -H 'Content-Type: application/json' -d '{"title": " "}'
```

```text 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:

```sh
curl -X POST localhost:3000/posts -H 'Content-Type: application/json' -d '{"title": "Request bodies",'
```

```text title="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:

```sh
curl -X POST localhost:3000/posts -d 'title=From+a+form&body=Forms+work+too'
```

```text title="Output"
{"id":4,"title":"From a form","body":"Forms work too"}
```

:::note
The store lives in the App's memory. Each time you save a file, `act serve` restarts the App with a new store holding the two starting posts, so the posts you created are gone and IDs start again from 3.
:::

## 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](services-and-providers.md) moves the store into the App's container, so more than the routes can use it.

See also: [Body parser](../guides/basics/body-parser.md), [Exception handling](../guides/basics/exception-handling.md), [Response](../guides/basics/response.md)
