feat: declare API errors and authentication with developer tooling

This commit is contained in:
2026-09-23 18:17:43 +08:00
parent ab8eac5fc4
commit 2913e94a0a
35 changed files with 2246 additions and 107 deletions

View File

@@ -0,0 +1,197 @@
package apigen
import (
"fmt"
"net/http"
"regexp"
"sort"
"strings"
)
var errorCode = regexp.MustCompile(`^[A-Z][A-Z0-9_]*$`)
var credentialName = regexp.MustCompile("^[!#$%&'*+.^_`|~0-9A-Za-z-]+$")
// prepareMetadata returns a detached spec; repeated compilation must not prepend
// authentication middleware more than once or mutate the parser's output.
func prepareMetadata(original *Spec) (*Spec, error) {
spec := *original
spec.Routes = append([]Route(nil), original.Routes...)
definitions := map[string]ErrorDefinition{}
codes, symbols := map[string]bool{}, map[string]bool{}
for _, e := range spec.Errors {
if !exportedName.MatchString(e.Name) || e.Status < 400 || e.Status > 599 || !errorCode.MatchString(e.Code) || strings.TrimSpace(e.Message) == "" {
return nil, fmt.Errorf("%s: error requires UpperCamelCase name, status 400..599, UPPER_SNAKE_CASE code and a nonempty public message", e.Position)
}
if codes[e.Code] {
return nil, fmt.Errorf("%s: duplicate error code %s", e.Position, e.Code)
}
for _, symbol := range []string{e.Name, "Code" + e.Name} {
if symbols[symbol] {
return nil, fmt.Errorf("%s: duplicate error symbol %s", e.Position, symbol)
}
symbols[symbol] = true
}
codes[e.Code] = true
definitions[e.Name] = e
}
auth := map[string]AuthDefinition{}
for _, a := range spec.Auth {
if !exportedName.MatchString(a.Name) || !goName.MatchString(a.Middleware) || strings.EqualFold(a.Middleware, "defaults") || strings.EqualFold(a.Middleware, "registryGen") {
return nil, fmt.Errorf("%s: auth requires an UpperCamelCase name and a valid named middleware", a.Position)
}
if _, ok := auth[a.Name]; ok {
return nil, fmt.Errorf("%s: duplicate auth %s", a.Position, a.Name)
}
switch a.Type {
case "bearer":
if a.In != "" || a.KeyName != "" {
return nil, fmt.Errorf("%s: bearer auth does not accept in/name", a.Position)
}
case "apiKey":
if (a.In != "header" && a.In != "cookie") || !credentialName.MatchString(a.KeyName) || a.BearerFormat != "" {
return nil, fmt.Errorf("%s: apiKey requires in header/cookie and a valid name; bearerFormat is not supported", a.Position)
}
if a.In == "header" && (strings.EqualFold(a.KeyName, "Authorization") || strings.EqualFold(a.KeyName, "Cookie")) {
return nil, fmt.Errorf("%s: use bearer for Authorization or in cookie for a session", a.Position)
}
default:
return nil, fmt.Errorf("%s: auth type must be bearer or apiKey", a.Position)
}
auth[a.Name] = a
}
for i := range spec.Routes {
r := &spec.Routes[i]
r.Middleware = append([]string(nil), r.Middleware...)
if r.Auth != "" && r.Auth != "none" {
a, ok := auth[r.Auth]
if !ok {
return nil, fmt.Errorf("%s: unknown auth %s; declare auth %s (...) first", r.Position, r.Auth, r.Auth)
}
for _, m := range r.Middleware {
if strings.EqualFold(m, a.Middleware) {
return nil, fmt.Errorf("%s: auth %s already installs %s; remove it from middleware", r.Position, r.Auth, a.Middleware)
}
}
r.Middleware = append([]string{a.Middleware}, r.Middleware...)
}
seen := map[string]bool{}
for _, name := range r.Errors {
if _, ok := definitions[name]; !ok {
return nil, fmt.Errorf("%s: unknown error %s; declare it in error (...)", r.Position, name)
}
if seen[name] {
return nil, fmt.Errorf("%s: duplicate error %s", r.Position, name)
}
seen[name] = true
}
seen = map[string]bool{}
for _, tag := range r.Tags {
if strings.TrimSpace(tag) == "" || seen[tag] {
return nil, fmt.Errorf("%s: @doc tags must be nonempty and unique", r.Position)
}
seen[tag] = true
}
}
return &spec, nil
}
func decorateDocument(doc map[string]any, spec *Spec) {
if spec.Description != "" {
doc["info"].(map[string]any)["description"] = spec.Description
}
schemes := map[string]any{}
for _, a := range spec.Auth {
scheme := map[string]any{}
if a.Type == "bearer" {
scheme["type"], scheme["scheme"] = "http", "bearer"
if a.BearerFormat != "" {
scheme["bearerFormat"] = a.BearerFormat
}
} else {
scheme["type"], scheme["in"], scheme["name"] = "apiKey", a.In, a.KeyName
}
if a.Description != "" {
scheme["description"] = a.Description
}
schemes[a.Name] = scheme
}
doc["components"].(map[string]any)["securitySchemes"] = schemes
definitions := map[string]ErrorDefinition{}
for _, e := range spec.Errors {
definitions[e.Name] = e
}
if len(spec.Errors) > 0 {
doc["x-omni-errors"] = spec.Errors
}
for _, r := range spec.Routes {
parts := strings.Split(r.Path, "/")
for i, part := range parts {
if strings.HasPrefix(part, ":") {
parts[i] = "{" + part[1:] + "}"
}
}
op := doc["paths"].(map[string]any)[strings.Join(parts, "/")].(map[string]any)[r.Method].(map[string]any)
if len(r.Middleware) > 0 {
op["x-omni-middleware"] = r.Middleware
}
if r.Auth != "" && r.Auth != "none" {
op["security"] = []map[string][]string{{r.Auth: {}}}
} else {
op["security"] = []any{}
}
if r.Description != "" {
op["description"] = r.Description
}
if len(r.Tags) > 0 {
op["tags"] = r.Tags
} else {
op["tags"] = []string{r.Group}
}
if r.Deprecated {
op["deprecated"] = true
}
responses := op["responses"].(map[string]any)
if r.Auth != "" && r.Auth != "none" {
responses["401"] = genericErrorResponse("Authentication required")
}
grouped := map[int][]ErrorDefinition{}
for _, name := range r.Errors {
e := definitions[name]
grouped[e.Status] = append(grouped[e.Status], e)
}
if len(r.Errors) > 0 {
op["x-omni-errors"] = r.Errors
}
for status, errs := range grouped {
key := fmt.Sprint(status)
response, exists := responses[key].(map[string]any)
if !exists {
response = genericErrorResponse(http.StatusText(status))
responses[key] = response
}
media := response["content"].(map[string]any)["application/json"].(map[string]any)
examples := map[string]any{}
codes := []any{}
description := []string{}
sort.Slice(errs, func(i, j int) bool { return errs[i].Name < errs[j].Name })
for _, e := range errs {
examples[e.Name] = map[string]any{"summary": e.Message, "value": map[string]any{"code": e.Code, "message": e.Message, "requestId": "exampleRequest"}}
codes = append(codes, e.Code)
description = append(description, e.Code+": "+e.Message)
}
media["examples"] = examples
if !exists {
media["schema"].(map[string]any)["properties"].(map[string]any)["code"].(map[string]any)["enum"] = codes
}
response["description"] = strings.Join(description, "\n")
}
}
}
func genericErrorResponse(description string) map[string]any {
if description == "" {
description = "Error"
}
return map[string]any{"description": description, "content": map[string]any{"application/json": map[string]any{"schema": map[string]any{
"type": "object", "required": []string{"code", "message"}, "properties": map[string]any{"code": map[string]any{"type": "string"}, "message": map[string]any{"type": "string"}, "requestId": map[string]any{"type": "string"}},
}}}}
}