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 } 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 registry.Catalog().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}}} }