echox

package module
v0.1.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 14, 2026 License: MIT Imports: 20 Imported by: 0

README

CI Go Reference

echox

JSON HTTP APIs on Echo v5 with the building blocks of httpx: responses as values, errors as RFC 9457 Problem Details, strict JSON bodies, and validated pagination and sorting. Echo stays the router, and everything it provides stays available.

echox is an unofficial extension of Echo. It is not affiliated with or endorsed by LabStack.

  • Native first - Echo's binder, serializer, Recover, BodyLimit and RequestLogger; go-playground/validator's tags and English translations. echox adds only what has no native counterpart.
  • Handlers read like the contract - a chain binds each input and calls the service method, and the compiler checks one against the other.
  • One error format - binding, validation, JSON, Echo's own 404, 405 and 413, panics and unknown errors all answer as Problem Details, and nothing internal leaks.
  • One source per binder - path, query, headers and body are bound separately, so a query parameter never fills a body field.
  • The service's logger - a *slog.Logger of your choice, and one record for every 5xx response.
go get github.com/uchaloop/echox

echox requires Go 1.27: handler chains use generic methods.

Quick start

func main() {
	app, err := echox.Make(echox.Config{
		Logger: slog.Default(),
		ProblemRules: []problem.Rule{
			problem.WhenIs(article.ErrNotFound, problem.Template{
				Status: http.StatusNotFound,
				Code:   "article_not_found",
				Detail: "Article was not found",
			}),
		},
	})
	if err != nil {
		log.Fatal(err)
	}

	articles := &ArticleHandler{service: article.MakeService(store)}
	app.GET("/articles/:id", articles.Get)
	app.POST("/articles", articles.Create)

	log.Fatal(http.ListenAndServe(":8080", app))
}

type ArticleHandler struct {
	service *article.Service
}

func (h *ArticleHandler) Get(ctx *echo.Context) error {
	return echox.MakeHandler(ctx).PositivePathID[int64]().Data(h.service.Get)
}

func (h *ArticleHandler) Create(ctx *echo.Context) error {
	return echox.MakeHandler(ctx).JSON[article.CreateParams]().Created(
		h.service.Create,
		func(created article.Article) string { return fmt.Sprintf("/articles/%d", created.ID) },
	)
}

The service knows nothing about HTTP:

func (s *Service) Get(ctx context.Context, id int64) (Article, error)
func (s *Service) Create(ctx context.Context, params CreateParams) (Article, error)

type CreateParams struct {
	Title  string `json:"title" validate:"notblank,max=200"`
	Status Status `json:"status" validate:"required,enum"`
}

What a client gets:

GET /articles/42     200 {"data":{"id":42,"title":"HTTP boundaries","status":"published"}}
GET /articles/7      404 {"type":"about:blank","title":"Not Found","status":404,"detail":"Article was not found","instance":"/articles/7","code":"article_not_found"}
GET /articles/0      422 ... "errors":[{"path":"id","code":"min","message":"id must be 1 or greater"}]
GET /articles/abc    400 ... "errors":[{"path":"id","code":"type","message":"id must be an integer"}]
GET /ping            204
POST /articles
Content-Type: application/json

{"title": " ", "status": "archived"}
HTTP/1.1 422 Unprocessable Entity
Content-Type: application/problem+json

{
  "type": "about:blank",
  "title": "Unprocessable Entity",
  "status": 422,
  "detail": "Request parameters are invalid",
  "instance": "/articles",
  "code": "invalid_request",
  "errors": [
    {"path": "title", "code": "notblank", "message": "title must not be blank"},
    {"path": "status", "code": "enum", "message": "status must be one of the allowed values"}
  ]
}

The application

echox.Make creates an Echo application, or completes one passed in Config.Echo:

Part Default Config
Logger slog.Default() for a new application Logger
Validator MakeValidator() Validator
Error handler MakeErrorHandler with the standard rules ProblemRules, ProblemMapper, HTTPErrorHandler
Log of 5xx responses always through the logger
Echo's Recover always
Echo's BodyLimit none, as in Echo BodyLimit
Your middleware Middleware, installed inside all of the above
GET /ping 204

A 5xx response is logged once, with the status the client received, through the request's logger:

level=ERROR msg="HTTP request failed" method=GET path=/articles/42 status=500 error="query article: connection refused"

A middleware may give each request a logger of its own, and the record uses it:

echox.Config{
	Logger: logger,
	Middleware: []echo.MiddlewareFunc{
		func(next echo.HandlerFunc) echo.HandlerFunc {
			return func(ctx *echo.Context) error {
				ctx.SetLogger(ctx.Logger().With("request_id", ctx.Request().Header.Get("X-Request-Id")))

				return next(ctx)
			}
		},
	},
}

A body over BodyLimit is answered with 413. A panic is recovered, answered with a 500 internal_error problem and logged.

Handler chains

MakeHandler starts a chain. Each step binds and validates one input; a terminal calls the service with the request context and writes the answer:

func (h *ArticleHandler) Update(ctx *echo.Context) error {
	return echox.MakeHandler(ctx).
		PositivePathID[int64]().
		JSON[article.Patch]().
		Data(h.service.Update) // func(context.Context, int64, article.Patch) (article.Article, error)
}

func (h *ArticleHandler) Delete(ctx *echo.Context) error {
	return echox.MakeHandler(ctx).PositivePathID[int64]().NoContent(h.service.Delete)
}
Step Binds
PositivePathID[T]() the :id path parameter as a positive integer
Query[T]() query parameters into T by query tags
Headers[T]() request headers into T by header tags
JSON[T](options...) one strict JSON body into T by json tags
Bind(binder) anything else, with a func(*echo.Context) (T, error)
Terminal Answers
Data(execute) 200 {"data": ...}
Created(execute, location) 201 {"data": ...} with Location
NoContent(execute) 204

A chain holds one or two inputs. The first failure skips the remaining steps and the service and goes to the error handler unchanged. For anything else - a list, another response shape, several service calls - write the handler with the functions below.

Binding and validation

input, err := echox.DecodeAndValidateJSON[article.CreateParams](ctx)
query, err := echox.BindQueryAndValidate[SearchQuery](ctx)
headers, err := echox.BindHeadersAndValidate[ClientHeaders](ctx)
path, err := echox.BindPathAndValidate[SlugPath](ctx)
id, err := echox.BindPositivePathID[int64](ctx)

Each function binds one source with Echo's binder and validates the result with the application's validator. JSON bodies are decoded by httpx: a JSON media type, no unknown fields, exactly one document.

Failure Status code errors[].code
A value that does not decode into its field 400 invalid_request type
A validation tag 422 invalid_request the tag name
Malformed JSON, an unknown field 400 invalid_json
A body that is not JSON 415
GET /search?q=http&tag=go&tag=api   200 {"data":{"q":"http","tags":["go","api"]}}
GET /search?q=http&q=api&tag=go     200 {"data":{"q":"http","tags":["go"]}}
GET /search?tag=go                  422 ... {"path":"q","code":"required","message":"q is a required field"}

A parameter sent more than once fills a slice field with every value; a scalar field gets the first one, as Echo binds it.

Fields are named after their json, query, param or header tag, so an error names what the client sent. Messages are go-playground/validator's English translations. echox adds two tags: enum for a type with a Valid() bool method, and notblank from go-playground's non-standard validators.

Your own tags are registered on the validator, with a translation:

requestValidator, err := echox.MakeValidator()

requestValidator.GoPlayground().RegisterValidation("slug", validateSlug)
requestValidator.GoPlayground().RegisterTranslation("slug", requestValidator.Translator(),
	func(translator ut.Translator) error {
		return translator.Add("slug", "{0} must be a lowercase slug", false)
	},
	func(translator ut.Translator, field validator.FieldError) string {
		message, _ := translator.T("slug", field.Field())

		return message
	},
)

app, err := echox.Make(echox.Config{Validator: requestValidator})
// {"path":"slug","code":"slug","message":"slug must be a lowercase slug"}

A tag without a translation gets the message {field} is invalid rather than Go type names.

Lists

Requests holds the pagination policy of an API version or an endpoint:

requests, err := echox.MakeRequests(echox.RequestConfig{
	Page: page.Config{DefaultSize: 20, MaxSize: 100},
})

func (h *ArticleHandler) List(ctx *echo.Context) error {
	query, err := h.requests.BindListQuery[ArticleFilters, article.SortField](ctx)
	if err != nil {
		return err
	}

	result, err := h.service.List(ctx.Request().Context(), article.ListParams{
		Status: query.Filters.Status,
		Sort: query.Sort.Make(func(field article.SortField, direction sortby.Direction) article.Sort {
			return article.Sort{Field: field, Descending: direction == sortby.Descending}
		}),
		Page: query.Page.Number(),
		Size: query.Page.Size(),
	})
	if err != nil {
		return err
	}

	return echox.Page(ctx, result.Articles, result.Total, query.Page)
}

type ArticleFilters struct {
	Status *article.Status `query:"status" validate:"omitempty,enum"`
}

page, size and repeated sort parameters are reserved:

GET /articles?status=draft&sort=updatedAt:desc&sort=title&page=2
200 {"data":{"items":[...],"total":21,"page":2,"size":20}}

GET /articles?sort=author
422 ... {"path":"sort.0","code":"enum","message":"sort[0] must be one of the allowed values"}

GET /articles?page=x
400 ... {"path":"page","code":"type","message":"page must be a non-negative integer"}

The sort field is a domain type that parses itself, without importing echox. The compiler rejects a type without FromString on its pointer:

func (f *SortField) FromString(value string) error {
	parsed := SortField(value)
	if !parsed.Valid() {
		return fmt.Errorf("unsupported sort field %q", value)
	}

	*f = parsed

	return nil
}

BindPageQuery is the same without sorting.

Responses

echox.OK(ctx, article)                      // 200 {"data": article}
echox.Created(ctx, "/articles/42", article) // 201 {"data": article}, Location
echox.Data(ctx, http.StatusAccepted, job)   // any status, {"data": job}
echox.Page(ctx, items, total, query.Page)   // 200 {"data": {"items", "total", "page", "size"}}
ctx.NoContent(http.StatusNoContent)         // Echo's own

Bodies go through Echo's JSON serializer. echox.Write writes any httpx response.Response, for example one with a custom header:

answer := response.OK(profile)
answer.Header = http.Header{"x-user": {userID}} // sent as X-User

return echox.Write(ctx, answer)

Errors

MakeErrorHandler answers every error as Problem Details. Make builds its mapper from ProblemRules, followed by the standard rules: JSON, pagination and sorting from httpx, binding and validation from echox, and errors made with problem.MakeError.

Errors Echo raises itself keep their status: 404 for an unknown route, 405 with Allow, 413 over the body limit. An error nothing classifies becomes a 500 that shows nothing - including an echo.HTTPError with status 500 and a message:

{"type":"about:blank","title":"Internal Server Error","status":500,"instance":"/articles/42","code":"internal_error"}

A response already written is never replaced, and a HEAD request gets the problem's headers without its body. An application composed by hand can take the error handler alone:

mapper, err := echox.MakeProblemMapper(rules...)

app := echo.New()
app.HTTPErrorHandler = echox.MakeErrorHandler(mapper)

Routes

echox registers no route besides GET /ping. Register yours with Echo itself; a group per API version keeps the whole contract in one table:

v1 := app.Group("/v1", authenticate)
v1.POST("/articles", articles.Create)
v1.GET("/articles", articles.List)
v1.GET("/articles/:id", articles.Get)

Testing

*echo.Echo is an http.Handler, so the application is tested with httpx/apitest through a real HTTP client and an in-memory server:

func TestGetArticle(t *testing.T) {
	t.Parallel()

	api := apitest.Make(t, app)

	api.Get("/articles/42").Do().
		Status(http.StatusOK).
		JSONEqual(map[string]any{
			"data": map[string]any{"id": 42, "title": "HTTP boundaries", "status": "published"},
		})
}

Documentation

Every type and function, with runnable examples: pkg.go.dev/github.com/uchaloop/echox.

Acknowledgements

I am grateful to the authors of Echo and of go-playground/validator. echox stands on their work and keeps to their conventions.

License

MIT

Documentation

Overview

Package echox builds JSON HTTP APIs on Echo v5 from the building blocks of httpx: responses as values, errors as RFC 9457 Problem Details, strict JSON bodies, and validated pagination and sorting. It is an unofficial extension of Echo, not affiliated with LabStack. Echo stays the router, and everything Echo provides stays available.

Application

Make creates an Echo application, or completes one passed in Config.Echo:

app, err := echox.Make(echox.Config{
	Logger: logger,
	ProblemRules: []problem.Rule{
		problem.WhenIs(article.ErrNotFound, problem.Template{
			Status: http.StatusNotFound,
			Code:   "article_not_found",
		}),
	},
	BodyLimit: 1 << 20,
})

It installs:

  • the service's *slog.Logger as the Echo logger, or slog.Default for a new application, so that nothing is written by a logger of Echo's own;
  • a Validator with the English translations of go-playground/validator;
  • the error handler of MakeErrorHandler, with the application's rules followed by the standard ones;
  • a log record for every response with a 5xx status, written through the request's logger, which a middleware may replace with Context.SetLogger;
  • Echo's Recover middleware, so a panic becomes a logged 500 problem;
  • Echo's BodyLimit middleware when Config.BodyLimit is set; as in Echo, there is no limit by default;
  • Config.Middleware, inside all of the above;
  • GET /ping, answered by Ping with 204.

Routes stay the application's. Each part can be replaced through Config, or used on its own in an application composed by hand.

Handlers

MakeHandler starts a typed chain. Each step binds and validates one input, and a terminal calls the service and writes the answer:

func (h *Handler) Get(ctx *echo.Context) error {
	return echox.MakeHandler(ctx).PositivePathID[int64]().Data(h.service.Get)
}

func (h *Handler) Update(ctx *echo.Context) error {
	return echox.MakeHandler(ctx).
		PositivePathID[int64]().
		JSON[article.Patch]().
		Data(h.service.Update)
}

The steps are Handler.PositivePathID, Handler.Query, Handler.Headers, Handler.JSON, and Handler.Bind for a Binder of the application. A chain holds one or two inputs, and the compiler checks them against the signature of the service method. The terminals are BoundHandler.Data with 200, BoundHandler.Created with 201 and a Location, and BoundHandler.NoContent with 204. The first failure skips the remaining steps and the service, and the terminal returns it to the error handler unchanged. The service receives the request context and values of its own types, never Echo types.

For anything else, such as a list endpoint, another response shape or several service calls, a handler uses the bind and response functions directly.

Binding

Each function binds one source with Echo's binder and validates the result: BindPathAndValidate reads param tags, BindQueryAndValidate query tags, BindHeadersAndValidate header tags, and DecodeAndValidateJSON a strict JSON body. No function mixes sources, so a query parameter never fills a body field. BindPositivePathID binds the :id path parameter as a positive integer.

A value that does not decode into its field is a *BindError with code type, answered with status 400, such as "id must be an integer". A parameter sent more than once fills a slice field with every value, and a scalar field gets the first one, as Echo binds it.

Validation

MakeValidator creates the go-playground validator that Make installs. Fields are named after their json, query, param or header tag, so a failure names the value the client sent. A failure is a *ValidationError, answered with status 422: the code is the validation tag and the message is its English translation.

Title string `json:"title" validate:"required,max=200"`
// {"path":"title","code":"required","message":"title is a required field"}

Two tags are added: enum, for a value whose type implements EnumValid, and notblank from go-playground's non-standard validators. The application registers its own tags and their translations through Validator.GoPlayground and Validator.Translator. A tag without a translation gets the message "{field} is invalid".

Pagination and sorting

Requests holds the pagination policy of an API version or an endpoint. Requests.BindPageQuery binds the filters of an endpoint with the page and size query parameters, and Requests.BindListQuery also parses the repeated sort parameter into a typed order:

// GET /articles?status=draft&sort=updatedAt:desc&page=2&size=20
query, err := h.requests.BindListQuery[articleFilters, article.SortField](ctx)

The sort field type implements EnumStringable on its pointer, so the compiler rejects a type that cannot parse itself. The page, size and sort parameters are reserved and must not be declared as filters.

Responses

OK, Data, Created and Page write the envelopes of httpx/response, and Write writes any response.Response. The body goes through Echo's JSONSerializer, and headers are written under canonical names.

Errors

MakeErrorHandler answers every error as Problem Details. MakeProblemMapper puts the application's rules first, then the input rule of httpx/problem, BindProblemRule for binding and validation, and the rule for errors made with problem.MakeError.

An error Echo raises itself keeps its status: 404 for an unknown route, 405 with Allow, 413 over the body limit. An error that nothing classifies becomes a 500 that shows nothing, including an echo.HTTPError with status 500 and a message. A response already written is never replaced, and a HEAD request gets the headers of the problem without its body.

Routes

echox registers no route besides GET /ping. The application registers its routes with Echo itself, for example with one Group per API version, so the whole contract reads as one table.

Index

Examples

Constants

This section is empty.

Variables

This section is empty.

Functions

func BindHeadersAndValidate

func BindHeadersAndValidate[T any](ctx *echo.Context) (T, error)

BindHeadersAndValidate binds request headers into T and validates *T through the validator configured on the Echo application.

func BindPathAndValidate

func BindPathAndValidate[T any](ctx *echo.Context) (T, error)

BindPathAndValidate binds path parameters into T and validates *T through the validator configured on the Echo application.

func BindPositivePathID

func BindPositivePathID[ID positiveInteger](ctx *echo.Context) (ID, error)

BindPositivePathID binds the conventional id path parameter and validates that it is a positive integer.

func BindProblemRule

func BindProblemRule() problem.Rule

BindProblemRule maps Echo binding and tag validation errors to RFC 9457 templates. Application rules may be placed before it to override the default public text.

func BindQueryAndValidate

func BindQueryAndValidate[T any](ctx *echo.Context) (T, error)

BindQueryAndValidate binds query parameters into T and validates *T through the validator configured on the Echo application. It does not bind path or body values.

Example
package main

import (
	"fmt"
	"log"
	"net/http"
	"net/http/httptest"
	"strings"

	"github.com/labstack/echo/v5"
	"github.com/uchaloop/echox"
)

func main() {
	type searchQuery struct {
		Text string   `query:"q" json:"q" validate:"required"`
		Tags []string `query:"tag" json:"tags"`
	}

	app, err := echox.Make(echox.Config{})
	if err != nil {
		log.Fatal(err)
	}

	app.GET("/search", func(ctx *echo.Context) error {
		query, err := echox.BindQueryAndValidate[searchQuery](ctx)
		if err != nil {
			return err
		}

		return echox.OK(ctx, query)
	})

	for _, target := range []string{
		"/search?q=http&tag=go&tag=api",
		"/search?q=http&q=api&tag=go", // a scalar field takes the first value
		"/search?tag=go",
	} {
		recorder := httptest.NewRecorder()
		app.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, target, nil))
		fmt.Println(recorder.Code, strings.TrimSpace(recorder.Body.String()))
	}
}
Output:
200 {"data":{"q":"http","tags":["go","api"]}}
200 {"data":{"q":"http","tags":["go"]}}
422 {"type":"about:blank","title":"Unprocessable Entity","status":422,"detail":"Request parameters are invalid","instance":"/search","code":"invalid_request","errors":[{"path":"q","code":"required","message":"q is a required field"}]}

func Created

func Created(ctx *echo.Context, location string, value any) error

Created writes value inside a data envelope with status 201 and sets the Location response header.

func Data

func Data(ctx *echo.Context, status int, value any) error

Data writes value inside a {"data": ...} JSON envelope.

func DecodeAndValidateJSON

func DecodeAndValidateJSON[T any](ctx *echo.Context, opts ...request.JSONOption) (T, error)

DecodeAndValidateJSON decodes JSON and validates *T through the validator configured on the Echo application.

func DecodeJSON

func DecodeJSON[T any](ctx *echo.Context, opts ...request.JSONOption) (T, error)

DecodeJSON decodes one required JSON document from the Echo request. Its behavior and options are defined by httpx/request.DecodeJSON.

func Make

func Make(cfg Config) (*echo.Echo, error)

Make creates or completes a conventional Echo application. It configures the logger, validation, centralized error handling, server error logging, panic recovery, and GET /ping. The service remains responsible for all application routes.

Example
package main

import (
	"context"
	"errors"
	"fmt"
	"log"
	"net/http"
	"net/http/httptest"
	"strings"

	"github.com/labstack/echo/v5"
	"github.com/uchaloop/echox"
	"github.com/uchaloop/httpx/problem"
)

var errNotFound = errors.New("article not found")

type articleStatus string

// Valid makes the status accepted by the enum validation tag.
func (s articleStatus) Valid() bool {
	return s == "draft" || s == "published"
}

type article struct {
	ID     int64         `json:"id"`
	Title  string        `json:"title"`
	Status articleStatus `json:"status"`
}

type createArticle struct {
	Title  string        `json:"title" validate:"notblank,max=200"`
	Status articleStatus `json:"status" validate:"required,enum"`
}

// articleService stands in for the application's service.
type articleService struct{}

func (articleService) Get(_ context.Context, id int64) (article, error) {
	if id != 42 {
		return article{}, fmt.Errorf("get article %d: %w", id, errNotFound)
	}

	return article{ID: 42, Title: "HTTP boundaries", Status: "published"}, nil
}

func (articleService) Create(_ context.Context, input createArticle) (article, error) {
	return article{ID: 42, Title: input.Title, Status: input.Status}, nil
}

func main() {
	app, err := echox.Make(echox.Config{
		ProblemRules: []problem.Rule{
			problem.WhenIs(errNotFound, problem.Template{
				Status: http.StatusNotFound,
				Code:   "article_not_found",
				Detail: "Article was not found",
			}),
		},
	})
	if err != nil {
		log.Fatal(err)
	}

	service := articleService{}
	app.GET("/articles/:id", func(ctx *echo.Context) error {
		return echox.MakeHandler(ctx).PositivePathID[int64]().Data(service.Get)
	})

	for _, target := range []string{"/articles/42", "/articles/7", "/ping"} {
		recorder := httptest.NewRecorder()
		app.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, target, nil))
		fmt.Println(recorder.Code, strings.TrimSpace(recorder.Body.String()))
	}
}
Output:
200 {"data":{"id":42,"title":"HTTP boundaries","status":"published"}}
404 {"type":"about:blank","title":"Not Found","status":404,"detail":"Article was not found","instance":"/articles/7","code":"article_not_found"}
204

func MakeErrorHandler

func MakeErrorHandler(m *problem.Mapper) echo.HTTPErrorHandler

MakeErrorHandler adapts a problem.Mapper to Echo's centralized error handler. Native HTTP status errors keep their status; application errors are classified by m. Unknown errors remain safe internal problems.

Example
package main

import (
	"errors"
	"fmt"
	"log"
	"net/http"
	"net/http/httptest"
	"strings"

	"github.com/labstack/echo/v5"
	"github.com/uchaloop/echox"
	"github.com/uchaloop/httpx/problem"
)

var errNotFound = errors.New("article not found")

func main() {
	mapper, err := echox.MakeProblemMapper(problem.WhenIs(errNotFound, problem.Template{
		Status: http.StatusNotFound,
		Code:   "article_not_found",
	}))
	if err != nil {
		log.Fatal(err)
	}

	// An application composed by hand takes only the error handler.
	app := echo.New()
	app.HTTPErrorHandler = echox.MakeErrorHandler(mapper)
	app.GET("/articles/:id", func(*echo.Context) error {
		return fmt.Errorf("get article: %w", errNotFound)
	})

	for _, method := range []string{http.MethodGet, http.MethodDelete} {
		recorder := httptest.NewRecorder()
		app.ServeHTTP(recorder, httptest.NewRequest(method, "/articles/42", nil))
		fmt.Println(recorder.Code, strings.TrimSpace(recorder.Body.String()))

		if allow := recorder.Header().Get("Allow"); len(allow) > 0 {
			fmt.Println("Allow:", allow)
		}
	}
}
Output:
404 {"type":"about:blank","title":"Not Found","status":404,"instance":"/articles/42","code":"article_not_found"}
405 {"type":"about:blank","title":"Method Not Allowed","status":405,"instance":"/articles/42"}
Allow: OPTIONS, GET

func MakeProblemMapper

func MakeProblemMapper(rules ...problem.Rule) (*problem.Mapper, error)

MakeProblemMapper creates a mapper with application rules followed by the standard httpx input, Echo binding, and explicit transport-error rules.

func OK

func OK(ctx *echo.Context, value any) error

OK writes a data envelope with status 200, including for a nil value.

func Page

func Page[Item any](ctx *echo.Context, items []Item, total uint64, current page.Page) error

Page writes a conventional paginated data envelope. A nil items slice is encoded as an empty JSON array.

func Ping

func Ping(ctx *echo.Context) error

Ping handles a liveness request with an empty 204 response. The service remains responsible for registering it on GET /ping.

func Write

func Write(ctx *echo.Context, resp response.Response) error

Write is the Echo backend for httpx responses. A body goes through the application's JSON serializer, which delays the status until it succeeds.

Example
package main

import (
	"fmt"
	"log"
	"net/http"
	"net/http/httptest"
	"strings"

	"github.com/labstack/echo/v5"
	"github.com/uchaloop/echox"
	"github.com/uchaloop/httpx/response"
)

func main() {
	app, err := echox.Make(echox.Config{})
	if err != nil {
		log.Fatal(err)
	}

	app.GET("/profile", func(ctx *echo.Context) error {
		answer := response.OK(map[string]string{"name": "Ada"})
		answer.Header = http.Header{"x-user": {"7"}}

		return echox.Write(ctx, answer)
	})

	recorder := httptest.NewRecorder()
	app.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/profile", nil))
	fmt.Println(recorder.Code, "X-User:", recorder.Header().Get("X-User"))
	fmt.Println(strings.TrimSpace(recorder.Body.String()))
}
Output:
200 X-User: 7
{"data":{"name":"Ada"}}

Types

type BindError

type BindError struct {
	Source  BindSource
	Path    string
	Code    string
	Message string
	// contains filtered or unexported fields
}

BindError describes a failed Echo binding operation. Path is empty when the failed field is unknown.

func (*BindError) Error

func (e *BindError) Error() string

func (*BindError) Unwrap

func (e *BindError) Unwrap() error

Unwrap returns the error produced by Echo's binder.

type BindSource

type BindSource string

BindSource identifies the request source that could not be decoded.

const (
	BindSourcePath   BindSource = "path"
	BindSourceQuery  BindSource = "query"
	BindSourceHeader BindSource = "header"
)

type Binder

type Binder[T any] func(*echo.Context) (T, error)

Binder reads and validates one input from the request.

type BoundHandler

type BoundHandler[Input any] struct {
	// contains filtered or unexported fields
}

BoundHandler holds one input or the first bind error.

func (BoundHandler[First]) Bind

func (h BoundHandler[First]) Bind[Second any](bind Binder[Second]) BoundHandler2[First, Second]

Bind appends a second input. No binder runs after an earlier failure.

func (BoundHandler[Input]) Created

func (h BoundHandler[Input]) Created[Result any](
	execute func(context.Context, Input) (Result, error),
	location func(Result) string,
) error

Created calls execute once, preserves its error, and writes the successful response.

func (BoundHandler[Input]) Data

func (h BoundHandler[Input]) Data[Result any](execute func(context.Context, Input) (Result, error)) error

Data calls execute once, preserves its error, and writes the successful response.

func (BoundHandler[First]) Headers

func (h BoundHandler[First]) Headers[Second any]() BoundHandler2[First, Second]

Headers binds and validates the named request input.

func (BoundHandler[First]) JSON

func (h BoundHandler[First]) JSON[Second any](opts ...request.JSONOption) BoundHandler2[First, Second]

JSON decodes one JSON body and validates its tags.

func (BoundHandler[Input]) NoContent

func (h BoundHandler[Input]) NoContent(execute func(context.Context, Input) error) error

NoContent calls execute once, preserves its error, and writes the successful response.

func (BoundHandler[First]) PositivePathID

func (h BoundHandler[First]) PositivePathID[ID positiveInteger]() BoundHandler2[First, ID]

PositivePathID binds and validates the named request input.

func (BoundHandler[First]) Query

func (h BoundHandler[First]) Query[Second any]() BoundHandler2[First, Second]

Query binds and validates the named request input.

type BoundHandler2

type BoundHandler2[First, Second any] struct {
	// contains filtered or unexported fields
}

BoundHandler2 holds two ordered inputs or the first bind error.

func (BoundHandler2[First, Second]) Created

func (h BoundHandler2[First, Second]) Created[Result any](
	execute func(context.Context, First, Second) (Result, error),
	location func(Result) string,
) error

Created calls execute once, preserves its error, and writes the successful response.

func (BoundHandler2[First, Second]) Data

func (h BoundHandler2[First, Second]) Data[Result any](
	execute func(context.Context, First, Second) (Result, error),
) error

Data calls execute once, preserves its error, and writes the successful response.

func (BoundHandler2[First, Second]) NoContent

func (h BoundHandler2[First, Second]) NoContent(execute func(context.Context, First, Second) error) error

NoContent calls execute once, preserves its error, and writes the successful response.

type Config

type Config struct {
	// Echo is an optional application instance. Make creates one when it is nil.
	Echo *echo.Echo
	// Logger replaces the application logger. When nil, Make keeps the logger
	// of a supplied Echo instance and gives a new one slog.Default. A service
	// middleware may still set a request-scoped logger with Context.SetLogger.
	Logger *slog.Logger
	// ProblemMapper classifies application errors. When nil, Make constructs
	// a mapper from ProblemRules followed by the standard rules.
	ProblemMapper *problem.Mapper
	// ProblemRules precede the standard rules. Cannot be combined with ProblemMapper.
	ProblemRules []problem.Rule
	// Validator replaces the standard echox validator when it is not nil.
	Validator echo.Validator
	// HTTPErrorHandler replaces the RFC 9457 handler when it is not nil.
	HTTPErrorHandler echo.HTTPErrorHandler
	// BodyLimit is the largest accepted request body in bytes; a larger body
	// is answered with 413. Zero sets no limit, as Echo sets none by default.
	BodyLimit int64
	// Middleware is installed inside server error logging, panic recovery and
	// the body limit.
	Middleware []echo.MiddlewareFunc
}

Config defines the conventional Echo composition applied by Make.

Example (BodyLimit)
package main

import (
	"context"
	"errors"
	"fmt"
	"log"
	"net/http"
	"net/http/httptest"
	"strings"

	"github.com/labstack/echo/v5"
	"github.com/uchaloop/echox"
)

var errNotFound = errors.New("article not found")

type articleStatus string

// Valid makes the status accepted by the enum validation tag.
func (s articleStatus) Valid() bool {
	return s == "draft" || s == "published"
}

type article struct {
	ID     int64         `json:"id"`
	Title  string        `json:"title"`
	Status articleStatus `json:"status"`
}

type createArticle struct {
	Title  string        `json:"title" validate:"notblank,max=200"`
	Status articleStatus `json:"status" validate:"required,enum"`
}

// articleService stands in for the application's service.
type articleService struct{}

func (articleService) Get(_ context.Context, id int64) (article, error) {
	if id != 42 {
		return article{}, fmt.Errorf("get article %d: %w", id, errNotFound)
	}

	return article{ID: 42, Title: "HTTP boundaries", Status: "published"}, nil
}

func (articleService) Create(_ context.Context, input createArticle) (article, error) {
	return article{ID: 42, Title: input.Title, Status: input.Status}, nil
}

func main() {
	app, err := echox.Make(echox.Config{BodyLimit: 64})
	if err != nil {
		log.Fatal(err)
	}

	service := articleService{}
	app.POST("/articles", func(ctx *echo.Context) error {
		return echox.MakeHandler(ctx).JSON[createArticle]().Data(service.Create)
	})

	body := `{"title":"` + strings.Repeat("a", 100) + `","status":"draft"}`
	r := httptest.NewRequest(http.MethodPost, "/articles", strings.NewReader(body))
	r.Header.Set("Content-Type", "application/json")

	recorder := httptest.NewRecorder()
	app.ServeHTTP(recorder, r)
	fmt.Println(recorder.Code, strings.TrimSpace(recorder.Body.String()))
}
Output:
413 {"type":"about:blank","title":"Request Entity Too Large","status":413,"instance":"/articles"}

type EnumStringable

type EnumStringable interface {
	FromString(string) error
}

EnumStringable describes an enum that can parse and assign a string value.

type EnumValid

type EnumValid interface {
	Valid() bool
}

EnumValid describes a value accepted by the enum validation tag.

type Handler

type Handler struct {
	// contains filtered or unexported fields
}

Handler starts a request-local sequence. Use a chain once, within its handler.

func MakeHandler

func MakeHandler(ctx *echo.Context) Handler

MakeHandler starts an eager bind sequence. Terminals return its first error.

Example
package main

import (
	"context"
	"errors"
	"fmt"
	"log"
	"net/http"
	"net/http/httptest"
	"strings"

	"github.com/labstack/echo/v5"
	"github.com/uchaloop/echox"
)

var errNotFound = errors.New("article not found")

type articleStatus string

// Valid makes the status accepted by the enum validation tag.
func (s articleStatus) Valid() bool {
	return s == "draft" || s == "published"
}

type article struct {
	ID     int64         `json:"id"`
	Title  string        `json:"title"`
	Status articleStatus `json:"status"`
}

type createArticle struct {
	Title  string        `json:"title" validate:"notblank,max=200"`
	Status articleStatus `json:"status" validate:"required,enum"`
}

// articleService stands in for the application's service.
type articleService struct{}

func (articleService) Get(_ context.Context, id int64) (article, error) {
	if id != 42 {
		return article{}, fmt.Errorf("get article %d: %w", id, errNotFound)
	}

	return article{ID: 42, Title: "HTTP boundaries", Status: "published"}, nil
}

func (articleService) Create(_ context.Context, input createArticle) (article, error) {
	return article{ID: 42, Title: input.Title, Status: input.Status}, nil
}

func main() {
	app, err := echox.Make(echox.Config{})
	if err != nil {
		log.Fatal(err)
	}

	service := articleService{}
	app.POST("/articles", func(ctx *echo.Context) error {
		return echox.MakeHandler(ctx).
			JSON[createArticle]().
			Created(service.Create, func(created article) string {
				return fmt.Sprintf("/articles/%d", created.ID)
			})
	})

	for _, body := range []string{
		`{"title":"HTTP boundaries","status":"draft"}`,
		`{"title":" ","status":"archived"}`,
	} {
		r := httptest.NewRequest(http.MethodPost, "/articles", strings.NewReader(body))
		r.Header.Set("Content-Type", "application/json")

		recorder := httptest.NewRecorder()
		app.ServeHTTP(recorder, r)
		fmt.Println(recorder.Code, strings.TrimSpace(recorder.Body.String()))

		if location := recorder.Header().Get("Location"); len(location) > 0 {
			fmt.Println("Location:", location)
		}
	}
}
Output:
201 {"data":{"id":42,"title":"HTTP boundaries","status":"draft"}}
Location: /articles/42
422 {"type":"about:blank","title":"Unprocessable Entity","status":422,"detail":"Request parameters are invalid","instance":"/articles","code":"invalid_request","errors":[{"path":"title","code":"notblank","message":"title must not be blank"},{"path":"status","code":"enum","message":"status must be one of the allowed values"}]}

func (Handler) Bind

func (h Handler) Bind[T any](bind Binder[T]) BoundHandler[T]

Bind reads one input. No binder runs after an earlier failure.

func (Handler) Headers

func (h Handler) Headers[T any]() BoundHandler[T]

Headers binds and validates the named request input.

func (Handler) JSON

func (h Handler) JSON[T any](opts ...request.JSONOption) BoundHandler[T]

JSON decodes one JSON body and validates its tags.

func (Handler) PositivePathID

func (h Handler) PositivePathID[ID positiveInteger]() BoundHandler[ID]

PositivePathID binds and validates the named request input.

Example
package main

import (
	"context"
	"errors"
	"fmt"
	"log"
	"net/http"
	"net/http/httptest"
	"strings"

	"github.com/labstack/echo/v5"
	"github.com/uchaloop/echox"
)

var errNotFound = errors.New("article not found")

type articleStatus string

// Valid makes the status accepted by the enum validation tag.
func (s articleStatus) Valid() bool {
	return s == "draft" || s == "published"
}

type article struct {
	ID     int64         `json:"id"`
	Title  string        `json:"title"`
	Status articleStatus `json:"status"`
}

type createArticle struct {
	Title  string        `json:"title" validate:"notblank,max=200"`
	Status articleStatus `json:"status" validate:"required,enum"`
}

// articleService stands in for the application's service.
type articleService struct{}

func (articleService) Get(_ context.Context, id int64) (article, error) {
	if id != 42 {
		return article{}, fmt.Errorf("get article %d: %w", id, errNotFound)
	}

	return article{ID: 42, Title: "HTTP boundaries", Status: "published"}, nil
}

func (articleService) Create(_ context.Context, input createArticle) (article, error) {
	return article{ID: 42, Title: input.Title, Status: input.Status}, nil
}

func main() {
	app, err := echox.Make(echox.Config{})
	if err != nil {
		log.Fatal(err)
	}

	service := articleService{}
	app.GET("/articles/:id", func(ctx *echo.Context) error {
		return echox.MakeHandler(ctx).PositivePathID[int64]().Data(service.Get)
	})

	for _, target := range []string{"/articles/42", "/articles/0", "/articles/abc"} {
		recorder := httptest.NewRecorder()
		app.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, target, nil))
		fmt.Println(recorder.Code, strings.TrimSpace(recorder.Body.String()))
	}
}
Output:
200 {"data":{"id":42,"title":"HTTP boundaries","status":"published"}}
422 {"type":"about:blank","title":"Unprocessable Entity","status":422,"detail":"Request parameters are invalid","instance":"/articles/0","code":"invalid_request","errors":[{"path":"id","code":"min","message":"id must be 1 or greater"}]}
400 {"type":"about:blank","title":"Bad Request","status":400,"detail":"Request parameters could not be decoded","instance":"/articles/abc","code":"invalid_request","errors":[{"path":"id","code":"type","message":"id must be an integer"}]}

func (Handler) Query

func (h Handler) Query[T any]() BoundHandler[T]

Query binds and validates the named request input.

type InvalidField

type InvalidField struct {
	Path    string
	Code    string
	Message string
}

InvalidField describes one failed validation rule. Code is the validation tag and Message its English go-playground/validator translation.

type ListInput

type ListInput[Filters, Field any] struct {
	PageInput[Filters]
	Sort sortby.Order[Field]
}

ListInput adds ordered, typed sort terms to a paginated input.

type PageInput

type PageInput[Filters any] struct {
	Filters Filters
	Page    page.Page
}

PageInput separates application filters from validated HTTP pagination.

type RequestConfig

type RequestConfig struct {
	Page page.Config
}

RequestConfig defines pagination policy for a version or endpoint.

type Requests

type Requests struct {
	// contains filtered or unexported fields
}

Requests holds immutable policy. It can be shared across concurrent requests.

func MakeRequests

func MakeRequests(cfg RequestConfig) (*Requests, error)

MakeRequests validates policy before serving requests.

func (*Requests) BindListQuery

func (r *Requests) BindListQuery[Filters, Field any, FieldPtr interface {
	*Field
	EnumStringable
}](ctx *echo.Context) (ListInput[Filters, Field], error)

BindListQuery also parses repeated sort terms. Sort is reserved for ordering.

Example
package main

import (
	"fmt"
	"log"
	"net/http"
	"net/http/httptest"
	"strings"

	"github.com/labstack/echo/v5"
	"github.com/uchaloop/echox"
	"github.com/uchaloop/httpx/page"
)

type articleStatus string

// Valid makes the status accepted by the enum validation tag.
func (s articleStatus) Valid() bool {
	return s == "draft" || s == "published"
}

type article struct {
	ID     int64         `json:"id"`
	Title  string        `json:"title"`
	Status articleStatus `json:"status"`
}

type articleSort string

const (
	sortByTitle     articleSort = "title"
	sortByUpdatedAt articleSort = "updatedAt"
)

// FromString lets BindListQuery parse a sort field; the compiler requires it.
func (s *articleSort) FromString(value string) error {
	switch parsed := articleSort(value); parsed {
	case sortByTitle, sortByUpdatedAt:
		*s = parsed

		return nil
	default:
		return fmt.Errorf("unsupported sort field %q", value)
	}
}

type articleFilters struct {
	Status *articleStatus `query:"status" validate:"omitempty,enum"`
}

func main() {
	app, err := echox.Make(echox.Config{})
	if err != nil {
		log.Fatal(err)
	}

	requests, err := echox.MakeRequests(echox.RequestConfig{
		Page: page.Config{DefaultSize: 20, MaxSize: 100},
	})
	if err != nil {
		log.Fatal(err)
	}

	app.GET("/articles", func(ctx *echo.Context) error {
		query, err := requests.BindListQuery[articleFilters, articleSort](ctx)
		if err != nil {
			return err
		}

		fmt.Println("status:", *query.Filters.Status)
		fmt.Println("sort:", query.Sort)
		fmt.Println("page:", query.Page.Number(), "size:", query.Page.Size())

		articles := []article{{ID: 42, Title: "HTTP boundaries", Status: "draft"}}

		return echox.Page(ctx, articles, 21, query.Page)
	})

	for _, target := range []string{
		"/articles?status=draft&sort=updatedAt:desc&sort=title&page=2",
		"/articles?sort=author",
		"/articles?page=x",
	} {
		recorder := httptest.NewRecorder()
		app.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, target, nil))
		fmt.Println(recorder.Code, strings.TrimSpace(recorder.Body.String()))
	}
}
Output:
status: draft
sort: [{updatedAt desc} {title asc}]
page: 2 size: 20
200 {"data":{"items":[{"id":42,"title":"HTTP boundaries","status":"draft"}],"total":21,"page":2,"size":20}}
422 {"type":"about:blank","title":"Unprocessable Entity","status":422,"detail":"Request parameters are invalid","instance":"/articles","code":"invalid_request","errors":[{"path":"sort.0","code":"enum","message":"sort[0] must be one of the allowed values"}]}
400 {"type":"about:blank","title":"Bad Request","status":400,"detail":"Request parameters could not be decoded","instance":"/articles","code":"invalid_request","errors":[{"path":"page","code":"type","message":"page must be a non-negative integer"}]}

func (*Requests) BindPageQuery

func (r *Requests) BindPageQuery[Filters any](ctx *echo.Context) (PageInput[Filters], error)

BindPageQuery binds filter tags and pagination from query parameters only. Filters must be a struct using query and validate tags. Page and size are reserved for pagination and must not be declared as application filters.

type ValidationError

type ValidationError struct {
	// contains filtered or unexported fields
}

ValidationError contains stable request-facing validation failures.

func (*ValidationError) Error

func (e *ValidationError) Error() string

func (*ValidationError) InvalidFields

func (e *ValidationError) InvalidFields() []InvalidField

InvalidFields returns a copy of the failed fields.

func (*ValidationError) Unwrap

func (e *ValidationError) Unwrap() error

Unwrap returns the error produced by go-playground/validator.

type Validator

type Validator struct {
	// contains filtered or unexported fields
}

Validator applies request validation tags.

func MakeValidator

func MakeValidator() (*Validator, error)

MakeValidator creates an Echo-compatible validator with the English translations of go-playground/validator. It registers request tag field names and the enum and notblank tags with their translations.

func (*Validator) GoPlayground

func (v *Validator) GoPlayground() *validator.Validate

GoPlayground returns the underlying validator for application-specific validation registrations.

func (*Validator) Translator

func (v *Validator) Translator() ut.Translator

Translator returns the English translator for registering translations of application-specific tags with GoPlayground().RegisterTranslation.

Example
package main

import (
	"fmt"
	"log"
	"net/http"
	"net/http/httptest"
	"regexp"
	"strings"

	ut "github.com/go-playground/universal-translator"
	"github.com/go-playground/validator/v10"
	"github.com/labstack/echo/v5"
	"github.com/uchaloop/echox"
)

func main() {
	type createTag struct {
		Slug string `json:"slug" validate:"slug"`
	}

	requestValidator, err := echox.MakeValidator()
	if err != nil {
		log.Fatal(err)
	}

	slug := regexp.MustCompile(`^[a-z0-9]+(-[a-z0-9]+)*$`)
	playground := requestValidator.GoPlayground()

	err = playground.RegisterValidation("slug", func(fl validator.FieldLevel) bool {
		return slug.MatchString(fl.Field().String())
	})
	if err != nil {
		log.Fatal(err)
	}

	err = playground.RegisterTranslation(
		"slug",
		requestValidator.Translator(),
		func(trans ut.Translator) error {
			return trans.Add("slug", "{0} must be a lowercase slug", false)
		},
		func(trans ut.Translator, fe validator.FieldError) string {
			message, _ := trans.T("slug", fe.Field())

			return message
		},
	)
	if err != nil {
		log.Fatal(err)
	}

	app, err := echox.Make(echox.Config{Validator: requestValidator})
	if err != nil {
		log.Fatal(err)
	}

	app.POST("/tags", func(ctx *echo.Context) error {
		input, err := echox.DecodeAndValidateJSON[createTag](ctx)
		if err != nil {
			return err
		}

		return echox.Created(ctx, "/tags/"+input.Slug, input)
	})

	r := httptest.NewRequest(http.MethodPost, "/tags", strings.NewReader(`{"slug":"Go Tips"}`))
	r.Header.Set("Content-Type", "application/json")

	recorder := httptest.NewRecorder()
	app.ServeHTTP(recorder, r)
	fmt.Println(recorder.Code, strings.TrimSpace(recorder.Body.String()))
}
Output:
422 {"type":"about:blank","title":"Unprocessable Entity","status":422,"detail":"Request parameters are invalid","instance":"/tags","code":"invalid_request","errors":[{"path":"slug","code":"slug","message":"slug must be a lowercase slug"}]}

func (*Validator) Validate

func (v *Validator) Validate(value any) error

Validate implements echo.Validator.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL