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
44 changes: 44 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,50 @@ domain shape under the catch-all `4XX` response — as soon as the route declare
explicit `Errors` entries the catch-all is suppressed and only the enumerated
status codes appear in the spec.

#### Hiding a route from the spec entirely

Some routes — internal admin endpoints, debug handlers, in-progress features —
serve traffic but shouldn't appear in the published spec. Set
`OpenAPIOptions.Hidden = true`:

```go
fiberoapi.Get(oapi, "/admin/debug/:id",
controller.Debug,
fiberoapi.OpenAPIOptions{
OperationID: "internalDebug",
Hidden: true, // ← serves traffic, absent from /openapi.json
},
)
```

The path entry is omitted and any type only referenced by hidden routes is
skipped from `components.schemas` (so reading the spec doesn't leak the shape
of internal endpoints). Types used by both hidden and visible routes still
surface — the visible route needs them.

#### Opting out of all default error responses

Some endpoints — health checks, readiness probes, anything with no body or no
error contract — don't need the framework's 400 / 422 / 404 / 4XX in the spec.
Pass an explicit empty `Errors` slice to suppress every default and document
only the 200 success path:

```go
fiberoapi.Get(oapi, "/health",
func(c fiber.Ctx, _ struct{}) (HealthStatus, struct{}) {
return HealthStatus{Status: "ok"}, struct{}{}
},
fiberoapi.OpenAPIOptions{
Summary: "Liveness probe",
Errors: []any{}, // ← explicit "no errors for this route"
},
)
```

The distinction matters: `Errors: nil` (the zero value) keeps the defaults,
`Errors: []any{}` (an explicitly empty slice) suppresses them. A slice that
contains only nil entries is treated as nil for back-compat.

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
Expand Down
71 changes: 71 additions & 0 deletions custom_errors_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -286,6 +286,77 @@ func TestCustomErrors_4XXStillEmittedWhenNoErrorsDeclared(t *testing.T) {
assert.True(t, has4xx, "4XX must still be emitted when no Errors[] is declared (legacy behaviour)")
}

func TestCustomErrors_EmptyErrorsSliceSuppressesAllDefaults(t *testing.T) {
// Passing Errors: []any{} (non-nil, length 0) is the explicit opt-out
// signal. The route should expose ONLY its 200 success response — no
// framework-emitted 400 / 422 / 404 / 4XX. Useful for /health-style
// endpoints that have no error contract.
app := fiber.New()
oapi := New(app)

Post(oapi, "/health", func(c fiber.Ctx, _ struct{}) (customErrOutput, *legacyTError) {
return customErrOutput{Message: "alive"}, nil
}, OpenAPIOptions{
OperationID: "health",
Errors: []any{},
})
oapi.UseNotFoundHandler() // would normally add 404 — must NOT for this route

spec := oapi.GenerateOpenAPISpec()
responses := spec["paths"].(map[string]any)["/health"].(map[string]any)["post"].(map[string]any)["responses"].(map[string]any)

_, has200 := responses["200"]
assert.True(t, has200, "200 success response must remain")
for _, code := range []string{"400", "404", "422", "4XX"} {
_, has := responses[code]
assert.False(t, has, "%s must be suppressed when Errors is an explicit empty slice", code)
}
assert.Len(t, responses, 1, "only 200 should appear, got %d responses", len(responses))
}

func TestCustomErrors_NilErrorsKeepsAllDefaults(t *testing.T) {
// Sanity / regression: the OPT-OUT behaviour is gated on a non-nil empty
// slice. Leaving Errors at its zero value (nil) keeps every framework
// default — this is what every existing user expects.
app := fiber.New()
oapi := New(app)

Post(oapi, "/items/:name", func(c fiber.Ctx, input customErrInput) (customErrOutput, *legacyTError) {
return customErrOutput{Message: "ok"}, nil
}, OpenAPIOptions{OperationID: "createItem"})

spec := oapi.GenerateOpenAPISpec()
responses := spec["paths"].(map[string]any)["/items/{name}"].(map[string]any)["post"].(map[string]any)["responses"].(map[string]any)

for _, code := range []string{"200", "400", "422", "4XX"} {
_, has := responses[code]
assert.True(t, has, "%s must remain when Errors is nil", code)
}
}

