diff --git a/openapi.go b/openapi.go index 64766dc..0025db5 100644 --- a/openapi.go +++ b/openapi.go @@ -132,6 +132,10 @@ func (sb *SpecBuilder) buildPaths(groups []RouteGroup) map[string]any { "responses": operationResponses(method, rd.Response), } + if params := pathParameters(fullPath); len(params) > 0 { + operation["parameters"] = params + } + // Add request body for methods that accept one. // The contract only excludes GET; other verbs may legitimately carry bodies. if rd.RequestBody != nil && method != "get" { @@ -307,6 +311,44 @@ func (sb *SpecBuilder) buildTags(groups []RouteGroup) []map[string]any { return tags } +// pathParameters extracts unique OpenAPI path parameters from a path template. +// Parameters are returned in the order they appear in the path. +func pathParameters(path string) []map[string]any { + const ( + open = '{' + close = '}' + ) + + seen := map[string]bool{} + params := make([]map[string]any, 0) + + for i := 0; i < len(path); i++ { + if path[i] != open { + continue + } + end := strings.IndexByte(path[i+1:], close) + if end < 0 { + continue + } + name := path[i+1 : i+1+end] + if name == "" || strings.ContainsAny(name, "/{}") || seen[name] { + continue + } + seen[name] = true + params = append(params, map[string]any{ + "name": name, + "in": "path", + "required": true, + "schema": map[string]any{ + "type": "string", + }, + }) + i += end + 1 + } + + return params +} + // 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 2087abb..54bfdca 100644 --- a/openapi_test.go +++ b/openapi_test.go @@ -567,6 +567,63 @@ func TestSpecBuilder_Good_RequestBodyOnHead(t *testing.T) { } } +func TestSpecBuilder_Good_PathParameters(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}/{slug}", + Summary: "Get user", + 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}/{slug}"].(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 path parameters, got %d", len(params)) + } + + first := params[0].(map[string]any) + if first["name"] != "id" { + t.Fatalf("expected first parameter name=id, got %v", first["name"]) + } + if first["in"] != "path" { + t.Fatalf("expected first parameter in=path, got %v", first["in"]) + } + if required, ok := first["required"].(bool); !ok || !required { + t.Fatalf("expected first parameter to be required, got %v", first["required"]) + } + + second := params[1].(map[string]any) + if second["name"] != "slug" { + t.Fatalf("expected second parameter name=slug, got %v", second["name"]) + } +} + func TestSpecBuilder_Good_NonDescribableGroup(t *testing.T) { sb := &api.SpecBuilder{ Title: "Test",