290 lines
11 KiB
Go
290 lines
11 KiB
Go
package apigen
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"git.yuntuo.hk/k2safe/go-omni/contract"
|
|
"net/http"
|
|
"regexp"
|
|
"sort"
|
|
"strings"
|
|
|
|
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
|
|
"git.yuntuo.hk/k2safe/go-omni/openapi"
|
|
"git.yuntuo.hk/k2safe/go-omni/response"
|
|
)
|
|
|
|
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...)
|
|
spec.Errors = append([]ErrorDefinition(nil), original.Errors...)
|
|
if err := validateResponses(&spec); err != nil {
|
|
return nil, err
|
|
}
|
|
definitions := map[string]ErrorDefinition{}
|
|
codes, symbols := map[string]bool{}, map[string]bool{"Catalog": true}
|
|
for _, e := range spec.Errors {
|
|
if !exportedName.MatchString(e.Name) || !apierrors.ValidErrorStatus(e.Status) || strings.TrimSpace(e.Message) == "" {
|
|
return nil, fmt.Errorf("%s: error requires UpperCamelCase name, a final body-bearing status (200..599 except 204/205/304) and a nonempty public message", e.Position)
|
|
}
|
|
if e.Catalog == "" && !errorCode.MatchString(e.Code) {
|
|
return nil, fmt.Errorf("%s: legacy error declaration requires UPPER_SNAKE_CASE code", 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
|
|
}
|
|
registry, err := apierrors.NewRegistry(errorCatalog(&spec, nil))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
normalized := registry.Catalog()
|
|
for i := range spec.Errors {
|
|
spec.Errors[i].BusinessCode = normalized.Definitions[spec.Errors[i].Code].Code
|
|
spec.Errors[i].Params = normalized.Definitions[spec.Errors[i].Code].Params
|
|
spec.Errors[i].Translation = normalized.Definitions[spec.Errors[i].Code].Translation
|
|
}
|
|
if err := validateErrorPolicies(&spec); err != nil {
|
|
return nil, err
|
|
}
|
|
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 errorCatalog or 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, bindings map[string]*contract.Binder) {
|
|
doc["x-omni-contract-sources"] = sourceMetadata(spec)
|
|
if spec.DefaultResponse != "" {
|
|
doc["x-omni-default-response"] = spec.DefaultResponse
|
|
}
|
|
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{}
|
|
declaredRegistry := apierrors.MustRegistry(errorCatalog(spec, nil))
|
|
var documentRegistry *apierrors.Registry
|
|
if spec.DocumentCatalog != nil {
|
|
documentRegistry = apierrors.MustRegistry(*spec.DocumentCatalog)
|
|
doc["x-omni-app-error-catalog"] = documentRegistry.Catalog()
|
|
}
|
|
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 {
|
|
registry := declaredRegistry
|
|
policy := response.MustNew(responseConfig(spec, r))
|
|
cfg := policy.Config()
|
|
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)
|
|
requestRoute := r
|
|
requestRoute.RequestPolicy = contract.MergePolicy(spec.RequestPolicy, r.RequestPolicy)
|
|
decorateRequestOptions(op, requestRoute)
|
|
if b := bindings[r.Request]; b != nil && b.HasMultipart() {
|
|
encoding := map[string]any{}
|
|
for _, name := range b.MultipartJSONFields() {
|
|
encoding[name] = map[string]any{"contentType": "application/json"}
|
|
}
|
|
op["requestBody"] = map[string]any{"required": true, "content": map[string]any{"multipart/form-data": map[string]any{"schema": b.MultipartSchema(), "encoding": encoding}}}
|
|
}
|
|
if r.ResponseFormat != "" {
|
|
op["x-omni-response"] = r.ResponseFormat
|
|
}
|
|
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", cfg)
|
|
}
|
|
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 && documentRegistry != nil {
|
|
registry = documentRegistry
|
|
op["x-omni-error-catalog-scope"] = "app"
|
|
for key, definition := range documentRouteCatalog(spec, r).Definitions {
|
|
grouped[definition.Status] = append(grouped[definition.Status], ErrorDefinition{Name: key, Code: key, Status: definition.Status, Message: definition.Message, BusinessCode: definition.Code, Params: definition.Params})
|
|
}
|
|
}
|
|
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), cfg)
|
|
responses[key] = response
|
|
}
|
|
content, ok := response["content"].(map[string]any)
|
|
if !ok {
|
|
content = map[string]any{}
|
|
response["content"] = content
|
|
}
|
|
media, hasJSON := content["application/json"].(map[string]any)
|
|
if !hasJSON {
|
|
media = map[string]any{}
|
|
content["application/json"] = media
|
|
}
|
|
errorMedia := genericErrorResponse("", cfg)["content"].(map[string]any)["application/json"].(map[string]any)
|
|
if exists && status < 400 && media["schema"] != nil {
|
|
media["schema"] = map[string]any{"anyOf": []any{media["schema"], errorMedia["schema"]}}
|
|
} else if media["schema"] == nil {
|
|
media["schema"] = errorMedia["schema"]
|
|
}
|
|
examples := map[string]any{}
|
|
codes := []any{}
|
|
seenCodes := map[string]bool{}
|
|
description := []string{}
|
|
if exists {
|
|
if text, ok := response["description"].(string); ok && text != "" {
|
|
description = append(description, text)
|
|
}
|
|
}
|
|
sort.Slice(errs, func(i, j int) bool { return errs[i].Name < errs[j].Name })
|
|
for _, e := range errs {
|
|
result := responseResult(e)
|
|
result.Message = registry.Example(e.Code).Message
|
|
value := policy.Value(result)
|
|
examples[e.Name] = map[string]any{"summary": result.Message, "value": value}
|
|
codeKey := fmt.Sprint(value[cfg.CodeField])
|
|
if !seenCodes[codeKey] {
|
|
codes = append(codes, value[cfg.CodeField])
|
|
seenCodes[codeKey] = true
|
|
}
|
|
description = append(description, e.Code+": "+e.Message)
|
|
}
|
|
media["examples"] = examples
|
|
if !exists && cfg.Body == nil && cfg.CodeField != "-" {
|
|
media["schema"].(map[string]any)["properties"].(map[string]any)[cfg.CodeField].(map[string]any)["enum"] = codes
|
|
}
|
|
response["description"] = strings.Join(description, "\n")
|
|
}
|
|
}
|
|
}
|
|
func responseResult(e ErrorDefinition) response.Result {
|
|
return response.Result{Status: e.Status, Code: e.Code, Message: e.Message, BusinessCode: e.BusinessCode, Failed: true, RequestID: "exampleRequest"}
|
|
}
|
|
func genericErrorResponse(description string, cfg response.Config) map[string]any {
|
|
if description == "" {
|
|
description = "Error"
|
|
}
|
|
schema, _ := openapi.EnvelopeWith(nil, cfg)
|
|
data, _ := json.Marshal(schema)
|
|
var value map[string]any
|
|
_ = json.Unmarshal(data, &value)
|
|
return map[string]any{"description": description, "content": map[string]any{"application/json": map[string]any{"schema": value}}}
|
|
}
|