diff --git a/README.md b/README.md index da271c0..40a9b08 100644 --- a/README.md +++ b/README.md @@ -24,9 +24,10 @@ A Go library that extends Fiber to add automatic OpenAPI documentation generatio go get github.com/labbs/fiber-oapi/v3 ``` -> **Upgrading from v1.x?** v3 tracks Fiber v3 and requires Go 1.26+. Two breaking changes to be aware of: +> **Upgrading from v1.x?** v3 tracks Fiber v3 and requires Go 1.26+. Breaking changes: > - Handlers now take `fiber.Ctx` (struct value) instead of `*fiber.Ctx`. > - The path-parameter struct tag is now `uri:` instead of `path:` (Fiber v3 binder convention). Query and header tags are unchanged. +> - The default validation/parse error response uses a new `ErrorEnvelope` shape (one entry per failing field, `response_context.response_id` mirrored from `X-Request-Id`) and validation errors are returned as **422 Unprocessable Entity** instead of 400. See [Error responses](#error-responses). ## Quick Start @@ -162,6 +163,235 @@ type Input struct { } ``` +## Error responses + +When the default validation / parse handler runs (i.e. no custom `ValidationErrorHandler` +is configured), errors are returned as a structured envelope with one entry per +failing field: + +```json +{ + "errors": [ + { + "type": "validation_error", + "code": 422, + "loc": ["body", "workspaceId"], + "field": "workspaceId", + "msg": "field 'workspaceId' must be at least 11", + "constraint": "min=11" + }, + { + "type": "validation_error", + "code": 422, + "loc": ["body", "nested", "slug"], + "field": "slug", + "msg": "field 'slug' must be at least 2", + "constraint": "min=2" + } + ], + "response_context": { + "response_id": "bf0e9029-576b-42e8-84f9-ad0622972f50" + } +} +``` + +Status codes used by the default handler: + +| Code | Category | Entry `type` | +|------|----------|--------------| +| 422 | Failed validation rules | `validation_error` | +| 400 | JSON parse / type mismatch | `type_error`, `parse_error` | +| 401 / 403 | Authentication / authorization | `authentication_error`, `authorization_error` | + +`response_context.response_id` mirrors the `X-Request-Id` request header when +present (no UUID is generated by the lib — pair with a `requestid` middleware +if you want one). The `loc` array starts with the request source (`body`, `path`, +`query`, `header`) followed by the field path using JSON / URI / header tag names. + +By default the offending value is omitted to avoid leaking secrets (e.g. a +password failing `min=8` validation). Opt in via `Config.IncludeInvalidValueInErrors: true` +if you want each entry to carry a `value` field. + +The OpenAPI spec exposes `ErrorEnvelope` / `ValidationErrorEntry` / `ResponseContext` +under `components.schemas` and adds a 422 response with a realistic example to +every operation, plus a 400 example for body-carrying methods. Routes that +declare a non-empty `TError` keep their domain shape under the catch-all `4XX` +response. + +If you need a different shape, set `Config.ValidationErrorHandler` / `Config.AuthErrorHandler` +— they receive the raw error (JSON type mismatches are wrapped so `err.Error()` +stays friendly, but `var ute *json.UnmarshalTypeError; errors.As(err, &ute)` still recovers +the original). + +### Unifying all errors under one shape (`DefaultErrorShape`) + +By default, the library emits its own `ErrorEnvelope` shape for framework-level +errors (parse, validation, auth, route-miss) while your custom errors use +whatever struct you declared. That mismatch shows up in tools like Redoc / +Stoplight as two distinct schemas per endpoint. + +Set `Config.DefaultErrorShape` to a (zero) instance of your error type and the +library uses that shape everywhere — both runtime responses and spec entries: + +```go +type ErrorResponse struct { + Code int `json:"code"` + Message string `json:"message"` + Details string `json:"details,omitempty"` + Type string `json:"type"` +} + +oapi := fiberoapi.New(app, fiberoapi.Config{ + DefaultErrorShape: &ErrorResponse{}, // empty template — fields are filled per error +}) +``` + +For each library-emitted error, the matching fields on your shape are populated +via reflection (case-sensitive, applied only if present and settable): + +| Field | Source | +|-------|--------| +| `StatusCode` or `Code` | HTTP status code (400, 401, 403, 404, 405) | +| `Message`, `Description`, or `Msg` | one-line human-readable summary | +| `Type` | discriminator (`type_error`, `parse_error`, `authentication_error`, `authorization_error`, `not_found`, `method_not_allowed`) | +| `Details` | joined extra context (allowed methods for 405, …) | + +**One exception:** the 422 validation response keeps the rich `ErrorEnvelope` +shape (one entry per failing field with `loc` / `constraint` / `field` / +`value`). Collapsing a multi-field validation failure into a single flat struct +would lose the structured info that form-level UX needs. If you really want a +flat 422, declare your own entry at status 422 in `OpenAPIOptions.Errors` — the +per-route override still wins. + +Result: in your spec every error response references `#/components/schemas/ErrorResponse` +**except 422**, which stays on `#/components/schemas/ErrorEnvelope`. + +Per-route entries declared via `OpenAPIOptions.Errors` still take precedence +for their status code — so you can selectively override the default shape on a +specific endpoint if you ever need to. + +### Custom domain errors (declared per route, visible in the spec) + +For handler-emitted errors (conflict, not-found, precondition-failed, …), +declare them via `OpenAPIOptions.Errors`. Each entry is an instance of any +struct describing one error case — the library inspects it to populate the +generated OpenAPI spec and the handler returns the same instance at runtime. + +```go +// One shared type in your app — all custom errors funnel through it so the +// spec has a single component schema and clients see one consistent shape. +type ErrorResponse struct { + Code int `json:"code"` + Message string `json:"message"` + Details string `json:"details,omitempty"` + Type string `json:"type"` +} + +func (e *ErrorResponse) Error() string { return e.Message } // optional + +func UserAlreadyExists(name string) *ErrorResponse { + return &ErrorResponse{Code: 409, Message: fmt.Sprintf("user %q already exists", name), Type: "Conflict"} +} + +func UserNotFound(name string) *ErrorResponse { + return &ErrorResponse{Code: 404, Message: fmt.Sprintf("user %q not found", name), Type: "NotFound"} +} + +fiberoapi.Post(oapi, "/users/:name", + func(c fiber.Ctx, in CreateUserInput) (CreateUserOutput, error) { + if in.Name == "admin" { + return CreateUserOutput{}, UserAlreadyExists(in.Name) + } + return CreateUserOutput{Message: "ok"}, nil + }, + fiberoapi.OpenAPIOptions{ + Errors: []any{ + UserAlreadyExists("admin"), // becomes the 409 example in the spec + UserNotFound("ghost"), // becomes the 404 example in the spec + }, + }, +) +``` + +How each entry maps to the spec: + +| Source | Extracted via | +|--------|---------------| +| Status code | `HTTPStatus() int` method, else `StatusCode` / `Code` int field, else `500` | +| Description | `Description() string` method, else `Message` / `Description` / `Msg` string field, else HTTP reason phrase | +| Schema | `reflect.TypeOf(entry)` — named types use `$ref` to `components.schemas` so multiple entries with the same type stay deduplicated | +| Example | the entry value itself, JSON-marshalled | + +The handler's return type can be `error` (when the entry implements `error`), +`*ErrorResponse`, or any other concrete type — the library uses reflection, +not a type assertion, to read the status code. + +Multiple entries at the same status code: the last one wins (typical when +mixing `Errors` with the auto-emitted default envelopes). Use this to +deliberately override the default `404` (route-miss) envelope with your own +domain-404 shape for routes that report "resource not found". + +### 404 Not Found + +The same envelope is produced for unmatched routes when you opt in via +`oapi.UseNotFoundHandler()`. **Call it after registering every route** — under +the hood it installs a catch-all `app.Use(handler)` middleware in Fiber, which +is matched in registration order, so it must come last to avoid swallowing real +routes. + +```go +app := fiber.New() +oapi := fiberoapi.New(app) + +fiberoapi.Get(oapi, "/users/:id", getUser, opts) +fiberoapi.Post(oapi, "/users", createUser, opts) +// ... every other route ... + +oapi.UseNotFoundHandler() // ← last +app.Listen(":3000") +``` + +Response shape: + +```json +{ + "errors": [ + { + "type": "not_found", + "code": 404, + "loc": ["path"], + "field": "/users/42", + "msg": "no route matches GET /users/42" + } + ], + "response_context": { "response_id": "bf0e9029-..." } +} +``` + +The default handler does more than just emit the 404 envelope: + +- **HEAD** requests get a bodyless 404 (HTTP-conformant). +- **OPTIONS** requests fall through (`c.Next()`) so downstream CORS middleware + can answer preflights. +- When the path is registered under another HTTP method, the response is **405** + with an `Allow` header listing the supported methods and the envelope's + entry `type` set to `method_not_allowed`. +- The `X-Request-Id` header is sanitised before being echoed (max 128 bytes, + charset `[A-Za-z0-9._\-:]`) — invalid values are dropped to neutralise + log-injection vectors. +- The echoed path is capped at ~1 KiB and validated as UTF-8. + +Calling `UseNotFoundHandler()` more than once on the same `OApiApp` is a no-op +after the first install. Once installed, the generated OpenAPI spec also lists +a 404 response on every operation (referencing `ErrorEnvelope`) so the contract +is documented for clients. + +Override the handler entirely via `Config.NotFoundHandler`. Your handler +receives a raw `fiber.Ctx` and owns the full response — call +`fiberoapi.NotFoundEnvelope(c)` to reuse the library's shape from inside it. +For users managing their own `fiber.Config`, `fiberoapi.DefaultNotFoundHandler()` +returns a no-op-405 version of the catch-all you can install yourself. + ## Authentication & Authorization ### Supported Security Schemes diff --git a/_examples/simple_error/go.mod b/_examples/simple_error/go.mod new file mode 100644 index 0000000..dcd57d0 --- /dev/null +++ b/_examples/simple_error/go.mod @@ -0,0 +1,34 @@ +module simpleerror + +go 1.26.0 + +replace github.com/labbs/fiber-oapi/v3 => ../.. + +require ( + github.com/gofiber/fiber/v3 v3.3.0 + github.com/labbs/fiber-oapi/v3 v3.0.0-00010101000000-000000000000 +) + +require ( + github.com/andybalholm/brotli v1.2.1 // indirect + github.com/gabriel-vasile/mimetype v1.4.13 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.30.2 // indirect + github.com/gofiber/schema v1.7.1 // indirect + github.com/gofiber/utils/v2 v2.0.6 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/klauspost/compress v1.18.6 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-isatty v0.0.22 // indirect + github.com/philhofer/fwd v1.2.0 // indirect + github.com/tinylib/msgp v1.6.4 // indirect + github.com/valyala/bytebufferpool v1.0.0 // indirect + github.com/valyala/fasthttp v1.71.0 // indirect + golang.org/x/crypto v0.52.0 // indirect + golang.org/x/net v0.55.0 // indirect + golang.org/x/sys v0.45.0 // indirect + golang.org/x/text v0.37.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/_examples/simple_error/go.sum b/_examples/simple_error/go.sum new file mode 100644 index 0000000..8636088 --- /dev/null +++ b/_examples/simple_error/go.sum @@ -0,0 +1,62 @@ +github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro= +github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/fxamacker/cbor/v2 v2.9.2 h1:X4Ksno9+x3cz0TZv69ec1hxP/+tymuR8PXQJyDwfh78= +github.com/fxamacker/cbor/v2 v2.9.2/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= +github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM= +github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.30.2 h1:JiFIMtSSHb2/XBUbWM4i/MpeQm9ZK2xqPNk8vgvu5JQ= +github.com/go-playground/validator/v10 v10.30.2/go.mod h1:mAf2pIOVXjTEBrwUMGKkCWKKPs9NheYGabeB04txQSc= +github.com/gofiber/fiber/v3 v3.3.0 h1:QBd3sYCqdy6Qs5gJYzSw4I4SbqL204jPqpdub/ueiw8= +github.com/gofiber/fiber/v3 v3.3.0/go.mod h1:YH7/TAoRaU4kF8slDCtQuFJ1NzC+3MtxUI4KfvQtaIA= +github.com/gofiber/schema v1.7.1 h1:oSJBKdgP8JeIME4TQSAqlNKTU2iBB+2RNmKi8Nsc+TI= +github.com/gofiber/schema v1.7.1/go.mod h1:A/X5Ffyru4p9eBdp99qu+nzviHzQiZ7odLT+TwxWhbk= +github.com/gofiber/utils/v2 v2.0.6 h1:7fXYy7nSsyqbH0GQUMtK4Kwjy4J7R5742VM7JsZxzOs= +github.com/gofiber/utils/v2 v2.0.6/go.mod h1:p7mAHAk3+oUK10ZX2xTw9fZQixb4hCg8SKd4IH2xroU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao= +github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4= +github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= +github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM= +github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/shamaton/msgpack/v3 v3.1.2 h1:d5gWAIyMU4M0WgDjz6IFSCuXJUA2dFwRHBpDclE8CLw= +github.com/shamaton/msgpack/v3 v3.1.2/go.mod h1:DcQG8jrdrQCIxr3HlMYkiXdMhK+KfN2CitkyzsQV4uc= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/tinylib/msgp v1.6.4 h1:mOwYbyYDLPj35mkA2BjjYejgJk9BuHxDdvRnb6v2ZcQ= +github.com/tinylib/msgp v1.6.4/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA= +github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +github.com/valyala/fasthttp v1.71.0 h1:tepR7H+Guh9VUqxxcPggYi8R3lGUu2Rsdh+z7/FCY3k= +github.com/valyala/fasthttp v1.71.0/go.mod h1:z1sDUvOShhXq/C9mwH/fSm1Vb71tUJwmQdgkBrBNwnA= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= +github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= +golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= +golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= +golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= +golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= +golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= +golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= +golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/_examples/simple_error/main.go b/_examples/simple_error/main.go new file mode 100644 index 0000000..32ceaec --- /dev/null +++ b/_examples/simple_error/main.go @@ -0,0 +1,103 @@ +package main + +import ( + "fmt" + + "github.com/gofiber/fiber/v3" + fiberoapi "github.com/labbs/fiber-oapi/v3" +) + +// ErrorResponse is the shared shape every custom error in this app emits. +// Declaring it once means the OpenAPI spec gets a single component schema +// shared by every status code, and every response is consistent for clients. +type ErrorResponse struct { + Code int `json:"code"` + Message string `json:"message"` + Details string `json:"details,omitempty"` + Type string `json:"type"` +} + +// Error implements the standard error interface so handlers can return +// *ErrorResponse via a (Output, error) signature. It is optional — handlers +// can also be declared as func(...) (Output, *ErrorResponse) and skip this. +func (e *ErrorResponse) Error() string { return e.Message } + +// Factory helpers — declare each error once, reuse across routes. The exact +// instance passed to OpenAPIOptions.Errors becomes the example shown in the +// spec, so write a representative payload. +func UserAlreadyExists(name string) *ErrorResponse { + return &ErrorResponse{ + Code: 409, + Message: fmt.Sprintf("user %q already exists", name), + Type: "Conflict", + Details: "Pick a different username.", + } +} + +func UserNotFound(name string) *ErrorResponse { + return &ErrorResponse{ + Code: 404, + Message: fmt.Sprintf("user %q not found", name), + Type: "NotFound", + } +} + +type CreateUserInput struct { + Name string `uri:"name" validate:"required,min=2"` + RequestID string `header:"x-request-id" validate:"omitempty"` +} + +type CreateUserOutput struct { + Message string `json:"message"` +} + +func main() { + app := fiber.New() + + oapi := fiberoapi.New(app, fiberoapi.Config{ + EnableValidation: true, + EnableOpenAPIDocs: true, + OpenAPIDocsPath: "/documentation", // Custom docs path + OpenAPIJSONPath: "/api-spec.json", // Custom spec path + OpenAPIYamlPath: "/api-spec.yaml", // Custom YAML spec path + OpenAPITitle: "Simple Example API", // Spec title + OpenAPIDescription: "A minimal fiber-oapi demo for the README", // Spec description + OpenAPIVersion: "0.1.0", + // Tell the library to use *our* ErrorResponse shape for every error it + // emits internally (validation, parse, auth, 404 / 405). Pass an empty + // instance — the lib fills the Code / Message / Type / Details fields + // from each error category. With this set the spec stops showing the + // envelope-shaped 400/422/404 and instead documents ErrorResponse + // everywhere, matching the per-route errors we declare below. + DefaultErrorShape: &ErrorResponse{}, + }) + + // The handler returns (Output, error). Returning a *ErrorResponse picks the + // matching status code; returning nil emits the 200 response. + fiberoapi.Post(oapi, "/users/:name", func(c fiber.Ctx, input CreateUserInput) (CreateUserOutput, error) { + switch input.Name { + case "admin": + return CreateUserOutput{}, UserAlreadyExists(input.Name) + case "ghost": + return CreateUserOutput{}, UserNotFound(input.Name) + } + return CreateUserOutput{Message: fmt.Sprintf("user %q created", input.Name)}, nil + }, fiberoapi.OpenAPIOptions{ + OperationID: "create-user", + Tags: []string{"users"}, + Summary: "Create a new user", + Description: "Demonstrates two custom error paths declared in the spec.", + // Each entry below produces its own response in the generated spec: + // 409 with this example payload, 404 with that one, etc. The schema + // is shared because both factories return *ErrorResponse. + Errors: []any{ + UserAlreadyExists("admin"), + UserNotFound("ghost"), + }, + }) + + oapi.UseNotFoundHandler() + + fmt.Println("🚀 :3000 docs: /documentation spec: /api-spec.json") + app.Listen(":3000") +} diff --git a/_examples/simple_error/simpleerror b/_examples/simple_error/simpleerror new file mode 100755 index 0000000..f33903c Binary files /dev/null and b/_examples/simple_error/simpleerror differ diff --git a/common.go b/common.go index 34e499b..0d27e54 100644 --- a/common.go +++ b/common.go @@ -1,8 +1,6 @@ package fiberoapi import ( - "encoding/json" - "errors" "fmt" "reflect" "strings" @@ -71,11 +69,13 @@ func parseInput[TInput any](app *OApiApp, c fiber.Ctx, path string, options *Ope if bodyLength > 0 || strings.Contains(contentType, "application/json") || strings.Contains(contentType, "application/x-www-form-urlencoded") { if err := c.Bind().Body(&input); err != nil { - // For POST without a body, tolerate the parsing failure + // For POST without a body, tolerate the parsing failure. if bodyLength == 0 && method == "POST" { // no-op - } else if friendly := translateJSONError(err); friendly != nil { - return input, friendly + } else if wrapped := wrapJSONTypeError(err); wrapped != nil { + // Type-mismatch errors get a friendly Error() while staying + // errors.As-recoverable down to the original *json.UnmarshalTypeError. + return input, wrapped } else { return input, err } @@ -114,24 +114,6 @@ func parseInput[TInput any](app *OApiApp, c fiber.Ctx, path string, options *Ope return input, nil } -// translateJSONError converts low-level json decoder errors into a stable, -// user-facing validation message. Returns nil if err is not a JSON type mismatch. -func translateJSONError(err error) error { - ute, ok := errors.AsType[*json.UnmarshalTypeError](err) - if !ok { - return nil - } - fieldName := ute.Field - if i := strings.LastIndex(fieldName, "."); i >= 0 { - fieldName = fieldName[i+1:] - } - if fieldName == "" { - return fmt.Errorf("invalid JSON: expected %s but got %s", ute.Type.String(), ute.Value) - } - return fmt.Errorf("invalid type for field '%s': expected %s but got %s", - fieldName, ute.Type.String(), ute.Value) -} - // Function to handle custom errors func handleCustomError(c fiber.Ctx, customErr interface{}) error { // Use reflection to extract error information @@ -166,9 +148,22 @@ func handleCustomError(c fiber.Ctx, customErr interface{}) error { return nil } -// Utility to check if a value is zero +// Utility to check if a value is zero. Handles three edge cases beyond the +// straightforward reflect.ValueOf().IsZero(): +// - untyped nil (e.g. the handler signature has TError = error and the handler +// returned nil) — reflect.ValueOf(nil) returns an invalid Value whose +// IsZero() would panic; +// - typed nil pointer (zero value of *Foo) — IsZero correctly reports true; +// - zero struct (Foo{}) — IsZero reports true. func isZero(v interface{}) bool { - return reflect.ValueOf(v).IsZero() + if v == nil { + return true + } + rv := reflect.ValueOf(v) + if !rv.IsValid() { + return true + } + return rv.IsZero() } // Validate that struct parameters match the path @@ -417,115 +412,6 @@ func getSchemaForType(t reflect.Type) map[string]interface{} { return schema } -// detectTypeMismatchFromBody attempts to identify which field caused a JSON type mismatch -// by parsing the request body and comparing against the expected struct type -func detectTypeMismatchFromBody(body []byte, input interface{}) (fieldName, expectedType, actualType string) { - // Parse the JSON body into a map to see what was actually sent - var bodyMap map[string]interface{} - if err := json.Unmarshal(body, &bodyMap); err != nil { - return "", "", "" - } - - // Get the struct type using reflection - inputValue := reflect.ValueOf(input) - if inputValue.Kind() == reflect.Ptr { - inputValue = inputValue.Elem() - } - inputType := inputValue.Type() - - if inputType.Kind() != reflect.Struct { - return "", "", "" - } - - // Iterate through struct fields to find the mismatch - for i := 0; i < inputType.NumField(); i++ { - field := inputType.Field(i) - - // Get the JSON tag name (default to field name if no tag) - jsonTag := field.Tag.Get("json") - if jsonTag == "" { - jsonTag = field.Name - } else { - // Remove omitempty and other options from the tag - jsonTag = strings.Split(jsonTag, ",")[0] - } - - // Check if this field is in the body map - if actualValue, exists := bodyMap[jsonTag]; exists { - expectedFieldType := dereferenceType(field.Type) - actualValueType := getJSONValueType(actualValue) - - // Check for type mismatch - mismatch := false - expectedTypeName := "" - - switch expectedFieldType.Kind() { - case reflect.String: - expectedTypeName = "string" - if actualValueType != "string" { - mismatch = true - } - case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, - reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: - expectedTypeName = "integer" - if actualValueType != "number" { - mismatch = true - } - case reflect.Float32, reflect.Float64: - expectedTypeName = "number" - if actualValueType != "number" { - mismatch = true - } - case reflect.Bool: - expectedTypeName = "boolean" - if actualValueType != "boolean" { - mismatch = true - } - case reflect.Slice, reflect.Array: - expectedTypeName = fmt.Sprintf("[]%s", dereferenceType(expectedFieldType.Elem()).Kind()) - if actualValueType != "array" { - mismatch = true - } - case reflect.Map: - expectedTypeName = "map" - if actualValueType != "object" { - mismatch = true - } - case reflect.Struct: - expectedTypeName = "object" - if actualValueType != "object" { - mismatch = true - } - } - - if mismatch { - return field.Name, expectedTypeName, actualValueType - } - } - } - - return "", "", "" -} - -// getJSONValueType returns the JSON type name for a value parsed from JSON -func getJSONValueType(value interface{}) string { - switch value.(type) { - case string: - return "string" - case float64, int, int64: - return "number" - case bool: - return "boolean" - case []interface{}: - return "array" - case map[string]interface{}: - return "object" - case nil: - return "null" - default: - return "unknown" - } -} // mergeParameters merges auto-generated parameters with manually defined ones // Manual parameters take precedence over auto-generated ones with the same name diff --git a/coverage_test.go b/coverage_test.go new file mode 100644 index 0000000..08071f8 --- /dev/null +++ b/coverage_test.go @@ -0,0 +1,181 @@ +package fiberoapi + +import ( + "encoding/json" + "io" + "net/http/httptest" + "testing" + + "github.com/gofiber/fiber/v3" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gopkg.in/yaml.v3" +) + +// Thin helper tests targeting code paths previously at 0% coverage so the +// project stays comfortably above 80% as more features ship. + +type covOut struct { + Message string `json:"message"` +} + +func TestHeadHelperRoutesAndSurfacesInSpec(t *testing.T) { + app := fiber.New() + oapi := New(app) + + Head(oapi, "/ping", func(c fiber.Ctx, _ struct{}) (covOut, struct{}) { + return covOut{Message: "ok"}, struct{}{} + }, OpenAPIOptions{OperationID: "ping"}) + + resp, err := app.Test(httptest.NewRequest("HEAD", "/ping", nil)) + require.NoError(t, err) + assert.Equal(t, 200, resp.StatusCode) + + spec := oapi.GenerateOpenAPISpec() + pingPath := spec["paths"].(map[string]any)["/ping"].(map[string]any) + _, hasHead := pingPath["head"] + assert.True(t, hasHead, "HEAD operation should appear in the spec") +} + +func TestPatchHelperRoutesAndSurfacesInSpec(t *testing.T) { + app := fiber.New() + oapi := New(app) + + type patchInput struct { + ID string `uri:"id" validate:"required"` + } + + Patch(oapi, "/users/:id", func(c fiber.Ctx, in patchInput) (covOut, struct{}) { + return covOut{Message: "patched " + in.ID}, struct{}{} + }, OpenAPIOptions{OperationID: "patchUser"}) + + resp, err := app.Test(httptest.NewRequest("PATCH", "/users/42", nil)) + require.NoError(t, err) + require.Equal(t, 200, resp.StatusCode) + body, _ := io.ReadAll(resp.Body) + var out covOut + require.NoError(t, json.Unmarshal(body, &out)) + assert.Equal(t, "patched 42", out.Message) + + spec := oapi.GenerateOpenAPISpec() + usersPath := spec["paths"].(map[string]any)["/users/{id}"].(map[string]any) + _, hasPatch := usersPath["patch"] + assert.True(t, hasPatch, "PATCH operation should appear in the spec") +} + +func TestGroupPackageLevelHelper_RouterApp(t *testing.T) { + // The free function Group(router, ...) dispatches to either *OApiApp or + // *OApiGroup. Cover the *OApiApp branch. + app := fiber.New() + oapi := New(app) + v1 := Group(oapi, "/api/v1") + + Get(v1, "/health", func(c fiber.Ctx, _ struct{}) (covOut, struct{}) { + return covOut{Message: "ok"}, struct{}{} + }, OpenAPIOptions{OperationID: "health"}) + + resp, err := app.Test(httptest.NewRequest("GET", "/api/v1/health", nil)) + require.NoError(t, err) + assert.Equal(t, 200, resp.StatusCode) +} + +func TestGroupPackageLevelHelper_RouterNestedGroup(t *testing.T) { + // Cover the *OApiGroup branch of the free Group helper (sub-group). + app := fiber.New() + oapi := New(app) + v1 := Group(oapi, "/api/v1") + users := Group(v1, "/users") + + Get(users, "/me", func(c fiber.Ctx, _ struct{}) (covOut, struct{}) { + return covOut{Message: "me"}, struct{}{} + }, OpenAPIOptions{OperationID: "me"}) + + resp, err := app.Test(httptest.NewRequest("GET", "/api/v1/users/me", nil)) + require.NoError(t, err) + assert.Equal(t, 200, resp.StatusCode) +} + +func TestGroupPackageLevelHelper_UnsupportedRouterPanics(t *testing.T) { + defer func() { + r := recover() + assert.NotNil(t, r, "Group with unsupported router type should panic") + }() + type fake struct{ OApiRouter } + Group(fake{}, "/x") +} + +func TestOApiAppUse_PassesMiddlewareToFiber(t *testing.T) { + // Verify the OApiApp.Use thin passthrough actually plumbs the middleware + // down to fiber.App.Use so registered handlers run on every request. + app := fiber.New() + oapi := New(app) + + calls := 0 + oapi.Use(func(c fiber.Ctx) error { + calls++ + return c.Next() + }) + + Get(oapi, "/echo", func(c fiber.Ctx, _ struct{}) (covOut, struct{}) { + return covOut{Message: "ok"}, struct{}{} + }, OpenAPIOptions{OperationID: "echo"}) + + resp, err := app.Test(httptest.NewRequest("GET", "/echo", nil)) + require.NoError(t, err) + assert.Equal(t, 200, resp.StatusCode) + assert.Equal(t, 1, calls, "middleware registered via OApiApp.Use must run") +} + +func TestOApiGroupUse_PassesMiddlewareToFiber(t *testing.T) { + // Same coverage for OApiGroup.Use — the group-scoped middleware should run + // only for routes registered under that group. + app := fiber.New() + oapi := New(app) + api := oapi.Group("/api") + + groupCalls := 0 + api.Use(func(c fiber.Ctx) error { + groupCalls++ + return c.Next() + }) + + Get(api, "/inside", func(c fiber.Ctx, _ struct{}) (covOut, struct{}) { + return covOut{Message: "in"}, struct{}{} + }, OpenAPIOptions{OperationID: "inside"}) + Get(oapi, "/outside", func(c fiber.Ctx, _ struct{}) (covOut, struct{}) { + return covOut{Message: "out"}, struct{}{} + }, OpenAPIOptions{OperationID: "outside"}) + + respIn, err := app.Test(httptest.NewRequest("GET", "/api/inside", nil)) + require.NoError(t, err) + assert.Equal(t, 200, respIn.StatusCode) + assert.Equal(t, 1, groupCalls, "group middleware must run for /api/* routes") + + respOut, err := app.Test(httptest.NewRequest("GET", "/outside", nil)) + require.NoError(t, err) + assert.Equal(t, 200, respOut.StatusCode) + assert.Equal(t, 1, groupCalls, "group middleware must NOT run for routes outside the group") +} + +func TestOpenAPIYamlEndpoint_ServesYAMLSpec(t *testing.T) { + // Exercise the YAML branch of the auto-registered docs routes. + app := fiber.New() + oapi := New(app) + Get(oapi, "/things", func(c fiber.Ctx, _ struct{}) (covOut, struct{}) { + return covOut{Message: "ok"}, struct{}{} + }, OpenAPIOptions{OperationID: "things"}) + + resp, err := app.Test(httptest.NewRequest("GET", "/openapi.yaml", nil)) + require.NoError(t, err) + require.Equal(t, 200, resp.StatusCode) + require.Contains(t, resp.Header.Get("Content-Type"), "yaml") + + body, _ := io.ReadAll(resp.Body) + var parsed map[string]any + require.NoError(t, yaml.Unmarshal(body, &parsed), "served YAML must be parseable: %s", body) + assert.Equal(t, "3.0.0", parsed["openapi"]) + paths, ok := parsed["paths"].(map[string]any) + require.True(t, ok) + _, hasThings := paths["/things"] + assert.True(t, hasThings, "YAML spec should include registered routes") +} diff --git a/custom_errors.go b/custom_errors.go new file mode 100644 index 0000000..d743d04 --- /dev/null +++ b/custom_errors.go @@ -0,0 +1,269 @@ +package fiberoapi + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + "reflect" + "strconv" + "strings" + + "github.com/go-playground/validator/v10" + "github.com/gofiber/fiber/v3" +) + +// HTTPStatusError is an optional interface a custom error instance can +// implement to expose its status code directly, bypassing field reflection. +type HTTPStatusError interface { + HTTPStatus() int +} + +// HTTPDescriptionError is an optional interface that lets a custom error +// instance provide the OpenAPI description string explicitly, bypassing the +// field-name fallback (Message → Description → Msg → HTTP reason phrase). +type HTTPDescriptionError interface { + Description() string +} + +// extractErrorStatusCode resolves the HTTP status code carried by a declared +// error instance. Priority: +// 1. HTTPStatus() int method (opt-in, type-safe) +// 2. StatusCode or Code int field on the (dereferenced) struct +// 3. fallback: 500 +func extractErrorStatusCode(v any) int { + if v == nil { + return http.StatusInternalServerError + } + if r, ok := v.(HTTPStatusError); ok { + if c := r.HTTPStatus(); c > 0 { + return c + } + } + val := reflect.ValueOf(v) + for val.Kind() == reflect.Ptr { + if val.IsNil() { + return http.StatusInternalServerError + } + val = val.Elem() + } + if val.Kind() != reflect.Struct { + return http.StatusInternalServerError + } + for _, name := range []string{"StatusCode", "Code"} { + if f := val.FieldByName(name); f.IsValid() && f.CanInt() { + if c := int(f.Int()); c > 0 { + return c + } + } + } + return http.StatusInternalServerError +} + +// extractErrorDescription returns the human-readable description used in the +// OpenAPI spec for a declared error. Priority: +// 1. Description() string method +// 2. Message / Description / Msg string field +// 3. fallback: HTTP reason phrase for the resolved status code +func extractErrorDescription(v any, code int) string { + if v == nil { + return http.StatusText(code) + } + if r, ok := v.(HTTPDescriptionError); ok { + if d := r.Description(); d != "" { + return d + } + } + val := reflect.ValueOf(v) + for val.Kind() == reflect.Ptr { + if val.IsNil() { + return http.StatusText(code) + } + val = val.Elem() + } + if val.Kind() == reflect.Struct { + for _, name := range []string{"Description", "Message", "Msg"} { + if f := val.FieldByName(name); f.IsValid() && f.Kind() == reflect.String { + if s := f.String(); s != "" { + return s + } + } + } + } + if text := http.StatusText(code); text != "" { + return text + } + return "Error response" +} + +// errorSchemaRef returns the schema reference for a declared error type. Named +// types are exposed via $ref so the spec deduplicates the schema; anonymous +// types fall back to an inline schema. +func errorSchemaRef(t reflect.Type) map[string]interface{} { + if t == nil { + return map[string]interface{}{"type": "object"} + } + t = dereferenceType(t) + if shouldInlineOperationSchema(t) { + return generateSchema(t) + } + if name := getTypeName(t); name != "" { + return map[string]interface{}{"$ref": "#/components/schemas/" + name} + } + return generateSchema(t) +} + +// buildErrorResponse turns a single declared error instance into an OpenAPI +// response object. The status code is returned alongside so the caller can +// place it under the right key. +func buildErrorResponse(errInst any) (statusCode int, response map[string]interface{}) { + statusCode = extractErrorStatusCode(errInst) + description := extractErrorDescription(errInst, statusCode) + t := reflect.TypeOf(errInst) + response = map[string]interface{}{ + "description": description, + "content": map[string]interface{}{ + "application/json": map[string]interface{}{ + "schema": errorSchemaRef(t), + "example": errInst, + }, + }, + } + return statusCode, response +} + +// statusCodeKey formats a status code as the string key expected by the +// OpenAPI responses map. We use the concrete code (e.g. "404") rather than +// the "4XX" wildcard so each declared error has its own slot. +func statusCodeKey(code int) string { + return strconv.Itoa(code) +} + +// errorCategory groups the inputs needed to materialise a user-defined error +// shape from a library-internal error. The category is what we know about the +// error before we know what shape the user wants. +type errorCategory struct { + Code int + Type string // entry-type discriminator: validation_error / type_error / parse_error / authentication_error / authorization_error / not_found / method_not_allowed + Message string // human-readable message (single line) + Details string // optional secondary context (joined field list, source error, ...) +} + +// isValidationError reports whether err is a go-playground/validator error. +// Used to keep validation responses on the rich ErrorEnvelope shape even when +// the user opted into a flat DefaultErrorShape — per-field info (loc / +// constraint / field) only makes sense in the array-of-entries shape. +func isValidationError(err error) bool { + var vErrs validator.ValidationErrors + return errors.As(err, &vErrs) +} + +// categorizeError extracts the (code, type, message, details) tuple from any +// internal error produced by parseInput. Used by the default error handler to +// build either an ErrorEnvelope or a user-supplied DefaultErrorShape instance. +func categorizeError(err error) errorCategory { + if authErr, ok := errors.AsType[*AuthError](err); ok { + t := errTypeAuthN + if authErr.StatusCode == fiber.StatusForbidden { + t = errTypeAuthZ + } + return errorCategory{ + Code: authErr.StatusCode, + Type: t, + Message: authErr.Message, + } + } + if ute, ok := errors.AsType[*json.UnmarshalTypeError](err); ok { + field := ute.Field + if i := strings.LastIndex(field, "."); i >= 0 { + field = field[i+1:] + } + msg := fmt.Sprintf(typeMismatchMsgFmt, field, ute.Type.String(), ute.Value) + if field == "" { + msg = fmt.Sprintf("invalid JSON: expected %s but got %s", ute.Type.String(), ute.Value) + } + return errorCategory{ + Code: statusParseError, + Type: errTypeTypeMismatch, + Message: msg, + Details: ute.Type.String(), + } + } + var vErrs validator.ValidationErrors + if errors.As(err, &vErrs) { + msgs := make([]string, 0, len(vErrs)) + for _, fe := range vErrs { + msgs = append(msgs, translateValidatorTag(fe.Field(), fe.Tag(), fe.Param())) + } + head := msgs[0] + if len(msgs) > 1 { + head = fmt.Sprintf("%s (and %d more)", msgs[0], len(msgs)-1) + } + return errorCategory{ + Code: statusValidationError, + Type: errTypeValidation, + Message: head, + Details: strings.Join(msgs, "; "), + } + } + return errorCategory{ + Code: statusParseError, + Type: errTypeParse, + Message: err.Error(), + } +} + +// materializeError builds a new instance of the user's DefaultErrorShape with +// reflection-populated fields. The shape parameter is a template (typically the +// empty value the user stored in Config.DefaultErrorShape). +// +// Returns: +// - the new instance (same kind as shape — struct or pointer-to-struct) when +// the shape's underlying type is a struct; +// - the shape value unchanged when it is not a struct (no fields to populate); +// - nil only when shape itself is nil. +// +// Field assignments (case-sensitive, applied if present and settable): +// - StatusCode, Code → cat.Code +// - Message, Description, Msg → cat.Message +// - Type → cat.Type +// - Details → cat.Details +func materializeError(shape any, cat errorCategory) any { + if shape == nil { + return nil + } + t := reflect.TypeOf(shape) + isPtr := t.Kind() == reflect.Ptr + if isPtr { + t = t.Elem() + } + if t.Kind() != reflect.Struct { + return shape + } + inst := reflect.New(t).Elem() + setIntFieldIfPresent(inst, "StatusCode", int64(cat.Code)) + setIntFieldIfPresent(inst, "Code", int64(cat.Code)) + setStringFieldIfPresent(inst, "Message", cat.Message) + setStringFieldIfPresent(inst, "Description", cat.Message) + setStringFieldIfPresent(inst, "Msg", cat.Message) + setStringFieldIfPresent(inst, "Type", cat.Type) + setStringFieldIfPresent(inst, "Details", cat.Details) + if isPtr { + return inst.Addr().Interface() + } + return inst.Interface() +} + +func setIntFieldIfPresent(v reflect.Value, name string, val int64) { + f := v.FieldByName(name) + if f.IsValid() && f.CanSet() && f.CanInt() { + f.SetInt(val) + } +} + +func setStringFieldIfPresent(v reflect.Value, name string, val string) { + f := v.FieldByName(name) + if f.IsValid() && f.CanSet() && f.Kind() == reflect.String { + f.SetString(val) + } +} diff --git a/custom_errors_test.go b/custom_errors_test.go new file mode 100644 index 0000000..012e7fa --- /dev/null +++ b/custom_errors_test.go @@ -0,0 +1,236 @@ +package fiberoapi + +import ( + "encoding/json" + "io" + "net/http/httptest" + "strings" + "testing" + + "github.com/gofiber/fiber/v3" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// AppError is the shared shape used across these tests, mirroring the pattern +// the example in _examples/simple_error demonstrates. +type AppError struct { + Code int `json:"code"` + Message string `json:"message"` + Type string `json:"type"` + Details string `json:"details,omitempty"` +} + +func (e *AppError) Error() string { return e.Message } + +func appConflict(msg string) *AppError { + return &AppError{Code: 409, Message: msg, Type: "Conflict", Details: "Duplicate resource"} +} + +func appNotFound(msg string) *AppError { + return &AppError{Code: 404, Message: msg, Type: "NotFound"} +} + +func appForbidden(msg string) *AppError { + return &AppError{Code: 403, Message: msg, Type: "Forbidden"} +} + +type customErrInput struct { + Name string `uri:"name" validate:"required,min=2"` +} + +type customErrOutput struct { + Message string `json:"message"` +} + +func TestCustomErrors_SpecListsEachDeclaredStatus(t *testing.T) { + app := fiber.New() + oapi := New(app) + + Post(oapi, "/items/:name", func(c fiber.Ctx, input customErrInput) (customErrOutput, error) { + return customErrOutput{Message: "ok"}, nil + }, OpenAPIOptions{ + OperationID: "createItem", + Errors: []any{ + appConflict("already exists"), + appNotFound("missing"), + appForbidden("not yours"), + }, + }) + + spec := oapi.GenerateOpenAPISpec() + post := spec["paths"].(map[string]any)["/items/{name}"].(map[string]any)["post"].(map[string]any) + responses := post["responses"].(map[string]any) + + for _, code := range []string{"403", "404", "409"} { + r, ok := responses[code].(map[string]any) + require.True(t, ok, "missing %s response", code) + content := r["content"].(map[string]any)["application/json"].(map[string]any) + require.NotNil(t, content["schema"], "%s missing schema", code) + require.NotNil(t, content["example"], "%s missing example", code) + } +} + +func TestCustomErrors_SchemaIsDeduplicatedViaRef(t *testing.T) { + app := fiber.New() + oapi := New(app) + + Post(oapi, "/items/:name", func(c fiber.Ctx, input customErrInput) (customErrOutput, error) { + return customErrOutput{Message: "ok"}, nil + }, OpenAPIOptions{ + OperationID: "createItem", + Errors: []any{ + appConflict("a"), + appNotFound("b"), + appForbidden("c"), + }, + }) + + spec := oapi.GenerateOpenAPISpec() + schemas := spec["components"].(map[string]any)["schemas"].(map[string]any) + _, hasAppError := schemas["AppError"] + assert.True(t, hasAppError, "shared AppError schema should be in components.schemas") + + post := spec["paths"].(map[string]any)["/items/{name}"].(map[string]any)["post"].(map[string]any) + responses := post["responses"].(map[string]any) + for _, code := range []string{"403", "404", "409"} { + schema := responses[code].(map[string]any)["content"].(map[string]any)["application/json"].(map[string]any)["schema"].(map[string]any) + assert.Equal(t, "#/components/schemas/AppError", schema["$ref"], "all entries should $ref the shared schema") + } +} + +func TestCustomErrors_DescriptionFallback(t *testing.T) { + app := fiber.New() + oapi := New(app) + + type noMessage struct { + Code int `json:"code"` + } + + Post(oapi, "/items/:name", func(c fiber.Ctx, input customErrInput) (customErrOutput, error) { + return customErrOutput{Message: "ok"}, nil + }, OpenAPIOptions{ + OperationID: "createItem", + Errors: []any{ + &noMessage{Code: 418}, // no Message field — fallback to HTTP reason + appConflict("custom msg"), + }, + }) + + spec := oapi.GenerateOpenAPISpec() + post := spec["paths"].(map[string]any)["/items/{name}"].(map[string]any)["post"].(map[string]any) + responses := post["responses"].(map[string]any) + + assert.Equal(t, "I'm a teapot", responses["418"].(map[string]any)["description"], "should fall back to HTTP reason phrase") + assert.Equal(t, "custom msg", responses["409"].(map[string]any)["description"], "should use Message field") +} + +type explicitDescriber struct { + Code int `json:"code"` +} + +func (e *explicitDescriber) Description() string { return "explicit override wins" } +func (e *explicitDescriber) HTTPStatus() int { return 451 } + +func TestCustomErrors_MethodsTakePriorityOverFields(t *testing.T) { + app := fiber.New() + oapi := New(app) + + Post(oapi, "/items/:name", func(c fiber.Ctx, input customErrInput) (customErrOutput, error) { + return customErrOutput{Message: "ok"}, nil + }, OpenAPIOptions{ + OperationID: "createItem", + Errors: []any{&explicitDescriber{Code: 999 /* ignored */}}, + }) + + spec := oapi.GenerateOpenAPISpec() + post := spec["paths"].(map[string]any)["/items/{name}"].(map[string]any)["post"].(map[string]any) + responses := post["responses"].(map[string]any) + + _, has451 := responses["451"] + assert.True(t, has451, "HTTPStatus() method should win over Code field") + _, has999 := responses["999"] + assert.False(t, has999, "the Code field should be ignored when HTTPStatus() is implemented") + assert.Equal(t, "explicit override wins", responses["451"].(map[string]any)["description"]) +} + +func TestCustomErrors_HandlerReturnEmitsRightStatusAndBody(t *testing.T) { + app := fiber.New() + oapi := New(app) + + Post(oapi, "/items/:name", func(c fiber.Ctx, input customErrInput) (customErrOutput, error) { + switch input.Name { + case "dup": + return customErrOutput{}, appConflict("already exists") + case "missing": + return customErrOutput{}, appNotFound("not found") + } + return customErrOutput{Message: "ok"}, nil + }, OpenAPIOptions{ + OperationID: "createItem", + Errors: []any{appConflict("a"), appNotFound("b")}, + }) + + cases := []struct { + path string + status int + bodyHas string + }{ + {"/items/dup", 409, "already exists"}, + {"/items/missing", 404, "not found"}, + {"/items/alice", 200, "ok"}, + } + + for _, tc := range cases { + t.Run(tc.path, func(t *testing.T) { + req := httptest.NewRequest("POST", tc.path, strings.NewReader("")) + req.Header.Set("Content-Type", "application/json") + resp, err := app.Test(req) + require.NoError(t, err) + assert.Equal(t, tc.status, resp.StatusCode) + raw, _ := io.ReadAll(resp.Body) + assert.Contains(t, string(raw), tc.bodyHas) + }) + } +} + +func TestCustomErrors_NilErrorReturnsSuccess(t *testing.T) { + // Regression check for the isZero hardening: a nil `error` interface must + // not panic — it must take the success branch. + app := fiber.New() + oapi := New(app) + + Post(oapi, "/items/:name", func(c fiber.Ctx, input customErrInput) (customErrOutput, error) { + return customErrOutput{Message: "ok"}, nil + }, OpenAPIOptions{OperationID: "createItem"}) + + resp, err := app.Test(httptest.NewRequest("POST", "/items/alice", strings.NewReader(""))) + require.NoError(t, err) + require.Equal(t, 200, resp.StatusCode) + raw, _ := io.ReadAll(resp.Body) + var out customErrOutput + require.NoError(t, json.Unmarshal(raw, &out)) + assert.Equal(t, "ok", out.Message) +} + +func TestCustomErrors_PrecedenceOverDefault404Envelope(t *testing.T) { + // When the user declares a 404 in Errors AND has called UseNotFoundHandler(), + // the declared shape (their AppError) wins for the per-route spec entry — + // the not-found envelope is for routing misses, not for handler-emitted 404s. + app := fiber.New() + oapi := New(app) + + Post(oapi, "/items/:name", func(c fiber.Ctx, input customErrInput) (customErrOutput, error) { + return customErrOutput{Message: "ok"}, nil + }, OpenAPIOptions{ + OperationID: "createItem", + Errors: []any{appNotFound("not found")}, + }) + oapi.UseNotFoundHandler() + + spec := oapi.GenerateOpenAPISpec() + post := spec["paths"].(map[string]any)["/items/{name}"].(map[string]any)["post"].(map[string]any) + resp404 := post["responses"].(map[string]any)["404"].(map[string]any) + schema := resp404["content"].(map[string]any)["application/json"].(map[string]any)["schema"].(map[string]any) + assert.Equal(t, "#/components/schemas/AppError", schema["$ref"], "Errors entry should override the default ErrorEnvelope 404") +} diff --git a/custom_validation_error_test.go b/custom_validation_error_test.go index 4db06c7..2cea9b7 100644 --- a/custom_validation_error_test.go +++ b/custom_validation_error_test.go @@ -111,16 +111,18 @@ func TestDefaultValidationErrorWhenNoCustomHandler(t *testing.T) { resp, err := app.Test(req) assert.NoError(t, err) - assert.Equal(t, fiber.StatusBadRequest, resp.StatusCode) + assert.Equal(t, fiber.StatusUnprocessableEntity, resp.StatusCode) - // Verify default error structure (ErrorResponse) + // Verify default error envelope shape body, _ := io.ReadAll(resp.Body) - var defaultErr ErrorResponse - err = json.Unmarshal(body, &defaultErr) + var envelope ErrorEnvelope + err = json.Unmarshal(body, &envelope) assert.NoError(t, err) - assert.Equal(t, 400, defaultErr.Code) - assert.Equal(t, "validation_error", defaultErr.Type) - assert.NotEmpty(t, defaultErr.Details) + assert.Len(t, envelope.Errors, 1) + assert.Equal(t, "validation_error", envelope.Errors[0].Type) + assert.Equal(t, 422, envelope.Errors[0].Code) + assert.NotEmpty(t, envelope.Errors[0].Msg) + assert.Equal(t, "name", envelope.Errors[0].Field) } func TestCustomValidationErrorHandlerWithDisabledDocs(t *testing.T) { @@ -288,7 +290,7 @@ func TestAuthErrorHandlerOnlyDoesNotDisableDefaults(t *testing.T) { req.Header.Set("Content-Type", "application/json") resp, err := app.Test(req) assert.NoError(t, err) - assert.Equal(t, fiber.StatusBadRequest, resp.StatusCode, "Validation should be enabled when only AuthErrorHandler is configured") + assert.Equal(t, fiber.StatusUnprocessableEntity, resp.StatusCode, "Validation should be enabled when only AuthErrorHandler is configured") // OpenAPI docs should still be enabled (default true) req = httptest.NewRequest("GET", "/docs", nil) diff --git a/default_error_shape_test.go b/default_error_shape_test.go new file mode 100644 index 0000000..1e642b1 --- /dev/null +++ b/default_error_shape_test.go @@ -0,0 +1,241 @@ +package fiberoapi + +import ( + "encoding/json" + "io" + "net/http/httptest" + "strings" + "testing" + + "github.com/gofiber/fiber/v3" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// UniErr is the shape under test — a typical flat error response struct. +type UniErr struct { + Code int `json:"code"` + Message string `json:"message"` + Details string `json:"details,omitempty"` + Type string `json:"type"` +} + +func (e *UniErr) Error() string { return e.Message } + +type uniInput struct { + Name string `uri:"name" validate:"required,min=2"` + Age int `json:"age" validate:"omitempty,min=18"` +} + +type uniOutput struct { + Message string `json:"message"` +} + +func registerUniRoute(t *testing.T) (*fiber.App, *OApiApp) { + t.Helper() + app := fiber.New() + oapi := New(app, Config{DefaultErrorShape: &UniErr{}}) + Post(oapi, "/users/:name", func(c fiber.Ctx, in uniInput) (uniOutput, error) { + return uniOutput{Message: "ok"}, nil + }, OpenAPIOptions{OperationID: "createUser"}) + oapi.UseNotFoundHandler() + return app, oapi +} + +func TestDefaultErrorShape_ValidationKeepsEnvelope(t *testing.T) { + // 422 validation errors stay on the rich ErrorEnvelope shape even when + // DefaultErrorShape is set — collapsing per-field info into a flat struct + // would lose loc / constraint / field needed by form-level client UX. + app, _ := registerUniRoute(t) + + resp, err := app.Test(httptest.NewRequest("POST", "/users/a", strings.NewReader(""))) + require.NoError(t, err) + require.Equal(t, 422, resp.StatusCode) + + raw, _ := io.ReadAll(resp.Body) + var env ErrorEnvelope + require.NoError(t, json.Unmarshal(raw, &env), "validation must stay on envelope: %s", raw) + require.GreaterOrEqual(t, len(env.Errors), 1) + assert.Equal(t, "validation_error", env.Errors[0].Type) + assert.Equal(t, 422, env.Errors[0].Code) + assert.NotEmpty(t, env.Errors[0].Loc) + assert.NotEmpty(t, env.Errors[0].Constraint) +} + +func TestDefaultErrorShape_ParseEmitsUniShape(t *testing.T) { + app, _ := registerUniRoute(t) + + // Send malformed JSON to trigger a parse error. + req := httptest.NewRequest("POST", "/users/alice", strings.NewReader(`{"age": "not a number"}`)) + req.Header.Set("Content-Type", "application/json") + resp, err := app.Test(req) + require.NoError(t, err) + require.Equal(t, 400, resp.StatusCode) + + raw, _ := io.ReadAll(resp.Body) + var got UniErr + require.NoError(t, json.Unmarshal(raw, &got)) + assert.Equal(t, 400, got.Code) + assert.Equal(t, "type_error", got.Type) + assert.Contains(t, got.Message, "expected int but got string") +} + +func TestDefaultErrorShape_NotFoundEmitsUniShape(t *testing.T) { + app, _ := registerUniRoute(t) + + resp, err := app.Test(httptest.NewRequest("GET", "/missing-route", nil)) + require.NoError(t, err) + require.Equal(t, 404, resp.StatusCode) + + raw, _ := io.ReadAll(resp.Body) + var got UniErr + require.NoError(t, json.Unmarshal(raw, &got)) + assert.Equal(t, 404, got.Code) + assert.Equal(t, "not_found", got.Type) + assert.Contains(t, got.Message, "GET /missing-route") +} + +func TestDefaultErrorShape_MethodNotAllowedEmitsUniShape(t *testing.T) { + app, _ := registerUniRoute(t) + + // GET on the POST-only route → 405 with Allow header + resp, err := app.Test(httptest.NewRequest("GET", "/users/alice", nil)) + require.NoError(t, err) + require.Equal(t, 405, resp.StatusCode) + assert.Equal(t, "POST", resp.Header.Get("Allow")) + + raw, _ := io.ReadAll(resp.Body) + var got UniErr + require.NoError(t, json.Unmarshal(raw, &got)) + assert.Equal(t, 405, got.Code) + assert.Equal(t, "method_not_allowed", got.Type) + assert.Contains(t, got.Message, "method GET not allowed") + assert.Equal(t, "POST", got.Details) +} + +func TestDefaultErrorShape_SpecMixesShapeAndEnvelope(t *testing.T) { + // With DefaultErrorShape set, spec entries for 400 / 404 (and 401 / 403 / + // 405 when applicable) reference the user's flat shape. 422 keeps the + // ErrorEnvelope schema so per-field validation info stays documented. + app := fiber.New() + oapi := New(app, Config{DefaultErrorShape: &UniErr{}}) + Post(oapi, "/users/:name", func(c fiber.Ctx, in uniInput) (uniOutput, error) { + return uniOutput{Message: "ok"}, nil + }, OpenAPIOptions{OperationID: "createUser"}) + oapi.UseNotFoundHandler() + + spec := oapi.GenerateOpenAPISpec() + post := spec["paths"].(map[string]any)["/users/{name}"].(map[string]any)["post"].(map[string]any) + responses := post["responses"].(map[string]any) + + // Flat shape for 400 and 404 + for _, code := range []string{"400", "404"} { + schema := responses[code].(map[string]any)["content"].(map[string]any)["application/json"].(map[string]any)["schema"].(map[string]any) + assert.Equal(t, "#/components/schemas/UniErr", schema["$ref"], "%s should use the flat user shape", code) + } + // Envelope kept for 422 + schema422 := responses["422"].(map[string]any)["content"].(map[string]any)["application/json"].(map[string]any)["schema"].(map[string]any) + assert.Equal(t, "#/components/schemas/ErrorEnvelope", schema422["$ref"], "422 must keep the envelope schema for per-field info") +} + +func TestDefaultErrorShape_NilShapeKeepsEnvelopeBehaviour(t *testing.T) { + // Sanity check: no regression — when DefaultErrorShape is nil, envelopes + // are still emitted (existing behaviour). + app := fiber.New() + oapi := New(app) + Post(oapi, "/users/:name", func(c fiber.Ctx, in uniInput) (uniOutput, error) { + return uniOutput{Message: "ok"}, nil + }, OpenAPIOptions{OperationID: "createUser"}) + + resp, err := app.Test(httptest.NewRequest("POST", "/users/a", strings.NewReader(""))) + require.NoError(t, err) + assert.Equal(t, 422, resp.StatusCode) + raw, _ := io.ReadAll(resp.Body) + assert.Contains(t, string(raw), `"errors":`, "envelope shape must still be emitted when DefaultErrorShape is nil") +} + +func TestDefaultErrorShape_PerRouteErrorsStillOverride(t *testing.T) { + // Even with DefaultErrorShape set, an entry in OpenAPIOptions.Errors should + // still override the default for its status code in the spec. + app := fiber.New() + oapi := New(app, Config{DefaultErrorShape: &UniErr{}}) + type ExplicitConflict struct { + Code int `json:"code"` + Message string `json:"message"` + Hint string `json:"hint"` + } + Post(oapi, "/users/:name", func(c fiber.Ctx, in uniInput) (uniOutput, error) { + return uniOutput{Message: "ok"}, nil + }, OpenAPIOptions{ + OperationID: "createUser", + Errors: []any{&ExplicitConflict{Code: 409, Message: "taken", Hint: "try another"}}, + }) + + spec := oapi.GenerateOpenAPISpec() + post := spec["paths"].(map[string]any)["/users/{name}"].(map[string]any)["post"].(map[string]any) + responses := post["responses"].(map[string]any) + + schema := responses["409"].(map[string]any)["content"].(map[string]any)["application/json"].(map[string]any)["schema"].(map[string]any) + assert.Equal(t, "#/components/schemas/ExplicitConflict", schema["$ref"], "per-route Errors should still override") + + // Default 422 stays on ErrorEnvelope (the validation carve-out applies even + // when DefaultErrorShape is configured). + schema422 := responses["422"].(map[string]any)["content"].(map[string]any)["application/json"].(map[string]any)["schema"].(map[string]any) + assert.Equal(t, "#/components/schemas/ErrorEnvelope", schema422["$ref"]) +} + +func TestDefaultErrorShape_SchemaAlwaysRegistered(t *testing.T) { + // Regression: when DefaultErrorShape is set, the type must appear in + // components.schemas even if no operation declares an OpenAPIOptions.Errors + // entry that would otherwise have collected it — otherwise the $ref the + // spec emits for 400 / 404 / 405 / auth points at a missing component. + type SoloShape struct { + Code int `json:"code"` + Message string `json:"message"` + Type string `json:"type"` + } + + app := fiber.New() + oapi := New(app, Config{DefaultErrorShape: &SoloShape{}}) + Post(oapi, "/u/:name", func(c fiber.Ctx, in uniInput) (uniOutput, error) { + return uniOutput{Message: "ok"}, nil + }, OpenAPIOptions{OperationID: "createUser"}) + oapi.UseNotFoundHandler() + + spec := oapi.GenerateOpenAPISpec() + schemas := spec["components"].(map[string]any)["schemas"].(map[string]any) + _, has := schemas["SoloShape"] + assert.True(t, has, "DefaultErrorShape's type must be registered in components.schemas; otherwise the 400/404 $refs dangle") + + // Cross-check that the dangling reference would have actually triggered: + // confirm at least one response references it. + post := spec["paths"].(map[string]any)["/u/{name}"].(map[string]any)["post"].(map[string]any) + r400 := post["responses"].(map[string]any)["400"].(map[string]any) + schema := r400["content"].(map[string]any)["application/json"].(map[string]any)["schema"].(map[string]any) + assert.Equal(t, "#/components/schemas/SoloShape", schema["$ref"]) +} + +func TestDefaultErrorShape_ValueShape(t *testing.T) { + // Passing a non-pointer struct as the template should also work. We exercise + // the path-not-found case (404) since 422 deliberately keeps the envelope. + type ValueShape struct { + Code int `json:"code"` + Message string `json:"message"` + Type string `json:"type"` + } + app := fiber.New() + oapi := New(app, Config{DefaultErrorShape: ValueShape{}}) + Post(oapi, "/users/:name", func(c fiber.Ctx, in uniInput) (uniOutput, error) { + return uniOutput{Message: "ok"}, nil + }, OpenAPIOptions{OperationID: "createUser"}) + oapi.UseNotFoundHandler() + + resp, err := app.Test(httptest.NewRequest("GET", "/no-such-route", nil)) + require.NoError(t, err) + require.Equal(t, 404, resp.StatusCode) + raw, _ := io.ReadAll(resp.Body) + var got ValueShape + require.NoError(t, json.Unmarshal(raw, &got)) + assert.Equal(t, 404, got.Code) + assert.Equal(t, "not_found", got.Type) +} diff --git a/delete_test.go b/delete_test.go index deabe14..3d68156 100644 --- a/delete_test.go +++ b/delete_test.go @@ -258,14 +258,14 @@ func TestDeleteOApi_Validation(t *testing.T) { { name: "Invalid category UUID", url: "/categories/invalid-uuid/products/550e8400-e29b-41d4-a716-446655440001", - expectedStatus: 400, + expectedStatus: 422, shouldPass: false, errorContains: "uuid4", }, { name: "Invalid product UUID", url: "/categories/550e8400-e29b-41d4-a716-446655440000/products/invalid-uuid", - expectedStatus: 400, + expectedStatus: 422, shouldPass: false, errorContains: "uuid4", }, @@ -284,14 +284,14 @@ func TestDeleteOApi_Validation(t *testing.T) { { name: "Reason too short", url: "/users/user123?reason=bad", - expectedStatus: 400, + expectedStatus: 422, shouldPass: false, errorContains: "min", }, { name: "Reason too long", url: fmt.Sprintf("/users/user123?reason=%s", strings.Repeat("a", 101)), - expectedStatus: 400, + expectedStatus: 422, shouldPass: false, errorContains: "max", }, diff --git a/error_envelope_test.go b/error_envelope_test.go new file mode 100644 index 0000000..c9cfd1a --- /dev/null +++ b/error_envelope_test.go @@ -0,0 +1,538 @@ +package fiberoapi + +import ( + "encoding/json" + "io" + "net/http/httptest" + "strings" + "testing" + + "github.com/gofiber/fiber/v3" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type envelopeInput struct { + ID string `uri:"id" validate:"required,len=5"` + Filter string `query:"filter" validate:"omitempty,min=3"` + RequestID string `header:"X-Request-Id" validate:"omitempty,uuid4"` + WorkspaceID string `json:"workspaceId" validate:"required,min=11"` + Email string `json:"email" validate:"required,email"` + Nested struct { + Slug string `json:"slug" validate:"required,min=2"` + } `json:"nested" validate:"required"` +} + +type envelopeOutput struct { + Message string `json:"message"` +} + +func registerEnvelopeRoute(t *testing.T) *fiber.App { + t.Helper() + app := fiber.New() + oapi := New(app) + Post(oapi, "/items/:id", func(c fiber.Ctx, input envelopeInput) (envelopeOutput, struct{}) { + return envelopeOutput{Message: "ok"}, struct{}{} + }, OpenAPIOptions{OperationID: "createItem"}) + return app +} + +func TestEnvelope_MultiFieldValidation(t *testing.T) { + app := registerEnvelopeRoute(t) + + // All three of: WorkspaceID too short, Email malformed, Nested.Slug too short. + body := `{"workspaceId":"short","email":"not-an-email","nested":{"slug":"a"}}` + req := httptest.NewRequest("POST", "/items/abcde", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + + resp, err := app.Test(req) + require.NoError(t, err) + require.Equal(t, 422, resp.StatusCode) + + raw, _ := io.ReadAll(resp.Body) + var env ErrorEnvelope + require.NoError(t, json.Unmarshal(raw, &env)) + + // One entry per failing field. + require.GreaterOrEqual(t, len(env.Errors), 3, "expected at least 3 entries, got: %s", raw) + seenFields := map[string]ValidationErrorEntry{} + for _, e := range env.Errors { + assert.Equal(t, errTypeValidation, e.Type) + assert.Equal(t, 422, e.Code) + assert.NotEmpty(t, e.Loc) + assert.NotEmpty(t, e.Msg) + assert.NotEmpty(t, e.Constraint) + seenFields[e.Field] = e + } + + if entry, ok := seenFields["workspaceId"]; ok { + assert.Equal(t, []any{"body", "workspaceId"}, entry.Loc) + assert.Equal(t, "min=11", entry.Constraint) + } else { + t.Errorf("missing workspaceId entry: %s", raw) + } + if entry, ok := seenFields["email"]; ok { + assert.Equal(t, "email", entry.Constraint) + } else { + t.Errorf("missing email entry: %s", raw) + } + if entry, ok := seenFields["slug"]; ok { + assert.Equal(t, []any{"body", "nested", "slug"}, entry.Loc, "nested loc should walk through JSON tags") + } else { + t.Errorf("missing nested slug entry: %s", raw) + } +} + +func TestEnvelope_TypeMismatch_NestedLocPreserved(t *testing.T) { + // Regression: when the failing field is inside a nested struct, the loc + // array must carry the full JSON path — not just ["body", ""]. The + // previous implementation routed ute.Field through the Go-name resolver, + // which couldn't match JSON tag names and silently dropped intermediate + // segments. + type Address struct { + Zipcode string `json:"zipcode" validate:"required"` + } + type Person struct { + Name string `json:"name" validate:"required"` + Addr Address `json:"address" validate:"required"` + } + + app := fiber.New() + oapi := New(app) + Post(oapi, "/persons", func(c fiber.Ctx, in Person) (envelopeOutput, struct{}) { + return envelopeOutput{Message: "ok"}, struct{}{} + }, OpenAPIOptions{OperationID: "createPerson"}) + + // zipcode expects a string but receives a number → UnmarshalTypeError on + // the nested path "address.zipcode". + body := `{"name":"Alice","address":{"zipcode":12345}}` + req := httptest.NewRequest("POST", "/persons", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + resp, err := app.Test(req) + require.NoError(t, err) + require.Equal(t, 400, resp.StatusCode) + + raw, _ := io.ReadAll(resp.Body) + var env ErrorEnvelope + require.NoError(t, json.Unmarshal(raw, &env)) + require.Len(t, env.Errors, 1) + entry := env.Errors[0] + assert.Equal(t, errTypeTypeMismatch, entry.Type) + assert.Equal(t, "zipcode", entry.Field) + // The whole JSON path must be preserved — body → address → zipcode. + assert.Equal(t, []any{"body", "address", "zipcode"}, entry.Loc, "nested JSON path segments must be preserved in loc") +} + +func TestEnvelope_TypeMismatch(t *testing.T) { + app := registerEnvelopeRoute(t) + + // workspaceId expects a string but receives a number. + body := `{"workspaceId":123,"email":"a@b.co","nested":{"slug":"ab"}}` + req := httptest.NewRequest("POST", "/items/abcde", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + + resp, err := app.Test(req) + require.NoError(t, err) + require.Equal(t, 400, resp.StatusCode) + + raw, _ := io.ReadAll(resp.Body) + var env ErrorEnvelope + require.NoError(t, json.Unmarshal(raw, &env)) + require.Len(t, env.Errors, 1) + entry := env.Errors[0] + assert.Equal(t, errTypeTypeMismatch, entry.Type) + assert.Equal(t, 400, entry.Code) + assert.Equal(t, "workspaceId", entry.Field) + assert.Contains(t, entry.Msg, "expected string but got number") +} + +func TestEnvelope_ReadsXRequestID(t *testing.T) { + app := registerEnvelopeRoute(t) + + body := `{"workspaceId":"short","email":"a@b.co","nested":{"slug":"ok"}}` + req := httptest.NewRequest("POST", "/items/abcde", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Request-Id", "bf0e9029-576b-42e8-84f9-ad0622972f50") + + resp, err := app.Test(req) + require.NoError(t, err) + require.Equal(t, 422, resp.StatusCode) + + raw, _ := io.ReadAll(resp.Body) + var env ErrorEnvelope + require.NoError(t, json.Unmarshal(raw, &env)) + assert.Equal(t, "bf0e9029-576b-42e8-84f9-ad0622972f50", env.ResponseContext.ResponseID) + + // No header → empty response_id. + req2 := httptest.NewRequest("POST", "/items/abcde", strings.NewReader(body)) + req2.Header.Set("Content-Type", "application/json") + resp2, err := app.Test(req2) + require.NoError(t, err) + raw2, _ := io.ReadAll(resp2.Body) + var env2 ErrorEnvelope + require.NoError(t, json.Unmarshal(raw2, &env2)) + assert.Empty(t, env2.ResponseContext.ResponseID) +} + +func TestEnvelope_IncludeInvalidValueOptIn(t *testing.T) { + app := fiber.New() + oapi := New(app, Config{IncludeInvalidValueInErrors: true}) + Post(oapi, "/items/:id", func(c fiber.Ctx, input envelopeInput) (envelopeOutput, struct{}) { + return envelopeOutput{Message: "ok"}, struct{}{} + }, OpenAPIOptions{OperationID: "createItem"}) + + body := `{"workspaceId":"short","email":"a@b.co","nested":{"slug":"ok"}}` + req := httptest.NewRequest("POST", "/items/abcde", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + + resp, err := app.Test(req) + require.NoError(t, err) + require.Equal(t, 422, resp.StatusCode) + + raw, _ := io.ReadAll(resp.Body) + var env ErrorEnvelope + require.NoError(t, json.Unmarshal(raw, &env)) + var found bool + for _, e := range env.Errors { + if e.Field == "workspaceId" { + assert.Equal(t, "short", e.Value) + found = true + } + } + assert.True(t, found, "workspaceId entry not found: %s", raw) +} + +func TestEnvelope_NotFound_DefaultEnvelope(t *testing.T) { + app := fiber.New() + oapi := New(app) + Get(oapi, "/exists", func(c fiber.Ctx, input struct{}) (envelopeOutput, struct{}) { + return envelopeOutput{Message: "ok"}, struct{}{} + }, OpenAPIOptions{OperationID: "exists"}) + oapi.UseNotFoundHandler() + + // Sanity: registered route still works (catch-all must not swallow it) + okResp, err := app.Test(httptest.NewRequest("GET", "/exists", nil)) + require.NoError(t, err) + require.Equal(t, 200, okResp.StatusCode) + + // Unmatched route returns the envelope + req := httptest.NewRequest("GET", "/does-not-exist", nil) + req.Header.Set("X-Request-Id", "rid-not-found") + resp, err := app.Test(req) + require.NoError(t, err) + require.Equal(t, 404, resp.StatusCode) + + raw, _ := io.ReadAll(resp.Body) + var env ErrorEnvelope + require.NoError(t, json.Unmarshal(raw, &env)) + require.Len(t, env.Errors, 1) + entry := env.Errors[0] + assert.Equal(t, errTypeNotFound, entry.Type) + assert.Equal(t, 404, entry.Code) + assert.Equal(t, []any{"path"}, entry.Loc) + assert.Equal(t, "/does-not-exist", entry.Field) + assert.Contains(t, entry.Msg, "GET /does-not-exist") + assert.Equal(t, "rid-not-found", env.ResponseContext.ResponseID) +} + +func TestEnvelope_NotFound_CustomHandlerWins(t *testing.T) { + called := false + app := fiber.New() + oapi := New(app, Config{NotFoundHandler: func(c fiber.Ctx) error { + called = true + return c.Status(404).JSON(fiber.Map{"custom": true, "path": c.Path()}) + }}) + oapi.UseNotFoundHandler() + + resp, err := app.Test(httptest.NewRequest("GET", "/nope", nil)) + require.NoError(t, err) + assert.True(t, called, "custom NotFoundHandler should run") + assert.Equal(t, 404, resp.StatusCode) + raw, _ := io.ReadAll(resp.Body) + assert.Contains(t, string(raw), `"custom":true`) +} + +func TestEnvelope_NotFound_NotRegisteredWithoutCall(t *testing.T) { + app := fiber.New() + oapi := New(app) + Get(oapi, "/exists", func(c fiber.Ctx, input struct{}) (envelopeOutput, struct{}) { + return envelopeOutput{Message: "ok"}, struct{}{} + }, OpenAPIOptions{OperationID: "exists"}) + // Deliberately NOT calling UseNotFoundHandler(). + + resp, err := app.Test(httptest.NewRequest("GET", "/does-not-exist", nil)) + require.NoError(t, err) + require.Equal(t, 404, resp.StatusCode) + + // Body should be Fiber's default (plain "Cannot GET ..."), not our envelope. + raw, _ := io.ReadAll(resp.Body) + assert.NotContains(t, string(raw), `"errors":`, "should not produce an envelope when UseNotFoundHandler is not called") +} + +func TestEnvelope_NotFound_DoesNotInterfereWithRegisteredRoutes(t *testing.T) { + // Routes registered BEFORE UseNotFoundHandler() keep working. The catch-all is + // a Use middleware in Fiber, matched in registration order, so it must be the + // last middleware installed (after every route). This is documented in the + // method comment; the test pins the invariant. + app := fiber.New() + oapi := New(app) + + Get(oapi, "/before", func(c fiber.Ctx, input struct{}) (envelopeOutput, struct{}) { + return envelopeOutput{Message: "before"}, struct{}{} + }, OpenAPIOptions{OperationID: "before"}) + + type V struct { + Name string `query:"name" validate:"required,min=3"` + } + Get(oapi, "/validated", func(c fiber.Ctx, input V) (envelopeOutput, struct{}) { + return envelopeOutput{Message: "ok"}, struct{}{} + }, OpenAPIOptions{OperationID: "validated"}) + + // Catch-all installed LAST. + oapi.UseNotFoundHandler() + + // Registered route — 200 OK + respBefore, err := app.Test(httptest.NewRequest("GET", "/before", nil)) + require.NoError(t, err) + assert.Equal(t, 200, respBefore.StatusCode) + + // Validation errors on a registered route surface a 422 envelope (not 404). + respV, err := app.Test(httptest.NewRequest("GET", "/validated?name=a", nil)) + require.NoError(t, err) + assert.Equal(t, 422, respV.StatusCode) + rawV, _ := io.ReadAll(respV.Body) + assert.Contains(t, string(rawV), `"validation_error"`) + + // Unmatched route — 404 envelope + respNF, err := app.Test(httptest.NewRequest("GET", "/missing", nil)) + require.NoError(t, err) + assert.Equal(t, 404, respNF.StatusCode) +} + +func TestEnvelope_NotFound_TopLevelHelper(t *testing.T) { + // fiberoapi.DefaultNotFoundHandler() returns a fiber.Handler that emits + // the envelope — useful for users who manage their own fiber.Config. + app := fiber.New() + app.Use(DefaultNotFoundHandler()) + + resp, err := app.Test(httptest.NewRequest("DELETE", "/missing", nil)) + require.NoError(t, err) + require.Equal(t, 404, resp.StatusCode) + raw, _ := io.ReadAll(resp.Body) + var env ErrorEnvelope + require.NoError(t, json.Unmarshal(raw, &env)) + assert.Equal(t, errTypeNotFound, env.Errors[0].Type) + assert.Contains(t, env.Errors[0].Msg, "DELETE /missing") +} + +func TestEnvelope_NotFound_HEADRequest_NoBody(t *testing.T) { + app := fiber.New() + oapi := New(app) + Get(oapi, "/exists", func(c fiber.Ctx, input struct{}) (envelopeOutput, struct{}) { + return envelopeOutput{Message: "ok"}, struct{}{} + }, OpenAPIOptions{OperationID: "exists"}) + oapi.UseNotFoundHandler() + + resp, err := app.Test(httptest.NewRequest("HEAD", "/missing", nil)) + require.NoError(t, err) + assert.Equal(t, 404, resp.StatusCode) + raw, _ := io.ReadAll(resp.Body) + assert.Empty(t, raw, "HEAD response must not carry a body") +} + +func TestEnvelope_NotFound_405WithAllowHeader(t *testing.T) { + app := fiber.New() + oapi := New(app) + Post(oapi, "/items/:id", func(c fiber.Ctx, input envelopeInput) (envelopeOutput, struct{}) { + return envelopeOutput{Message: "ok"}, struct{}{} + }, OpenAPIOptions{OperationID: "createItem"}) + Put(oapi, "/items/:id", func(c fiber.Ctx, input envelopeInput) (envelopeOutput, struct{}) { + return envelopeOutput{Message: "ok"}, struct{}{} + }, OpenAPIOptions{OperationID: "updateItem"}) + oapi.UseNotFoundHandler() + + // GET on a POST+PUT-only path → 405 with Allow header + resp, err := app.Test(httptest.NewRequest("GET", "/items/abc", nil)) + require.NoError(t, err) + assert.Equal(t, 405, resp.StatusCode) + allow := resp.Header.Get("Allow") + assert.Contains(t, allow, "POST") + assert.Contains(t, allow, "PUT") + + raw, _ := io.ReadAll(resp.Body) + var env ErrorEnvelope + require.NoError(t, json.Unmarshal(raw, &env)) + assert.Equal(t, "method_not_allowed", env.Errors[0].Type) + assert.Equal(t, 405, env.Errors[0].Code) + assert.Equal(t, []any{"method"}, env.Errors[0].Loc) +} + +func TestEnvelope_NotFound_OptionsFallthrough(t *testing.T) { + // OPTIONS preflights must be passed downstream so CORS-like middleware can + // respond. With nothing else installed, Fiber's own stack returns 404. + corsHandled := false + app := fiber.New() + oapi := New(app) + Get(oapi, "/exists", func(c fiber.Ctx, input struct{}) (envelopeOutput, struct{}) { + return envelopeOutput{Message: "ok"}, struct{}{} + }, OpenAPIOptions{OperationID: "exists"}) + // Install our catch-all FIRST so we can verify it does not eat OPTIONS. + oapi.UseNotFoundHandler() + // Then a CORS-like fallback registered after via app.Use — it should run + // when the catch-all calls c.Next() on OPTIONS. + app.Use(func(c fiber.Ctx) error { + if c.Method() == "OPTIONS" { + corsHandled = true + return c.Status(204).Send(nil) + } + return c.Next() + }) + + resp, err := app.Test(httptest.NewRequest("OPTIONS", "/whatever", nil)) + require.NoError(t, err) + assert.True(t, corsHandled, "OPTIONS must reach downstream middleware") + assert.Equal(t, 204, resp.StatusCode) +} + +func TestEnvelope_NotFound_IdempotentInstall(t *testing.T) { + // Calling UseNotFoundHandler() twice on the same OApiApp should be a no-op + // on the second call (no double-stacking of middleware). + app := fiber.New() + oapi := New(app) + Get(oapi, "/exists", func(c fiber.Ctx, input struct{}) (envelopeOutput, struct{}) { + return envelopeOutput{Message: "ok"}, struct{}{} + }, OpenAPIOptions{OperationID: "exists"}) + + oapi.UseNotFoundHandler() + oapi.UseNotFoundHandler() // no-op + oapi.UseNotFoundHandler() // no-op + + resp, err := app.Test(httptest.NewRequest("GET", "/missing", nil)) + require.NoError(t, err) + assert.Equal(t, 404, resp.StatusCode) + + // Sanity: registered route still reachable. + respOK, err := app.Test(httptest.NewRequest("GET", "/exists", nil)) + require.NoError(t, err) + assert.Equal(t, 200, respOK.StatusCode) +} + +func TestEnvelope_NotFound_RequestIDSanitization(t *testing.T) { + app := fiber.New() + oapi := New(app) + oapi.UseNotFoundHandler() + + cases := []struct { + name string + header string + want string + }{ + {"valid UUID is echoed", "bf0e9029-576b-42e8-84f9-ad0622972f50", "bf0e9029-576b-42e8-84f9-ad0622972f50"}, + // fasthttp rejects header values with raw CRLF / NUL at parse time, so + // the sanitizer only needs to cover characters fasthttp lets through. + {"semicolons dropped", "abc;DROP TABLE", ""}, + {"too long dropped", strings.Repeat("a", 200), ""}, + {"spaces dropped", "abc def", ""}, + {"valid hex echoed", "0123abcdef", "0123abcdef"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + req := httptest.NewRequest("GET", "/missing", nil) + if tc.header != "" { + req.Header.Set("X-Request-Id", tc.header) + } + resp, err := app.Test(req) + require.NoError(t, err) + raw, _ := io.ReadAll(resp.Body) + var env ErrorEnvelope + require.NoError(t, json.Unmarshal(raw, &env)) + assert.Equal(t, tc.want, env.ResponseContext.ResponseID) + }) + } +} + +func TestEnvelope_NotFound_PathTruncation(t *testing.T) { + app := fiber.New() + oapi := New(app) + oapi.UseNotFoundHandler() + + // Construct a very long path (> 1024 bytes). fasthttp itself imposes a path + // length limit so this also exercises that an over-long URL is gracefully + // handled rather than crashing. + longPath := "/" + strings.Repeat("a", 2000) + resp, err := app.Test(httptest.NewRequest("GET", longPath, nil)) + require.NoError(t, err) + // Status may be 404 (envelope) or 414 / 400 depending on fasthttp limits. + // What matters here is that no panic occurs and any envelope returned has a + // bounded msg/field length. + if resp.StatusCode == 404 { + raw, _ := io.ReadAll(resp.Body) + var env ErrorEnvelope + if json.Unmarshal(raw, &env) == nil { + // 1024 bytes + the ellipsis "…" (3 bytes UTF-8) is the upper bound. + assert.LessOrEqual(t, len(env.Errors[0].Field), 1024+3+8) + } + } +} + +func TestEnvelope_NotFound_SpecEntryGatedOnInstall(t *testing.T) { + // When UseNotFoundHandler() is NOT called, the generated spec should not + // include a 404 response (because routing falls through to Fiber's default, + // which is not an envelope). + app := fiber.New() + oapi := New(app) + Post(oapi, "/items/:id", func(c fiber.Ctx, input envelopeInput) (envelopeOutput, struct{}) { + return envelopeOutput{Message: "ok"}, struct{}{} + }, OpenAPIOptions{OperationID: "createItem"}) + + spec := oapi.GenerateOpenAPISpec() + post := spec["paths"].(map[string]interface{})["/items/{id}"].(map[string]interface{})["post"].(map[string]interface{}) + _, has404 := post["responses"].(map[string]interface{})["404"] + assert.False(t, has404, "404 must not appear in the spec without UseNotFoundHandler()") + + // After installing the handler, the entry appears. + oapi.UseNotFoundHandler() + spec = oapi.GenerateOpenAPISpec() + post = spec["paths"].(map[string]interface{})["/items/{id}"].(map[string]interface{})["post"].(map[string]interface{}) + _, has404 = post["responses"].(map[string]interface{})["404"] + assert.True(t, has404, "404 should appear in the spec after UseNotFoundHandler()") +} + +func TestEnvelope_OpenAPISpecExposesExamples(t *testing.T) { + app := registerEnvelopeRoute(t) + + req := httptest.NewRequest("GET", "/openapi.json", nil) + resp, err := app.Test(req) + require.NoError(t, err) + require.Equal(t, 200, resp.StatusCode) + + raw, _ := io.ReadAll(resp.Body) + var spec map[string]any + require.NoError(t, json.Unmarshal(raw, &spec)) + + post := spec["paths"].(map[string]any)["/items/{id}"].(map[string]any)["post"].(map[string]any) + responses := post["responses"].(map[string]any) + + // 422 — validation envelope with example + resp422 := responses["422"].(map[string]any) + assert.Equal(t, "Validation error", resp422["description"]) + content := resp422["content"].(map[string]any)["application/json"].(map[string]any) + assert.Equal(t, "#/components/schemas/ErrorEnvelope", content["schema"].(map[string]any)["$ref"]) + require.NotNil(t, content["example"], "expected an example payload on the 422 response") + example := content["example"].(map[string]any) + require.NotNil(t, example["errors"]) + require.NotNil(t, example["response_context"]) + + // 400 — parse / type-mismatch envelope (only for POST/PUT/PATCH). The + // description was widened to cover both malformed JSON and wrong-typed + // fields after the spec example was aligned with the runtime emission. + resp400 := responses["400"].(map[string]any) + assert.Equal(t, "Invalid request body (malformed JSON or wrong field type)", resp400["description"]) + + // Components include the envelope schema + schemas := spec["components"].(map[string]any)["schemas"].(map[string]any) + _, hasEnvelope := schemas["ErrorEnvelope"] + assert.True(t, hasEnvelope, "ErrorEnvelope should be registered in components.schemas") + _, hasEntry := schemas["ValidationErrorEntry"] + assert.True(t, hasEntry, "ValidationErrorEntry should be registered in components.schemas") +} diff --git a/error_response.go b/error_response.go new file mode 100644 index 0000000..277c7b4 --- /dev/null +++ b/error_response.go @@ -0,0 +1,571 @@ +package fiberoapi + +import ( + "encoding/json" + "errors" + "fmt" + "reflect" + "regexp" + "strings" + "sync" + "unicode/utf8" + + "github.com/go-playground/validator/v10" + "github.com/gofiber/fiber/v3" +) + +const ( + requestIDHeader = "X-Request-Id" + maxRequestIDLen = 128 // bytes — request-id is opaque, anything longer is abuse + maxNotFoundPath = 1024 // bytes — truncate echoed path to avoid log/UI blow-up +) + +// requestIDPattern accepts characters typical for trace IDs (UUID, ULID, hex, +// dotted notation). Anything else is dropped to neutralise CRLF / log-injection +// vectors when the value is echoed into a JSON body that later ends up in logs. +var requestIDPattern = regexp.MustCompile(`^[A-Za-z0-9._\-:]+$`) + +// sanitizeRequestID returns the header value if it is short enough and only +// contains safe characters; otherwise it returns an empty string. +func sanitizeRequestID(s string) string { + if s == "" || len(s) > maxRequestIDLen { + return "" + } + if !requestIDPattern.MatchString(s) { + return "" + } + return s +} + +// sanitizePath bounds and validates the path string echoed back to the client. +// Non-UTF-8 sequences are replaced and the result is truncated to maxNotFoundPath. +func sanitizePath(s string) string { + if !utf8.ValidString(s) { + s = strings.ToValidUTF8(s, "�") + } + if len(s) > maxNotFoundPath { + // Truncate on a rune boundary so we do not produce invalid UTF-8. + s = s[:maxNotFoundPath] + for !utf8.ValidString(s) && len(s) > 0 { + s = s[:len(s)-1] + } + s += "…" + } + return s +} + +// Status codes used by the default handlers. 422 follows the convention used by +// FastAPI/Pydantic and DRF: 400 for "I could not parse what you sent" and 422 +// for "I parsed it but the content failed validation rules". +const ( + statusParseError = fiber.StatusBadRequest // 400 + statusValidationError = fiber.StatusUnprocessableEntity // 422 +) + +// Entry type constants — kept stable so clients can branch on them. +const ( + errTypeValidation = "validation_error" + errTypeTypeMismatch = "type_error" + errTypeParse = "parse_error" + errTypeAuthN = "authentication_error" + errTypeAuthZ = "authorization_error" + errTypeNotFound = "not_found" +) + +// typeMismatchMsgFmt is the canonical format string used to render the human +// message for a *json.UnmarshalTypeError. Shared by buildEnvelope (runtime), +// wrapJSONTypeError (custom ValidationErrorHandler path), categorizeError +// (DefaultErrorShape runtime) and exampleParseEnvelope (spec example) so the +// four cannot drift apart silently. +const typeMismatchMsgFmt = "invalid type for field '%s': expected %s but got %s" + +// locResolverCache memoises per-type loc resolvers so error formatting does not +// pay the reflection cost on every request. +var locResolverCache sync.Map // map[reflect.Type]*locResolver + +type locResolver struct { + root reflect.Type +} + +func resolverFor(t reflect.Type) *locResolver { + if t == nil { + return &locResolver{} + } + if cached, ok := locResolverCache.Load(t); ok { + return cached.(*locResolver) + } + r := &locResolver{root: t} + actual, _ := locResolverCache.LoadOrStore(t, r) + return actual.(*locResolver) +} + +// resolve takes the validator namespace (Go struct names, dot-separated) and +// produces the JSON-flavoured loc array plus the leaf field name. The first +// element of loc is the source (body / path / query / header), the remaining +// elements are field names in the source's naming convention. +func (r *locResolver) resolve(namespace string) (loc []any, leaf string) { + if r.root == nil { + return []any{"body"}, "" + } + segs := strings.Split(namespace, ".") + // Drop the root struct name if validator prefixed it. + if len(segs) > 0 && segs[0] == r.root.Name() { + segs = segs[1:] + } + if len(segs) == 0 { + return []any{"body"}, "" + } + + t := dereferenceType(r.root) + if t.Kind() != reflect.Struct { + return []any{"body"}, "" + } + + loc = make([]any, 0, len(segs)+1) + for i, seg := range segs { + field, ok := t.FieldByName(seg) + if !ok { + break + } + if i == 0 { + if tag := field.Tag.Get("uri"); tag != "" { + loc = append(loc, "path", tag) + } else if tag := field.Tag.Get("query"); tag != "" { + loc = append(loc, "query", tag) + } else if tag := field.Tag.Get("header"); tag != "" { + loc = append(loc, "header", tag) + } else { + loc = append(loc, "body", jsonFieldName(field)) + } + } else { + loc = append(loc, jsonFieldName(field)) + } + t = dereferenceType(field.Type) + if t.Kind() != reflect.Struct { + // Cannot descend further; remaining segments would not be valid struct fields. + break + } + } + + if len(loc) > 0 { + if s, ok := loc[len(loc)-1].(string); ok { + leaf = s + } + } + return loc, leaf +} + +// jsonFieldName returns the JSON name of a struct field, falling back to the Go +// name when the field has no json tag or the tag explicitly hides the field. +func jsonFieldName(field reflect.StructField) string { + tag := field.Tag.Get("json") + if tag == "" || tag == "-" { + return field.Name + } + return strings.Split(tag, ",")[0] +} + +// friendlyJSONError wraps a *json.UnmarshalTypeError so the user-facing message +// (Error()) is the friendly "invalid type for field 'X'..." form while the +// original error is still recoverable via errors.As / errors.AsType. Used by +// parseInput so custom ValidationErrorHandlers that call err.Error() keep +// getting a nice message. +type friendlyJSONError struct { + msg string + ute *json.UnmarshalTypeError +} + +func (e *friendlyJSONError) Error() string { return e.msg } +func (e *friendlyJSONError) Unwrap() error { return e.ute } + +// wrapJSONTypeError builds a friendlyJSONError if err carries a JSON type +// mismatch. Returns nil if err is not a *json.UnmarshalTypeError. +func wrapJSONTypeError(err error) error { + ute, ok := errors.AsType[*json.UnmarshalTypeError](err) + if !ok { + return nil + } + field := ute.Field + if i := strings.LastIndex(field, "."); i >= 0 { + field = field[i+1:] + } + msg := fmt.Sprintf(typeMismatchMsgFmt, field, ute.Type.String(), ute.Value) + if field == "" { + msg = fmt.Sprintf("invalid JSON: expected %s but got %s", ute.Type.String(), ute.Value) + } + return &friendlyJSONError{msg: msg, ute: ute} +} + +// translateValidatorTag turns a validator tag + parameter into a human-readable +// message. Covers the most common tags from go-playground/validator; anything +// unknown falls back to a generic message that still exposes the tag name so the +// client side stays informative. +func translateValidatorTag(field, tag, param string) string { + switch tag { + case "required": + return fmt.Sprintf("field '%s' is required", field) + case "min": + return fmt.Sprintf("field '%s' must be at least %s", field, param) + case "max": + return fmt.Sprintf("field '%s' must be at most %s", field, param) + case "len": + return fmt.Sprintf("field '%s' must be exactly %s", field, param) + case "email": + return fmt.Sprintf("field '%s' must be a valid email address", field) + case "url": + return fmt.Sprintf("field '%s' must be a valid URL", field) + case "uuid", "uuid4": + return fmt.Sprintf("field '%s' must be a valid UUID", field) + case "alphanum": + return fmt.Sprintf("field '%s' must contain only alphanumeric characters", field) + case "alpha": + return fmt.Sprintf("field '%s' must contain only alphabetic characters", field) + case "numeric": + return fmt.Sprintf("field '%s' must be numeric", field) + case "oneof": + return fmt.Sprintf("field '%s' must be one of: %s", field, param) + case "gte": + return fmt.Sprintf("field '%s' must be greater than or equal to %s", field, param) + case "lte": + return fmt.Sprintf("field '%s' must be less than or equal to %s", field, param) + case "gt": + return fmt.Sprintf("field '%s' must be greater than %s", field, param) + case "lt": + return fmt.Sprintf("field '%s' must be less than %s", field, param) + default: + if param != "" { + return fmt.Sprintf("field '%s' failed validation: %s=%s", field, tag, param) + } + return fmt.Sprintf("field '%s' failed validation: %s", field, tag) + } +} + +// buildEnvelope produces an ErrorEnvelope from any error returned by parseInput. +// The status code carried in the envelope entries (and intended to be set on the +// response) is returned alongside so the handler can call c.Status() once. +func buildEnvelope(c fiber.Ctx, cfg Config, inputType reflect.Type, err error) (ErrorEnvelope, int) { + resolver := resolverFor(inputType) + ctx := ResponseContext{ResponseID: sanitizeRequestID(c.Get(requestIDHeader))} + + // AuthError — single entry, status from the error itself. + if authErr, ok := errors.AsType[*AuthError](err); ok { + status := authErr.StatusCode + entryType := errTypeAuthN + if status == fiber.StatusForbidden { + entryType = errTypeAuthZ + } + return ErrorEnvelope{ + Errors: []ValidationErrorEntry{{ + Type: entryType, + Code: status, + Loc: []any{"header", "Authorization"}, + Msg: authErr.Message, + }}, + ResponseContext: ctx, + }, status + } + + // JSON type mismatch — single entry, 400 Bad Request. ute.Field is already a + // dotted JSON path (e.g. "user.address.zipcode"), so build loc straight from + // its segments. Routing through the validator-namespace resolver here would + // fail: that resolver calls FieldByName which expects Go names like + // "Zipcode", not the JSON-tag names produced by the json decoder. + if ute, ok := errors.AsType[*json.UnmarshalTypeError](err); ok { + loc := []any{"body"} + var fieldName string + if ute.Field != "" { + segs := strings.Split(ute.Field, ".") + for _, seg := range segs { + if seg == "" { + continue + } + loc = append(loc, seg) + } + fieldName = segs[len(segs)-1] + } + msg := fmt.Sprintf(typeMismatchMsgFmt, fieldName, ute.Type.String(), ute.Value) + if fieldName == "" { + msg = fmt.Sprintf("invalid JSON: expected %s but got %s", ute.Type.String(), ute.Value) + } + entry := ValidationErrorEntry{ + Type: errTypeTypeMismatch, + Code: statusParseError, + Loc: loc, + Field: fieldName, + Msg: msg, + Constraint: ute.Type.String(), + } + if cfg.IncludeInvalidValueInErrors { + entry.Value = ute.Value + } + return ErrorEnvelope{Errors: []ValidationErrorEntry{entry}, ResponseContext: ctx}, statusParseError + } + + // validator.ValidationErrors — one entry per failing field, 422. + var vErrs validator.ValidationErrors + if errors.As(err, &vErrs) { + entries := make([]ValidationErrorEntry, 0, len(vErrs)) + for _, fe := range vErrs { + loc, leaf := resolver.resolve(fe.StructNamespace()) + if leaf == "" { + leaf = fe.Field() + } + entry := ValidationErrorEntry{ + Type: errTypeValidation, + Code: statusValidationError, + Loc: loc, + Field: leaf, + Msg: translateValidatorTag(leaf, fe.Tag(), fe.Param()), + Constraint: constraintString(fe.Tag(), fe.Param()), + } + if cfg.IncludeInvalidValueInErrors { + entry.Value = fe.Value() + } + entries = append(entries, entry) + } + return ErrorEnvelope{Errors: entries, ResponseContext: ctx}, statusValidationError + } + + // Anything else — generic parse error. + return ErrorEnvelope{ + Errors: []ValidationErrorEntry{{ + Type: errTypeParse, + Code: statusParseError, + Loc: []any{"body"}, + Msg: err.Error(), + }}, + ResponseContext: ctx, + }, statusParseError +} + +func constraintString(tag, param string) string { + if param == "" { + return tag + } + return tag + "=" + param +} + +// exampleEnvelope returns a representative ErrorEnvelope used as the OpenAPI +// example for the 422 response. It is deliberately compact but realistic enough +// to show the shape to consumers reading the spec. +func exampleValidationEnvelope() ErrorEnvelope { + return ErrorEnvelope{ + Errors: []ValidationErrorEntry{{ + Type: errTypeValidation, + Code: statusValidationError, + Loc: []any{"body", "workspaceId"}, + // Routing through translateValidatorTag (the same function the + // runtime calls) keeps the spec example wording perfectly aligned + // with what clients will receive — no risk of drift if either side + // changes the wording later. + Field: "workspaceId", + Msg: translateValidatorTag("workspaceId", "min", "11"), + Constraint: "min=11", + }}, + ResponseContext: ResponseContext{ResponseID: "bf0e9029-576b-42e8-84f9-ad0622972f50"}, + } +} + +// NotFoundEnvelope is the public counterpart to the internal builder: it +// produces the default 404 ErrorEnvelope for a request. Custom NotFoundHandler +// implementations can call it to reuse the library's shape while overriding +// only the response status or body. +func NotFoundEnvelope(c fiber.Ctx) ErrorEnvelope { + method := c.Method() + path := sanitizePath(c.Path()) + return ErrorEnvelope{ + Errors: []ValidationErrorEntry{{ + Type: errTypeNotFound, + Code: fiber.StatusNotFound, + Loc: []any{"path"}, + Field: path, + Msg: fmt.Sprintf("no route matches %s %s", method, path), + }}, + ResponseContext: ResponseContext{ResponseID: sanitizeRequestID(c.Get(requestIDHeader))}, + } +} + +// methodNotAllowedEnvelope is emitted when the path exists on other HTTP +// methods. It mirrors the 405 status code Fiber would otherwise emit, but in +// our envelope shape so clients only have to parse one structure. +func methodNotAllowedEnvelope(c fiber.Ctx, allowed []string) ErrorEnvelope { + method := c.Method() + path := sanitizePath(c.Path()) + return ErrorEnvelope{ + Errors: []ValidationErrorEntry{{ + Type: "method_not_allowed", + Code: fiber.StatusMethodNotAllowed, + Loc: []any{"method"}, + Field: method, + Msg: fmt.Sprintf("method %s not allowed on %s; allowed: %s", method, path, strings.Join(allowed, ", ")), + Constraint: strings.Join(allowed, ","), + }}, + ResponseContext: ResponseContext{ResponseID: sanitizeRequestID(c.Get(requestIDHeader))}, + } +} + +// defaultNotFoundHandler builds the closure installed when no user-supplied +// Config.NotFoundHandler is configured. The closure captures o.operations so it +// can emit 405 with an Allow header when the path exists on another method. +func (o *OApiApp) defaultNotFoundHandler() fiber.Handler { + return func(c fiber.Ctx) error { + // HEAD: HTTP forbids a body — emit just the status. + if c.Method() == fiber.MethodHead { + return c.SendStatus(fiber.StatusNotFound) + } + // OPTIONS: pass through so downstream CORS middleware can produce the + // preflight response. If nothing else handles it, Fiber's stack returns + // 404 naturally and that is the right outcome (the route does not exist). + if c.Method() == fiber.MethodOptions { + return c.Next() + } + shape := o.config.DefaultErrorShape + // 405: the path exists on another method. + if allowed := o.allowedMethodsFor(c.Path()); len(allowed) > 0 { + c.Set(fiber.HeaderAllow, strings.Join(allowed, ", ")) + if shape != nil { + cat := errorCategory{ + Code: fiber.StatusMethodNotAllowed, + Type: "method_not_allowed", + Message: fmt.Sprintf("method %s not allowed on %s", c.Method(), sanitizePath(c.Path())), + Details: strings.Join(allowed, ", "), + } + return c.Status(fiber.StatusMethodNotAllowed).JSON(materializeError(shape, cat)) + } + return c.Status(fiber.StatusMethodNotAllowed).JSON(methodNotAllowedEnvelope(c, allowed)) + } + if shape != nil { + cat := errorCategory{ + Code: fiber.StatusNotFound, + Type: errTypeNotFound, + Message: fmt.Sprintf("no route matches %s %s", c.Method(), sanitizePath(c.Path())), + } + return c.Status(fiber.StatusNotFound).JSON(materializeError(shape, cat)) + } + return c.Status(fiber.StatusNotFound).JSON(NotFoundEnvelope(c)) + } +} + +// allowedMethodsFor walks the registered operations and returns the HTTP +// methods that match the requested path (Fiber-style :param patterns supported). +func (o *OApiApp) allowedMethodsFor(path string) []string { + seen := map[string]struct{}{} + var allowed []string + for _, op := range o.operations { + if op.Method == "" { + continue + } + if !matchFiberPath(op.Path, path) { + continue + } + if _, dup := seen[op.Method]; dup { + continue + } + seen[op.Method] = struct{}{} + allowed = append(allowed, op.Method) + } + return allowed +} + +// pathParamRegex captures Fiber's :name and {name} placeholders so we can turn +// a route pattern into a regex that matches one path segment per placeholder. +var pathParamRegex = regexp.MustCompile(`:[A-Za-z_][A-Za-z0-9_]*|\{[A-Za-z_][A-Za-z0-9_]*\}`) + +// matchFiberPath returns true when the concrete `path` matches the route +// `pattern`. Only the common Fiber placeholder forms are supported (`:name`, +// `{name}`) — wildcards / regex constraints fall back to literal matching. +func matchFiberPath(pattern, path string) bool { + // Fast path: literal equality. + if pattern == path { + return true + } + if !strings.ContainsAny(pattern, ":{") { + return false + } + expr := "^" + pathParamRegex.ReplaceAllStringFunc(regexp.QuoteMeta(pattern), func(string) string { + return "[^/]+" + }) + "$" + // The replacement runs on the QuoteMeta'd pattern, where `:` becomes `:` + // (unchanged) and `{`/`}` become `\{`/`\}`. Strip those backslashes so the + // regex sees the original delimiters. + expr = strings.ReplaceAll(expr, `\{`, `{`) + expr = strings.ReplaceAll(expr, `\}`, `}`) + re, err := regexp.Compile(expr) + if err != nil { + return false + } + return re.MatchString(path) +} + +// UseNotFoundHandler installs a catch-all middleware that responds with the +// fiber-oapi ErrorEnvelope when no other route matches the request. +// +// Call this AFTER registering every route. The catch-all is installed via +// fiber.App.Use, which Fiber matches in registration order — install it +// before any route and that route will be unreachable. Calling the method more +// than once on the same OApiApp is a no-op after the first install. +// +// The default handler does three things beyond emitting the 404 envelope: +// - HEAD requests get a bodyless 404 (HTTP-conformant). +// - OPTIONS requests fall through to the next handler so downstream CORS +// middleware can answer preflights. +// - When the requested path is registered under another HTTP method, the +// response is 405 with an Allow header listing the supported methods. +// +// To customise the response, set Config.NotFoundHandler. The handler runs in +// place of the default and receives a raw fiber.Ctx — it owns the entire +// response (status code and body). Call NotFoundEnvelope(c) to reuse the +// library's envelope shape from inside a custom handler. +func (o *OApiApp) UseNotFoundHandler() { + if o.notFoundInstalled { + return + } + handler := o.config.NotFoundHandler + if handler == nil { + handler = o.defaultNotFoundHandler() + } + o.f.Use(handler) + o.notFoundInstalled = true +} + +// DefaultNotFoundHandler returns the default envelope-producing fiber.Handler +// without any operation-aware 405 detection. Useful for users who manage their +// own fiber.Config and want to install the catch-all outside of fiber-oapi. +func DefaultNotFoundHandler() fiber.Handler { + return func(c fiber.Ctx) error { + if c.Method() == fiber.MethodHead { + return c.SendStatus(fiber.StatusNotFound) + } + if c.Method() == fiber.MethodOptions { + return c.Next() + } + return c.Status(fiber.StatusNotFound).JSON(NotFoundEnvelope(c)) + } +} + +func exampleNotFoundEnvelope() ErrorEnvelope { + return ErrorEnvelope{ + Errors: []ValidationErrorEntry{{ + Type: errTypeNotFound, + Code: fiber.StatusNotFound, + Loc: []any{"path"}, + Field: "/users/42", + Msg: "no route matches GET /users/42", + }}, + ResponseContext: ResponseContext{ResponseID: "bf0e9029-576b-42e8-84f9-ad0622972f50"}, + } +} + +func exampleParseEnvelope() ErrorEnvelope { + return ErrorEnvelope{ + Errors: []ValidationErrorEntry{{ + Type: errTypeTypeMismatch, + Code: statusParseError, + Loc: []any{"body", "age"}, + Field: "age", + Msg: fmt.Sprintf(typeMismatchMsgFmt, "age", "int", "string"), + Constraint: "int", + }}, + ResponseContext: ResponseContext{ResponseID: "bf0e9029-576b-42e8-84f9-ad0622972f50"}, + } +} diff --git a/fiberoapi.go b/fiberoapi.go index dbfa447..97d7b04 100644 --- a/fiberoapi.go +++ b/fiberoapi.go @@ -52,7 +52,9 @@ func New(app *fiber.App, config ...Config) *OApiApp { provided.OpenAPIDescription != "" || provided.OpenAPIVersion != "" || provided.ValidationErrorHandler != nil || - provided.AuthErrorHandler != nil + provided.AuthErrorHandler != nil || + provided.NotFoundHandler != nil || + provided.DefaultErrorShape != nil // Only override boolean defaults if the config appears to be explicitly set if hasExplicitConfig { @@ -79,7 +81,9 @@ func New(app *fiber.App, config ...Config) *OApiApp { provided.OpenAPIDescription != "" || provided.OpenAPIVersion != "" || provided.ValidationErrorHandler != nil || - provided.AuthErrorHandler != nil) + provided.AuthErrorHandler != nil || + provided.NotFoundHandler != nil || + provided.DefaultErrorShape != nil) // Only restore defaults if ALL boolean fields are false (suggesting they weren't explicitly set) allBooleansAreFalse := !provided.EnableValidation && !provided.EnableOpenAPIDocs && !provided.EnableAuthorization @@ -128,6 +132,15 @@ func New(app *fiber.App, config ...Config) *OApiApp { if provided.AuthErrorHandler != nil { cfg.AuthErrorHandler = provided.AuthErrorHandler } + if provided.NotFoundHandler != nil { + cfg.NotFoundHandler = provided.NotFoundHandler + } + if provided.DefaultErrorShape != nil { + cfg.DefaultErrorShape = provided.DefaultErrorShape + } + if provided.IncludeInvalidValueInErrors { + cfg.IncludeInvalidValueInErrors = true + } } oapi := &OApiApp{ @@ -223,6 +236,22 @@ func (o *OApiApp) GenerateOpenAPISpec() map[string]interface{} { if op.ErrorType != nil && !isEmptyStruct(op.ErrorType) { collectAllTypes(op.ErrorType, allTypes) } + // Each declared custom error contributes its own type to components.schemas + // so multiple error responses sharing a struct ($ref via the same name) + // stay deduplicated. + for _, errInst := range op.Options.Errors { + if errInst == nil { + continue + } + collectAllTypes(reflect.TypeOf(errInst), allTypes) + } + } + + // When the user opted into a unified shape via Config.DefaultErrorShape, the + // per-operation responses below reference it by $ref. Make sure its schema + // (and any nested types) is collected so we never emit dangling references. + if o.config.DefaultErrorShape != nil { + collectAllTypes(reflect.TypeOf(o.config.DefaultErrorShape), allTypes) } // Second pass: generate all schemas @@ -230,6 +259,18 @@ func (o *OApiApp) GenerateOpenAPISpec() map[string]interface{} { schemas[typeName] = generateSchema(typeInfo) } + // Always expose the default error envelope shape so every route can $ref it. + collectAllTypes(reflect.TypeOf(ErrorEnvelope{}), allTypes) + if _, ok := schemas["ErrorEnvelope"]; !ok { + schemas["ErrorEnvelope"] = generateSchema(reflect.TypeOf(ErrorEnvelope{})) + } + if _, ok := schemas["ValidationErrorEntry"]; !ok { + schemas["ValidationErrorEntry"] = generateSchema(reflect.TypeOf(ValidationErrorEntry{})) + } + if _, ok := schemas["ResponseContext"]; !ok { + schemas["ResponseContext"] = generateSchema(reflect.TypeOf(ResponseContext{})) + } + for _, op := range o.operations { // Convert Fiber path format (:param) to OpenAPI format ({param}) openAPIPath := convertFiberPathToOpenAPI(op.Path) @@ -358,7 +399,9 @@ func (o *OApiApp) GenerateOpenAPISpec() map[string]interface{} { } } - // Error response (400/500) + // Custom TError response — only when the handler returns a non-empty TError. + // Emitted as a 4xx response separate from the default validation envelope so + // callers see both shapes in the spec. if op.ErrorType != nil && !isEmptyStruct(op.ErrorType) { errorType := dereferenceType(op.ErrorType) @@ -371,8 +414,8 @@ func (o *OApiApp) GenerateOpenAPISpec() map[string]interface{} { } } - responses["400"] = map[string]interface{}{ - "description": "Validation error", + responses["4XX"] = map[string]interface{}{ + "description": "Domain error returned by the handler", "content": map[string]interface{}{ "application/json": map[string]interface{}{ "schema": schemaRef, @@ -381,6 +424,77 @@ func (o *OApiApp) GenerateOpenAPISpec() map[string]interface{} { } } + // Default error responses produced by parseInput. When the user opted + // into a unified shape via Config.DefaultErrorShape, every default entry + // uses that shape (schema + a representative example built via the same + // reflection helpers used at runtime). Otherwise we fall back to the + // built-in ErrorEnvelope. + shape := o.config.DefaultErrorShape + defaultErrContent := func(cat errorCategory, envExample func() ErrorEnvelope) map[string]interface{} { + if shape != nil { + return map[string]interface{}{ + "schema": errorSchemaRef(reflect.TypeOf(shape)), + "example": materializeError(shape, cat), + } + } + return map[string]interface{}{ + "schema": map[string]interface{}{"$ref": "#/components/schemas/ErrorEnvelope"}, + "example": envExample(), + } + } + + // 422 always uses ErrorEnvelope so per-field info (loc / constraint / + // field / value) stays first-class for clients building form-level UX, + // even when DefaultErrorShape is set for the other error categories. + responses["422"] = map[string]interface{}{ + "description": "Validation error", + "content": map[string]interface{}{ + "application/json": map[string]interface{}{ + "schema": map[string]interface{}{"$ref": "#/components/schemas/ErrorEnvelope"}, + "example": exampleValidationEnvelope(), + }, + }, + } + // Only POST/PUT/PATCH can produce JSON parse / type-mismatch errors. + // The 400 covers both syntactically-malformed bodies and well-formed + // bodies whose fields have the wrong JSON type — the example uses the + // type-mismatch form because it's the most common in practice and the + // most useful to show clients. + if op.Method == "POST" || op.Method == "PUT" || op.Method == "PATCH" { + responses["400"] = map[string]interface{}{ + "description": "Invalid request body (malformed JSON or wrong field type)", + "content": map[string]interface{}{"application/json": defaultErrContent(errorCategory{ + Code: 400, + Type: errTypeTypeMismatch, + Message: fmt.Sprintf(typeMismatchMsgFmt, "age", "int", "string"), + Details: "int", + }, exampleParseEnvelope)}, + } + } + // When UseNotFoundHandler() has been installed, every operation can + // surface the same shape under 404 — document it. + if o.notFoundInstalled { + responses["404"] = map[string]interface{}{ + "description": "Route not found", + "content": map[string]interface{}{"application/json": defaultErrContent(errorCategory{ + Code: 404, + Type: errTypeNotFound, + Message: "no route matches GET /users/42", + }, exampleNotFoundEnvelope)}, + } + } + // Per-route custom errors declared via OpenAPIOptions.Errors. Each instance + // produces a response entry keyed by its status code, taking precedence over + // any default envelope entry for the same code so the user-provided shape + // is what the spec advertises. + for _, errInst := range op.Options.Errors { + if errInst == nil { + continue + } + code, response := buildErrorResponse(errInst) + responses[statusCodeKey(code)] = response + } + enhancedOptions["responses"] = responses pathItem[strings.ToLower(op.Method)] = enhancedOptions } @@ -947,35 +1061,34 @@ func Method[TInput any, TOutput any, TError any]( ErrorType: reflect.TypeOf(errorZero), }) + inputType := reflect.TypeOf(inputZero) + // Wrapper fiberHandler := func(c fiber.Ctx) error { input, err := parseInput[TInput](app, c, fullPath, &options) if err != nil { - // Check for authentication/authorization errors first - if authErr, ok := errors.AsType[*AuthError](err); ok { - if app.config.AuthErrorHandler != nil { - return app.config.AuthErrorHandler(c, authErr) - } - errType := "authentication_error" - if authErr.StatusCode == 403 { - errType = "authorization_error" - } - return c.Status(authErr.StatusCode).JSON(ErrorResponse{ - Code: authErr.StatusCode, - Details: authErr.Message, - Type: errType, - }) + // Custom handlers, when configured, still take precedence and receive + // the raw error — they may produce any shape they want. + if authErr, ok := errors.AsType[*AuthError](err); ok && app.config.AuthErrorHandler != nil { + return app.config.AuthErrorHandler(c, authErr) } - // Use custom validation error handler if configured if app.config.ValidationErrorHandler != nil { return app.config.ValidationErrorHandler(c, err) } - // Default validation error response - return c.Status(400).JSON(ErrorResponse{ - Code: 400, - Details: err.Error(), - Type: "validation_error", - }) + // If the user opted into a unified shape, emit it for parse / auth / + // generic errors. Validation errors keep the rich ErrorEnvelope shape + // regardless — collapsing a multi-field validation failure into a + // single flat struct would lose the per-field info (loc / constraint + // / field) that clients rely on for form-level UX. + if app.config.DefaultErrorShape != nil && !isValidationError(err) { + cat := categorizeError(err) + return c.Status(cat.Code).JSON(materializeError(app.config.DefaultErrorShape, cat)) + } + // Default response: structured envelope, one entry per failing field, + // status code chosen per error category (422 validation, 400 parse, + // 401/403 auth). + envelope, status := buildEnvelope(c, app.config, inputType, err) + return c.Status(status).JSON(envelope) } output, customErr := handler(c, input) diff --git a/get_oapi_test.go b/get_oapi_test.go index 9a5cfde..efa5b1a 100644 --- a/get_oapi_test.go +++ b/get_oapi_test.go @@ -347,8 +347,8 @@ func TestGetOApi_Validation(t *testing.T) { if err != nil { t.Fatalf("Expected no error, got %v", err) } - if resp2.StatusCode != 400 { - t.Errorf("Expected status 400 for validation error, got %d", resp2.StatusCode) + if resp2.StatusCode != 422 { + t.Errorf("Expected status 422 for validation error, got %d", resp2.StatusCode) } body2, _ := io.ReadAll(resp2.Body) @@ -362,8 +362,8 @@ func TestGetOApi_Validation(t *testing.T) { if err != nil { t.Fatalf("Expected no error, got %v", err) } - if resp3.StatusCode != 400 { - t.Errorf("Expected status 400 for invalid email, got %d", resp3.StatusCode) + if resp3.StatusCode != 422 { + t.Errorf("Expected status 422 for invalid email, got %d", resp3.StatusCode) } // Test 4: Invalid age (too high) @@ -372,8 +372,8 @@ func TestGetOApi_Validation(t *testing.T) { if err != nil { t.Fatalf("Expected no error, got %v", err) } - if resp4.StatusCode != 400 { - t.Errorf("Expected status 400 for invalid age, got %d", resp4.StatusCode) + if resp4.StatusCode != 422 { + t.Errorf("Expected status 422 for invalid age, got %d", resp4.StatusCode) } // Test 5: Required parameter missing (no name in path) @@ -411,8 +411,8 @@ func TestGetOApi_ValidationRequired(t *testing.T) { if err != nil { t.Fatalf("Expected no error, got %v", err) } - if resp.StatusCode != 400 { - t.Errorf("Expected status 400 for missing required field, got %d", resp.StatusCode) + if resp.StatusCode != 422 { + t.Errorf("Expected status 422 for missing required field, got %d", resp.StatusCode) } body, _ := io.ReadAll(resp.Body) diff --git a/header_params_test.go b/header_params_test.go index 55c21c5..878545a 100644 --- a/header_params_test.go +++ b/header_params_test.go @@ -79,7 +79,7 @@ func TestHeaderParameterValidation(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "/test", nil) resp, err := app.Test(req) require.NoError(t, err) - assert.Equal(t, 400, resp.StatusCode) + assert.Equal(t, 422, resp.StatusCode) } func TestHeaderParameterOpenAPIGeneration(t *testing.T) { diff --git a/json_type_error_test.go b/json_type_error_test.go index 84aa642..bdb2945 100644 --- a/json_type_error_test.go +++ b/json_type_error_test.go @@ -269,9 +269,9 @@ func TestJSONTypeMismatchErrors(t *testing.T) { if !strings.Contains(bodyStr, tt.errorContains) { t.Errorf("Expected error to contain '%s', got %s", tt.errorContains, bodyStr) } - // Ensure it returns validation_error type - if !strings.Contains(bodyStr, "validation_error") { - t.Errorf("Expected validation_error type, got %s", bodyStr) + // Ensure it returns type_error in the envelope (JSON type mismatch category) + if !strings.Contains(bodyStr, "type_error") { + t.Errorf("Expected type_error in envelope, got %s", bodyStr) } } }) diff --git a/post_test.go b/post_test.go index 6e2d94e..34b3865 100644 --- a/post_test.go +++ b/post_test.go @@ -164,35 +164,35 @@ func TestPostOApi_Validation(t *testing.T) { { name: "Missing required field", body: `{"email":"alice@example.com","age":28}`, - expectedStatus: 400, + expectedStatus: 422, shouldPass: false, errorContains: "required", }, { name: "Username too short", body: `{"username":"al","email":"alice@example.com","age":28}`, - expectedStatus: 400, + expectedStatus: 422, shouldPass: false, errorContains: "min", }, { name: "Invalid email", body: `{"username":"alice123","email":"not-an-email","age":28}`, - expectedStatus: 400, + expectedStatus: 422, shouldPass: false, errorContains: "email", }, { name: "Age too young", body: `{"username":"alice123","email":"alice@example.com","age":10}`, - expectedStatus: 400, + expectedStatus: 422, shouldPass: false, errorContains: "min", }, { name: "Bio too long", body: fmt.Sprintf(`{"username":"alice123","email":"alice@example.com","age":28,"bio":"%s"}`, strings.Repeat("a", 501)), - expectedStatus: 400, + expectedStatus: 422, shouldPass: false, errorContains: "max", }, @@ -201,7 +201,7 @@ func TestPostOApi_Validation(t *testing.T) { body: `{"username":"alice123","email":"alice@example.com","age":28,}`, expectedStatus: 400, shouldPass: false, - errorContains: "validation_error", + errorContains: "parse_error", }, } @@ -226,8 +226,8 @@ func TestPostOApi_Validation(t *testing.T) { t.Errorf("Expected success message, got %s", bodyStr) } } else { - if !strings.Contains(bodyStr, "validation_error") { - t.Errorf("Expected validation error, got %s", bodyStr) + if !strings.Contains(bodyStr, `"errors":`) { + t.Errorf("Expected error envelope, got %s", bodyStr) } if tt.errorContains != "" && !strings.Contains(bodyStr, tt.errorContains) { t.Errorf("Expected error to contain '%s', got %s", tt.errorContains, bodyStr) @@ -280,7 +280,7 @@ func TestPostOApi_ComplexValidation(t *testing.T) { name: "Invalid category UUID", url: "/categories/invalid-uuid/products", body: `{"name":"Laptop","price":999.99,"quantity":10}`, - expectedStatus: 400, + expectedStatus: 422, shouldPass: false, errorContains: "uuid4", }, @@ -288,7 +288,7 @@ func TestPostOApi_ComplexValidation(t *testing.T) { name: "Negative price", url: "/categories/550e8400-e29b-41d4-a716-446655440000/products", body: `{"name":"Laptop","price":-100,"quantity":10}`, - expectedStatus: 400, + expectedStatus: 422, shouldPass: false, errorContains: "min", }, @@ -296,7 +296,7 @@ func TestPostOApi_ComplexValidation(t *testing.T) { name: "Negative quantity", url: "/categories/550e8400-e29b-41d4-a716-446655440000/products", body: `{"name":"Laptop","price":999.99,"quantity":-1}`, - expectedStatus: 400, + expectedStatus: 422, shouldPass: false, errorContains: "min", }, @@ -304,7 +304,7 @@ func TestPostOApi_ComplexValidation(t *testing.T) { name: "Invalid tag (empty string)", url: "/categories/550e8400-e29b-41d4-a716-446655440000/products", body: `{"name":"Laptop","price":999.99,"quantity":10,"tags":["gaming",""]}`, - expectedStatus: 400, + expectedStatus: 422, shouldPass: false, errorContains: "min", }, @@ -312,7 +312,7 @@ func TestPostOApi_ComplexValidation(t *testing.T) { name: "Tag too long", url: "/categories/550e8400-e29b-41d4-a716-446655440000/products", body: fmt.Sprintf(`{"name":"Laptop","price":999.99,"quantity":10,"tags":["%s"]}`, strings.Repeat("a", 21)), - expectedStatus: 400, + expectedStatus: 422, shouldPass: false, errorContains: "max", }, @@ -339,8 +339,8 @@ func TestPostOApi_ComplexValidation(t *testing.T) { t.Errorf("Expected success message, got %s", bodyStr) } } else { - if !strings.Contains(bodyStr, "validation_error") { - t.Errorf("Expected validation error, got %s", bodyStr) + if !strings.Contains(bodyStr, `"errors":`) { + t.Errorf("Expected error envelope, got %s", bodyStr) } if tt.errorContains != "" && !strings.Contains(bodyStr, tt.errorContains) { t.Errorf("Expected error to contain '%s', got %s", tt.errorContains, bodyStr) diff --git a/put_test.go b/put_test.go index 9f79af4..6a0ee74 100644 --- a/put_test.go +++ b/put_test.go @@ -188,7 +188,7 @@ func TestPutOApi_Validation(t *testing.T) { name: "Username too short", url: "/users/user123", body: `{"username":"al"}`, - expectedStatus: 400, + expectedStatus: 422, shouldPass: false, errorContains: "min", }, @@ -196,7 +196,7 @@ func TestPutOApi_Validation(t *testing.T) { name: "Invalid email format", url: "/users/user123", body: `{"email":"not-an-email"}`, - expectedStatus: 400, + expectedStatus: 422, shouldPass: false, errorContains: "email", }, @@ -204,7 +204,7 @@ func TestPutOApi_Validation(t *testing.T) { name: "Age too young", url: "/users/user123", body: `{"age":10}`, - expectedStatus: 400, + expectedStatus: 422, shouldPass: false, errorContains: "min", }, @@ -212,7 +212,7 @@ func TestPutOApi_Validation(t *testing.T) { name: "Bio too long", url: "/users/user123", body: fmt.Sprintf(`{"bio":"%s"}`, strings.Repeat("a", 501)), - expectedStatus: 400, + expectedStatus: 422, shouldPass: false, errorContains: "max", }, @@ -222,7 +222,7 @@ func TestPutOApi_Validation(t *testing.T) { body: `{"username":"alice123","email":"alice@example.com",}`, expectedStatus: 400, shouldPass: false, - errorContains: "validation_error", + errorContains: "parse_error", }, } @@ -247,8 +247,8 @@ func TestPutOApi_Validation(t *testing.T) { t.Errorf("Expected success message, got %s", bodyStr) } } else { - if !strings.Contains(bodyStr, "validation_error") { - t.Errorf("Expected validation error, got %s", bodyStr) + if !strings.Contains(bodyStr, `"errors":`) { + t.Errorf("Expected error envelope, got %s", bodyStr) } if tt.errorContains != "" && !strings.Contains(bodyStr, tt.errorContains) { t.Errorf("Expected error to contain '%s', got %s", tt.errorContains, bodyStr) diff --git a/spec_runtime_alignment_test.go b/spec_runtime_alignment_test.go new file mode 100644 index 0000000..a7ec29b --- /dev/null +++ b/spec_runtime_alignment_test.go @@ -0,0 +1,162 @@ +package fiberoapi + +import ( + "encoding/json" + "io" + "net/http/httptest" + "strings" + "testing" + + "github.com/gofiber/fiber/v3" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// These tests pin the spec examples and the runtime responses together so +// they cannot drift apart silently. Each one fires a request that triggers the +// matching error category, captures the runtime envelope, and compares the +// stable identifiers (Type, Code, Constraint pattern, Loc shape) against what +// the spec advertises for that response. We deliberately do NOT compare the +// human Msg string verbatim — that's checked separately for the case it +// matters most (type mismatch) below. + +type alignInput struct { + Name string `uri:"name" validate:"required,min=2"` + Age int `json:"age" validate:"omitempty,min=18"` +} + +type alignOutput struct { + Message string `json:"message"` +} + +func registerAlignRoute(t *testing.T) *fiber.App { + t.Helper() + app := fiber.New() + oapi := New(app) + Post(oapi, "/users/:name", func(c fiber.Ctx, _ alignInput) (alignOutput, struct{}) { + return alignOutput{Message: "ok"}, struct{}{} + }, OpenAPIOptions{OperationID: "createUser"}) + oapi.UseNotFoundHandler() + return app +} + +// TestAlign_400_RuntimeMatchesSpecExample reproduces the exact scenario the +// spec example describes (a body with a wrong-typed `age` field) and asserts +// the runtime payload's Type and Msg match exampleParseEnvelope() — i.e. the +// values clients would parse from the OpenAPI document. This is the test that +// would have caught the recent drift between exampleParseEnvelope.Msg and the +// runtime format string. +func TestAlign_400_RuntimeMatchesSpecExample(t *testing.T) { + app := registerAlignRoute(t) + + body := `{"age":"not a number"}` + req := httptest.NewRequest("POST", "/users/alice", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + resp, err := app.Test(req) + require.NoError(t, err) + require.Equal(t, 400, resp.StatusCode) + + raw, _ := io.ReadAll(resp.Body) + var runtime ErrorEnvelope + require.NoError(t, json.Unmarshal(raw, &runtime)) + require.Len(t, runtime.Errors, 1) + runtimeEntry := runtime.Errors[0] + + spec := exampleParseEnvelope() + require.Len(t, spec.Errors, 1) + specEntry := spec.Errors[0] + + assert.Equal(t, specEntry.Type, runtimeEntry.Type, "Type discriminator must match what the spec advertises") + assert.Equal(t, specEntry.Code, runtimeEntry.Code, "Status code in entry must match the spec") + // Msg is built from the same format constant on both sides, so the prose + // must match exactly (modulo the per-request field name / type names). + assert.Equal(t, specEntry.Msg, runtimeEntry.Msg, "Msg format must match between spec example and runtime") + // Loc shape: both start with "body" then carry the JSON field name. + require.GreaterOrEqual(t, len(runtimeEntry.Loc), 2) + assert.Equal(t, "body", runtimeEntry.Loc[0], "first loc segment must be 'body' for body-derived type errors") + assert.Equal(t, "body", specEntry.Loc[0], "spec example must place 'body' first") +} + +// TestAlign_DefaultErrorShape_400_TypeAndDetailsMatchRuntime exercises the +// DefaultErrorShape branch of the 400 emission and confirms the spec example +// uses the same Type ("type_error") and Details ("int") the runtime computes +// via categorizeError for the same scenario. +func TestAlign_DefaultErrorShape_400_TypeAndDetailsMatchRuntime(t *testing.T) { + type Shape struct { + Code int `json:"code"` + Message string `json:"message"` + Type string `json:"type"` + Details string `json:"details,omitempty"` + } + + app := fiber.New() + oapi := New(app, Config{DefaultErrorShape: &Shape{}}) + Post(oapi, "/users/:name", func(c fiber.Ctx, _ alignInput) (alignOutput, struct{}) { + return alignOutput{Message: "ok"}, struct{}{} + }, OpenAPIOptions{OperationID: "createUser"}) + + // 1) What the runtime emits for a *json.UnmarshalTypeError + req := httptest.NewRequest("POST", "/users/alice", strings.NewReader(`{"age":"oops"}`)) + req.Header.Set("Content-Type", "application/json") + resp, err := app.Test(req) + require.NoError(t, err) + require.Equal(t, 400, resp.StatusCode) + + raw, _ := io.ReadAll(resp.Body) + var runtime Shape + require.NoError(t, json.Unmarshal(raw, &runtime)) + + // 2) What the spec advertises for the same 400 response + spec := oapi.GenerateOpenAPISpec() + post := spec["paths"].(map[string]any)["/users/{name}"].(map[string]any)["post"].(map[string]any) + content := post["responses"].(map[string]any)["400"].(map[string]any)["content"].(map[string]any)["application/json"].(map[string]any) + specExampleJSON, _ := json.Marshal(content["example"]) + var advertised Shape + require.NoError(t, json.Unmarshal(specExampleJSON, &advertised)) + + // 3) Pin the stable identifiers: Type and Details. (Msg uses different + // field names — runtime says "age", advertised example also says "age" — + // so it should match exactly, too.) + assert.Equal(t, advertised.Type, runtime.Type, "Type discriminator in spec example must match runtime emission") + assert.Equal(t, advertised.Details, runtime.Details, "Details (Go type name) must match between spec and runtime") + assert.Equal(t, advertised.Code, runtime.Code, "Code must match between spec and runtime") + assert.Equal(t, advertised.Message, runtime.Message, "Message format must match between spec and runtime") + // Sanity: the discriminator is the type-mismatch one, not a generic parse_error. + assert.Equal(t, errTypeTypeMismatch, advertised.Type) +} + +// TestAlign_DefaultErrorShape_404_TypeMatchesRuntime keeps the 404 example +// and runtime in sync the same way. +func TestAlign_DefaultErrorShape_404_TypeMatchesRuntime(t *testing.T) { + type Shape struct { + Code int `json:"code"` + Message string `json:"message"` + Type string `json:"type"` + } + app := fiber.New() + oapi := New(app, Config{DefaultErrorShape: &Shape{}}) + Get(oapi, "/exists", func(c fiber.Ctx, _ struct{}) (alignOutput, struct{}) { + return alignOutput{Message: "ok"}, struct{}{} + }, OpenAPIOptions{OperationID: "exists"}) + oapi.UseNotFoundHandler() + + // Runtime + resp, err := app.Test(httptest.NewRequest("GET", "/missing", nil)) + require.NoError(t, err) + require.Equal(t, 404, resp.StatusCode) + raw, _ := io.ReadAll(resp.Body) + var runtime Shape + require.NoError(t, json.Unmarshal(raw, &runtime)) + + // Spec example + spec := oapi.GenerateOpenAPISpec() + get := spec["paths"].(map[string]any)["/exists"].(map[string]any)["get"].(map[string]any) + content := get["responses"].(map[string]any)["404"].(map[string]any)["content"].(map[string]any)["application/json"].(map[string]any) + specJSON, _ := json.Marshal(content["example"]) + var advertised Shape + require.NoError(t, json.Unmarshal(specJSON, &advertised)) + + assert.Equal(t, errTypeNotFound, advertised.Type) + assert.Equal(t, advertised.Type, runtime.Type, "404 Type must match between spec example and runtime") + assert.Equal(t, advertised.Code, runtime.Code) +} diff --git a/time_type_test.go b/time_type_test.go index 60b2bc1..3d88514 100644 --- a/time_type_test.go +++ b/time_type_test.go @@ -315,7 +315,9 @@ func TestTimeTypeAsTopLevelErrorBody(t *testing.T) { } post := spec["paths"].(map[string]interface{})["/timestamp-error"].(map[string]interface{})["post"].(map[string]interface{}) - errSchema := post["responses"].(map[string]interface{})["400"].(map[string]interface{})["content"].(map[string]interface{})["application/json"].(map[string]interface{})["schema"].(map[string]interface{}) + // Custom TError schemas are now emitted under "4XX" so the dedicated 400/422 entries + // can carry the default ErrorEnvelope schema with their structured examples. + errSchema := post["responses"].(map[string]interface{})["4XX"].(map[string]interface{})["content"].(map[string]interface{})["application/json"].(map[string]interface{})["schema"].(map[string]interface{}) if _, hasRef := errSchema["$ref"]; hasRef { t.Errorf("Expected top-level time.Time error body to be inlined, got $ref: %v", errSchema["$ref"]) diff --git a/types.go b/types.go index e106b88..6f3b582 100644 --- a/types.go +++ b/types.go @@ -14,9 +14,10 @@ type OApiRouter interface { // OApiApp wraps fiber.App with OpenAPI capabilities type OApiApp struct { - f *fiber.App - operations []OpenAPIOperation - config Config + f *fiber.App + operations []OpenAPIOperation + config Config + notFoundInstalled bool // true once UseNotFoundHandler has installed the catch-all } // Implement OApiRouter interface for OApiApp @@ -77,6 +78,21 @@ type Config struct { DefaultSecurity []map[string][]string // Default security requirements ValidationErrorHandler ValidationErrorHandler // Custom handler for validation errors AuthErrorHandler AuthErrorHandler // Custom handler for auth errors (401/403/5xx) + NotFoundHandler fiber.Handler // Custom handler for unmatched routes. Receives a raw fiber.Ctx and owns the response (status + body). To reuse the library's envelope shape, call NotFoundEnvelope(c). Has no effect unless UseNotFoundHandler() is also called. + + // DefaultErrorShape, when non-nil, replaces the built-in ErrorEnvelope + // across the library — both at runtime (parse, auth, 404/405 responses) + // and in the generated OpenAPI spec. Pass an empty/zero instance of any + // struct (or pointer-to-struct); the library fills its Code/StatusCode, + // Message/Description/Msg, Type, and Details fields per error category via + // reflection. + // + // Note: 422 validation responses intentionally keep the rich ErrorEnvelope + // shape (one entry per failing field) even when DefaultErrorShape is set. + // Leaving DefaultErrorShape nil keeps the default ErrorEnvelope everywhere. + DefaultErrorShape any + + IncludeInvalidValueInErrors bool // Include offending value in default error envelope (default: false — may leak secrets) } // OpenAPIOptions represents options for OpenAPI operations @@ -91,6 +107,25 @@ type OpenAPIOptions struct { RequireAllRoles bool `json:"-"` // If true, all RequiredRoles must match (AND semantics) RequiredPermissions []string `json:"-"` // Ex: ["document:read", "workspace:admin"] ResourceType string `json:"-"` // Type de ressource concernée + + // Errors declares the custom error responses this operation can emit. Each + // entry is an instance of any struct (or pointer-to-struct) describing one + // error case. The library inspects each entry to populate the generated + // OpenAPI spec: + // - status code: from a HTTPStatus() int method, or from a "StatusCode" + // or "Code" int field (defaults to 500 if none found) + // - description: from a Description() string method, or from "Message", + // "Description", or "Msg" string fields (falls back to the HTTP reason + // phrase for the status code) + // - schema: generated from the entry's reflect.Type and shared via $ref + // when the type is named, so multiple entries with the same shape do + // not duplicate the schema + // - example: the entry value itself, marshalled as JSON + // + // At runtime the handler returns one of these instances via its TError + // generic parameter (which can be `error`, a concrete `*ErrorResponse`, + // or any other type) and the library emits it with the matching status. + Errors []any `json:"-"` } // OpenAPIOperation represents a registered operation @@ -122,8 +157,41 @@ type OpenAPIRequestBody struct { Content map[string]any `json:"content"` } +// ErrorResponse is the legacy flat error shape. Still emitted by handleCustomError +// when a handler returns a non-zero TError, so existing custom error types keep +// working. New code should prefer ErrorEnvelope, which is what the default +// validation / parse / auth handlers now produce. type ErrorResponse struct { Code int `json:"code"` Details string `json:"details"` Type string `json:"type"` } + +// ErrorEnvelope is the default response shape for validation, parsing and auth +// errors. It carries one entry per failing field plus a context block that lets +// callers correlate the response with their tracing setup. +type ErrorEnvelope struct { + Errors []ValidationErrorEntry `json:"errors"` + ResponseContext ResponseContext `json:"response_context"` +} + +// ValidationErrorEntry describes a single failure (one field, one constraint). +// The same shape is used for body validation errors, JSON type mismatches and +// authentication / authorization failures so clients only have to parse one +// envelope. +type ValidationErrorEntry struct { + Type string `json:"type"` // validation_error | type_error | parse_error | authentication_error | authorization_error | not_found | method_not_allowed + Code int `json:"code"` // HTTP status code carried in the response + Loc []any `json:"loc"` // path to the field, e.g. ["body", "address", "zipcode"] + Field string `json:"field,omitempty"` // leaf field name, redundant with Loc but convenient + Msg string `json:"msg"` // human-readable message + Constraint string `json:"constraint,omitempty"` // failing rule, e.g. "min=11", "required", "email" + Value any `json:"value,omitempty"` // offending value (opt-in via Config.IncludeInvalidValueInErrors) +} + +// ResponseContext carries metadata that helps a client correlate the response +// with their tracing setup. ResponseID mirrors the incoming X-Request-Id header +// when present, otherwise it is left empty. +type ResponseContext struct { + ResponseID string `json:"response_id,omitempty"` +} diff --git a/validation_test.go b/validation_test.go index ad5da94..f0bf256 100644 --- a/validation_test.go +++ b/validation_test.go @@ -58,42 +58,42 @@ func TestAdvancedValidation_UserCreate(t *testing.T) { { name: "Username too short", url: "/users/jo?email=john@example.com&age=25&role=user", - expectedStatus: 400, + expectedStatus: 422, shouldPass: false, errorContains: "min", }, { name: "Username with special chars", url: "/users/john@123?email=john@example.com&age=25&role=user", - expectedStatus: 400, + expectedStatus: 422, shouldPass: false, errorContains: "alphanum", }, { name: "Invalid email", url: "/users/john123?email=not-an-email&age=25&role=user", - expectedStatus: 400, + expectedStatus: 422, shouldPass: false, errorContains: "email", }, { name: "Age too young", url: "/users/john123?email=john@example.com&age=12&role=user", - expectedStatus: 400, + expectedStatus: 422, shouldPass: false, errorContains: "min", }, { name: "Invalid role", url: "/users/john123?email=john@example.com&age=25&role=superadmin", - expectedStatus: 400, + expectedStatus: 422, shouldPass: false, errorContains: "oneof", }, { name: "Invalid website URL", url: "/users/john123?email=john@example.com&age=25&role=user&website=not-a-url", - expectedStatus: 400, + expectedStatus: 422, shouldPass: false, errorContains: "url", }, @@ -119,8 +119,8 @@ func TestAdvancedValidation_UserCreate(t *testing.T) { t.Errorf("Expected success message, got %s", bodyStr) } } else { - if !strings.Contains(bodyStr, "validation_error") { - t.Errorf("Expected validation error, got %s", bodyStr) + if !strings.Contains(bodyStr, `"errors":`) { + t.Errorf("Expected error envelope, got %s", bodyStr) } if tt.errorContains != "" && !strings.Contains(bodyStr, tt.errorContains) { t.Errorf("Expected error to contain '%s', got %s", tt.errorContains, bodyStr) @@ -161,28 +161,28 @@ func TestAdvancedValidation_Product(t *testing.T) { { name: "Invalid UUID for categoryId", url: "/categories/not-a-uuid/products/12345?minPrice=10.50&maxPrice=99.99", - expectedStatus: 400, + expectedStatus: 422, shouldPass: false, errorContains: "uuid4", }, { name: "Non-numeric productId", url: "/categories/550e8400-e29b-41d4-a716-446655440000/products/abc123?minPrice=10.50&maxPrice=99.99", - expectedStatus: 400, + expectedStatus: 422, shouldPass: false, errorContains: "numeric", }, { name: "Negative price", url: "/categories/550e8400-e29b-41d4-a716-446655440000/products/12345?minPrice=-5.00&maxPrice=99.99", - expectedStatus: 400, + expectedStatus: 422, shouldPass: false, errorContains: "min", }, { name: "MaxPrice less than MinPrice", url: "/categories/550e8400-e29b-41d4-a716-446655440000/products/12345?minPrice=50.00&maxPrice=10.00", - expectedStatus: 400, + expectedStatus: 422, shouldPass: false, errorContains: "gtfield", }, @@ -208,8 +208,8 @@ func TestAdvancedValidation_Product(t *testing.T) { t.Errorf("Expected success message, got %s", bodyStr) } } else { - if !strings.Contains(bodyStr, "validation_error") { - t.Errorf("Expected validation error, got %s", bodyStr) + if !strings.Contains(bodyStr, `"errors":`) { + t.Errorf("Expected error envelope, got %s", bodyStr) } if tt.errorContains != "" && !strings.Contains(bodyStr, tt.errorContains) { t.Errorf("Expected error to contain '%s', got %s", tt.errorContains, bodyStr) @@ -242,20 +242,21 @@ func TestValidation_CustomMessages(t *testing.T) { if err != nil { t.Fatalf("Expected no error, got %v", err) } - if resp.StatusCode != 400 { - t.Errorf("Expected status 400, got %d", resp.StatusCode) + if resp.StatusCode != 422 { + t.Errorf("Expected status 422, got %d", resp.StatusCode) } body, _ := io.ReadAll(resp.Body) bodyStr := string(body) - // Check that we receive the validation error + // Check that we receive the validation error envelope if !strings.Contains(bodyStr, "validation_error") { t.Errorf("Expected validation error, got %s", bodyStr) } - // The error message contains validator details - if !strings.Contains(bodyStr, "min") || !strings.Contains(bodyStr, "Name") { + // The error message contains validator details. The field name in the envelope + // uses the JSON/URI tag (lowercase "name"), not the Go field name. + if !strings.Contains(bodyStr, "min") || !strings.Contains(bodyStr, "name") { t.Errorf("Expected detailed validation error with field name and rule, got %s", bodyStr) } }