From 0ed72c49527cd3690d657d02069bd6ffdc88861c Mon Sep 17 00:00:00 2001 From: Virgil Date: Wed, 1 Apr 2026 19:12:51 +0000 Subject: [PATCH] feat(api): document explicit route parameters Co-Authored-By: Virgil --- docs/architecture.md | 1 + group.go | 22 +++++++++--- openapi.go | 80 +++++++++++++++++++++++++++++++++++++++++++ openapi_test.go | 81 ++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 179 insertions(+), 5 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index b418adb..9e18931 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -128,6 +128,7 @@ type RouteDescription struct { Summary string Description string Tags []string + Parameters []ParameterDescription RequestBody map[string]any Response map[string]any } diff --git a/group.go b/group.go index 46d0cf4..b6e0c0c 100644 --- a/group.go +++ b/group.go @@ -34,11 +34,23 @@ type DescribableGroup interface { // RouteDescription describes a single endpoint for OpenAPI generation. type RouteDescription struct { - Method string // HTTP method: GET, POST, PUT, DELETE, PATCH - Path string // Path relative to BasePath, e.g. "/generate" - Summary string // Short summary - Description string // Long description - Tags []string // OpenAPI tags for grouping + Method string // HTTP method: GET, POST, PUT, DELETE, PATCH + Path string // Path relative to BasePath, e.g. "/generate" + Summary string // Short summary + Description string // Long description + Tags []string // OpenAPI tags for grouping + Parameters []ParameterDescription RequestBody map[string]any // JSON Schema for request body (nil for GET) Response map[string]any // JSON Schema for success response data } + +// ParameterDescription describes an OpenAPI parameter for a route. +type ParameterDescription struct { + Name string // Parameter name. + In string // Parameter location: path, query, header, or cookie. + Description string // Human-readable parameter description. + Required bool // Whether the parameter is required. + Deprecated bool // Whether the parameter is deprecated. + Schema map[string]any // JSON Schema for the parameter value. + Example any // Optional example value. +} diff --git a/openapi.go b/openapi.go index 0025db5..35aa67c 100644 --- a/openapi.go +++ b/openapi.go @@ -135,6 +135,9 @@ func (sb *SpecBuilder) buildPaths(groups []RouteGroup) map[string]any { if params := pathParameters(fullPath); len(params) > 0 { operation["parameters"] = params } + if explicit := operationParameters(rd.Parameters); len(explicit) > 0 { + operation["parameters"] = mergeOperationParameters(operation["parameters"], explicit) + } // Add request body for methods that accept one. // The contract only excludes GET; other verbs may legitimately carry bodies. @@ -349,6 +352,83 @@ func pathParameters(path string) []map[string]any { return params } +// operationParameters converts explicit route parameter descriptions into +// OpenAPI parameter objects. +func operationParameters(params []ParameterDescription) []map[string]any { + if len(params) == 0 { + return nil + } + + out := make([]map[string]any, 0, len(params)) + for _, param := range params { + if param.Name == "" || param.In == "" { + continue + } + + entry := map[string]any{ + "name": param.Name, + "in": param.In, + "required": param.Required || param.In == "path", + } + if param.Description != "" { + entry["description"] = param.Description + } + if param.Deprecated { + entry["deprecated"] = true + } + if len(param.Schema) > 0 { + entry["schema"] = param.Schema + } else if param.In == "path" || param.In == "query" || param.In == "header" || param.In == "cookie" { + entry["schema"] = map[string]any{"type": "string"} + } + if param.Example != nil { + entry["example"] = param.Example + } + + out = append(out, entry) + } + + return out +} + +// mergeOperationParameters combines generated and explicit parameter +// definitions, letting explicit entries override auto-generated path params. +func mergeOperationParameters(existing any, explicit []map[string]any) []map[string]any { + merged := make([]map[string]any, 0, len(explicit)) + index := map[string]int{} + + add := func(param map[string]any) { + name, _ := param["name"].(string) + in, _ := param["in"].(string) + if name == "" || in == "" { + return + } + key := in + ":" + name + if pos, ok := index[key]; ok { + merged[pos] = param + return + } + index[key] = len(merged) + merged = append(merged, param) + } + + if params, ok := existing.([]map[string]any); ok { + for _, param := range params { + add(param) + } + } + + for _, param := range explicit { + add(param) + } + + if len(merged) == 0 { + return nil + } + + return merged +} + // resolvedOperationTags returns the explicit route tags when provided, or a // stable fallback derived from the group's name when the route omits tags. func resolvedOperationTags(g RouteGroup, rd RouteDescription) []string { diff --git a/openapi_test.go b/openapi_test.go index 54bfdca..b75f3ea 100644 --- a/openapi_test.go +++ b/openapi_test.go @@ -624,6 +624,87 @@ func TestSpecBuilder_Good_PathParameters(t *testing.T) { } } +func TestSpecBuilder_Good_ExplicitParameters(t *testing.T) { + sb := &api.SpecBuilder{ + Title: "Test", + Version: "1.0.0", + } + + group := &specStubGroup{ + name: "users", + basePath: "/api", + descs: []api.RouteDescription{ + { + Method: "GET", + Path: "/users/{id}", + Summary: "Get user", + Parameters: []api.ParameterDescription{ + { + Name: "id", + In: "path", + Description: "User identifier", + Schema: map[string]any{ + "type": "string", + }, + }, + { + Name: "verbose", + In: "query", + Description: "Include verbose details", + Schema: map[string]any{ + "type": "boolean", + }, + }, + }, + Response: map[string]any{ + "type": "object", + }, + }, + }, + } + + data, err := sb.Build([]api.RouteGroup{group}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var spec map[string]any + if err := json.Unmarshal(data, &spec); err != nil { + t.Fatalf("invalid JSON: %v", err) + } + + op := spec["paths"].(map[string]any)["/api/users/{id}"].(map[string]any)["get"].(map[string]any) + params, ok := op["parameters"].([]any) + if !ok { + t.Fatalf("expected parameters array, got %T", op["parameters"]) + } + if len(params) != 2 { + t.Fatalf("expected 2 parameters, got %d", len(params)) + } + + pathParam := params[0].(map[string]any) + if pathParam["name"] != "id" { + t.Fatalf("expected path parameter name=id, got %v", pathParam["name"]) + } + if pathParam["in"] != "path" { + t.Fatalf("expected path parameter in=path, got %v", pathParam["in"]) + } + if pathParam["description"] != "User identifier" { + t.Fatalf("expected merged path parameter description, got %v", pathParam["description"]) + } + + queryParam := params[1].(map[string]any) + if queryParam["name"] != "verbose" { + t.Fatalf("expected query parameter name=verbose, got %v", queryParam["name"]) + } + if queryParam["in"] != "query" { + t.Fatalf("expected query parameter in=query, got %v", queryParam["in"]) + } + if required, ok := queryParam["required"].(bool); !ok || required { + t.Fatalf("expected query parameter to be optional, got %v", queryParam["required"]) + } +} + func TestSpecBuilder_Good_NonDescribableGroup(t *testing.T) { sb := &api.SpecBuilder{ Title: "Test",