diff --git a/AGENTS.md b/AGENTS.md index e37dd1e..d1ddc53 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,20 +8,20 @@ These instructions describe how to understand and work with this repository. - Route registration is dynamic; there is no CLI. ## Key Files -- `virtuous/rpc/router.go`: RPC route registration, guards, metadata inference. -- `virtuous/rpc/handler.go`: RPC handler adapter and signature validation. -- `virtuous/rpc/openapi.go`: RPC OpenAPI 3.0.3 document generation. -- `virtuous/rpc/client_spec.go`: RPC client spec builder shared by emitters. -- `virtuous/rpc/client_js_gen.go`: RPC JS client template and helpers. -- `virtuous/rpc/client_ts.go`: RPC TS client template and helpers. -- `virtuous/httpapi/router.go`: HTTP route registration, guards, metadata inference. -- `virtuous/httpapi/typed_handler.go`: adapter to attach request/response types and metadata. -- `virtuous/schema/registry.go`: reflection-based type registry and override logic. -- `virtuous/schema/openapi_schema.go`: OpenAPI schema generation for types. -- `virtuous/httpapi/openapi.go`: OpenAPI 3.0.3 document generation. -- `virtuous/httpapi/client_spec.go`: client spec builder shared by emitters. -- `virtuous/httpapi/client_js_gen.go`: JS client template and helpers. -- `virtuous/httpapi/client_ts.go`: TS client template and helpers. +- `rpc/router.go`: RPC route registration, guards, metadata inference. +- `rpc/handler.go`: RPC handler adapter and signature validation. +- `rpc/openapi.go`: RPC OpenAPI 3.0.3 document generation. +- `rpc/client_spec.go`: RPC client spec builder shared by emitters. +- `rpc/client_js_gen.go`: RPC JS client template and helpers. +- `rpc/client_ts.go`: RPC TS client template and helpers. +- `httpapi/router.go`: HTTP route registration, guards, metadata inference. +- `httpapi/typed_handler.go`: adapter to attach request/response types and metadata. +- `schema/registry.go`: reflection-based type registry and override logic. +- `schema/openapi_schema.go`: OpenAPI schema generation for types. +- `httpapi/openapi.go`: OpenAPI 3.0.3 document generation. +- `httpapi/client_spec.go`: client spec builder shared by emitters. +- `httpapi/client_js_gen.go`: JS client template and helpers. +- `httpapi/client_ts.go`: TS client template and helpers. - `example/`: reference app and generated outputs. ## Architecture Notes @@ -38,7 +38,9 @@ These instructions describe how to understand and work with this repository. - Use `rpc.NewRouter(rpc.WithPrefix("/rpc"))` and `HandleRPC` for RPC handlers. - Prefer method-prefixed patterns (`GET /path`) to ensure docs/clients are emitted. - Use `Wrap` to attach request/response types to handlers. -- For no-body responses, use the sentinel types in `virtuous/types.go`. +- For optional `httpapi` request bodies, wrap request type with `httpapi.Optional[Req]()`. +- For multi-status or custom-media `httpapi` routes, declare `HandlerMeta.Responses` with `httpapi.ResponseSpec`. +- For no-body `httpapi` responses, use `httpapi.NoResponse200`, `httpapi.NoResponse204`, or `httpapi.NoResponse500`. - Add `doc:"..."` tags to improve schema and client docs. - Update `CHANGELOG.md` with a new version entry whenever adding functionality, fixing bugs, or changing behavior. - For Python, do not use `from __future__ import annotations`. @@ -58,5 +60,5 @@ These instructions describe how to understand and work with this repository. - Custom guards for auth schemes and middleware. ## Reference Specs -- See `SPEC-RPC.md` and `SPEC-RPC-SIMPLE.md` for RPC details and examples. -- See `SPEC.md` for the legacy `httpapi` contract. +- See `docs/specs/overview.md` for current spec locations. +- Historical design specs are in `_design/SPEC-RPC.md`, `_design/SPEC-RPC-SIMPLE.md`, and `_design/SPEC.md`. diff --git a/CHANGELOG.md b/CHANGELOG.md index 29c7a43..e01a0f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## 0.0.25 + +- Add typed `httpapi` response media support for `string` (`text/plain`) and `[]byte` (`application/octet-stream`) in OpenAPI and generated JS/TS/PY clients. +- Add `httpapi.Optional[Req]()` request marker to model optional JSON request bodies in OpenAPI and generated clients. +- Add `httpapi.HandlerMeta.Responses` / `httpapi.ResponseSpec` for explicit multi-status and custom-media response contracts. +- Add migration and guard documentation examples for composite OR auth semantics and typed/non-typed non-JSON migration patterns. + ## 0.0.24 - Fix README license badge link to target the canonical GitHub `LICENSE` file URL. diff --git a/README.md b/README.md index f18c80c..4772a8a 100644 --- a/README.md +++ b/README.md @@ -13,11 +13,12 @@ Virtuous is an **agent-first, typed RPC API framework for Go** with **self-gener ## Table of contents -- [Why RPC (default)](#why-rpc-default) -- [RPC (recommended)](#rpc-recommended) -- [httpapi (compatibility)](#httpapi-compatibility) +- [RPC](#rpc) +- [HTTP API (httpapi)](#http-api-httpapi) - [Combined (migration demo)](#combined-migration-demo) +- [Why RPC?](#why-rpc) - [Docs](#docs) +- [Requirements](#requirements) ## Why RPC (default) @@ -27,12 +28,12 @@ What this means in practice: - Inputs/outputs are Go structs; they *are* the contract and generate OpenAPI + SDKs automatically. - Routes derive from package + function names, so naming stays consistent without manual path design. -- A minimal status model (200/401/422/500) keeps error handling explicit and uniform. +- A minimal handler status model (200/422/500) keeps error handling explicit and uniform. - Docs and clients are emitted from the running server, so they cannot drift from the code. `httpapi` stays in the toolbox for teams migrating existing handlers or preserving exact legacy responses. -## RPC (recommended) +## RPC RPC uses plain Go functions with typed requests and responses. Routes, schemas, and clients are inferred from package and function names. @@ -105,6 +106,130 @@ Run it: go run . ``` +### Advanced patterns + +#### 1) One guard for a collection of routes (group-level) + +Use a dedicated router for the guarded group, then mount both routers on one mux. + +```go +admin := rpc.NewRouter( + rpc.WithPrefix("/rpc/admin"), + rpc.WithGuards(sessionGuard{}), // applies to every admin route +) +admin.HandleRPC(adminusers.GetMany) +admin.HandleRPC(adminusers.Disable) + +public := rpc.NewRouter(rpc.WithPrefix("/rpc/public")) +public.HandleRPC(publichealth.Check) + +mux := http.NewServeMux() +mux.Handle("/rpc/admin/", admin) +mux.Handle("/rpc/public/", public) +``` + +If you wrap a mux subtree with middleware directly, that works for transport security, but `rpc.WithGuards(...)` is the docs/client-aware path because it emits OpenAPI security metadata. + +#### 2) Multiple documentation sets (Public Service, Secret Service) + +Today, one router emits one OpenAPI document for all routes on that router. +For separate docs, split routes across routers. + +```go +publicAPI := rpc.NewRouter(rpc.WithPrefix("/rpc/public")) +publicAPI.HandleRPC(publicsvc.GetCatalog) +publicAPI.ServeAllDocs( + rpc.WithDocsOptions( + rpc.WithDocsPath("/rpc/public/docs"), + rpc.WithOpenAPIPath("/rpc/public/openapi.json"), + ), + rpc.WithClientJSPath("/rpc/public/client.gen.js"), + rpc.WithClientTSPath("/rpc/public/client.gen.ts"), + rpc.WithClientPYPath("/rpc/public/client.gen.py"), +) + +secretAPI := rpc.NewRouter( + rpc.WithPrefix("/rpc/secret"), + rpc.WithGuards(internalTokenGuard{}), +) +secretAPI.HandleRPC(secretsvc.RotateKeys) +secretAPI.ServeAllDocs( + rpc.WithDocsOptions( + rpc.WithDocsPath("/rpc/secret/docs"), + rpc.WithOpenAPIPath("/rpc/secret/openapi.json"), + ), + rpc.WithClientJSPath("/rpc/secret/client.gen.js"), + rpc.WithClientTSPath("/rpc/secret/client.gen.ts"), + rpc.WithClientPYPath("/rpc/secret/client.gen.py"), +) + +mux := http.NewServeMux() +mux.Handle("/rpc/public/", publicAPI) +mux.Handle("/rpc/secret/", secretAPI) +``` + +#### 3) Basic auth on the docs route + +Protect docs/OpenAPI paths at the top-level mux. + +```go +func docsBasicAuth(user, pass string, next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + u, p, ok := r.BasicAuth() + if !ok || u != user || p != pass { + // Sends a Basic Auth challenge so browsers show a username/password prompt. + w.Header().Set("WWW-Authenticate", `Basic realm="Virtuous Docs"`) + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + next.ServeHTTP(w, r) + }) +} + +router := rpc.NewRouter(rpc.WithPrefix("/rpc")) +router.HandleRPC(states.GetMany) +router.ServeAllDocs() + +mux := http.NewServeMux() +mux.Handle("/rpc/", router) // API routes +mux.Handle("/rpc/openapi.json", docsBasicAuth("docs", "secret", router)) +mux.Handle("/rpc/docs", docsBasicAuth("docs", "secret", router)) +mux.Handle("/rpc/docs/", docsBasicAuth("docs", "secret", router)) +``` + +Note: there is no first-class docs auth option yet; mux-level middleware is the current path. + +#### 4) OR auth semantics (accept either of two schemes) + +When a route should accept either credential type, express that logic in one composite guard and attach it once. + +```go +type bearerOrAPIKeyGuard struct { + bearer bearerGuard + apiKey apiKeyGuard +} + +func (g bearerOrAPIKeyGuard) Spec() guard.Spec { + return guard.Spec{ + Name: "BearerOrApiKey", + In: "header", + Param: "Authorization", + } +} + +func (g bearerOrAPIKeyGuard) Middleware() func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if g.bearer.authenticate(r) || g.apiKey.authenticate(r) { + next.ServeHTTP(w, r) + return + } + http.Error(w, "unauthorized", http.StatusUnauthorized) + }) + } +} +``` + ### Handler signature RPC handlers must follow one of these forms: @@ -121,26 +246,140 @@ RPC handlers return an HTTP status code directly. Supported statuses: - `200` — success -- `401` — unauthorized (guard) - `422` — invalid input - `500` — server error +Guarded routes may also return `401` when middleware rejects the request. + Docs and SDKs are served at: - `/rpc/docs` - `/rpc/client.gen.*` - Responses should include a canonical `error` field (string or struct) when errors occur. -## httpapi (compatibility) +## HTTP API (httpapi) -`httpapi` wraps classic `net/http` handlers and preserves existing request/response shapes. It also implements automatic OpenAPI 3.0 specs for all handlers wrapped in this way. +`httpapi` wraps classic `net/http` handlers and preserves existing request/response shapes. It also emits OpenAPI 3.0 specs for typed handlers. Use this when: - Migrating an existing API to Virtuous -- Developing rich http APIs. +- Developing rich HTTP APIs - Maintaining compatibility with established OpenAPI contracts +### Quick start + +Method-prefixed patterns (`GET /path`) are required for docs and client generation. +Typed `httpapi` routes are JSON-focused for generated docs/clients. `string` and `[]byte` responses are supported directly, and `HandlerMeta.Responses` can declare custom media types and multiple statuses. + +```go +router := httpapi.NewRouter() +router.HandleTyped( + "GET /api/v1/lookup/states/{code}", + httpapi.WrapFunc(StateByCode, nil, StateResponse{}, httpapi.HandlerMeta{ + Service: "States", + Method: "GetByCode", + }), +) +router.ServeAllDocs() +``` + +### Advanced patterns + +#### 1) One guard for a collection of routes (group-level intent) + +`httpapi` does not have a router-wide `WithGuards(...)` option today. +Use a shared guard slice and pass it to each route in the collection. + ```go +adminGuards := []httpapi.Guard{sessionGuard{}} + +router := httpapi.NewRouter() +router.HandleTyped( + "GET /api/admin/users", + httpapi.WrapFunc(AdminUsersGetMany, nil, UsersResponse{}, httpapi.HandlerMeta{ + Service: "AdminUsers", + Method: "GetMany", + }), + adminGuards..., +) +router.HandleTyped( + "POST /api/admin/users/disable", + httpapi.WrapFunc(AdminUsersDisable, nil, DisableUserResponse{}, httpapi.HandlerMeta{ + Service: "AdminUsers", + Method: "Disable", + }), + adminGuards..., +) +``` + +If you apply middleware only at mux level, requests are still protected, but auth metadata is not emitted in OpenAPI unless guards are attached to typed routes. + +#### 2) Multiple documentation sets (Public Service, Secret Service) + +Use separate routers, each with its own docs/OpenAPI/client paths. + +```go +publicAPI := httpapi.NewRouter() +publicAPI.HandleTyped( + "GET /public/health", + httpapi.WrapFunc(PublicHealth, nil, HealthResponse{}, httpapi.HandlerMeta{ + Service: "PublicService", + Method: "Health", + }), +) +publicAPI.ServeAllDocs( + httpapi.WithDocsOptions( + httpapi.WithDocsPath("/public/docs"), + httpapi.WithOpenAPIPath("/public/openapi.json"), + ), + httpapi.WithClientJSPath("/public/client.gen.js"), + httpapi.WithClientTSPath("/public/client.gen.ts"), + httpapi.WithClientPYPath("/public/client.gen.py"), +) + +secretGuards := []httpapi.Guard{internalTokenGuard{}} +secretAPI := httpapi.NewRouter() +secretAPI.HandleTyped( + "POST /secret/rotate-keys", + httpapi.WrapFunc(RotateKeys, nil, RotateKeysResponse{}, httpapi.HandlerMeta{ + Service: "SecretService", + Method: "RotateKeys", + }), + secretGuards..., +) +secretAPI.ServeAllDocs( + httpapi.WithDocsOptions( + httpapi.WithDocsPath("/secret/docs"), + httpapi.WithOpenAPIPath("/secret/openapi.json"), + ), + httpapi.WithClientJSPath("/secret/client.gen.js"), + httpapi.WithClientTSPath("/secret/client.gen.ts"), + httpapi.WithClientPYPath("/secret/client.gen.py"), +) + +mux := http.NewServeMux() +mux.Handle("/public/", publicAPI) +mux.Handle("/secret/", secretAPI) +``` + +#### 3) Basic auth on the docs route + +Protect docs/OpenAPI paths at the top-level mux. + +```go +func docsBasicAuth(user, pass string, next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + u, p, ok := r.BasicAuth() + if !ok || u != user || p != pass { + // Sends a Basic Auth challenge so browsers show a username/password prompt. + w.Header().Set("WWW-Authenticate", `Basic realm="Virtuous Docs"`) + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + next.ServeHTTP(w, r) + }) +} + router := httpapi.NewRouter() router.HandleTyped( "GET /api/v1/lookup/states/{code}", @@ -150,6 +389,80 @@ router.HandleTyped( }), ) router.ServeAllDocs() + +mux := http.NewServeMux() +mux.Handle("/", router) // API routes +mux.Handle("/openapi.json", docsBasicAuth("docs", "secret", router)) +mux.Handle("/docs", docsBasicAuth("docs", "secret", router)) +mux.Handle("/docs/", docsBasicAuth("docs", "secret", router)) +``` + +Note: there is no first-class docs auth option yet; mux-level middleware is the current path. + +#### 4) OR auth semantics (accept either of two schemes) + +When a route should accept either credential type, express that logic in one composite guard and attach it once. + +```go +type bearerOrAPIKeyGuard struct { + bearer bearerGuard + apiKey apiKeyGuard +} + +func (g bearerOrAPIKeyGuard) Spec() guard.Spec { + return guard.Spec{ + Name: "BearerOrApiKey", + In: "header", + Param: "Authorization", + } +} + +func (g bearerOrAPIKeyGuard) Middleware() func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if g.bearer.authenticate(r) || g.apiKey.authenticate(r) { + next.ServeHTTP(w, r) + return + } + http.Error(w, "unauthorized", http.StatusUnauthorized) + }) + } +} +``` + +#### 5) Optional request body contract + +Request bodies are required by default when you pass a typed request. +Use `httpapi.Optional` when a route should accept either no body or a JSON body. + +```go +router := httpapi.NewRouter() +router.HandleTyped( + "POST /api/v1/search", + httpapi.WrapFunc(Search, httpapi.Optional[SearchRequest](), SearchResponse{}, httpapi.HandlerMeta{ + Service: "Search", + Method: "Run", + }), +) +``` + +#### 6) Explicit response specs + +Use `HandlerMeta.Responses` when a route needs multiple statuses or a custom response media type. + +```go +router := httpapi.NewRouter() +router.HandleTyped( + "GET /api/v1/assets/{id}/preview.png", + httpapi.WrapFunc(ServePreviewPNG, nil, nil, httpapi.HandlerMeta{ + Service: "Assets", + Method: "GetPreview", + Responses: []httpapi.ResponseSpec{ + {Status: 200, Body: []byte{}, MediaType: "image/png"}, + {Status: 404, Body: ErrorResponse{}}, + }, + }), +) ``` ## Combined (migration demo) diff --git a/docs/agent_quickstart.md b/docs/agent_quickstart.md index 1ba5279..0bbb6cd 100644 --- a/docs/agent_quickstart.md +++ b/docs/agent_quickstart.md @@ -23,6 +23,7 @@ _ = server.ListenAndServe() - RPC handlers are plain functions: `func(ctx, req) (resp, status)`. - Status must be 200, 422, or 500. +- Guarded routes may also surface 401 when middleware rejects requests. - `HandleRPC` infers the path from package + function name. - Use a canonical `error` field in response payloads (string or struct) when errors occur. @@ -61,6 +62,12 @@ func (bearerGuard) Middleware() func(http.Handler) http.Handler { - Method-prefixed patterns like `GET /path` are required for docs/clients. - Use `Wrap` or `WrapFunc` so request/response types attach to handlers. - `HandlerMeta.Service` and `HandlerMeta.Method` control client method names. +- Typed `httpapi` docs/clients are JSON-focused. +- Typed `string`/`[]byte` responses map to `text/plain`/`application/octet-stream`. +- Use `httpapi.HandlerMeta.Responses` for multi-status routes or custom response media types. +- Use `httpapi.Optional[Req]()` for optional JSON request bodies on typed routes. +- Keep other non-JSON routes untyped (`Handle`) during migration. +- Router registration is source of truth when legacy annotations drift. ## Query params (legacy) diff --git a/docs/agents/overview.md b/docs/agents/overview.md index 8d6f774..cf524b4 100644 --- a/docs/agents/overview.md +++ b/docs/agents/overview.md @@ -15,7 +15,7 @@ Virtuous is designed to be deterministic for agents. Keep project layout and han ```text You are implementing a Virtuous RPC API. -- Target Virtuous version: read `VERSION` in the repo and pin it in the output (current: `0.0.21`). +- Target Virtuous version: read `VERSION` in the repo and pin it in the output. - Create router.go with rpc.NewRouter(rpc.WithPrefix("/rpc")). - Put handlers in package folders (states, users, admin). - Use func(ctx, req) (Resp, int). @@ -27,7 +27,7 @@ You are implementing a Virtuous RPC API. ```text Migrate Swaggo routes to Virtuous using the canonical guide at docs/tutorials/migrate-swaggo.md. -- Target Virtuous version: read `VERSION` in the repo and pin it in the output (current: `0.0.21`). +- Target Virtuous version: read `VERSION` in the repo and pin it in the output. - For Swaggo migrations, use httpapi first. - Use rpc only for explicit phase-2 moves. - Move field docs to doc struct tags. @@ -47,3 +47,4 @@ That tutorial is the canonical transformation guide, including mapping rules, mi - Use `doc:"..."` tags on struct fields to populate OpenAPI and client docs. - Keep section names consistent across documents for reliable agent parsing. +- During migrations, treat runtime router registration as source of truth over stale annotations. diff --git a/docs/analysis/swaggo-migration-gap-audit.md b/docs/analysis/swaggo-migration-gap-audit.md new file mode 100644 index 0000000..fa3a9cb --- /dev/null +++ b/docs/analysis/swaggo-migration-gap-audit.md @@ -0,0 +1,74 @@ +# Swaggo Migration Feedback Audit + +Date: 2026-02-27 + +## Scope + +This document captures the classification of user feedback into: + +- Knowledge gaps: current behavior exists but is not clearly documented. +- Capability gaps: behavior is not currently supported (or is mismatched) and needs product/design work. + +This file is intended as a working draft for follow-up prompts. + +## Gap Classification (Q1-Q11) + +| Q | Topic | Classification | Notes | +| --- | --- | --- | --- | +| 1 | Non-200 status preservation in `httpapi` migration | Resolved + Knowledge | Supported via `httpapi.HandlerMeta.Responses` / `httpapi.ResponseSpec`; docs need to point migration users to the explicit response metadata path. | +| 2 | Non-JSON routes (`image/png`, `text/html`, files) | Resolved + Knowledge | Typed handlers support `string`, `[]byte`, and custom text/byte media types via `httpapi.HandlerMeta.Responses`; runtime headers remain handler-owned. | +| 3 | Optional request body (`@Param body ... false`) | Resolved + Knowledge | Supported via `httpapi.Optional[Req]()`; docs/examples need to point migration users to this marker. | +| 4 | Mixed body+query with tag conflicts | Knowledge + Constraint | Supported when query/body use different fields. Same field cannot have both `query` and `json` tags by design. Needs explicit modeling guidance in docs. | +| 5 | Two security schemes (OR/AND + generated client semantics) | Capability + Knowledge | Runtime middleware composes all guards; OpenAPI encodes multiple security entries; generated clients currently use only first guard auth parameter. Needs behavior clarification and eventual feature work. | +| 6 | Path/query type fidelity (`int/bool` vs string) | Capability + Knowledge | OpenAPI and generated clients model path/query values as strings during migration path. Query string behavior is partially documented; path typing behavior is not explicit. | +| 7 | Handler factory methods (`func(...) http.HandlerFunc`) | Knowledge | Already supported through `WrapFunc` / `Wrap` because factory output is still a standard handler function. Needs explicit migration example. | +| 8 | Annotation vs router drift: source of truth | Knowledge | Runtime router registration is source of truth. Migration docs should explicitly define conflict policy. | +| 9 | Trailing slash normalization/preservation | Knowledge | Current docs do not define slash policy clearly for migration. Needs explicit guidance and examples. | +| 10 | Stale pinned version (`0.0.21` vs `VERSION`) | Knowledge | Prompt templates include stale literals and should rely on `VERSION` only. | +| 11 | RPC status model inconsistency (401 vs 200/422/500) | Knowledge | Must distinguish handler-return statuses (200/422/500) from guard-driven OpenAPI 401 response documentation. | + +## Knowledge Gap Backlog (Documentation and Guidance Only) + +- [x] Migration guide clarity + Files updated: `docs/tutorials/migrate-swaggo.md` + Notes: Added capability matrix, non-JSON lane, optional-body callout, mixed query/body guidance, source-of-truth policy, and trailing-slash guidance. +- [x] Security semantics clarification + Files updated: `docs/tutorials/migrate-swaggo.md`, `docs/http-legacy/overview.md`, `docs/rpc/guards.md` + Notes: Documented runtime middleware composition, OpenAPI security representation, and current generated-client auth limitation. +- [x] Status model unification + Files updated: `README.md`, `docs/overview.md`, `docs/rpc/handlers.md`, `docs/internals/openapi.md`, `docs/agent_quickstart.md` + Notes: Unified wording to handler-return statuses `200/422/500`, with guard-driven `401` clarified separately. +- [x] Version-template hygiene + Files updated: `docs/overview.md`, `docs/tutorials/migrate-swaggo.md`, `docs/agents/overview.md` + Notes: Removed stale pinned literals and standardized on "read `VERSION`". +- [x] Examples for advanced migrations + Files updated: `docs/http-legacy/typed-handlers.md`, `docs/http-legacy/query-params.md`, `docs/tutorials/migrate-swaggo.md`, `docs/http-legacy/overview.md` + Notes: Added factory handler, non-JSON side-by-side, and mixed query/body examples. +- [x] Documentation reference hygiene + Files updated: `docs/specs/overview.md`, `docs/reference/public-api.md`, `AGENTS.md` + Notes: Corrected stale spec-path references and aligned agent guidance references. + +## Capability Gaps Ranked by Impact + +### High / Big Impact + +1. Dual/multi-security scheme behavior consistency across runtime, OpenAPI, and generated clients (Q5) + +Reasoning: these directly affect large Swaggo migration cohorts and can block correctness at contract level. + +### Medium Impact + +2. Path/query typed fidelity beyond "string" in OpenAPI/client generation (Q6) + +Reasoning: important for contract precision and strict consumers, but many teams can migrate with documented constraints. + +### Low Impact + +3. (No additional pure capability gaps beyond Q5/Q6 from this feedback set) + +Reasoning: remaining items are primarily documentation/policy clarity problems rather than missing runtime/product capability. + +## Working Notes + +- This audit intentionally avoids proposing implementation details for capability gaps. +- Next iteration can convert "Knowledge Gap Backlog" into concrete doc patch tasks per file/section. diff --git a/docs/http-legacy/overview.md b/docs/http-legacy/overview.md index 195ed03..1a00672 100644 --- a/docs/http-legacy/overview.md +++ b/docs/http-legacy/overview.md @@ -8,6 +8,12 @@ httpapi is a compatibility layer for legacy `net/http` handlers and existing RES - Routes must be method-prefixed (for example, `GET /users/{id}`) to appear in OpenAPI and client output. - Handlers must be wrapped or typed so request and response types can be reflected. +- Typed route docs/clients are JSON-focused by default; `string` and `[]byte` response types are also supported as `text/plain` and `application/octet-stream`. +- Use `HandlerMeta.Responses` when a route needs multiple statuses or a custom response media type such as `image/png` or `text/html`. +- Request bodies are required by default when present; use `httpapi.Optional[Req]()` to mark optional bodies in generated docs/clients. +- Untyped routes still run normally but are skipped in generated OpenAPI and clients. +- Route registration is source of truth for path/method (including trailing slashes). +- Query and path params are emitted as string transport values in generated docs/clients; cast to domain types inside handlers as needed. ## Example @@ -22,3 +28,21 @@ router.HandleTyped( ) router.ServeAllDocs() ``` + +## JSON + non-JSON side by side + +```go +router := httpapi.NewRouter() + +// Included in OpenAPI + generated clients (typed JSON route) +router.HandleTyped( + "GET /api/v1/reports/{id}", + httpapi.WrapFunc(GetReportMeta, nil, ReportMetaResponse{}, httpapi.HandlerMeta{ + Service: "Reports", + Method: "GetMeta", + }), +) + +// Served at runtime only (non-JSON route) +router.Handle("GET /api/v1/reports/{id}/preview.png", http.HandlerFunc(ServeReportPreviewPNG)) +``` diff --git a/docs/http-legacy/query-params.md b/docs/http-legacy/query-params.md index 6ab4a13..a52588f 100644 --- a/docs/http-legacy/query-params.md +++ b/docs/http-legacy/query-params.md @@ -22,3 +22,66 @@ Rules: - Query params are serialized as strings and URL-escaped. - Nested structs and maps are not supported. - A field cannot use both `query` and `json` tags. +- Tag aliases are literal wire names. If you set `query:"limit"`, the query key is exactly `limit`. + +## Mixed query + JSON body + +Use separate fields for query and JSON body in one request type: + +```go +type SearchUsersRequest struct { + IncludeDisabled bool `query:"include_disabled,omitempty"` + Cursor string `query:"cursor,omitempty"` + Name string `json:"name"` + Role string `json:"role,omitempty"` +} +``` + +Notes: + +- Query-tagged fields become query params. +- JSON-tagged fields become request body fields. +- Query/path values are represented as strings in generated transport docs/clients. +- Alias overlap across query/body is valid when using different fields (for example `QueryLimit string \`query:"limit"\`` and `BodyLimit int \`json:"limit"\``). + +## Casting in handlers + +Use `query` tags for request fields, then cast path/query values to domain types in handler code as needed: + +```go +type SearchRequest struct { + Limit string `query:"limit,omitempty"` // docs/client transport metadata + Name string `json:"name,omitempty"` // JSON body field +} + +func SearchUsers(w http.ResponseWriter, r *http.Request) { + // Path params are transport strings. + orgIDRaw := r.PathValue("org_id") + orgID, err := strconv.Atoi(orgIDRaw) + if err != nil { + httpapi.Encode(w, r, http.StatusBadRequest, map[string]string{"error": "invalid org_id"}) + return + } + + // Query params are transport strings. + limitRaw := r.URL.Query().Get("limit") + limit := 0 + if limitRaw != "" { + limit, err = strconv.Atoi(limitRaw) + if err != nil { + httpapi.Encode(w, r, http.StatusBadRequest, map[string]string{"error": "invalid limit"}) + return + } + } + + req, err := httpapi.Decode[SearchRequest](r) + if err != nil && !errors.Is(err, io.EOF) { + httpapi.Encode(w, r, http.StatusBadRequest, map[string]string{"error": "invalid request"}) + return + } + + _ = req.Name + _ = orgID + _ = limit +} +``` diff --git a/docs/http-legacy/typed-handlers.md b/docs/http-legacy/typed-handlers.md index 92cbd71..e6e9f95 100644 --- a/docs/http-legacy/typed-handlers.md +++ b/docs/http-legacy/typed-handlers.md @@ -20,6 +20,28 @@ handler := httpapi.WrapFunc( `Wrap` accepts an `http.Handler` while `WrapFunc` accepts `func(http.ResponseWriter, *http.Request)`. +## Factory handlers + +Handler factories are supported. If your constructor returns `http.HandlerFunc`, pass the returned function to `WrapFunc`: + +```go +func BuildReportHandler(store Store) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + // use injected dependencies + } +} + +handler := httpapi.WrapFunc( + BuildReportHandler(store), + ReportRequest{}, + ReportResponse{}, + httpapi.HandlerMeta{ + Service: "Reports", + Method: "GetOne", + }, +) +``` + ## Handler metadata `HandlerMeta` controls client method names and doc fields: @@ -29,9 +51,54 @@ handler := httpapi.WrapFunc( - `Summary` - `Description` - `Tags` +- `Responses` If metadata is omitted, the router infers `Service` and `Method` when possible. +## Explicit response specs + +Use `HandlerMeta.Responses` when a route needs multiple statuses or a custom response media type: + +```go +handler := httpapi.WrapFunc( + ServePreview, + nil, + nil, // primary response can be inferred from HandlerMeta.Responses + httpapi.HandlerMeta{ + Service: "Assets", + Method: "GetPreview", + Responses: []httpapi.ResponseSpec{ + {Status: 200, Body: []byte{}, MediaType: "image/png"}, + {Status: 404, Body: ErrorResponse{}}, + }, + }, +) +``` + +Notes: + +- OpenAPI emits every declared response entry. +- Generated clients use the first `2xx` response as the primary return type. +- Runtime headers such as `Content-Type` and `Content-Disposition` are still set by the handler itself. + +## Request body note + +When body fields exist on a typed request, generated OpenAPI marks the request body as required by default. + +To mark the body optional, wrap the request type with `Optional`: + +```go +handler := httpapi.WrapFunc( + MyHandler, + httpapi.Optional[MyRequest](), // optional request body + MyResponse{}, + httpapi.HandlerMeta{ + Service: "MyService", + Method: "MyMethod", + }, +) +``` + ## No-body responses Use sentinel types to express responses with no body: @@ -39,3 +106,13 @@ Use sentinel types to express responses with no body: - `NoResponse200` - `NoResponse204` - `NoResponse500` + +## Non-JSON routes + +Typed handlers support: + +- `string` response type -> `text/plain` +- `[]byte` response type -> `application/octet-stream` +- `HandlerMeta.Responses` can override media type for typed `string` / `[]byte` responses (for example `text/html` or `image/png`) + +For runtime-only routes that should not appear in generated OpenAPI/clients, continue to use untyped handlers with `Handle`. diff --git a/docs/internals/openapi.md b/docs/internals/openapi.md index ea28b63..da8c048 100644 --- a/docs/internals/openapi.md +++ b/docs/internals/openapi.md @@ -15,4 +15,5 @@ Virtuous generates OpenAPI 3.0.3 documents at runtime by reflecting registered h - RPC operations are always POST. - httpapi operations use the HTTP method from the route pattern. -- For guarded routes, a 401 response is included in OpenAPI. +- RPC guarded routes include a documented 401 response entry. +- httpapi guarded routes emit security requirements; 401 response entries are not auto-added today. diff --git a/docs/overview.md b/docs/overview.md index de26cce..205ccb8 100644 --- a/docs/overview.md +++ b/docs/overview.md @@ -17,7 +17,7 @@ This is the canonical overview for Virtuous. It is RPC-first by design, with leg Virtuous models APIs as **typed functions** running over HTTP. Requests and responses are Go structs; they *are* the contract that drives OpenAPI and SDK generation. This keeps surface area small, prevents drift, and makes the system agent-friendly. In practice this means: -- Plain Go functions with explicit inputs/outputs and a narrow status model (200/401/422/500). +- Plain Go functions with explicit inputs/outputs and a narrow handler status model (200/422/500). - Routes derive from package + function names—no manual path design to maintain. - Docs and clients are emitted from the running server, ensuring runtime truth. - `httpapi` exists only for compatibility when you cannot yet move a handler to RPC. @@ -44,6 +44,7 @@ func(context.Context) (Resp, int) - Return `(Resp, status)` from handlers. - Status must be 200, 422, or 500. +- Guarded routes may also surface 401 when middleware rejects a request. - Responses should include a canonical `error` field (string or struct) when errors occur. ### Router wiring @@ -73,6 +74,15 @@ Example: Use `httpapi` when you need to retain classic `net/http` handlers or preserve an existing OpenAPI shape. This is a compatibility layer, not the canonical path for new APIs. +Notes: + +- Typed `httpapi` routes are JSON-focused for generated OpenAPI and clients. +- Typed `string`/`[]byte` responses map to `text/plain`/`application/octet-stream`. +- Use `httpapi.HandlerMeta.Responses` for multi-status routes or custom response media types. +- Use `httpapi.Optional[Req]()` when a typed route should accept an optional JSON body. +- Untyped routes can still be served for other non-JSON endpoints during migration. +- Runtime route registration is source of truth if legacy annotations drift. + Example: ```go @@ -131,7 +141,7 @@ deps/ ```text You are implementing a Virtuous RPC API. -- Target Virtuous version: read `VERSION` in the repo and pin it in the output (current: `0.0.21`). +- Target Virtuous version: read `VERSION` in the repo and pin it in the output. - Create router.go with rpc.NewRouter(rpc.WithPrefix("/rpc")). - Put handlers in package folders (states, users, admin). - Use func(ctx, req) (Resp, int). @@ -143,7 +153,7 @@ You are implementing a Virtuous RPC API. ```text Use the canonical Swaggo migration prompt in docs/tutorials/migrate-swaggo.md. -- Target Virtuous version: read `VERSION` in the repo and pin it in the output (current: `0.0.21`). +- Target Virtuous version: read `VERSION` in the repo and pin it in the output. - Default to httpapi for Swaggo routes. - Use rpc only for phase-2 moves. - Validate against the migration Definition of Done in that guide. @@ -173,7 +183,7 @@ Agent prompt (porting legacy handlers): ```text Port legacy handlers into Virtuous. -- Target Virtuous version: read `VERSION` in the repo and pin it in the output (current: `0.0.21`). +- Target Virtuous version: read `VERSION` in the repo and pin it in the output. - For each handler, decide: RPC (new) or httpapi (legacy). - For legacy: wrap http.HandlerFunc with httpapi.WrapFunc and register a method-prefixed route. - For new: create an RPC handler and register with router.HandleRPC. diff --git a/docs/reference/public-api.md b/docs/reference/public-api.md index f4d45aa..f47b6bd 100644 --- a/docs/reference/public-api.md +++ b/docs/reference/public-api.md @@ -2,7 +2,11 @@ ## Overview -This is a quick index of the primary entry points used in Virtuous apps. For full details, see the specs in `SPEC-RPC.md` and `SPEC.md`. +This is a quick index of the primary entry points used in Virtuous apps. For fuller behavior details, see: + +- `docs/overview.md` +- `docs/tutorials/migrate-swaggo.md` +- `docs/specs/overview.md` ## RPC package @@ -28,6 +32,8 @@ This is a quick index of the primary entry points used in Virtuous apps. For ful - `(*httpapi.Router).HandleTyped(pattern string, h httpapi.TypedHandler, guards ...httpapi.Guard)` - `httpapi.Wrap(handler http.Handler, req any, resp any, meta httpapi.HandlerMeta)` - `httpapi.WrapFunc(handler func(http.ResponseWriter, *http.Request), req any, resp any, meta httpapi.HandlerMeta)` +- `httpapi.Optional[T any](req ...T)` +- `httpapi.ResponseSpec` - `(*httpapi.Router).ServeDocs(opts ...httpapi.DocOpt)` - `(*httpapi.Router).ServeAllDocs(opts ...httpapi.ServeAllDocsOpt)` - `(*httpapi.Router).AttachLogger(next http.Handler)` diff --git a/docs/rpc/guards.md b/docs/rpc/guards.md index 54f517d..8d044df 100644 --- a/docs/rpc/guards.md +++ b/docs/rpc/guards.md @@ -4,6 +4,12 @@ Guards provide middleware and auth metadata. They are used to secure handlers and to emit OpenAPI security schemes and client auth injection. +## Semantics + +- Runtime middleware semantics: attached guards compose in order. +- OpenAPI semantics: guard specs become security requirements for each guarded operation. +- Generated client semantics: current JS/TS/PY clients expose one auth input derived from the first attached guard. + ## Interface ```go @@ -42,3 +48,34 @@ func (bearerGuard) Middleware() func(http.Handler) http.Handler { } } ``` + +## Composite OR guard example + +For routes that accept either bearer token or API key, compose guard logic into one guard: + +```go +type bearerOrAPIKeyGuard struct { + bearer bearerGuard + apiKey apiKeyGuard +} + +func (g bearerOrAPIKeyGuard) Spec() guard.Spec { + return guard.Spec{ + Name: "BearerOrApiKey", + In: "header", + Param: "Authorization", + } +} + +func (g bearerOrAPIKeyGuard) Middleware() func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if g.bearer.authenticate(r) || g.apiKey.authenticate(r) { + next.ServeHTTP(w, r) + return + } + http.Error(w, "unauthorized", http.StatusUnauthorized) + }) + } +} +``` diff --git a/docs/rpc/handlers.md b/docs/rpc/handlers.md index 73023bc..7a6f1bb 100644 --- a/docs/rpc/handlers.md +++ b/docs/rpc/handlers.md @@ -25,6 +25,7 @@ Rules: - 200 indicates success. - 422 indicates a user or validation error. - 500 indicates a server error. +- 401 may also appear in OpenAPI for guarded routes, but handlers still return only 200/422/500. Any other status is treated as 500. diff --git a/docs/specs/overview.md b/docs/specs/overview.md index 3d614d4..3479345 100644 --- a/docs/specs/overview.md +++ b/docs/specs/overview.md @@ -2,10 +2,10 @@ ## Overview -Virtuous keeps the canonical specs in the repo root: +Historical design specs live under `_design/`: -- `SPEC-RPC.md`: RPC runtime spec. -- `SPEC-RPC-SIMPLE.md`: proposed simplified signature spec. -- `SPEC.md`: legacy httpapi spec. +- `_design/SPEC-RPC.md`: RPC runtime design. +- `_design/SPEC-RPC-SIMPLE.md`: simplified RPC design. +- `_design/SPEC.md`: legacy httpapi design. -These documents are authoritative for behavior guarantees and edge cases. +For current behavior guarantees, prefer package docs and source in `rpc/` and `httpapi/`. diff --git a/docs/tutorials/migrate-swaggo.md b/docs/tutorials/migrate-swaggo.md index 7a43f56..6ff8c04 100644 --- a/docs/tutorials/migrate-swaggo.md +++ b/docs/tutorials/migrate-swaggo.md @@ -29,6 +29,20 @@ Use this table for exceptions and phase-2 planning: | Move to typed RPC operations and allow inferred RPC paths (phase 2) | `rpc` | | Migrate incrementally with both models in one process | Combined (`httpapi` + `rpc`) | +## Migration capability matrix (current behavior) + +Use this matrix to separate "supported now" from "known product limitations": + +| Migration concern | Current behavior | Recommendation | +| --- | --- | --- | +| Many response status codes (`201`, `202`, `400`, `404`, `409`, `503`, etc.) | Supported via `httpapi.HandlerMeta.Responses`. | Declare explicit `ResponseSpec` entries for each documented status. | +| Non-JSON responses (`image/png`, `text/html`, files) | Supported for typed `string`/`[]byte` responses, including custom media types via `httpapi.HandlerMeta.Responses`. | Use `ResponseSpec{MediaType: ...}` for typed custom media responses; keep runtime headers in the handler. | +| Optional request body (`@Param ... body ... false`) | Supported via `httpapi.Optional[...]` request marker. | Wrap request type with `httpapi.Optional[Req]()` when body is optional. | +| Mixed query + body requests | Supported when query and JSON use different struct fields. | Use separate fields; do not dual-tag a single field with both `query` and `json`. Tag aliases are literal wire names and can overlap across query/body on different fields. | +| Multiple security schemes on one route | Runtime middleware composes all guards. OpenAPI emits multiple security entries. Generated clients currently use only the first guard auth input. | Keep route security in middleware; treat generated-client multi-auth parity as a known capability gap. | +| Query/path param type fidelity | Query/path values are documented and generated as strings. | Accept string transport types during phase-1 migration; cast to `int`/`bool`/etc. in handler logic when needed. | +| Swaggo annotation drift vs router wiring | Runtime registration drives OpenAPI and clients. | Treat router registration as source of truth. | + ## Annotation mapping (Swaggo -> Virtuous) | Swaggo annotation | Virtuous equivalent | Notes | @@ -38,10 +52,10 @@ Use this table for exceptions and phase-2 planning: | `@Param ... body` | Typed request struct with `json` tags | RPC request is always JSON body when request type exists. | | `@Param ... query` | Request struct fields with `query:"..."` tags (`httpapi`) | Query tags are migration-only; nested structs/maps are not supported. | | `@Param ... path` | Method-prefixed route pattern with `{param}` (`httpapi`) | Path params come from route pattern, not struct tags. | -| `@Success` / `@Failure` | Typed response struct | RPC always documents 200/422/500 with the same response schema. | +| `@Success` / `@Failure` | Typed response struct and optional `httpapi.HandlerMeta.Responses` | RPC always documents 200/422/500 with the same response schema. `httpapi` can declare explicit response entries per status. | | `@Security` | `guard.Guard` with `Spec()` + middleware | Security schemes are emitted from guard specs. | | `@Router /path [method]` | `router.HandleTyped("METHOD /path", ...)` (`httpapi`) or `router.HandleRPC(fn)` (`rpc`) | RPC path is inferred: `/{prefix}/{package}/{kebab(function)}`. | -| `@Accept`, `@Produce` | Implicit JSON | Virtuous generated docs/clients are JSON-focused. | +| `@Accept`, `@Produce` | Implicit JSON by default for typed routes; override response media with `httpapi.HandlerMeta.Responses` | Use `ResponseSpec{MediaType: ...}` for typed custom response media types. | ## Behavioral differences that matter @@ -49,6 +63,7 @@ Use this table for exceptions and phase-2 planning: 2. RPC operations are HTTP POST only. 3. RPC operation summary/description is not comment-driven. 4. `httpapi` is the compatibility lane when you must preserve REST routes and per-route metadata. +5. Typed `httpapi` is JSON-first for OpenAPI/client output. ## Phase 1 (required): Preserve existing routes with `httpapi` @@ -145,6 +160,112 @@ func (bearerGuard) Middleware() func(http.Handler) http.Handler { Attach globally with `rpc.WithGuards(...)` or per route in `HandleRPC(...)` / `HandleTyped(...)`. +### Security semantics (important) + +- Runtime middleware semantics: guards compose in order; all attached guard middleware runs. +- OpenAPI semantics: each guard spec is emitted as a security requirement entry. +- Generated client semantics: current JS/TS/PY generators expose auth input for the first guard only. + +For routes that require strict multi-scheme client ergonomics, track this as a capability gap during migration. + +### Composite OR guard (no framework changes required) + +If a route should accept either of two credentials, combine that logic inside one guard and attach that guard to the route. + +```go +type bearerOrAPIKeyGuard struct { + bearer bearerGuard + apiKey apiKeyGuard +} + +func (g bearerOrAPIKeyGuard) Spec() guard.Spec { + return guard.Spec{ + Name: "BearerOrApiKey", + In: "header", + Param: "Authorization", + } +} + +func (g bearerOrAPIKeyGuard) Middleware() func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if g.bearer.authenticate(r) || g.apiKey.authenticate(r) { + next.ServeHTTP(w, r) + return + } + http.Error(w, "unauthorized", http.StatusUnauthorized) + }) + } +} + +router := httpapi.NewRouter() +router.HandleTyped( + "GET /api/v1/secure/report", + httpapi.WrapFunc(GetSecureReport, nil, ReportResponse{}, httpapi.HandlerMeta{ + Service: "Reports", + Method: "GetSecure", + }), + bearerOrAPIKeyGuard{}, +) +``` + +## Non-JSON migration pattern + +Use typed handlers for JSON, plain text, raw bytes, and custom text/byte media types. Keep only fully untyped runtime-only routes on `Handle` during migration: + +```go +router := httpapi.NewRouter() + +// JSON route (typed; included in OpenAPI + generated clients) +router.HandleTyped( + "GET /api/v1/reports/{id}", + httpapi.WrapFunc(GetReportMeta, nil, ReportMetaResponse{}, httpapi.HandlerMeta{ + Service: "Reports", + Method: "GetMeta", + }), +) + +// Plain text route (typed; included as text/plain) +router.HandleTyped( + "GET /api/v1/reports/{id}/summary.txt", + httpapi.WrapFunc(GetReportSummaryText, nil, "", httpapi.HandlerMeta{ + Service: "Reports", + Method: "GetSummaryText", + }), +) + +// Binary route (typed; included as application/octet-stream) +router.HandleTyped( + "GET /api/v1/reports/{id}/raw", + httpapi.WrapFunc(GetReportRawBytes, nil, []byte{}, httpapi.HandlerMeta{ + Service: "Reports", + Method: "GetRaw", + }), +) + +// Custom media route (typed; included as image/png) +router.HandleTyped( + "GET /api/v1/reports/{id}/preview.png", + httpapi.WrapFunc(ServeReportPreviewPNG, nil, nil, httpapi.HandlerMeta{ + Service: "Reports", + Method: "GetPreview", + Responses: []httpapi.ResponseSpec{ + {Status: 200, Body: []byte{}, MediaType: "image/png"}, + {Status: 404, Body: ErrorResponse{}}, + }, + }), +) + +// Fully untyped route (served at runtime, skipped from generated OpenAPI + clients) +router.Handle("GET /internal/debug/raw", http.HandlerFunc(ServeDebugDump)) +``` + +## Source of truth and slash policy + +- During migration, router registration is source of truth for path + method. +- If Swaggo annotations disagree with runtime registration, trust the registered route. +- Preserve trailing-slash behavior intentionally. Register the exact path shape you want clients and docs to reflect. + ## Route-by-route checklist 1. Keep or create typed request/response structs. @@ -175,7 +296,7 @@ Use this prompt for migration automation: You are migrating a Go API from Swaggo annotations to Virtuous. Goal: -- Pin the target Virtuous version from `VERSION` and report it explicitly (current: `0.0.21`). +- Read the target Virtuous version from `VERSION` and report it explicitly. - Replace annotation-driven docs with Virtuous runtime docs/clients. - For Swaggo migrations, migrate routes to httpapi first. - Use RPC as an explicit phase-2 optimization after compatibility is preserved. @@ -203,5 +324,6 @@ Deliverables: - RPC does not currently provide Swaggo-style per-operation comment metadata (`@Summary`, `@Description`) as direct handler annotations. - RPC always documents the same response schema for 200, 422, and 500. - Query-tag behavior is intentionally limited and exists for migration, not new design. +- Generated clients currently expose one auth input even when multiple guards are attached. If those are hard requirements for a route, keep that route on `httpapi` until constraints can be relaxed. diff --git a/httpapi/client_gen_test.go b/httpapi/client_gen_test.go index 513408e..03aa72f 100644 --- a/httpapi/client_gen_test.go +++ b/httpapi/client_gen_test.go @@ -8,6 +8,7 @@ import ( "os" "os/exec" "path/filepath" + "reflect" "strings" "testing" ) @@ -29,6 +30,14 @@ type testQueryRequest struct { Name string `json:"name"` } +type optionalClientRequest struct { + Name string `json:"name"` +} + +type responseSpecClientError struct { + Error string `json:"error"` +} + type testHandler struct{} func (testHandler) ServeHTTP(_ http.ResponseWriter, _ *http.Request) {} @@ -38,6 +47,80 @@ func (testHandler) Metadata() HandlerMeta { return HandlerMeta{Service: "States", Method: "GetByCode"} } +type textClientHandler struct{} + +func (textClientHandler) ServeHTTP(_ http.ResponseWriter, _ *http.Request) {} +func (textClientHandler) RequestType() any { return nil } +func (textClientHandler) ResponseType() any { return "" } +func (textClientHandler) Metadata() HandlerMeta { + return HandlerMeta{Service: "Assets", Method: "GetText"} +} + +type bytesClientHandler struct{} + +func (bytesClientHandler) ServeHTTP(_ http.ResponseWriter, _ *http.Request) {} +func (bytesClientHandler) RequestType() any { return nil } +func (bytesClientHandler) ResponseType() any { return []byte{} } +func (bytesClientHandler) Metadata() HandlerMeta { + return HandlerMeta{Service: "Assets", Method: "GetBytes"} +} + +type optionalBodyClientHandler struct{} + +func (optionalBodyClientHandler) ServeHTTP(_ http.ResponseWriter, _ *http.Request) {} +func (optionalBodyClientHandler) RequestType() any { return Optional[optionalClientRequest]() } +func (optionalBodyClientHandler) ResponseType() any { return testResponse{} } +func (optionalBodyClientHandler) Metadata() HandlerMeta { + return HandlerMeta{Service: "States", Method: "OptionalCreate"} +} + +type responseSpecClientHandler struct{} + +func (responseSpecClientHandler) ServeHTTP(_ http.ResponseWriter, _ *http.Request) {} +func (responseSpecClientHandler) RequestType() any { return nil } +func (responseSpecClientHandler) ResponseType() any { return nil } +func (responseSpecClientHandler) Metadata() HandlerMeta { + return HandlerMeta{ + Service: "Assets", + Method: "GetPreview", + Responses: []ResponseSpec{ + {Status: 200, Body: []byte{}, MediaType: "image/png"}, + {Status: 404, Body: responseSpecClientError{}}, + }, + } +} + +type responseSpecMultiMediaClientHandler struct{} + +func (responseSpecMultiMediaClientHandler) ServeHTTP(_ http.ResponseWriter, _ *http.Request) {} +func (responseSpecMultiMediaClientHandler) RequestType() any { return nil } +func (responseSpecMultiMediaClientHandler) ResponseType() any { return nil } +func (responseSpecMultiMediaClientHandler) Metadata() HandlerMeta { + return HandlerMeta{ + Service: "Assets", + Method: "GetArtifact", + Responses: []ResponseSpec{ + {Status: 200, Body: "", MediaType: "text/plain"}, + {Status: 200, Body: []byte{}, MediaType: "application/pdf"}, + }, + } +} + +type responseSpecPointerClientHandler struct{} + +func (responseSpecPointerClientHandler) ServeHTTP(_ http.ResponseWriter, _ *http.Request) {} +func (responseSpecPointerClientHandler) RequestType() any { return nil } +func (responseSpecPointerClientHandler) ResponseType() any { return nil } +func (responseSpecPointerClientHandler) Metadata() HandlerMeta { + return HandlerMeta{ + Service: "Assets", + Method: "GetPointerPayload", + Responses: []ResponseSpec{ + {Status: 200, Body: &responseSpecPayload{}}, + }, + } +} + func TestGeneratedClientsAreValid(t *testing.T) { router := NewRouter() router.HandleTyped("GET /api/v1/lookup/states/{code}", testHandler{}) @@ -109,6 +192,143 @@ func TestOpenAPIIsValidJSON(t *testing.T) { } } +func TestGeneratedClientsSupportTextAndBytesResponses(t *testing.T) { + router := NewRouter() + router.HandleTyped("GET /assets/text", textClientHandler{}) + router.HandleTyped("GET /assets/blob", bytesClientHandler{}) + + js := renderClient(t, func(buf *bytes.Buffer) error { return router.WriteClientJS(buf) }) + ts := renderClient(t, func(buf *bytes.Buffer) error { return router.WriteClientTS(buf) }) + py := renderClient(t, func(buf *bytes.Buffer) error { return router.WriteClientPY(buf) }) + + jsText := string(js) + if !strings.Contains(jsText, `"Accept": "text/plain"`) { + t.Fatalf("js client missing text/plain accept header") + } + if !strings.Contains(jsText, `"Accept": "application/octet-stream"`) { + t.Fatalf("js client missing octet-stream accept header") + } + if !strings.Contains(jsText, "new Uint8Array(raw)") { + t.Fatalf("js client missing Uint8Array binary decode") + } + + tsText := string(ts) + if !strings.Contains(tsText, "Promise") { + t.Fatalf("ts client missing Uint8Array return type") + } + if !strings.Contains(tsText, `"Accept": "application/octet-stream"`) { + t.Fatalf("ts client missing octet-stream accept header") + } + + pyText := string(py) + if !strings.Contains(pyText, "def getBytes") || !strings.Contains(pyText, "return payload") { + t.Fatalf("python client missing bytes response handling") + } + if !strings.Contains(pyText, `"Accept": "text/plain"`) { + t.Fatalf("python client missing text/plain accept header") + } +} + +func TestGeneratedClientsSupportOptionalRequestBody(t *testing.T) { + router := NewRouter() + router.HandleTyped("POST /states/optional", optionalBodyClientHandler{}) + + js := renderClient(t, func(buf *bytes.Buffer) error { return router.WriteClientJS(buf) }) + ts := renderClient(t, func(buf *bytes.Buffer) error { return router.WriteClientTS(buf) }) + + jsText := string(js) + if !strings.Contains(jsText, "request === undefined || request === null ? undefined : JSON.stringify(request)") { + t.Fatalf("js client missing optional request body handling") + } + + tsText := string(ts) + if !strings.Contains(tsText, "async optionalCreate(request?: ") { + t.Fatalf("ts client missing optional request argument") + } + if !strings.Contains(tsText, "request === undefined || request === null ? undefined : JSON.stringify(request)") { + t.Fatalf("ts client missing optional request body handling") + } +} + +func TestGeneratedClientsUsePrimaryResponseSpec(t *testing.T) { + router := NewRouter() + router.HandleTyped("GET /assets/preview/{id}", responseSpecClientHandler{}) + + js := renderClient(t, func(buf *bytes.Buffer) error { return router.WriteClientJS(buf) }) + ts := renderClient(t, func(buf *bytes.Buffer) error { return router.WriteClientTS(buf) }) + py := renderClient(t, func(buf *bytes.Buffer) error { return router.WriteClientPY(buf) }) + + jsText := string(js) + if !strings.Contains(jsText, `"Accept": "image/png"`) { + t.Fatalf("js client missing custom media type accept header") + } + if !strings.Contains(jsText, "new Uint8Array(raw)") { + t.Fatalf("js client missing binary decode for response spec") + } + + tsText := string(ts) + if !strings.Contains(tsText, `"Accept": "image/png"`) { + t.Fatalf("ts client missing custom media type accept header") + } + if !strings.Contains(tsText, "Promise") { + t.Fatalf("ts client missing binary return type for response spec") + } + + pyText := string(py) + if !strings.Contains(pyText, `"Accept": "image/png"`) { + t.Fatalf("python client missing custom media type accept header") + } + if !strings.Contains(pyText, "return payload") { + t.Fatalf("python client missing bytes return for response spec") + } +} + +func TestGeneratedClientsUseFirstListedMediaForSameStatus(t *testing.T) { + router := NewRouter() + router.HandleTyped("GET /assets/artifact/{id}", responseSpecMultiMediaClientHandler{}) + + js := renderClient(t, func(buf *bytes.Buffer) error { return router.WriteClientJS(buf) }) + ts := renderClient(t, func(buf *bytes.Buffer) error { return router.WriteClientTS(buf) }) + py := renderClient(t, func(buf *bytes.Buffer) error { return router.WriteClientPY(buf) }) + + jsText := string(js) + if !strings.Contains(jsText, `"Accept": "text/plain"`) { + t.Fatalf("js client should use first listed media type for same-status response specs") + } + + tsText := string(ts) + if !strings.Contains(tsText, `"Accept": "text/plain"`) { + t.Fatalf("ts client should use first listed media type for same-status response specs") + } + if !strings.Contains(tsText, "Promise") { + t.Fatalf("ts client should use text return type for first listed media type") + } + + pyText := string(py) + if !strings.Contains(pyText, `"Accept": "text/plain"`) { + t.Fatalf("python client should use first listed media type for same-status response specs") + } +} + +func TestGeneratedClientsSupportPointerResponseSpecTypes(t *testing.T) { + router := NewRouter() + router.HandleTyped("GET /assets/pointer/{id}", responseSpecPointerClientHandler{}) + + ts := renderClient(t, func(buf *bytes.Buffer) error { return router.WriteClientTS(buf) }) + py := renderClient(t, func(buf *bytes.Buffer) error { return router.WriteClientPY(buf) }) + + expectedType := preferredSchemaName(HandlerMeta{Service: "Assets"}, reflect.TypeOf(responseSpecPayload{})) + tsText := string(ts) + if !strings.Contains(tsText, "Promise<"+expectedType+">") { + t.Fatalf("ts client missing pointer response spec type %q", expectedType) + } + + pyText := string(py) + if !strings.Contains(pyText, "->\""+expectedType+"\"") || !strings.Contains(pyText, "_decode_value(\""+expectedType+"\"") { + t.Fatalf("python client missing pointer response spec type %q", expectedType) + } +} + func renderClient(t *testing.T, fn func(*bytes.Buffer) error) []byte { t.Helper() var buf bytes.Buffer diff --git a/httpapi/client_js_gen.go b/httpapi/client_js_gen.go index dfb10c6..a1c75c5 100644 --- a/httpapi/client_js_gen.go +++ b/httpapi/client_js_gen.go @@ -44,7 +44,7 @@ export function createClient(basepath = "/") { * @param {Object} pathParams {{- end }} {{- if $method.HasBody }} - * @param { {{- if $method.RequestType }}{{ $method.RequestType }}{{ else }}any{{ end }} } [request] + * @param { {{- if $method.RequestType }}{{ $method.RequestType }}{{ else }}any{{ end }} }{{ if $method.BodyOptional }} [request]{{ else }} request{{ end }} {{- end }} {{- if $method.HasQuery }} * @param {Object} [query] @@ -53,12 +53,14 @@ export function createClient(basepath = "/") { {{- end }} {{- end }} * @param {AuthOptions} [options] - * @returns {Promise<{{- if $method.ResponseType }}{{ $method.ResponseType }}{{ else }}any{{ end }}>} - */ - async {{ $method.Name }}({{ if $method.PathParams }}pathParams, {{ end }}{{ if $method.HasBody }}request, {{ end }}{{ if $method.HasQuery }}query, {{ end }}options) { - const headers = { - "Accept": "application/json", - "Content-Type": "application/json", + * @returns {Promise<{{- if eq $method.ResponseMode "none" }}void{{ else if $method.ResponseType }}{{ $method.ResponseType }}{{ else }}any{{ end }}>} + */ + async {{ $method.Name }}({{ if $method.PathParams }}pathParams, {{ end }}{{ if $method.HasBody }}request, {{ end }}{{ if $method.HasQuery }}query, {{ end }}options) { + const headers = { + "Accept": "{{ $method.AcceptType }}", +{{- if $method.HasBody }} + "Content-Type": "application/json", +{{- end }} } let url = basepath + "{{ $method.Path }}" {{- if $method.PathParams }} @@ -132,9 +134,14 @@ export function createClient(basepath = "/") { {{- end }} {{- end }} {{- if $method.HasBody }} +{{- if $method.BodyOptional }} + body: request === undefined || request === null ? undefined : JSON.stringify(request), +{{- else }} body: JSON.stringify(request || {}), +{{- end }} {{- end }} }) +{{- if eq $method.ResponseMode "json" }} const text = await response.text() let json = null if (text) { @@ -154,6 +161,24 @@ export function createClient(basepath = "/") { throw new Error(response.status + " " + response.statusText) } return json || {} +{{- else if eq $method.ResponseMode "text" }} + const text = await response.text() + if (!response.ok) { + throw new Error(text || (response.status + " " + response.statusText)) + } + return text +{{- else if eq $method.ResponseMode "bytes" }} + const raw = await response.arrayBuffer() + if (!response.ok) { + throw new Error(response.status + " " + response.statusText) + } + return new Uint8Array(raw) +{{- else }} + if (!response.ok) { + throw new Error(response.status + " " + response.statusText) + } + return +{{- end }} }, {{- end }} }, diff --git a/httpapi/client_py_gen.go b/httpapi/client_py_gen.go index 3842c7e..f3323a4 100644 --- a/httpapi/client_py_gen.go +++ b/httpapi/client_py_gen.go @@ -44,10 +44,12 @@ class _{{ $service.Name }}Service: self._basepath = basepath {{- range $method := $service.Methods }} - def {{ $method.Name }}(self{{- if $method.PathParams }}{{- range $param := $method.PathParams }}, {{ $param }}: str{{- end }}{{- end }}{{- if $method.HasBody }}, body: Optional[{{- if $method.RequestType }}{{ $method.RequestType }}{{- else }}Any{{- end }}] = None{{- end }}{{- if $method.HasQuery }}, query: Optional[dict[str, Any]] = None{{- end }}{{- if $method.HasAuth }}, {{ $method.AuthParam }}: Optional[str] = None{{- end }}) -> {{- if $method.ResponseType }}{{ $method.ResponseType }}{{- else }}None{{- end }}: + def {{ $method.Name }}(self{{- if $method.PathParams }}{{- range $param := $method.PathParams }}, {{ $param }}: str{{- end }}{{- end }}{{- if $method.HasBody }}, body: Optional[{{- if $method.RequestType }}{{ $method.RequestType }}{{- else }}Any{{- end }}] = None{{- end }}{{- if $method.HasQuery }}, query: Optional[dict[str, Any]] = None{{- end }}{{- if $method.HasAuth }}, {{ $method.AuthParam }}: Optional[str] = None{{- end }}) -> {{- if eq $method.ResponseMode "none" }}None{{- else if $method.ResponseType }}{{ $method.ResponseType }}{{- else }}Any{{- end }}: headers = { - "Accept": "application/json", + "Accept": "{{ $method.AcceptType }}", +{{- if $method.HasBody }} "Content-Type": "application/json", +{{- end }} } url = self._basepath + "{{ $method.Path }}" {{- if $method.PathParams }} @@ -104,29 +106,45 @@ class _{{ $service.Name }}Service: {{- end }} req = request.Request(url, data=data, method="{{ $method.HTTPMethod }}", headers=headers) status = 0 - text = "" + payload = b"" try: with request.urlopen(req) as resp: status = resp.getcode() - text = resp.read().decode("utf-8") + payload = resp.read() except error.HTTPError as err: status = err.code - text = err.read().decode("utf-8") - body = None + payload = err.read() +{{- if eq $method.ResponseMode "json" }} + text = payload.decode("utf-8") if payload else "" + decoded = None if text: try: - body = json.loads(text) + decoded = json.loads(text) except json.JSONDecodeError as err: if status >= 400: raise RuntimeError(f"{status} {_status_text(status)}") from err raise if status >= 400: - if isinstance(body, dict) and "error" in body: - raise RuntimeError(str(body["error"])) + if isinstance(decoded, dict) and "error" in decoded: + raise RuntimeError(str(decoded["error"])) raise RuntimeError(f"{status} {_status_text(status)}") {{- if $method.ResponseType }} - return _decode_value({{ $method.ResponseType }}, body) + return _decode_value({{ $method.ResponseType }}, decoded) +{{- else }} + return None +{{- end }} +{{- else if eq $method.ResponseMode "text" }} + text = payload.decode("utf-8") if payload else "" + if status >= 400: + raise RuntimeError(text or f"{status} {_status_text(status)}") + return text +{{- else if eq $method.ResponseMode "bytes" }} + if status >= 400: + raise RuntimeError(f"{status} {_status_text(status)}") + return payload {{- else }} + if status >= 400: + raise RuntimeError(f"{status} {_status_text(status)}") return None {{- end }} diff --git a/httpapi/client_spec.go b/httpapi/client_spec.go index e3ae33d..ed42c83 100644 --- a/httpapi/client_spec.go +++ b/httpapi/client_spec.go @@ -25,8 +25,11 @@ type clientMethod struct { Path string PathParams []string HasBody bool + BodyOptional bool HasQuery bool QueryParams []clientQueryParam + AcceptType string + ResponseMode string HasAuth bool Auth GuardSpec AuthParam string @@ -46,19 +49,20 @@ type clientQueryParam struct { func buildClientSpec(routes []Route, overrides map[string]TypeOverride) (clientSpec, error) { return buildClientSpecWith(routes, overrides, func(registry *schema.Registry) func(reflect.Type) string { return registry.JSTypeOf - }) + }, "Uint8Array") } func buildPythonClientSpec(routes []Route, overrides map[string]TypeOverride) (clientSpec, error) { return buildClientSpecWith(routes, overrides, func(registry *schema.Registry) func(reflect.Type) string { return registry.PyTypeOf - }) + }, "bytes") } func buildClientSpecWith( routes []Route, overrides map[string]TypeOverride, typeFnFactory func(*schema.Registry) func(reflect.Type) string, + byteType string, ) (clientSpec, error) { serviceMap := make(map[string]*clientService) registry := schema.NewRegistry(overrides) @@ -77,15 +81,14 @@ func buildClientSpecWith( cs = &clientService{Name: service} serviceMap[service] = cs } - reqType := route.Handler.RequestType() - respType := route.Handler.ResponseType() - hasBody := reqType != nil + reqInfo := resolveRequestType(route.Handler.RequestType()) + hasBody := reqInfo.Present hasQuery := false var queryParams []clientQueryParam requestType := "" responseType := "" - if reqType != nil { - reqReflect := reflect.TypeOf(reqType) + if reqInfo.Present { + reqReflect := reqInfo.Type if preferred := preferredSchemaName(route.Meta, reqReflect); preferred != "" { registry.PreferNameOf(reqReflect, preferred) } @@ -111,16 +114,31 @@ func buildClientSpecWith( requestType = typeFn(reqReflect) } } - if respType != nil { - respReflect := reflect.TypeOf(respType) - if !isNoResponse(respReflect, reflect.TypeOf(NoResponse200{})) && + primaryResp, hasPrimaryResponse, err := primaryClientResponse(route) + if err != nil { + return clientSpec{}, err + } + acceptType := "application/json" + responseMode := "none" + if hasPrimaryResponse { + respReflect := primaryResp.BodyType + if primaryResp.MediaType != "" { + acceptType = primaryResp.MediaType + } + responseMode = responseModeForType(respReflect) + if respReflect != nil && + !isNoResponse(respReflect, reflect.TypeOf(NoResponse200{})) && !isNoResponse(respReflect, reflect.TypeOf(NoResponse204{})) && !isNoResponse(respReflect, reflect.TypeOf(NoResponse500{})) { - if preferred := preferredSchemaName(route.Meta, respReflect); preferred != "" { - registry.PreferNameOf(respReflect, preferred) + if isByteSliceResponse(respReflect) { + responseType = byteType + } else { + if preferred := preferredSchemaName(route.Meta, respReflect); preferred != "" { + registry.PreferNameOf(respReflect, preferred) + } + registry.AddTypeOf(respReflect) + responseType = typeFn(respReflect) } - registry.AddTypeOf(respReflect) - responseType = typeFn(respReflect) } } method := clientMethod{ @@ -130,12 +148,17 @@ func buildClientSpecWith( Path: route.Path, PathParams: route.PathParams, HasBody: hasBody, + BodyOptional: reqInfo.Optional && hasBody, HasQuery: hasQuery, QueryParams: queryParams, + AcceptType: acceptType, + ResponseMode: responseMode, RequestType: requestType, ResponseType: responseType, } if len(route.Guards) > 0 { + // Current client templates expose a single auth input, so they bind + // to the first declared guard for the route. method.HasAuth = true method.Auth = route.Guards[0] method.AuthParam = authParamName(route.Guards[0].Name) @@ -170,3 +193,28 @@ func authParamName(name string) string { } return candidate } + +func responseModeFor(respType any) string { + if respType == nil { + return "none" + } + return responseModeForType(reflect.TypeOf(respType)) +} + +func responseModeForType(t reflect.Type) string { + if t == nil { + return "none" + } + if isNoResponse(t, reflect.TypeOf(NoResponse200{})) || + isNoResponse(t, reflect.TypeOf(NoResponse204{})) || + isNoResponse(t, reflect.TypeOf(NoResponse500{})) { + return "none" + } + if isStringResponse(t) { + return "text" + } + if isByteSliceResponse(t) { + return "bytes" + } + return "json" +} diff --git a/httpapi/client_ts.go b/httpapi/client_ts.go index d284e4d..bc1f385 100644 --- a/httpapi/client_ts.go +++ b/httpapi/client_ts.go @@ -25,10 +25,12 @@ export function createClient(basepath: string = "/") { {{- range $service := .Services }} {{ $service.Name }}: { {{- range $method := $service.Methods }} - async {{ $method.Name }}({{ if $method.PathParams }}pathParams: { {{- range $param := $method.PathParams }}{{ $param }}: string; {{- end }} }, {{ end }}{{ if $method.HasBody }}request: {{ $method.RequestType }}, {{ end }}{{ if $method.HasQuery }}query?: { {{- range $param := $method.QueryParams }}{{ $param.Name }}{{ if $param.Optional }}?{{ end }}: {{ if $param.IsArray }}string[]{{ else }}string{{ end }}; {{- end }} }, {{ end }}options?: AuthOptions): Promise<{{ if $method.ResponseType }}{{ $method.ResponseType }}{{ else }}void{{ end }}> { + async {{ $method.Name }}({{ if $method.PathParams }}pathParams: { {{- range $param := $method.PathParams }}{{ $param }}: string; {{- end }} }, {{ end }}{{ if $method.HasBody }}request{{ if $method.BodyOptional }}?{{ end }}: {{ $method.RequestType }}, {{ end }}{{ if $method.HasQuery }}query?: { {{- range $param := $method.QueryParams }}{{ $param.Name }}{{ if $param.Optional }}?{{ end }}: {{ if $param.IsArray }}string[]{{ else }}string{{ end }}; {{- end }} }, {{ end }}options?: AuthOptions): Promise<{{ if eq $method.ResponseMode "none" }}void{{ else if $method.ResponseType }}{{ $method.ResponseType }}{{ else }}unknown{{ end }}> { const headers: Record = { - "Accept": "application/json", + "Accept": "{{ $method.AcceptType }}", +{{- if $method.HasBody }} "Content-Type": "application/json", +{{- end }} } let url = basepath + "{{ $method.Path }}" {{- if $method.PathParams }} @@ -102,9 +104,14 @@ export function createClient(basepath: string = "/") { {{- end }} {{- end }} {{- if $method.HasBody }} +{{- if $method.BodyOptional }} + body: request === undefined || request === null ? undefined : JSON.stringify(request), +{{- else }} body: JSON.stringify(request || {}), +{{- end }} {{- end }} }) +{{- if eq $method.ResponseMode "json" }} const text = await response.text() let json: {{ if $method.ResponseType }}{{ $method.ResponseType }}{{ else }}Record{{ end }} | null = null if (text) { @@ -124,7 +131,25 @@ export function createClient(basepath: string = "/") { } throw new Error(response.status + " " + response.statusText) } - return json as {{ if $method.ResponseType }}{{ $method.ResponseType }}{{ else }}void{{ end }} + return json as {{ if $method.ResponseType }}{{ $method.ResponseType }}{{ else }}unknown{{ end }} +{{- else if eq $method.ResponseMode "text" }} + const text = await response.text() + if (!response.ok) { + throw new Error(text || (response.status + " " + response.statusText)) + } + return text as {{ if $method.ResponseType }}{{ $method.ResponseType }}{{ else }}string{{ end }} +{{- else if eq $method.ResponseMode "bytes" }} + const raw = await response.arrayBuffer() + if (!response.ok) { + throw new Error(response.status + " " + response.statusText) + } + return new Uint8Array(raw) as {{ if $method.ResponseType }}{{ $method.ResponseType }}{{ else }}Uint8Array{{ end }} +{{- else }} + if (!response.ok) { + throw new Error(response.status + " " + response.statusText) + } + return +{{- end }} }, {{- end }} }, diff --git a/httpapi/openapi.go b/httpapi/openapi.go index 95d8521..6431c39 100644 --- a/httpapi/openapi.go +++ b/httpapi/openapi.go @@ -2,8 +2,6 @@ package httpapi import ( "encoding/json" - "errors" - "net/http" "os" "reflect" "sort" @@ -46,9 +44,9 @@ func (r *Router) OpenAPI() ([]byte, error) { op.Security = secReq } - reqType := route.Handler.RequestType() - if reqType != nil { - reqReflect := reflect.TypeOf(reqType) + reqInfo := resolveRequestType(route.Handler.RequestType()) + if reqInfo.Present { + reqReflect := reqInfo.Type if preferred := preferredSchemaName(route.Meta, reqReflect); preferred != "" { gen.PreferNameOf(reqReflect, preferred) } @@ -72,7 +70,7 @@ func (r *Router) OpenAPI() ([]byte, error) { reqSchema := requestBodySchema(gen, reqReflect, queryInfo.QueryFieldSet) if reqSchema != nil { op.RequestBody = &openAPIRequestBody{ - Required: true, + Required: !reqInfo.Optional, Content: map[string]openAPIMedia{ "application/json": {Schema: reqSchema}, }, @@ -81,25 +79,27 @@ func (r *Router) OpenAPI() ([]byte, error) { } } - respType := route.Handler.ResponseType() - if respType == nil { - return nil, errors.New("response type is required for " + route.Pattern) + responses, err := routeResponseSpecs(route) + if err != nil { + return nil, err } - respReflect := reflect.TypeOf(respType) - if preferred := preferredSchemaName(route.Meta, respReflect); preferred != "" { - gen.PreferNameOf(respReflect, preferred) - } - status, respSchema := responseSchema(gen, respReflect) - op.Responses[status] = openAPIResponse{ - Description: http.StatusText(parseStatus(status)), - Content: map[string]openAPIMedia{ - "application/json": {Schema: respSchema}, - }, - } - if status == "204" || respSchema == nil { - op.Responses[status] = openAPIResponse{ - Description: http.StatusText(parseStatus(status)), + for _, resp := range responses { + preferResponseSchemaName(gen, route.Meta, resp.BodyType) + respSchema := responseBodySchema(gen, resp.BodyType) + response := op.Responses[resp.Status] + if response.Description == "" { + response.Description = resp.Description + } + if resp.Description != "" { + response.Description = resp.Description + } + if respSchema != nil { + if response.Content == nil { + response.Content = map[string]openAPIMedia{} + } + response.Content[resp.MediaType] = openAPIMedia{Schema: respSchema} } + op.Responses[resp.Status] = response } for _, param := range route.PathParams { @@ -152,33 +152,17 @@ func (r *Router) WriteOpenAPIFile(path string) error { return os.WriteFile(path, data, 0644) } -func responseSchema(gen *schema.Generator, t reflect.Type) (string, *schema.OpenAPISchema) { +func responseMediaType(t reflect.Type) string { if t == nil { - return "500", nil + return "application/json" } - if isNoResponse(t, reflect.TypeOf(NoResponse200{})) { - return "200", nil + if isByteSliceResponse(t) { + return "application/octet-stream" } - if isNoResponse(t, reflect.TypeOf(NoResponse204{})) { - return "204", nil - } - if isNoResponse(t, reflect.TypeOf(NoResponse500{})) { - return "500", nil - } - return "200", gen.SchemaForType(t) -} - -func parseStatus(status string) int { - switch status { - case "200": - return http.StatusOK - case "204": - return http.StatusNoContent - case "500": - return http.StatusInternalServerError - default: - return http.StatusOK + if isStringResponse(t) { + return "text/plain" } + return "application/json" } func isNoResponse(t, target reflect.Type) bool { @@ -188,6 +172,34 @@ func isNoResponse(t, target reflect.Type) bool { return t == target } +func isStringResponse(t reflect.Type) bool { + t = reflectutil.DerefType(t) + return t != nil && t.Kind() == reflect.String +} + +func isByteSliceResponse(t reflect.Type) bool { + t = reflectutil.DerefType(t) + return t != nil && + t.Kind() == reflect.Slice && + t.Elem().Kind() == reflect.Uint8 +} + +func preferResponseSchemaName(gen *schema.Generator, meta HandlerMeta, t reflect.Type) { + if t == nil { + return + } + if isNoResponse(t, reflect.TypeOf(NoResponse200{})) || + isNoResponse(t, reflect.TypeOf(NoResponse204{})) || + isNoResponse(t, reflect.TypeOf(NoResponse500{})) || + isStringResponse(t) || + isByteSliceResponse(t) { + return + } + if preferred := preferredSchemaName(meta, t); preferred != "" { + gen.PreferNameOf(t, preferred) + } +} + func requestBodySchema(gen *schema.Generator, t reflect.Type, skip map[string]struct{}) *schema.OpenAPISchema { base := reflectutil.DerefType(t) if base == nil { diff --git a/httpapi/openapi_test.go b/httpapi/openapi_test.go index a7049af..5dd14ee 100644 --- a/httpapi/openapi_test.go +++ b/httpapi/openapi_test.go @@ -112,6 +112,40 @@ type queryRequestMixed struct { Name string `json:"name"` } +type optionalBodyRequest struct { + Name string `json:"name"` +} + +type responseSpecError struct { + Error string `json:"error"` +} + +type responseSpecPayload struct { + ID string `json:"id"` +} + +type responseSpecAltPayload struct { + Name string `json:"name"` +} + +type textResponseHandler struct{} + +func (textResponseHandler) ServeHTTP(_ http.ResponseWriter, _ *http.Request) {} +func (textResponseHandler) RequestType() any { return nil } +func (textResponseHandler) ResponseType() any { return "" } +func (textResponseHandler) Metadata() HandlerMeta { + return HandlerMeta{Service: "Files", Method: "GetText"} +} + +type bytesResponseHandler struct{} + +func (bytesResponseHandler) ServeHTTP(_ http.ResponseWriter, _ *http.Request) {} +func (bytesResponseHandler) RequestType() any { return nil } +func (bytesResponseHandler) ResponseType() any { return []byte{} } +func (bytesResponseHandler) Metadata() HandlerMeta { + return HandlerMeta{Service: "Files", Method: "GetBytes"} +} + type queryHandlerOnly struct{} func (queryHandlerOnly) ServeHTTP(_ http.ResponseWriter, _ *http.Request) {} @@ -130,6 +164,93 @@ func (queryHandlerMixed) Metadata() HandlerMeta { return HandlerMeta{Service: "Test", Method: "QueryMixed"} } +type optionalBodyHandler struct{} + +func (optionalBodyHandler) ServeHTTP(_ http.ResponseWriter, _ *http.Request) {} +func (optionalBodyHandler) RequestType() any { return Optional[optionalBodyRequest]() } +func (optionalBodyHandler) ResponseType() any { return nullableResponse{} } +func (optionalBodyHandler) Metadata() HandlerMeta { + return HandlerMeta{Service: "Test", Method: "OptionalBody"} +} + +type responseSpecHandler struct{} + +func (responseSpecHandler) ServeHTTP(_ http.ResponseWriter, _ *http.Request) {} +func (responseSpecHandler) RequestType() any { return nil } +func (responseSpecHandler) ResponseType() any { return nil } +func (responseSpecHandler) Metadata() HandlerMeta { + return HandlerMeta{ + Service: "Assets", + Method: "GetPreview", + Responses: []ResponseSpec{ + {Status: 200, Body: []byte{}, MediaType: "image/png"}, + {Status: 404, Body: responseSpecError{}}, + }, + } +} + +type responseSpecDescriptionHandler struct{} + +func (responseSpecDescriptionHandler) ServeHTTP(_ http.ResponseWriter, _ *http.Request) {} +func (responseSpecDescriptionHandler) RequestType() any { return nil } +func (responseSpecDescriptionHandler) ResponseType() any { return nil } +func (responseSpecDescriptionHandler) Metadata() HandlerMeta { + return HandlerMeta{ + Service: "Assets", + Method: "DescribeFailure", + Responses: []ResponseSpec{ + {Status: 404, Body: responseSpecError{}, Description: "preview asset missing"}, + }, + } +} + +type responseSpecMultiMediaHandler struct{} + +func (responseSpecMultiMediaHandler) ServeHTTP(_ http.ResponseWriter, _ *http.Request) {} +func (responseSpecMultiMediaHandler) RequestType() any { return nil } +func (responseSpecMultiMediaHandler) ResponseType() any { return nil } +func (responseSpecMultiMediaHandler) Metadata() HandlerMeta { + return HandlerMeta{ + Service: "Assets", + Method: "GetArtifact", + Responses: []ResponseSpec{ + {Status: 200, Body: "", MediaType: "text/plain"}, + {Status: 200, Body: []byte{}, MediaType: "application/pdf"}, + }, + } +} + +type responseSpecNamedSchemasHandler struct{} + +func (responseSpecNamedSchemasHandler) ServeHTTP(_ http.ResponseWriter, _ *http.Request) {} +func (responseSpecNamedSchemasHandler) RequestType() any { return nil } +func (responseSpecNamedSchemasHandler) ResponseType() any { return nil } +func (responseSpecNamedSchemasHandler) Metadata() HandlerMeta { + return HandlerMeta{ + Service: "Assets", + Method: "GetNamedSchemas", + Responses: []ResponseSpec{ + {Status: 200, Body: responseSpecPayload{}}, + {Status: 404, Body: responseSpecAltPayload{}}, + }, + } +} + +type responseSpecPointerHandler struct{} + +func (responseSpecPointerHandler) ServeHTTP(_ http.ResponseWriter, _ *http.Request) {} +func (responseSpecPointerHandler) RequestType() any { return nil } +func (responseSpecPointerHandler) ResponseType() any { return nil } +func (responseSpecPointerHandler) Metadata() HandlerMeta { + return HandlerMeta{ + Service: "Assets", + Method: "GetPointerPayload", + Responses: []ResponseSpec{ + {Status: 200, Body: &responseSpecPayload{}}, + }, + } +} + func TestOpenAPIQueryParamsOnly(t *testing.T) { router := NewRouter() router.HandleTyped("GET /query", queryHandlerOnly{}) @@ -189,6 +310,206 @@ func TestOpenAPIQueryParamsMixed(t *testing.T) { } } +func TestOpenAPIResponseMediaTypesForTextAndBytes(t *testing.T) { + router := NewRouter() + router.HandleTyped("GET /assets/readme", textResponseHandler{}) + router.HandleTyped("GET /assets/blob", bytesResponseHandler{}) + + data, err := router.OpenAPI() + if err != nil { + t.Fatalf("OpenAPI: %v", err) + } + var doc map[string]any + if err := json.Unmarshal(data, &doc); err != nil { + t.Fatalf("OpenAPI JSON invalid: %v", err) + } + + paths := getMap(t, doc, "paths") + + readmePath := getMap(t, paths, "/assets/readme") + readmeGet := getMap(t, readmePath, "get") + readmeResponses := getMap(t, readmeGet, "responses") + readme200 := getMap(t, readmeResponses, "200") + readmeContent := getMap(t, readme200, "content") + textPlain := getMap(t, readmeContent, "text/plain") + textSchema := getMap(t, textPlain, "schema") + if textSchema["type"] != "string" { + t.Fatalf("text/plain schema type = %v, want string", textSchema["type"]) + } + + blobPath := getMap(t, paths, "/assets/blob") + blobGet := getMap(t, blobPath, "get") + blobResponses := getMap(t, blobGet, "responses") + blob200 := getMap(t, blobResponses, "200") + blobContent := getMap(t, blob200, "content") + octet := getMap(t, blobContent, "application/octet-stream") + octetSchema := getMap(t, octet, "schema") + if octetSchema["type"] != "string" { + t.Fatalf("binary schema type = %v, want string", octetSchema["type"]) + } + if octetSchema["format"] != "binary" { + t.Fatalf("binary schema format = %v, want binary", octetSchema["format"]) + } +} + +func TestOpenAPIOptionalRequestBody(t *testing.T) { + router := NewRouter() + router.HandleTyped("POST /optional", optionalBodyHandler{}) + + data, err := router.OpenAPI() + if err != nil { + t.Fatalf("OpenAPI: %v", err) + } + var doc map[string]any + if err := json.Unmarshal(data, &doc); err != nil { + t.Fatalf("OpenAPI JSON invalid: %v", err) + } + + paths := getMap(t, doc, "paths") + optionalPath := getMap(t, paths, "/optional") + postOp := getMap(t, optionalPath, "post") + requestBody := getMap(t, postOp, "requestBody") + if requestBody["required"] != false { + t.Fatalf("optional request body should be marked required=false") + } +} + +func TestOpenAPIResponseSpecsSupportMultiStatusAndCustomMedia(t *testing.T) { + router := NewRouter() + router.HandleTyped("GET /assets/preview/{id}", responseSpecHandler{}) + + data, err := router.OpenAPI() + if err != nil { + t.Fatalf("OpenAPI: %v", err) + } + var doc map[string]any + if err := json.Unmarshal(data, &doc); err != nil { + t.Fatalf("OpenAPI JSON invalid: %v", err) + } + + paths := getMap(t, doc, "paths") + previewPath := getMap(t, paths, "/assets/preview/{id}") + getOp := getMap(t, previewPath, "get") + responses := getMap(t, getOp, "responses") + + okResp := getMap(t, responses, "200") + okContent := getMap(t, okResp, "content") + png := getMap(t, okContent, "image/png") + pngSchema := getMap(t, png, "schema") + if pngSchema["type"] != "string" || pngSchema["format"] != "binary" { + t.Fatalf("expected binary image/png response schema") + } + + notFound := getMap(t, responses, "404") + notFoundContent := getMap(t, notFound, "content") + jsonMedia := getMap(t, notFoundContent, "application/json") + jsonSchema := getMap(t, jsonMedia, "schema") + if _, ok := jsonSchema["$ref"]; !ok { + t.Fatalf("expected 404 response to use JSON schema ref") + } +} + +func TestOpenAPIResponseSpecCustomDescription(t *testing.T) { + router := NewRouter() + router.HandleTyped("GET /assets/missing/{id}", responseSpecDescriptionHandler{}) + + data, err := router.OpenAPI() + if err != nil { + t.Fatalf("OpenAPI: %v", err) + } + var doc map[string]any + if err := json.Unmarshal(data, &doc); err != nil { + t.Fatalf("OpenAPI JSON invalid: %v", err) + } + + paths := getMap(t, doc, "paths") + missingPath := getMap(t, paths, "/assets/missing/{id}") + getOp := getMap(t, missingPath, "get") + responses := getMap(t, getOp, "responses") + notFound := getMap(t, responses, "404") + if notFound["description"] != "preview asset missing" { + t.Fatalf("custom description = %v, want preview asset missing", notFound["description"]) + } +} + +func TestOpenAPIResponseSpecsMergeMultipleMediaForSameStatus(t *testing.T) { + router := NewRouter() + router.HandleTyped("GET /assets/artifact/{id}", responseSpecMultiMediaHandler{}) + + data, err := router.OpenAPI() + if err != nil { + t.Fatalf("OpenAPI: %v", err) + } + var doc map[string]any + if err := json.Unmarshal(data, &doc); err != nil { + t.Fatalf("OpenAPI JSON invalid: %v", err) + } + + paths := getMap(t, doc, "paths") + artifactPath := getMap(t, paths, "/assets/artifact/{id}") + getOp := getMap(t, artifactPath, "get") + responses := getMap(t, getOp, "responses") + okResp := getMap(t, responses, "200") + content := getMap(t, okResp, "content") + if _, ok := content["text/plain"]; !ok { + t.Fatalf("expected text/plain media for 200 response") + } + if _, ok := content["application/pdf"]; !ok { + t.Fatalf("expected application/pdf media for 200 response") + } +} + +func TestOpenAPIResponseSpecsUseStableSchemaNames(t *testing.T) { + router := NewRouter() + router.HandleTyped("GET /assets/named/{id}", responseSpecNamedSchemasHandler{}) + + data, err := router.OpenAPI() + if err != nil { + t.Fatalf("OpenAPI: %v", err) + } + var doc map[string]any + if err := json.Unmarshal(data, &doc); err != nil { + t.Fatalf("OpenAPI JSON invalid: %v", err) + } + + components := getMap(t, doc, "components") + schemas := getMap(t, components, "schemas") + okName := preferredSchemaName(HandlerMeta{Service: "Assets"}, reflect.TypeOf(responseSpecPayload{})) + errName := preferredSchemaName(HandlerMeta{Service: "Assets"}, reflect.TypeOf(responseSpecAltPayload{})) + if _, ok := schemas[okName]; !ok { + t.Fatalf("missing response schema %q", okName) + } + if _, ok := schemas[errName]; !ok { + t.Fatalf("missing response schema %q", errName) + } +} + +func TestOpenAPIResponseSpecPointerBodyUsesSchemaRef(t *testing.T) { + router := NewRouter() + router.HandleTyped("GET /assets/pointer/{id}", responseSpecPointerHandler{}) + + data, err := router.OpenAPI() + if err != nil { + t.Fatalf("OpenAPI: %v", err) + } + var doc map[string]any + if err := json.Unmarshal(data, &doc); err != nil { + t.Fatalf("OpenAPI JSON invalid: %v", err) + } + + paths := getMap(t, doc, "paths") + pointerPath := getMap(t, paths, "/assets/pointer/{id}") + getOp := getMap(t, pointerPath, "get") + responses := getMap(t, getOp, "responses") + okResp := getMap(t, responses, "200") + content := getMap(t, okResp, "content") + jsonMedia := getMap(t, content, "application/json") + jsonSchema := getMap(t, jsonMedia, "schema") + if _, ok := jsonSchema["$ref"]; !ok { + t.Fatalf("expected pointer response body to use schema ref") + } +} + func getMapFromList(t *testing.T, list []any, idx int) map[string]any { t.Helper() if idx < 0 || idx >= len(list) { diff --git a/httpapi/request_optionality.go b/httpapi/request_optionality.go new file mode 100644 index 0000000..beab165 --- /dev/null +++ b/httpapi/request_optionality.go @@ -0,0 +1,54 @@ +package httpapi + +import "reflect" + +// Optional marks a typed request body as optional in generated OpenAPI and SDKs. +// +// Usage: +// - Optional[MyRequest]() +// - Optional(MyRequest{}) +func Optional[T any](req ...T) any { + var t reflect.Type + if len(req) > 0 { + t = reflect.TypeOf(req[0]) + } + if t == nil { + var ptr *T + t = reflect.TypeOf(ptr).Elem() + } + return optionalRequest{typ: t} +} + +type optionalRequest struct { + typ reflect.Type +} + +type requestTypeInfo struct { + Type reflect.Type + Present bool + Optional bool +} + +func resolveRequestType(req any) requestTypeInfo { + if req == nil { + return requestTypeInfo{} + } + if marker, ok := req.(optionalRequest); ok { + if marker.typ == nil { + return requestTypeInfo{} + } + return requestTypeInfo{ + Type: marker.typ, + Present: true, + Optional: true, + } + } + t := reflect.TypeOf(req) + if t == nil { + return requestTypeInfo{} + } + return requestTypeInfo{ + Type: t, + Present: true, + } +} diff --git a/httpapi/request_optionality_test.go b/httpapi/request_optionality_test.go new file mode 100644 index 0000000..891ae5c --- /dev/null +++ b/httpapi/request_optionality_test.go @@ -0,0 +1,36 @@ +package httpapi + +import ( + "reflect" + "testing" +) + +type optionalTypeRequest struct { + Name string `json:"name"` +} + +func TestOptionalRequestTypeFromGeneric(t *testing.T) { + info := resolveRequestType(Optional[optionalTypeRequest]()) + if !info.Present { + t.Fatalf("expected request type to be present") + } + if !info.Optional { + t.Fatalf("expected request type to be optional") + } + if info.Type != reflect.TypeOf(optionalTypeRequest{}) { + t.Fatalf("unexpected request type: %v", info.Type) + } +} + +func TestOptionalRequestTypeFromValue(t *testing.T) { + info := resolveRequestType(Optional(optionalTypeRequest{})) + if !info.Present { + t.Fatalf("expected request type to be present") + } + if !info.Optional { + t.Fatalf("expected request type to be optional") + } + if info.Type != reflect.TypeOf(optionalTypeRequest{}) { + t.Fatalf("unexpected request type: %v", info.Type) + } +} diff --git a/httpapi/response_specs.go b/httpapi/response_specs.go new file mode 100644 index 0000000..ea0d706 --- /dev/null +++ b/httpapi/response_specs.go @@ -0,0 +1,143 @@ +package httpapi + +import ( + "fmt" + "net/http" + "reflect" + "strconv" + + "github.com/swetjen/virtuous/schema" +) + +type resolvedResponseSpec struct { + StatusCode int + Status string + BodyType reflect.Type + MediaType string + Description string +} + +func routeResponseSpecs(route Route) ([]resolvedResponseSpec, error) { + if len(route.Meta.Responses) > 0 { + specs := make([]resolvedResponseSpec, 0, len(route.Meta.Responses)) + for _, spec := range route.Meta.Responses { + resolved, err := resolveExplicitResponseSpec(spec) + if err != nil { + return nil, fmt.Errorf("%s: %w", route.Pattern, err) + } + specs = append(specs, resolved) + } + return specs, nil + } + + resolved, err := resolveLegacyResponse(route.Handler.ResponseType()) + if err != nil { + return nil, fmt.Errorf("%s: %w", route.Pattern, err) + } + return []resolvedResponseSpec{resolved}, nil +} + +func primaryClientResponse(route Route) (resolvedResponseSpec, bool, error) { + if len(route.Meta.Responses) > 0 { + for _, spec := range route.Meta.Responses { + resolved, err := resolveExplicitResponseSpec(spec) + if err != nil { + return resolvedResponseSpec{}, false, fmt.Errorf("%s: %w", route.Pattern, err) + } + if resolved.StatusCode >= 200 && resolved.StatusCode < 300 { + return resolved, true, nil + } + } + return resolvedResponseSpec{}, false, nil + } + + resolved, err := resolveLegacyResponse(route.Handler.ResponseType()) + if err != nil { + return resolvedResponseSpec{}, false, fmt.Errorf("%s: %w", route.Pattern, err) + } + return resolved, true, nil +} + +func resolveExplicitResponseSpec(spec ResponseSpec) (resolvedResponseSpec, error) { + if spec.Status < 100 || spec.Status > 599 { + return resolvedResponseSpec{}, fmt.Errorf("invalid response status %d", spec.Status) + } + bodyType := responseBodyType(spec.Body) + mediaType := spec.MediaType + if mediaType == "" && bodyType != nil { + mediaType = responseMediaType(bodyType) + } + description := spec.Description + if description == "" { + description = http.StatusText(spec.Status) + if description == "" { + description = "Response" + } + } + return resolvedResponseSpec{ + StatusCode: spec.Status, + Status: strconv.Itoa(spec.Status), + BodyType: bodyType, + MediaType: mediaType, + Description: description, + }, nil +} + +func resolveLegacyResponse(respType any) (resolvedResponseSpec, error) { + if respType == nil { + return resolvedResponseSpec{}, fmt.Errorf("response type is required") + } + bodyType := responseBodyType(respType) + statusCode := defaultStatusForResponseType(bodyType) + return resolvedResponseSpec{ + StatusCode: statusCode, + Status: strconv.Itoa(statusCode), + BodyType: bodyType, + MediaType: responseMediaType(bodyType), + Description: http.StatusText(statusCode), + }, nil +} + +func responseBodyType(v any) reflect.Type { + if v == nil { + return nil + } + return reflect.TypeOf(v) +} + +func defaultStatusForResponseType(t reflect.Type) int { + if t == nil { + return http.StatusInternalServerError + } + switch { + case isNoResponse(t, reflect.TypeOf(NoResponse200{})): + return http.StatusOK + case isNoResponse(t, reflect.TypeOf(NoResponse204{})): + return http.StatusNoContent + case isNoResponse(t, reflect.TypeOf(NoResponse500{})): + return http.StatusInternalServerError + default: + return http.StatusOK + } +} + +func responseBodySchema(gen *schema.Generator, t reflect.Type) *schema.OpenAPISchema { + if t == nil { + return nil + } + switch { + case isNoResponse(t, reflect.TypeOf(NoResponse200{})), + isNoResponse(t, reflect.TypeOf(NoResponse204{})), + isNoResponse(t, reflect.TypeOf(NoResponse500{})): + return nil + case isByteSliceResponse(t): + return &schema.OpenAPISchema{ + Type: "string", + Format: "binary", + } + case isStringResponse(t): + return &schema.OpenAPISchema{Type: "string"} + default: + return gen.SchemaForType(t) + } +} diff --git a/httpapi/router.go b/httpapi/router.go index ee72943..da9cd8a 100644 --- a/httpapi/router.go +++ b/httpapi/router.go @@ -15,6 +15,15 @@ type HandlerMeta struct { Summary string Description string Tags []string + Responses []ResponseSpec +} + +// ResponseSpec describes an explicit response contract for a typed route. +type ResponseSpec struct { + Status int + Body any + MediaType string + Description string } // TypedHandler is an http.Handler with type metadata. diff --git a/httpapi_aliases.go b/httpapi_aliases.go index 0bf5a68..7606642 100644 --- a/httpapi_aliases.go +++ b/httpapi_aliases.go @@ -10,6 +10,7 @@ import ( type Guard = httpapi.Guard type GuardSpec = httpapi.GuardSpec type HandlerMeta = httpapi.HandlerMeta +type ResponseSpec = httpapi.ResponseSpec type TypedHandler = httpapi.TypedHandler type TypedHandlerFunc = httpapi.TypedHandlerFunc type Route = httpapi.Route @@ -50,6 +51,10 @@ func WrapFunc(handler func(http.ResponseWriter, *http.Request), req any, resp an return httpapi.WrapFunc(handler, req, resp, meta) } +func Optional[T any](req ...T) any { + return httpapi.Optional(req...) +} + func Encode(w http.ResponseWriter, r *http.Request, status int, v any) { httpapi.Encode(w, r, status, v) } diff --git a/rpc/client_spec.go b/rpc/client_spec.go index 7cd2538..ba26277 100644 --- a/rpc/client_spec.go +++ b/rpc/client_spec.go @@ -86,6 +86,8 @@ func buildClientSpecWith( ErrorType: responseType, } if len(route.Guards) > 0 { + // Current client templates expose a single auth input, so they bind + // to the first declared guard for the route. method.HasAuth = true method.Auth = route.Guards[0] method.AuthParam = authParamName(route.Guards[0].Name)