Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
232 changes: 231 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
34 changes: 34 additions & 0 deletions _examples/simple_error/go.mod
Original file line number Diff line number Diff line change
@@ -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
)
62 changes: 62 additions & 0 deletions _examples/simple_error/go.sum
Original file line number Diff line number Diff line change
@@ -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=
Loading
Loading