func TestCustomErrors_OnlyNilEntriesKeepsDefaults(t *testing.T) {
// Edge case revisited: `Errors: []any{nil, nil}` — a non-empty slice with
// no real entries — is still treated as "nothing declared", same as nil.
// Only an explicitly empty []any{} triggers the suppression.
app := fiber.New()
oapi := New(app)

Post(oapi, "/items/:name", func(c fiber.Ctx, input customErrInput) (customErrOutput, *legacyTError) {
return customErrOutput{Message: "ok"}, nil
}, OpenAPIOptions{
OperationID: "createItem",
Errors: []any{nil, nil},
})

spec := oapi.GenerateOpenAPISpec()
responses := spec["paths"].(map[string]any)["/items/{name}"].(map[string]any)["post"].(map[string]any)["responses"].(map[string]any)

_, has422 := responses["422"]
_, has4XX := responses["4XX"]
assert.True(t, has422, "422 default must remain when Errors contains only nil entries")
assert.True(t, has4XX, "4XX legacy must remain when Errors contains only nil entries")
}

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 —
Expand Down
101 changes: 64 additions & 37 deletions fiberoapi.go
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,12 @@ func (o *OApiApp) GenerateOpenAPISpec() map[string]interface{} {
allTypes := make(map[string]reflect.Type)

for _, op := range o.operations {
// Hidden operations contribute neither to the spec paths nor to the
// schemas index, so a type only ever referenced by a hidden route does
// not leak as a public component.
if op.Options.Hidden {
continue
}
if op.InputType != nil {
collectAllTypes(op.InputType, allTypes)
}
Expand Down Expand Up @@ -272,6 +278,11 @@ func (o *OApiApp) GenerateOpenAPISpec() map[string]interface{} {
}

for _, op := range o.operations {
// Skip hidden operations entirely — the route still serves traffic,
// it just is not advertised in the generated spec.
if op.Options.Hidden {
continue
}
// Convert Fiber path format (:param) to OpenAPI format ({param})
openAPIPath := convertFiberPathToOpenAPI(op.Path)

Expand Down Expand Up @@ -399,6 +410,17 @@ func (o *OApiApp) GenerateOpenAPISpec() map[string]interface{} {
}
}

// A route can opt out of every framework-emitted error response (the 4XX
// catch-all, 400 parse, 422 validation, 404 not-found) by passing an
// explicit empty slice — `Errors: []any{}`. This is the canonical way
// to document an endpoint like /health that has no error contract at all.
//
// We distinguish nil (field not set → default behaviour) from a non-nil
// empty slice (user explicitly says "no errors"). A slice with only nil
// entries still counts as "not declared" — same reasoning as the legacy
// 4XX guard below.
suppressAllDefaultErrors := op.Options.Errors != nil && len(op.Options.Errors) == 0

// Custom TError response — only when the handler returns a non-empty TError.
// Emitted as a 4XX catch-all so legacy users who do not declare per-status
// entries via OpenAPIOptions.Errors still get their domain error documented.
Expand All @@ -409,7 +431,7 @@ func (o *OApiApp) GenerateOpenAPISpec() map[string]interface{} {
// the spec. Count non-nil entries — a slice that only contains nil is
// equivalent to no declaration since the emission loop below would skip
// every entry, leaving the route with zero documented error responses.
if op.ErrorType != nil && !isEmptyStruct(op.ErrorType) && !hasNonNilErrorEntry(op.Options.Errors) {
if op.ErrorType != nil && !isEmptyStruct(op.ErrorType) && !hasNonNilErrorEntry(op.Options.Errors) && !suppressAllDefaultErrors {
errorType := dereferenceType(op.ErrorType)

var schemaRef map[string]interface{}
Expand Down Expand Up @@ -450,44 +472,49 @@ func (o *OApiApp) GenerateOpenAPISpec() map[string]interface{} {
}
}

// 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(),
// All three default envelope responses (422 validation, 400 parse,
// 404 not-found) honour the opt-out so a /health-style endpoint can
// document just its 200.
if !suppressAllDefaultErrors {
// 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)},
// 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
Expand Down
130 changes: 130 additions & 0 deletions hidden_path_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
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"
)

type hiddenInput struct {
ID string `uri:"id" validate:"required"`
}

type hiddenOutput struct {
Message string `json:"message"`
}

// HiddenOnlyType is referenced ONLY by the hidden route in the leak test.
// We deliberately give it a distinctive name so we can grep for it in the
// generated spec.
type HiddenOnlyType struct {
Secret string `json:"secret"`
}

func TestHidden_RouteServesTrafficButAbsentFromSpec(t *testing.T) {
app := fiber.New()
oapi := New(app)

Get(oapi, "/admin/debug/:id", func(c fiber.Ctx, in hiddenInput) (hiddenOutput, struct{}) {
return hiddenOutput{Message: "debug " + in.ID}, struct{}{}
}, OpenAPIOptions{
OperationID: "internalDebug",
Hidden: true,
})
Get(oapi, "/public/:id", func(c fiber.Ctx, in hiddenInput) (hiddenOutput, struct{}) {
return hiddenOutput{Message: "public " + in.ID}, struct{}{}
}, OpenAPIOptions{OperationID: "public"})

// Runtime: both routes serve traffic.
respHidden, err := app.Test(httptest.NewRequest("GET", "/admin/debug/abc", nil))
require.NoError(t, err)
defer respHidden.Body.Close()
require.Equal(t, 200, respHidden.StatusCode)
body, err := io.ReadAll(respHidden.Body)
require.NoError(t, err)
var got hiddenOutput
require.NoError(t, json.Unmarshal(body, &got))
assert.Equal(t, "debug abc", got.Message)

respPublic, err := app.Test(httptest.NewRequest("GET", "/public/xyz", nil))
require.NoError(t, err)
defer respPublic.Body.Close()
require.Equal(t, 200, respPublic.StatusCode)

// Spec: only the public path appears.
spec := oapi.GenerateOpenAPISpec()
paths := spec["paths"].(map[string]any)
_, hasPublic := paths["/public/{id}"]
_, hasHidden := paths["/admin/debug/{id}"]
assert.True(t, hasPublic, "public route must appear in the spec")
assert.False(t, hasHidden, "hidden route must NOT appear in the spec")
}

func TestHidden_TypesOnlyUsedByHiddenRouteDoNotLeak(t *testing.T) {
app := fiber.New()
oapi := New(app)

// A type that only the hidden route references — it must not surface
// under components.schemas. Otherwise an attacker reading the spec could
// guess the route's shape even without the path entry.
Get(oapi, "/admin/secret", func(c fiber.Ctx, _ struct{}) (HiddenOnlyType, struct{}) {
return HiddenOnlyType{Secret: "shh"}, struct{}{}
}, OpenAPIOptions{
OperationID: "adminSecret",
Hidden: true,
})
// Visible route using a distinct type, so we can confirm schema gen still works.
Get(oapi, "/public", func(c fiber.Ctx, _ struct{}) (hiddenOutput, struct{}) {
return hiddenOutput{Message: "ok"}, struct{}{}
}, OpenAPIOptions{OperationID: "publicProbe"})

spec := oapi.GenerateOpenAPISpec()
schemas := spec["components"].(map[string]any)["schemas"].(map[string]any)
_, hasHiddenType := schemas["HiddenOnlyType"]
_, hasVisibleType := schemas["hiddenOutput"]
assert.False(t, hasHiddenType, "type only used by a hidden route must not appear in components.schemas")
assert.True(t, hasVisibleType, "type used by a visible route should still appear")
}

func TestHidden_AbsentOptionKeepsRouteInSpec(t *testing.T) {
// Regression: by default (Hidden zero value = false), every route surfaces.
app := fiber.New()
oapi := New(app)

Get(oapi, "/public/:id", func(c fiber.Ctx, in hiddenInput) (hiddenOutput, struct{}) {
return hiddenOutput{Message: "ok"}, struct{}{}
}, OpenAPIOptions{OperationID: "public"})

spec := oapi.GenerateOpenAPISpec()
paths := spec["paths"].(map[string]any)
_, hasPublic := paths["/public/{id}"]
assert.True(t, hasPublic, "route without Hidden must appear in the spec")
}

func TestHidden_TypeSharedBetweenHiddenAndVisibleStillSurfaces(t *testing.T) {
// Edge case: when a type is used by BOTH a hidden and a visible route,
// it must remain in components.schemas because the visible route still
// needs to $ref it. The Hidden skip only suppresses contributions from
// hidden routes — it does not retroactively remove a type that another
// visible route depends on.
app := fiber.New()
oapi := New(app)

Get(oapi, "/admin/secret/:id", func(c fiber.Ctx, in hiddenInput) (hiddenOutput, struct{}) {
return hiddenOutput{Message: "secret " + in.ID}, struct{}{}
}, OpenAPIOptions{OperationID: "secret", Hidden: true})

Get(oapi, "/public/:id", func(c fiber.Ctx, in hiddenInput) (hiddenOutput, struct{}) {
return hiddenOutput{Message: "public " + in.ID}, struct{}{}
}, OpenAPIOptions{OperationID: "public"})

spec := oapi.GenerateOpenAPISpec()
schemas := spec["components"].(map[string]any)["schemas"].(map[string]any)
_, hasShared := schemas["hiddenInput"]
assert.True(t, hasShared, "type shared with a visible route must remain in components.schemas")
}
Loading
Loading