feat(api): document explicit route parameters

Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
Virgil 2026-04-01 19:12:51 +00:00
parent 862604dc22
commit 0ed72c4952
4 changed files with 179 additions and 5 deletions

View file

@ -128,6 +128,7 @@ type RouteDescription struct {
Summary string
Description string
Tags []string
Parameters []ParameterDescription
RequestBody map[string]any
Response map[string]any
}

View file

@ -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.
}

View file

@ -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 {

View file

@ -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",