Weeks 1-7 of the template upgrade plan: - pkg/api: typed HTTPError with sentinels, Wrap/WrapMiddleware, Bind, health probes, OpenAPI schema/param builders - skeleton/packages: ui (design tokens, components), layout (DashboardShell), auth (AuthProvider, ProtectedRoute), api-client - skeleton/pkg: httperror, app/handler, app/bind, app/health, auth (JWT/API key middleware) - components/app-nextjs: Next.js 14 App Router template with dashboard, server actions, auth - cookbooks/feature-development.md with test and validation scripts - Handler tests for components, project management, and woodpecker webhook - 3 rounds of code review fixes applied Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
255 lines
6.7 KiB
Go
255 lines
6.7 KiB
Go
package api
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"sync"
|
|
|
|
scalargo "github.com/bdpiprava/scalar-go"
|
|
)
|
|
|
|
// OpenAPIInfo contains metadata about the API.
|
|
type OpenAPIInfo struct {
|
|
Title string `json:"title"`
|
|
Description string `json:"description,omitempty"`
|
|
Version string `json:"version"`
|
|
}
|
|
|
|
// OpenAPIServer describes a server endpoint.
|
|
type OpenAPIServer struct {
|
|
URL string `json:"url"`
|
|
Description string `json:"description,omitempty"`
|
|
}
|
|
|
|
// OpenAPIComponents contains reusable schema definitions.
|
|
type OpenAPIComponents struct {
|
|
Schemas map[string]any `json:"schemas,omitempty"`
|
|
SecuritySchemes map[string]any `json:"securitySchemes,omitempty"`
|
|
}
|
|
|
|
// OpenAPISpec represents a minimal OpenAPI 3.0 specification.
|
|
type OpenAPISpec struct {
|
|
OpenAPI string `json:"openapi"`
|
|
Info OpenAPIInfo `json:"info"`
|
|
Servers []OpenAPIServer `json:"servers,omitempty"`
|
|
Paths map[string]map[string]any `json:"paths"`
|
|
Tags []OpenAPITag `json:"tags,omitempty"`
|
|
Components *OpenAPIComponents `json:"components,omitempty"`
|
|
Security []map[string][]string `json:"security,omitempty"`
|
|
|
|
mu sync.RWMutex
|
|
}
|
|
|
|
// OpenAPITag groups operations together.
|
|
type OpenAPITag struct {
|
|
Name string `json:"name"`
|
|
Description string `json:"description,omitempty"`
|
|
}
|
|
|
|
// NewOpenAPISpec creates a new OpenAPI specification builder.
|
|
func NewOpenAPISpec(title, version string) *OpenAPISpec {
|
|
return &OpenAPISpec{
|
|
OpenAPI: "3.0.3",
|
|
Info: OpenAPIInfo{
|
|
Title: title,
|
|
Version: version,
|
|
},
|
|
Paths: make(map[string]map[string]any),
|
|
}
|
|
}
|
|
|
|
// WithDescription sets the API description.
|
|
func (s *OpenAPISpec) WithDescription(desc string) *OpenAPISpec {
|
|
s.Info.Description = desc
|
|
return s
|
|
}
|
|
|
|
// WithServer adds a server to the spec.
|
|
func (s *OpenAPISpec) WithServer(url, description string) *OpenAPISpec {
|
|
s.Servers = append(s.Servers, OpenAPIServer{
|
|
URL: url,
|
|
Description: description,
|
|
})
|
|
return s
|
|
}
|
|
|
|
// WithTag adds a tag for grouping operations.
|
|
func (s *OpenAPISpec) WithTag(name, description string) *OpenAPISpec {
|
|
s.Tags = append(s.Tags, OpenAPITag{
|
|
Name: name,
|
|
Description: description,
|
|
})
|
|
return s
|
|
}
|
|
|
|
// AddPath adds an operation to the spec.
|
|
// method should be lowercase (get, post, put, patch, delete).
|
|
func (s *OpenAPISpec) AddPath(path, method string, operation map[string]any) *OpenAPISpec {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
if s.Paths[path] == nil {
|
|
s.Paths[path] = make(map[string]any)
|
|
}
|
|
s.Paths[path][method] = operation
|
|
return s
|
|
}
|
|
|
|
// ensureComponents initializes the Components field if nil.
|
|
// Must be called while holding s.mu.
|
|
func (s *OpenAPISpec) ensureComponents() {
|
|
if s.Components == nil {
|
|
s.Components = &OpenAPIComponents{
|
|
Schemas: make(map[string]any),
|
|
SecuritySchemes: make(map[string]any),
|
|
}
|
|
}
|
|
}
|
|
|
|
// WithSchema adds a reusable schema to components/schemas.
|
|
func (s *OpenAPISpec) WithSchema(name string, schema Schema) *OpenAPISpec {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
s.ensureComponents()
|
|
if s.Components.Schemas == nil {
|
|
s.Components.Schemas = make(map[string]any)
|
|
}
|
|
s.Components.Schemas[name] = schema
|
|
return s
|
|
}
|
|
|
|
// WithAPIKeySecurity adds API key security scheme.
|
|
func (s *OpenAPISpec) WithAPIKeySecurity(name, headerName, description string) *OpenAPISpec {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
s.ensureComponents()
|
|
if s.Components.SecuritySchemes == nil {
|
|
s.Components.SecuritySchemes = make(map[string]any)
|
|
}
|
|
s.Components.SecuritySchemes[name] = map[string]any{
|
|
"type": "apiKey",
|
|
"in": "header",
|
|
"name": headerName,
|
|
"description": description,
|
|
}
|
|
return s
|
|
}
|
|
|
|
// WithBearerSecurity adds Bearer token security scheme.
|
|
func (s *OpenAPISpec) WithBearerSecurity(name, description string) *OpenAPISpec {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
s.ensureComponents()
|
|
if s.Components.SecuritySchemes == nil {
|
|
s.Components.SecuritySchemes = make(map[string]any)
|
|
}
|
|
s.Components.SecuritySchemes[name] = map[string]any{
|
|
"type": "http",
|
|
"scheme": "bearer",
|
|
"bearerFormat": "JWT",
|
|
"description": description,
|
|
}
|
|
return s
|
|
}
|
|
|
|
// WithGlobalSecurity sets global security requirements.
|
|
func (s *OpenAPISpec) WithGlobalSecurity(schemeName string) *OpenAPISpec {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
s.Security = append(s.Security, map[string][]string{
|
|
schemeName: {},
|
|
})
|
|
return s
|
|
}
|
|
|
|
// JSON returns the spec as JSON bytes.
|
|
func (s *OpenAPISpec) JSON() ([]byte, error) {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
return json.MarshalIndent(s, "", " ")
|
|
}
|
|
|
|
// EnableDocs adds /docs and /openapi.json endpoints to the app.
|
|
func (a *App) EnableDocs(spec *OpenAPISpec) {
|
|
// Serve OpenAPI JSON
|
|
a.router.Get("/openapi.json", func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.Header().Set("Access-Control-Allow-Origin", "*")
|
|
|
|
specBytes, err := spec.JSON()
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
_, _ = w.Write(specBytes)
|
|
})
|
|
|
|
// Serve Scalar docs UI
|
|
a.router.Get("/docs", func(w http.ResponseWriter, r *http.Request) {
|
|
// Detect scheme: check X-Forwarded-Proto first (for reverse proxy/TLS termination),
|
|
// then fall back to r.TLS for direct HTTPS connections
|
|
scheme := "http"
|
|
if proto := r.Header.Get("X-Forwarded-Proto"); proto != "" {
|
|
scheme = proto
|
|
} else if r.TLS != nil {
|
|
scheme = "https"
|
|
}
|
|
specURL := fmt.Sprintf("%s://%s/openapi.json", scheme, r.Host)
|
|
|
|
html, err := scalargo.NewV2(
|
|
scalargo.WithSpecURL(specURL),
|
|
scalargo.WithDarkMode(),
|
|
)
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
_, _ = fmt.Fprint(w, html)
|
|
})
|
|
|
|
a.logger.Info("API documentation enabled", "docs", "/docs", "spec", "/openapi.json")
|
|
}
|
|
|
|
// Op creates an OpenAPI operation helper.
|
|
func Op(summary, description string, tags ...string) map[string]any {
|
|
return map[string]any{
|
|
"summary": summary,
|
|
"description": description,
|
|
"tags": tags,
|
|
"responses": map[string]any{
|
|
"200": map[string]any{"description": "Success"},
|
|
},
|
|
}
|
|
}
|
|
|
|
// OpWithBody creates an OpenAPI operation with a request body.
|
|
func OpWithBody(summary, description string, tags ...string) map[string]any {
|
|
return map[string]any{
|
|
"summary": summary,
|
|
"description": description,
|
|
"tags": tags,
|
|
"requestBody": map[string]any{
|
|
"required": true,
|
|
"content": map[string]any{
|
|
"application/json": map[string]any{
|
|
"schema": map[string]any{
|
|
"type": "object",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
"responses": map[string]any{
|
|
"200": map[string]any{"description": "Success"},
|
|
"201": map[string]any{"description": "Created"},
|
|
},
|
|
}
|
|
}
|