148 lines
4.6 KiB
Go
148 lines
4.6 KiB
Go
// Package apispec embeds the OpenAPI document, looks up per-operation
|
|
// slices, and writes the locked 501 envelope. The 501 body carries the
|
|
// operation's slice of the OpenAPI document so consumers discover the
|
|
// contract from the endpoint itself — no duplicate planned/contract
|
|
// metadata lives in code.
|
|
//
|
|
// The same document is served verbatim at /api/v1/openapi.yaml so
|
|
// tooling that wants the whole spec can fetch it once.
|
|
package apispec
|
|
|
|
import (
|
|
_ "embed"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"strings"
|
|
"sync"
|
|
|
|
"github.com/go-chi/chi/v5/middleware"
|
|
yaml "gopkg.in/yaml.v3"
|
|
)
|
|
|
|
//go:embed openapi.yaml
|
|
var openapiYAML []byte
|
|
|
|
// Spec is the parsed, in-memory view of the embedded OpenAPI document. It
|
|
// preserves the YAML shape verbatim so the JSON we emit on 501 responses
|
|
// matches the on-disk source.
|
|
type Spec struct {
|
|
doc map[string]any
|
|
rawYAML []byte
|
|
}
|
|
|
|
var (
|
|
defaultOnce sync.Once
|
|
defaultSpec *Spec
|
|
defaultErr error
|
|
)
|
|
|
|
// Default returns the process-wide spec parsed from the embedded YAML. It
|
|
// panics on a malformed embed — that is a build-time bug, not a runtime
|
|
// one, so failing fast at first use is the right call.
|
|
func Default() *Spec {
|
|
defaultOnce.Do(func() {
|
|
s, err := New(openapiYAML)
|
|
defaultSpec = s
|
|
defaultErr = err
|
|
})
|
|
if defaultErr != nil {
|
|
panic(fmt.Sprintf("apispec: embedded openapi.yaml failed to parse: %v", defaultErr))
|
|
}
|
|
return defaultSpec
|
|
}
|
|
|
|
// New parses the supplied YAML bytes. Exposed so tests can construct an
|
|
// independent spec without touching the embedded default.
|
|
func New(yamlBytes []byte) (*Spec, error) {
|
|
var doc map[string]any
|
|
if err := yaml.Unmarshal(yamlBytes, &doc); err != nil {
|
|
return nil, fmt.Errorf("parse openapi: %w", err)
|
|
}
|
|
if doc == nil {
|
|
return nil, fmt.Errorf("parse openapi: empty document")
|
|
}
|
|
return &Spec{doc: doc, rawYAML: yamlBytes}, nil
|
|
}
|
|
|
|
// YAML returns the raw YAML bytes this spec was built from.
|
|
func (s *Spec) YAML() []byte {
|
|
return s.rawYAML
|
|
}
|
|
|
|
// Operation returns the spec slice for a single (method, path) pair, ready
|
|
// to be JSON-serialised. The slice is the OpenAPI Operation object (the
|
|
// inner block under e.g. paths./projects.get), with parent path-level
|
|
// parameters merged in for completeness.
|
|
//
|
|
// Returns nil if the path or method is not in the spec; that is treated as
|
|
// a developer error (route registered without spec coverage) — callers
|
|
// log/fail loudly rather than silently writing a partial 501 body.
|
|
func (s *Spec) Operation(method, path string) map[string]any {
|
|
paths, _ := s.doc["paths"].(map[string]any)
|
|
if paths == nil {
|
|
return nil
|
|
}
|
|
pathItem, _ := paths[path].(map[string]any)
|
|
if pathItem == nil {
|
|
return nil
|
|
}
|
|
op, _ := pathItem[strings.ToLower(method)].(map[string]any)
|
|
if op == nil {
|
|
return nil
|
|
}
|
|
|
|
// Path-level parameters apply to every method on that path; merge them
|
|
// in so the slice is self-contained.
|
|
out := make(map[string]any, len(op)+1)
|
|
for k, v := range op {
|
|
out[k] = v
|
|
}
|
|
if params, ok := pathItem["parameters"]; ok {
|
|
// Prefer the operation's own parameters when both are present;
|
|
// otherwise inherit from the path level.
|
|
if _, exists := out["parameters"]; !exists {
|
|
out["parameters"] = params
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// notImplementedResponse is the wire shape for 501 — APIError envelope
|
|
// plus a `spec` field carrying the operation slice. Mirrors the
|
|
// NotImplementedResponse schema in openapi.yaml.
|
|
type notImplementedResponse struct {
|
|
Error string `json:"error"`
|
|
Code string `json:"code"`
|
|
Message string `json:"message"`
|
|
RequestID string `json:"requestId,omitempty"`
|
|
Spec map[string]any `json:"spec"`
|
|
}
|
|
|
|
// NotImplemented writes the locked 501 envelope, embedding the OpenAPI
|
|
// Operation slice for the capability that is currently unavailable.
|
|
func NotImplemented(w http.ResponseWriter, r *http.Request, method, path string) {
|
|
op := Default().Operation(method, path)
|
|
if op == nil {
|
|
panic(fmt.Sprintf("apispec: missing operation for %s %s", method, path))
|
|
}
|
|
body := notImplementedResponse{
|
|
Error: "not_implemented",
|
|
Code: "NOT_IMPLEMENTED",
|
|
Message: method + " " + path + " is unavailable in this daemon",
|
|
RequestID: middleware.GetReqID(r.Context()),
|
|
Spec: op,
|
|
}
|
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
|
w.WriteHeader(http.StatusNotImplemented)
|
|
// A write error here means the client went away mid-response.
|
|
_ = json.NewEncoder(w).Encode(body)
|
|
}
|
|
|
|
// ServeYAML serves the embedded OpenAPI document for SDK generators, tests, and
|
|
// developer tooling.
|
|
func ServeYAML(w http.ResponseWriter, _ *http.Request) {
|
|
w.Header().Set("Content-Type", "application/yaml; charset=utf-8")
|
|
_, _ = w.Write(openapiYAML)
|
|
}
|