Skip to content

improvements of OpenAPI generation - #20

Open
esmin wants to merge 1 commit into
coregx:mainfrom
esmin:feat/openAPIgen
Open

improvements of OpenAPI generation#20
esmin wants to merge 1 commit into
coregx:mainfrom
esmin:feat/openAPIgen

Conversation

@esmin

@esmin esmin commented Sep 11, 2026

Copy link
Copy Markdown

Feature details

A — Safety & spec validity — ✅

  • A1. Add cycle detection to generateSchema: thread a seen map[reflect.Type]bool; on revisit return &Schema{} (or a $ref) instead of recursing. Prevents stack overflow for recursive/mutually-recursive types.
  • A2. Auto-declare path parameters in GenerateOpenAPI: after convertPathToOpenAPI, scan {name} tokens and synthesize missing Parameter{Name, In: "path", Required: true, Schema: string}. User-supplied parameters override.
  • A3. Stop clobbering user-supplied 400/500 in RouteOptions.Responses (assign defaults only when absent).
  • A4. Tests: recursive type terminates; /users/{id} includes a required path parameter; explicit 400 survives.
  • A5. Fix the malformed Go deprecation markers on the 7 package-level functions in router_generic.go (GET/POST/PUT/DELETE/PATCH/HEAD/OPTIONS): move Deprecated: into its own paragraph (blank comment line before it) so staticcheck SA1019 / gopls / pkg.go.dev recognize it. Scope: this is the only planned change to these 7 functions. They remain deprecated shims that delegate to r.Handle(...); no functional/metadata changes to them.

Verified: go test -race ./..., go vet ./..., gofmt clean, golangci-lint run ./... 0 issues; A5 confirmed via staticcheck SA1019 from a separate package.

B — Wire generic types (core) — ✅

  • B1. Add genericBodyType[T]() returning nil for the Empty sentinel, else reflect.TypeFor[T]() (mirrors the check in Box.Bind, box.go:231-234).
  • B2. Refactor HandleWithOptions to delegate to a private registerRoute(..., reqType, resType reflect.Type) that sets RouteInfo.RequestType/ResponseType.
  • B3. Add registerGeneric[Req, Res](method, path, handler, opts) and route the 7 *Router methods (GET/POST/PUT/DELETE/PATCH/HEAD/OPTIONS) through it, with variadic opts ...*RouteOptions. Scope: *Router methods only — the deprecated package-level functions in router_generic.go are out of scope (see A5).
  • B4. Tests: registration records both types; Empty yields nil; generated spec contains request/response schemas.

Verified: go test -race ./..., go vet ./..., gofmt clean, golangci-lint run ./... 0 issues; examples/02-rest-api-crud still compiles. Deferred to Phase C: SuccessStatus (default remains 200).

C — Completeness — ✅

  • C1. Add RouteOptions.SuccessStatus int (default 200; 204 emits no body) and thread it into generation. This is what makes POST/DELETE accurate.
  • C2. Add RouteOptions.OptionalRequestBody bool (request bodies default to required: true).
  • C3. Group support: add a types/options-aware variant of handleWithGroupMiddleware and variadic options on RouteGroup generic methods.
  • C4. Tests: SuccessStatus: 201/204; explicit Responses override inference; group generics documented.

Verified: go test -race ./..., go vet ./..., gofmt clean, golangci-lint run ./... 0 issues; example module compiles. Also added RouteGroup.HandleWithOptions for plain-handler parity.

Phase D — Docs & example — ✅

  • D1. Demonstrate WithInfo(...) + RouteOptions (e.g. Summary, Tags, SuccessStatus) in a runnable example — delivered as examples/03-rest-api-with-openapi/
  • D2. Fix the README, which advertised a non-existent r.OpenAPI(fursy.OpenAPIConfig{...}) API; the real API is WithInfo(Info{...}) + ServeOpenAPI(path) / GenerateOpenAPI(Info{}).
  • D3. Update CHANGELOG.md and llms.md.

Verified: examples/03-rest-api-with-openapi/ generates 201/204 responses, summaries/tags, named component schemas ($ref), and auto-declared path params; the example compiles (gofmt clean).

Phase E — Enhancements (E1–E2 done; E3–E4 pending)

  • E1. Named schemas + $ref into components.schemas (replaces inline duplication, e.g. a shared type referenced from several operations). — ✅ DONE
  • E2. operationId auto-generation from method + path. — ✅ DONE
  • E3. Content negotiation awareness (application/json, XML, form).
  • E4. Optional caching of the generated document.
  • E5. skip openAPI gen for route (e.g. frontend serving or openapi.json endpoint

Every generic route method accepts optional *RouteOptions as a trailing variadic argument, on both *Router and *RouteGroup:

router.POST("/users", h.CreateUser,
    &fursy.RouteOptions{Summary: "Create user", Tags: []string{"users"}, SuccessStatus: 201})

router.DELETE("/users/:id", h.DeleteUser,
    &fursy.RouteOptions{Summary: "Delete user", Tags: []string{"users"}, SuccessStatus: 204})

Plain handlers get the same metadata via Router.HandleWithOptions and RouteGroup.HandleWithOptions.

RouteOptions fields

type RouteOptions struct {
    Summary     string
    Description string
    Tags        []string
    OperationID string
    Deprecated  bool
    Parameters  []RouteParameter
    Responses   map[int]RouteResponse

    // SuccessStatus is the default success status for the inferred response
    // schema. Defaults to 200. If 204, no response body is emitted.
    SuccessStatus int

    // OptionalRequestBody marks the inferred request body as not required.
    OptionalRequestBody bool
}

Behaviour

  • Req/Res on Box[Req, Res] are recorded automatically; named types become components.schemas entries referenced by $ref (Empty means "no body").
  • SuccessStatus sets the inferred success response (0 ⇒ 200); 204 emits no content.
  • Explicit Responses take precedence over inference and are never overwritten by the default 400/500.
  • :param templates are auto-declared as required in: path parameters; explicit Parameters override.
  • OperationID is preserved; otherwise a unique id is derived from method + path (E2).

Type capture

// genericBodyType returns nil for the Empty sentinel, otherwise the type T.
func genericBodyType[T any]() reflect.Type {
    var zero T
    if _, ok := any(zero).(Empty); ok {
        return nil
    }
    return reflect.TypeFor[T]() // reflect.TypeFor: Go 1.22+
}

Registration core

func (r *Router) HandleWithOptions(method, path string, handler HandlerFunc, opts *RouteOptions) {
    r.registerRoute(method, path, handler, opts, nil, nil)
}

func (r *Router) registerRoute(method, path string, handler HandlerFunc,
    opts *RouteOptions, reqType, resType reflect.Type) {

    // existing panics + r.trees insert (unchanged)

    routeInfo := RouteInfo{
        Method: method, Path: path,
        RequestType: reqType, ResponseType: resType, // the fix
    }
    applyRouteOptions(&routeInfo, opts) // Summary/Tags/Responses/SuccessStatus/...
    r.routes = append(r.routes, routeInfo)
}

Generic registration

func (r *Router) registerGeneric[Req, Res any](method, path string,
    handler Handler[Req, Res], opts *RouteOptions) {
    r.registerRoute(method, path, adaptGenericHandler(handler), opts,
        genericBodyType[Req](), genericBodyType[Res]())
}

func (r *Router) POST[Req, Res any](path string, handler Handler[Req, Res], opts ...*RouteOptions) {
    r.registerGeneric(http.MethodPost, path, handler, firstRouteOptions(opts))
}
// GET/PUT/DELETE/PATCH/HEAD/OPTIONS: identical.

adaptGenericHandler is untouched — types are pure metadata; runtime binding and maxBodySize limits are unaffected.

Generator adjustments (openapi.go)

GenerateOpenAPI consumes RouteInfo.RequestType/ResponseType and:

  1. Emits the inferred success status from SuccessStatus (default 200; 204 ⇒ no content) — openapi.go:719-731.
  2. Uses explicit Responses when provided; never overwrites user 400/500 with defaults.
  3. Auto-declares path parameters (Phase A2).
  4. Guards against recursive types (Phase A1).
  5. Registers named package types once in components.schemas and references them with $ref via a schemaRegistry (Phase E1) — openapi.go:401-473.
  6. Assigns a unique operationId (explicit, or derived from method + path) (Phase E2).

The RequestType check is at openapi.go:692.

Semantics

Req Request body Res Default success response
Empty none T 200 + $ref/schema for T
non-Empty application/json schema for Req (required unless OptionalRequestBody) T 200 + $ref/schema for T
non-Empty as above Empty 200, no body
any any any SuccessStatus override wins; 204 ⇒ no body

Named package types are emitted as $ref to components.schemas (E1); unnamed types are inlined.

Precedence: explicit Responses > inferred (RequestType/ResponseType + SuccessStatus) > bare 200 Success.


Test coverage

Implemented in openapi_test.go:

  • genericBodyType: Empty → nil; struct/pointer/slice/string → non-nil.
  • Registration: POST[Req,Res] records both types on RouteInfo; variadic *RouteOptions are recorded.
  • Generation: request body + success schema present (as named $refs); SuccessStatus: 201/204 (204 ⇒ no content); explicit Responses override inference and are not clobbered; group generics are documented.
  • Validity: /users/{id} declares id as in: path, required: true; explicit Parameters override auto-declaration.
  • Safety: recursive and mutually-recursive structs terminate.
  • operationId: deterministic, unique ids.

Not done (optional):

  • A golden test reusing the example's types — the example is a separate module, so tests use a local testUser.

Verification: go test -race ./..., go vet ./..., golangci-lint run ./..., gofmt.


5. Backward compatibility

  • Variadic opts ...*RouteOptions — existing generic 2-arg calls compile unchanged.
  • RouteOptions gains fields — additive; HandleWithOptions signature unchanged.
  • RouteInfo.RequestType/ResponseType already exported.
  • Behavioral change: generic routes now appear with schemas/params (intended). Nothing broke because no pre-existing test asserted the old bare output; tests now cover the new schemas/$refs.

6. Decisions & open questions

Decided

  • POST default success status — keep 200; use SuccessStatus for 201/204 .
  • Request body required — defaults to true; OptionalRequestBody opts out
  • Schema dedup — named components registered once in components.schemas and referenced by $ref .

Open

  • Content type — inference emits application/json only, while binding also accepts XML/form (E3).
  • Res == Empty without SuccessStatus still emits 200 Success; only SuccessStatus: 204 removes the body.
  • ServeOpenAPI regenerates per request with no caching (E4) and documents itself (E5) (/openapi.json) plus any GET / docs route.

References

  • fursy/openapi.go — generation, generateSchema, convertPathToOpenAPI, ServeOpenAPI.
  • fursy/router.go — generic methods, Handle/HandleWithOptions, route metadata.
  • fursy/group.go, fursy/route_info.go, fursy/handler_generic.go, fursy/box.go.
  • fursy/openapi_test.go — current coverage.
  • examples/03-rest-api-with-openapi/ — example showcasing the generated OpenAPI.

Appendix — Deferred static lint findings (pre-existing, not OpenAPI-related)

Surfaced on 2026-09-11 after rebuilding the linters with the local Go 1.27.1 toolchain (staticcheck v0.8.1, golangci-lint v2.13.2, gopls v0.23.0). Deliberately not fixed yet — recorded here so they aren't lost.

Location Check Summary Runtime impact Recommended action
internal/binding/binding_test.go:497 U1000 (unused) Intentionally-unexported hidden field in the withUnexported test struct; kept only to verify form binding skips unexported fields. None (test-only; field never read) Leave as-is, or silence with //lint:ignore U1000 <reason> (standalone staticcheck does not honor //nolint:unused).
middleware/circuitbreaker.go:318 S1040 (gosimple) Redundant type assertion originalResponse.(http.ResponseWriter)Context.Response is already declared as http.ResponseWriter (context.go:95-105), so ok is always true. The adjacent comment claiming c.Response is of type any (line 315) is stale/wrong. None (assertion cannot fail for a non-nil writer) Shipped production code: prefer removing the assertion and wrapping directly (or keeping a != nil guard) and deleting the misleading comment, over suppression.

Why they were invisible before

  • The previously-installed staticcheck (v0.6.1, built with Go 1.25.3) could not analyze this Go 1.27 module at all (export data version 4 > 2; module requires at least go1.27, but Staticcheck was built with go1.25.3), so it reported nothing.
  • CI (.github/workflows/test.yml) runs golangci-lint, and .golangci.yml enables staticcheck (SA* only) but not gosimple (S1040) or unused (U1000); hence golangci-lint run ./... reports 0 issues.
  • Both lines are old and unrelated to this work: circuitbreaker.go:317-318 dates to 2025-11-16; binding_test.go:497 dates to 2026-03-05.

To enable detection in CI (optional)

Add gosimple and unused to linters.enable in .golangci.yml, or add a standalone staticcheck ./... CI step. Doing so would also make the deprecation markers actionable in CI.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant