This repository has been archived on 2026-04-03. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
core/server/handlers/openapi.go
Jeffrey Smith 680ec3b897
All checks were successful
CI/CD / detect-changes (push) Successful in 3s
CI/CD / test-frontend (push) Successful in 5s
CI/CD / test-go-pg (push) Successful in 2m34s
CI/CD / test-sqlite (push) Successful in 2m46s
CI/CD / build-and-deploy (push) Successful in 1m55s
Feat rebrand armature (#43)
Co-authored-by: Jeffrey Smith <jasafpro@gmail.com>
Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
2026-03-31 23:25:37 +00:00

276 lines
7.2 KiB
Go

// Package handlers — openapi.go
//
// with extension-declared API routes. Extensions with api_schema get rich
// path items; others get auto-generated stubs.
//
// Two tiers:
// Tier 1 — Stubs: zero extension author effort, every route gets a stub
// Tier 2 — api_schema: optional manifest field for richer docs
package handlers
import (
"context"
"fmt"
"log"
"strings"
"armature/store"
"gopkg.in/yaml.v3"
)
// BuildOpenAPISpec produces a complete OpenAPI 3.0 spec by merging the static
// kernel spec with dynamically discovered extension routes.
//
// staticSpec is the raw YAML of the kernel's openapi.yaml.
// version is substituted into the spec's info.version field.
func BuildOpenAPISpec(stores store.Stores, staticSpec []byte, version string) (map[string]any, error) {
// 1. Parse static spec
spec := make(map[string]any)
patched := strings.Replace(string(staticSpec), "${VERSION}", version, 1)
if err := yaml.Unmarshal([]byte(patched), &spec); err != nil {
return nil, fmt.Errorf("failed to parse static OpenAPI spec: %w", err)
}
// Ensure paths map exists
paths, _ := spec["paths"].(map[string]any)
if paths == nil {
paths = make(map[string]any)
spec["paths"] = paths
}
// Ensure tags array exists
tags, _ := spec["tags"].([]any)
// 2. Enumerate enabled extensions with API routes
if stores.Packages == nil {
return spec, nil
}
pkgs, err := stores.Packages.List(context.Background())
if err != nil {
log.Printf("[openapi] Failed to list packages: %v", err)
return spec, nil // degrade gracefully — return kernel-only spec
}
for _, pkg := range pkgs {
if !pkg.Enabled || pkg.Manifest == nil {
continue
}
if !HasAPIRoutes(pkg.Manifest) {
continue
}
slug := pkg.ID
tagName := "extensions/" + slug
// Add extension tag
tags = append(tags, map[string]any{
"name": tagName,
"description": fmt.Sprintf("API routes for %s extension", pkg.Title),
})
// Build schema lookup from api_schema (if present)
schemaEntries := ParseAPISchema(pkg.Manifest)
schemaMap := make(map[string]APISchemaEntry) // key: "METHOD /path"
for _, entry := range schemaEntries {
key := entry.Method + " " + entry.Path
schemaMap[key] = entry
}
// Iterate declared routes
routes := ExtractAPIRoutes(pkg.Manifest)
if len(routes) == 0 {
// api_routes: true — no specific routes to enumerate.
// If api_schema is declared, generate entries from those.
for _, entry := range schemaEntries {
oaPath := fmt.Sprintf("/s/%s/api%s", slug, entry.Path)
method := strings.ToLower(entry.Method)
pathItem := getOrCreatePathItem(paths, oaPath)
pathItem[method] = buildSchemaOperation(entry, tagName)
}
continue
}
for _, route := range routes {
// Skip wildcard paths — can't represent meaningfully in OpenAPI
if strings.HasSuffix(route.Path, "*") {
continue
}
oaPath := fmt.Sprintf("/s/%s/api%s", slug, route.Path)
method := strings.ToLower(route.Method)
// Wildcard method: generate for common methods
methods := []string{method}
if route.Method == "*" {
methods = []string{"get", "post", "put", "delete", "patch"}
}
for _, m := range methods {
pathItem := getOrCreatePathItem(paths, oaPath)
key := strings.ToUpper(m) + " " + route.Path
if schema, ok := schemaMap[key]; ok {
pathItem[m] = buildSchemaOperation(schema, tagName)
} else {
pathItem[m] = buildStubOperation(slug, m, route.Path, tagName)
}
}
}
}
spec["tags"] = tags
return spec, nil
}
// getOrCreatePathItem returns the path item map for the given OpenAPI path,
// creating it if it doesn't exist.
func getOrCreatePathItem(paths map[string]any, path string) map[string]any {
if existing, ok := paths[path].(map[string]any); ok {
return existing
}
item := make(map[string]any)
paths[path] = item
return item
}
// buildStubOperation generates an auto-generated stub operation for routes
// without api_schema declarations.
func buildStubOperation(slug, method, path, tag string) map[string]any {
return map[string]any{
"summary": fmt.Sprintf("%s: %s %s", slug, strings.ToUpper(method), path),
"tags": []any{tag},
"responses": map[string]any{
"200": map[string]any{
"description": "Extension-defined response",
},
},
}
}
// buildSchemaOperation converts an APISchemaEntry into a full OpenAPI operation.
func buildSchemaOperation(entry APISchemaEntry, tag string) map[string]any {
op := map[string]any{
"summary": entry.Summary,
"tags": []any{tag},
}
if entry.Summary == "" {
op["summary"] = fmt.Sprintf("%s %s", entry.Method, entry.Path)
}
if entry.Description != "" {
op["description"] = entry.Description
}
// Query/path parameters
if len(entry.Params) > 0 {
var params []any
for name, def := range entry.Params {
param := map[string]any{
"name": name,
"in": "query",
}
if m, ok := def.(map[string]any); ok {
if t, ok := m["type"].(string); ok {
param["schema"] = map[string]any{"type": mapJSONType(t)}
}
if desc, ok := m["description"].(string); ok {
param["description"] = desc
}
if req, ok := m["required"].(bool); ok && req {
param["required"] = true
}
} else {
// Simple type string
if t, ok := def.(string); ok {
param["schema"] = map[string]any{"type": mapJSONType(t)}
}
}
params = append(params, param)
}
op["parameters"] = params
}
// Request body
if len(entry.Body) > 0 {
properties := make(map[string]any)
var required []any
for name, def := range entry.Body {
prop := map[string]any{}
if m, ok := def.(map[string]any); ok {
if t, ok := m["type"].(string); ok {
prop["type"] = mapJSONType(t)
}
if desc, ok := m["description"].(string); ok {
prop["description"] = desc
}
if req, ok := m["required"].(bool); ok && req {
required = append(required, name)
}
} else if t, ok := def.(string); ok {
prop["type"] = mapJSONType(t)
}
properties[name] = prop
}
schema := map[string]any{
"type": "object",
"properties": properties,
}
if len(required) > 0 {
schema["required"] = required
}
op["requestBody"] = map[string]any{
"required": true,
"content": map[string]any{
"application/json": map[string]any{
"schema": schema,
},
},
}
}
// Response
resp := map[string]any{
"200": map[string]any{
"description": "Successful response",
},
}
if entry.Response != nil {
respSchema := make(map[string]any)
if t, ok := entry.Response["type"].(string); ok {
respSchema["type"] = mapJSONType(t)
}
if ex, ok := entry.Response["example"]; ok {
respSchema["example"] = ex
}
resp["200"] = map[string]any{
"description": "Successful response",
"content": map[string]any{
"application/json": map[string]any{
"schema": respSchema,
},
},
}
}
op["responses"] = resp
return op
}
// mapJSONType converts common type names to OpenAPI types.
func mapJSONType(t string) string {
switch strings.ToLower(t) {
case "int", "integer":
return "integer"
case "float", "number", "double":
return "number"
case "bool", "boolean":
return "boolean"
case "array", "list":
return "array"
case "object", "map", "dict":
return "object"
default:
return "string"
}
}