JavaRush /Courses /Go SELF /POST create — decode JSON, validate, responses 201/400

POST create — decode JSON, validate, responses 201/400

Go SELF
Level 61, Lesson 0
Available

1. Contract POST /api/v1/tasks

Before writing code, it helps to “sit down on the shore” for a minute and agree on the rules of the game. An HTTP API is not “whatever works”; it is a contract between the client and the server. If the contract is fuzzy, the client starts guessing behavior from error texts, which is about as good as fixing a printer with a shamanic tambourine: sometimes it works, but it’s embarrassing.

In this lecture, we implement task creation and lock down the contract:

  • Method and path: POST /api/v1/tasks
  • Request body: a JSON object like { "title": "..." }
  • On success: 201 Created, header Location: /api/v1/tasks/{id}, response body — JSON of the created task
  • On bad input: 400 Bad Request in a unified error envelope, error code validation, and in fields we put an explanation of what exactly is wrong

There’s an important psychological detail here: 400 is not “the server is being mean”; it is “the client asked for something weird.” Therefore we must respond predictably and consistently across all endpoints.

A small handler pipeline diagram — we’ll stick to it throughout the module so the code doesn’t turn into spaghetti:

flowchart LR
  A[HTTP request] --> B[Decode JSON]
  B --> C[Validate + normalize]
  C --> D[App/Create]
  D --> E[201 + Location + JSON body]
  B -->|bad json| X[400 validation]
  C -->|invalid fields| X
  D -->|unexpected| Y[500 internal]

2. DTOs and input validation

When a beginner sees JSON, they often get tempted: “Let me just make a type Task struct and json.Unmarshal straight into it.” It looks short, but it usually breaks at the first turn.

The problem is that the JSON request structure (external contract) and the domain model structure (internal invariants) are different things. A request may be incomplete, contain typos, have extra fields, include whitespace, while the domain model must be neat and “internally correct.”

So we make two DTOs:

  • CreateTaskRequest — what comes from the client
  • TaskDTO (or TaskResponse) — what we return to the outside

Request DTO

A tiny example of a request DTO:


package api

type CreateTaskRequest struct {
	Title string `json:"title"`
}

It’s only one line of a useful field, but it already matters: we explicitly say “in JSON we expect the title field.” Next we’ll add validation, because JSON by itself guarantees nothing except that it “looks like JSON.”

Normalization and validation of title

Validation is the boring part of programming, like washing dishes: people don’t like it, but without it at some point things start to smell. In an API, validation is especially important because “dirty input” is not an exception; statistically it’s inevitable.

We need at least two actions:

1) Normalize the string (trim whitespace at the edges) so that " buy milk " becomes "buy milk".

2) Check that after normalization the string is not empty. An empty title means it’s impossible to show the task properly to the user.

Let’s add a Validate() method to the DTO. It will return our typed ValidationError so that the shared writeError (which we already have from the previous lecture) returns 400 in the correct format.

package api

import "strings"

func (r CreateTaskRequest) Validate() error {
	if strings.TrimSpace(r.Title) == "" {
		return &ValidationError{
			Fields: map[string]string{"title": "must not be empty"},
		}
	}
	return nil
}

Notice a detail: we return *ValidationError, not just fmt.Errorf("bad title"). This way, the error can be recognized reliably even if it gets wrapped somewhere above (fmt.Errorf("create task: %w", err)), because in Go it’s customary to “carry context up the stack” while recognizing the error class separately.

Response DTO and mapping

The response DTO can be very simple:

package api

type TaskDTO struct {
	ID    int    `json:"id"`
	Title string `json:"title"`
	Done  bool   `json:"done"`
}

Mapping from the domain app.Task to TaskDTO is usually done explicitly. Yes, it’s 3 assignments. But you won’t cry later when the domain model changes and you want to keep the API backward compatible.

package api

import "example.com/project/app"

func toTaskDTO(t app.Task) TaskDTO {
	return TaskDTO{ID: t.ID, Title: t.Title, Done: t.Done}
}

3. Decoding JSON: strict and safe

Now let’s move on to the “Decode JSON” part. There are several typical threats here (don’t be scared by the word “threats” — it’s not cyberpunk, it’s just practice).

The first threat: the client can send a very large body, and you may accidentally read it all, spending memory and time. Even without malicious intent, this happens when the client is buggy.

The second threat: the client sent JSON with extra fields, for example { "titlle": "..." } (a typo). If we aren’t strict, the server will “silently ignore” the field and create a task with an empty title, and then we’ll wonder why empty tasks showed up in the database.

The third threat: the client sent two JSON objects in a row: {...}{...}. Formally, that’s a stream of JSON values, but for an HTTP API it’s almost always an error. We agreed that the body contains exactly one JSON object.

We write a helper decodeJSON that does three things: limits the size, enables strict mode, and checks for “one object.”

package api

import (
	"encoding/json"
	"io"
	"net/http"
)

func decodeJSON(w http.ResponseWriter, r *http.Request, dst any) error {
	r.Body = http.MaxBytesReader(w, r.Body, 1<<20) // 1 MiB
	defer r.Body.Close()

	dec := json.NewDecoder(r.Body)
	dec.DisallowUnknownFields()

	if err := dec.Decode(dst); err != nil {
		return &ValidationError{Fields: map[string]string{"body": "invalid json"}}
	}
	if err := dec.Decode(&struct{}{}); err != io.EOF {
		return &ValidationError{Fields: map[string]string{"body": "must contain single json object"}}
	}
	return nil
}

Yes, the messages "invalid json" and "must contain single json object" look simple. And that’s good: externally we return stable and safe wording, while the details (if we want) can be logged separately at the boundary. This is discipline: the API doesn’t turn into a “dump of internal messages” to the outside.

4. Application layer: creating a task outside the handler

It’s very tempting to write everything directly in handleCreateTask: parse JSON, validate, append to a slice, return a response. But then the task creation logic lives inside the HTTP layer, and we block ourselves from a sane architecture: any reuse or testing will be painful.

So we keep a simple model: the handler calls app.Create(...). Here app is the application layer that knows the domain rules.

A minimal domain task structure for the learning project:

package app

type Task struct {
	ID    int
	Title string
	Done  bool
}

And the simplest service that creates tasks in memory. We are not discussing concurrency and locks here — that’s a separate topic — and right now it’s important for us to understand the data flow.

package app

import "context"

type App struct {
	nextID int
	tasks  []Task
}

func (a *App) Create(ctx context.Context, title string) (Task, error) {
	a.nextID++
	t := Task{ID: a.nextID, Title: title, Done: false}
	a.tasks = append(a.tasks, t)
	return t, nil
}

We don’t use ctx yet, but it’s standard practice: the HTTP layer always passes r.Context() down so that request cancellation can “propagate” down the stack when we start doing I/O or long operations.

5. Response 201 Created: Location and headers

Now the nicest part: the successful response. But HTTP, like any decent protocol, has its own “rules of good manners.”

When you create a resource, status 201 Created tells the client: “I created a new entity.” The Location header says: “here is the address of that entity.” Even if the client won’t GET that address right now, the contract becomes richer and more convenient.

And there’s a purely technical nuance in Go: headers must be set before WriteHeader. After WriteHeader, the headers have already gone out on the wire, and it’s too late to shake your fist.

6. Handler handleCreateTask: the full route

Now let’s assemble the full handler. It will look linear and boring — and that’s a compliment. A good handler should read top-to-bottom like an instruction: “first decode, then validate, then create, then respond.”

Assume we already have writeError(w, err) from the previous lecture (a single point for error responses), and also a Server with a dependency on app.

package api

import (
	"encoding/json"
	"fmt"
	"net/http"
	"strings"

	"example.com/project/app"
)

type Server struct {
	app *app.App
}

func (s *Server) handleCreateTask(w http.ResponseWriter, r *http.Request) {
	var req CreateTaskRequest
	if err := decodeJSON(w, r, &req); err != nil {
		writeError(w, err)
		return
	}
	if err := req.Validate(); err != nil {
		writeError(w, err)
		return
	}

	title := strings.TrimSpace(req.Title)
	t, err := s.app.Create(r.Context(), title)
	if err != nil {
		writeError(w, err)
		return
	}

	w.Header().Set("Location", fmt.Sprintf("/api/v1/tasks/%d", t.ID))
	w.Header().Set("Content-Type", "application/json; charset=utf-8")
	w.WriteHeader(http.StatusCreated)
	_ = json.NewEncoder(w).Encode(toTaskDTO(t))
}

Several important observations about this code.

First, there isn’t a single place where we “guess” the status manually for errors. We simply return the error up to writeError, and it already knows that ValidationError is 400.

Second, we normalize title once in one place (title := strings.TrimSpace(req.Title)), so that the domain receives an already normal value. This is good style: the boundary is responsible for cleaning input.

Third, we set Location before WriteHeader, and Content-Type — also before. Otherwise you can get “it kind of works, but for some reason the header is missing.”

Why “broken JSON” is 400, not 500

A very common beginner mistake: if json.Decoder.Decode returned an error, then “something broke,” so it must be 500. But that’s a logical trap.

500 means: “the client did everything correctly, and the server fell over for no reason.” Broken JSON is almost always a client error. So it’s 400 (validation), even if the internal error text was scary.

In Go, this fits nicely into the idea of “errors as values”: you don’t have to treat every error as a catastrophe; you should classify it and make a decision.

Route registration

Finally, a small glue step: how to attach this handler to a ServeMux. We assume Go 1.22+ patterns, so the route string includes the method.

package main

import (
	"net/http"

	"example.com/project/api"
	"example.com/project/app"
)

func main() {
	a := &app.App{}
	srv := &api.Server{app: a}

	mux := http.NewServeMux()
	mux.HandleFunc("POST /api/v1/tasks", srv.handleCreateTask)

	_ = http.ListenAndServe(":8080", mux)
}

If your field in Server is named differently, adjust the initialization. Right now, the idea of the route matters more: POST must land in the correct handler, and the handler must go through the same clear pipeline.

7. Common mistakes when implementing POST /api/v1/tasks

Mistake #1: using json.Unmarshal instead of json.Decoder and “I read the entire body at once.”
People often write body, _ := io.ReadAll(r.Body) and then json.Unmarshal(body, &dst). This works for small requests, but it gives you extra allocations and the risk of accidentally reading a huge body. With json.Decoder and MaxBytesReader, you set a limit immediately and decode in a streaming fashion.

Mistake #2: not enabling DisallowUnknownFields(), so typos slip through silently.
The client sent { "titlle": "buy milk" }, the server stayed silent, created a task with an empty title, and then you chase the bug “tasks are empty.” A strict decoder turns such situations into a clear 400 validation error.

Mistake #3: not checking “exactly one JSON object” and accepting garbage after the JSON.
Some clients may accidentally (or intentionally) send {...}\n{...}. If you do a single Decode, you’ll read only the first object and “leave a tail.” The second Decode with a check for io.EOF makes the contract honest: the body must contain one value.

Mistake #4: doing validation inside app.Create, but not returning a ValidationError from the handler.
If validation returns just errors.New("bad title"), the shared error mapping may decide it’s internal, and you’ll get 500 instead of 400. For bad input, stick to the typed ValidationError so it can be recognized via errors.As even with wrapping.

Mistake #5: setting Location after WriteHeader.
In Go, headers are sent together with the status. If you first call w.WriteHeader(http.StatusCreated) and then w.Header().Set("Location", "..."), then Location won’t reach the client. This is one of those bugs that is especially annoying because the code “looks right,” but it doesn’t work.

1
Task
Go SELF, level 61, lesson 0
Locked
Task title
Task title
1
Task
Go SELF, level 61, lesson 0
Locked
Strict JSON
Strict JSON
1
Task
Go SELF, level 61, lesson 0
Locked
Task creation
Task creation
1
Task
Go SELF, level 61, lesson 0
Locked
Unified error handling
Unified error handling
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION