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 ¶
- func BindHeadersAndValidate[T any](ctx *echo.Context) (T, error)
- func BindPathAndValidate[T any](ctx *echo.Context) (T, error)
- func BindPositivePathID[ID positiveInteger](ctx *echo.Context) (ID, error)
- func BindProblemRule() problem.Rule
- func BindQueryAndValidate[T any](ctx *echo.Context) (T, error)
- func Created(ctx *echo.Context, location string, value any) error
- func Data(ctx *echo.Context, status int, value any) error
- func DecodeAndValidateJSON[T any](ctx *echo.Context, opts ...request.JSONOption) (T, error)
- func DecodeJSON[T any](ctx *echo.Context, opts ...request.JSONOption) (T, error)
- func Make(cfg Config) (*echo.Echo, error)
- func MakeErrorHandler(m *problem.Mapper) echo.HTTPErrorHandler
- func MakeProblemMapper(rules ...problem.Rule) (*problem.Mapper, error)
- func OK(ctx *echo.Context, value any) error
- func Page[Item any](ctx *echo.Context, items []Item, total uint64, current page.Page) error
- func Ping(ctx *echo.Context) error
- func Write(ctx *echo.Context, resp response.Response) error
- type BindError
- type BindSource
- type Binder
- type BoundHandler
- func (h BoundHandler[First]) Bind[Second any](bind Binder[Second]) BoundHandler2[First, Second]
- func (h BoundHandler[Input]) Created[Result any](execute func(context.Context, Input) (Result, error), ...) error
- func (h BoundHandler[Input]) Data[Result any](execute func(context.Context, Input) (Result, error)) error
- func (h BoundHandler[First]) Headers[Second any]() BoundHandler2[First, Second]
- func (h BoundHandler[First]) JSON[Second any](opts ...request.JSONOption) BoundHandler2[First, Second]
- func (h BoundHandler[Input]) NoContent(execute func(context.Context, Input) error) error
- func (h BoundHandler[First]) PositivePathID[ID positiveInteger]() BoundHandler2[First, ID]
- func (h BoundHandler[First]) Query[Second any]() BoundHandler2[First, Second]
- type BoundHandler2
- func (h BoundHandler2[First, Second]) Created[Result any](execute func(context.Context, First, Second) (Result, error), ...) error
- func (h BoundHandler2[First, Second]) Data[Result any](execute func(context.Context, First, Second) (Result, error)) error
- func (h BoundHandler2[First, Second]) NoContent(execute func(context.Context, First, Second) error) error
- type Config
- type EnumStringable
- type EnumValid
- type Handler
- func (h Handler) Bind[T any](bind Binder[T]) BoundHandler[T]
- func (h Handler) Headers[T any]() BoundHandler[T]
- func (h Handler) JSON[T any](opts ...request.JSONOption) BoundHandler[T]
- func (h Handler) PositivePathID[ID positiveInteger]() BoundHandler[ID]
- func (h Handler) Query[T any]() BoundHandler[T]
- type InvalidField
- type ListInput
- type PageInput
- type RequestConfig
- type Requests
- type ValidationError
- type Validator
Examples ¶
Constants ¶
This section is empty.
Variables ¶
This section is empty.
Functions ¶
func BindHeadersAndValidate ¶
BindHeadersAndValidate binds request headers into T and validates *T through the validator configured on the Echo application.
func BindPathAndValidate ¶
BindPathAndValidate binds path parameters into T and validates *T through the validator configured on the Echo application.
func BindPositivePathID ¶
BindPositivePathID binds the conventional id path parameter and validates that it is a positive integer.
func BindProblemRule ¶
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 ¶
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 ¶
Created writes value inside a data envelope with status 201 and sets the Location response header.
func DecodeAndValidateJSON ¶
DecodeAndValidateJSON decodes JSON and validates *T through the validator configured on the Echo application.
func DecodeJSON ¶
DecodeJSON decodes one required JSON document from the Echo request. Its behavior and options are defined by httpx/request.DecodeJSON.
func Make ¶
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 ¶
MakeProblemMapper creates a mapper with application rules followed by the standard httpx input, Echo binding, and explicit transport-error rules.
func Page ¶
Page writes a conventional paginated data envelope. A nil items slice is encoded as an empty JSON array.
func Ping ¶
Ping handles a liveness request with an empty 204 response. The service remains responsible for registering it on GET /ping.
func Write ¶
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.
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 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.
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 ¶
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 ¶
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 ¶
InvalidField describes one failed validation rule. Code is the validation tag and Message its English go-playground/validator translation.
type RequestConfig ¶
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 ¶
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 ¶
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 ¶
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"}]}