feat(api): document explicit route parameters
Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
parent
862604dc22
commit
0ed72c4952
4 changed files with 179 additions and 5 deletions
|
|
@ -128,6 +128,7 @@ type RouteDescription struct {
|
|||
Summary string
|
||||
Description string
|
||||
Tags []string
|
||||
Parameters []ParameterDescription
|
||||
RequestBody map[string]any
|
||||
Response map[string]any
|
||||
}
|
||||
|
|
|
|||
22
group.go
22
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.
|
||||
}
|
||||
|
|
|
|||
80
openapi.go
80
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 {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue