rdev/pkg/api/openapi.go
jordan 002c32aedb feat: add album generation system to skeleton
Adds anchor-based image album generation across docs, skeleton, and rendered
full-monorepo. One subject description + one anchor image + N directed shots,
covering personas, products, characters, and brand assets out of the box.

## What ships

**Skeleton packages:**
- pkg/album/types.go — Album, Shot, ShotStatus, ShotTemplate, AlbumUpdater
- pkg/album/templates.go — PortraitSession, ProductShoot, CharacterSheet built-ins
- pkg/album/handler.go — AnchorHandler + ShotHandler queue job handlers
- packages/realtime/src/useAlbumGeneration.ts — SSE hook owning all album state
- packages/ui/src/components/AlbumGrid.tsx — responsive shot grid with shimmer
- packages/ui/src/components/ShotCard.tsx — pending/generating/complete/failed states
- packages/ui/src/components/AnchorPreview.tsx — anchor CTA + image with controls

**Component service template:**
- internal/port/album.go — AlbumRepository interface
- internal/adapter/memory/album.go — in-memory repo for standalone dev
- internal/service/album.go — create, list, get, generateAnchor, generateAllShots
- internal/api/handlers/album.go — HTTP handlers (CRUD + 202 generation endpoints)
- Routes: GET/POST /albums, GET/DELETE /albums/{id}, POST /albums/{id}/anchor,
  POST/DELETE /albums/{id}/shots, POST /albums/{id}/shots/{index}

**Documentation:**
- .claude/guides/album.md — full guide with API, SSE events, frontend usage

**Key architecture decisions:**
- Anchor bytes never stored in queue payload — workers fetch AnchorURL at runtime
- Generation order enforced: POST /shots returns 422 if no anchor exists
- All album SSE events on existing user:<userId> channel (no new channel)
- AlbumUpdater interface lets job handlers update repo from inside queue workers

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-22 23:57:21 -07:00

283 lines
7.4 KiB
Go

package api
import (
"encoding/json"
"fmt"
"net/http"
"strings"
"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")
}
// opSummaryToID converts a human-readable summary to a camelCase operationId.
func opSummaryToID(summary string) string {
words := strings.Fields(summary)
var sb strings.Builder
first := true
for _, word := range words {
clean := strings.Map(func(r rune) rune {
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') {
return r
}
return -1
}, word)
if clean == "" {
continue
}
if first {
sb.WriteString(strings.ToLower(clean[:1]) + clean[1:])
first = false
} else {
sb.WriteString(strings.ToUpper(clean[:1]) + clean[1:])
}
}
return sb.String()
}
// Op creates an OpenAPI operation helper.
func Op(summary, description string, tags ...string) map[string]any {
return map[string]any{
"operationId": opSummaryToID(summary),
"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{
"operationId": opSummaryToID(summary),
"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"},
},
}
}