diff --git a/README.md b/README.md index 40504c7..21b6793 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/custom_errors_test.go b/custom_errors_test.go index bf05c93..a9d9ecf 100644 --- a/custom_errors_test.go +++ b/custom_errors_test.go @@ -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 — diff --git a/fiberoapi.go b/fiberoapi.go index 6553003..bef0af3 100644 --- a/fiberoapi.go +++ b/fiberoapi.go @@ -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) } @@ -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) @@ -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. @@ -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{} @@ -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 diff --git a/hidden_path_test.go b/hidden_path_test.go new file mode 100644 index 0000000..7892d69 --- /dev/null +++ b/hidden_path_test.go @@ -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") +} diff --git a/types.go b/types.go index 6f3b582..dc2af72 100644 --- a/types.go +++ b/types.go @@ -108,6 +108,14 @@ type OpenAPIOptions struct { RequiredPermissions []string `json:"-"` // Ex: ["document:read", "workspace:admin"] ResourceType string `json:"-"` // Type de ressource concernée + // Hidden, when true, excludes this operation from the generated OpenAPI + // spec. The route is still registered on the underlying fiber.App and + // serves traffic normally — it just does not appear under paths in the + // generated JSON/YAML and any type only used by hidden operations does + // not leak into components.schemas. Useful for internal admin endpoints, + // in-progress routes, or anything you want to ship without publishing. + Hidden bool `json:"-"` + // 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