diff --git a/apps/landing/src/content/docs/docs/core-concepts/options.mdx b/apps/landing/src/content/docs/docs/core-concepts/options.mdx index 3cb9b96..371eb8f 100644 --- a/apps/landing/src/content/docs/docs/core-concepts/options.mdx +++ b/apps/landing/src/content/docs/docs/core-concepts/options.mdx @@ -13,7 +13,7 @@ ShiftAPI uses a layered option system. Options configure the API, groups, and in |------|---------------|----------| | `Option` | API, Group, and Route | `WithError`, `WithMiddleware`, `WithResponseHeader` | | `APIOption` | API only (`New`) | `WithInfo`, `WithBadRequestError`, `WithInternalServerError` | -| `RouteOption` | Route only (`Get`, `Post`, etc.) | `WithStatus`, `WithRouteInfo` | +| `RouteOption` | Route only (`Get`, `Post`, etc.) | `WithStatus`, `WithRouteInfo`, `WithHidden` | `Option` implements all three level interfaces, so `WithError` and `WithMiddleware` work everywhere. Level-specific options like `WithStatus` are restricted to their level at compile time. @@ -84,3 +84,13 @@ shiftapi.Get(admin, "/stats", getStats) // GET /api/v1/admin/stats ``` Options merge across all three levels: `New()` (API-wide) → `Group()` (group-level) → route registration (route-level). + +## Hiding routes from the spec + +Use `WithHidden()` to exclude a route from the generated OpenAPI schema (and therefore from any generated client). The route is still registered and serves requests normally — it just won't appear in the spec or docs. + +```go +shiftapi.Get(api, "/internal/health", healthCheck, shiftapi.WithHidden()) +``` + +This is useful for internal endpoints, health checks, or debug routes that shouldn't be part of the public API surface. diff --git a/doc.go b/doc.go index 3e44be6..9e93c49 100644 --- a/doc.go +++ b/doc.go @@ -211,8 +211,13 @@ // [WithError], [WithMiddleware], and [WithResponseHeader] all return [Option]. // // Some options are level-specific: [WithInfo] and [WithBadRequestError] only work -// with [New] ([APIOption]), while [WithStatus] and [WithRouteInfo] only work with -// route registration functions ([RouteOption]). +// with [New] ([APIOption]), while [WithStatus], [WithRouteInfo], and [WithHidden] +// only work with route registration functions ([RouteOption]). +// +// Use [WithHidden] to exclude a route from the generated OpenAPI schema (and any +// generated client) while still serving it normally: +// +// shiftapi.Get(api, "/internal/health", healthCheck, shiftapi.WithHidden()) // // Use [ComposeOptions] to bundle multiple [Option] values into a reusable option: // diff --git a/handlerFuncs.go b/handlerFuncs.go index dfb0855..547cbcd 100644 --- a/handlerFuncs.go +++ b/handlerFuncs.go @@ -99,8 +99,10 @@ func registerRoute[In, Resp any]( pathType = rawInType } - if err := api.updateSchema(method, fullPath, pathType, queryType, headerType, bodyType, outType, hasRespHeader, noBody, hasForm, rawInType, cfg.info, cfg.status, allErrors, allStaticHeaders); err != nil { - panic(fmt.Sprintf("shiftapi: schema generation failed for %s %s: %v", method, fullPath, err)) + if !cfg.hidden { + if err := api.updateSchema(method, fullPath, pathType, queryType, headerType, bodyType, outType, hasRespHeader, noBody, hasForm, rawInType, cfg.info, cfg.status, allErrors, allStaticHeaders); err != nil { + panic(fmt.Sprintf("shiftapi: schema generation failed for %s %s: %v", method, fullPath, err)) + } } errLookup := buildErrorLookup(allErrors) diff --git a/handlerOptions.go b/handlerOptions.go index 57d5882..a782267 100644 --- a/handlerOptions.go +++ b/handlerOptions.go @@ -10,6 +10,7 @@ type routeConfig struct { errors []errorEntry middleware []func(http.Handler) http.Handler staticRespHeaders []staticResponseHeader + hidden bool } func (c *routeConfig) addError(e errorEntry) { @@ -52,6 +53,17 @@ func WithRouteInfo(info RouteInfo) routeOptionFunc { } } +// WithHidden excludes the route from the generated OpenAPI schema (and +// therefore from any generated client). The route is still registered and +// serves requests normally. +// +// shiftapi.Get(api, "/internal/health", healthCheck, shiftapi.WithHidden()) +func WithHidden() routeOptionFunc { + return func(cfg *routeConfig) { + cfg.hidden = true + } +} + // WithStatus sets the success HTTP status code for the route (default: 200). // Use this for routes that should return 201 Created, 204 No Content, etc. func WithStatus(status int) routeOptionFunc { diff --git a/shiftapi_test.go b/shiftapi_test.go index ae30029..94db866 100644 --- a/shiftapi_test.go +++ b/shiftapi_test.go @@ -560,6 +560,37 @@ func TestSpecHasPath(t *testing.T) { } } +func TestWithHiddenExcludesRouteFromSpec(t *testing.T) { + api := newTestAPI(t) + shiftapi.Get(api, "/health", func(r *http.Request, _ struct{}) (*Status, error) { + return &Status{OK: true}, nil + }, shiftapi.WithHidden()) + + spec := api.Spec() + if spec.Paths.Find("/health") != nil { + t.Fatal("hidden route should not appear in spec paths") + } +} + +func TestWithHiddenRouteStillServes(t *testing.T) { + api := newTestAPI(t) + shiftapi.Get(api, "/health", func(r *http.Request, _ struct{}) (*Status, error) { + return &Status{OK: true}, nil + }, shiftapi.WithHidden()) + + srv := httptest.NewServer(api) + defer srv.Close() + + resp, err := http.Get(srv.URL + "/health") + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected 200, got %d", resp.StatusCode) + } +} + func TestSpecGetHasNoRequestBody(t *testing.T) { api := newTestAPI(t) shiftapi.Get(api, "/health", func(r *http.Request, _ struct{}) (*Status, error) {