329 lines
11 KiB
Go
329 lines
11 KiB
Go
package apigen
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
gotoken "go/token"
|
|
"reflect"
|
|
"regexp"
|
|
"sort"
|
|
"strings"
|
|
|
|
"git.yuntuo.hk/k2safe/go-omni/contract"
|
|
"git.yuntuo.hk/k2safe/go-omni/httpx"
|
|
"git.yuntuo.hk/k2safe/go-omni/openapi"
|
|
)
|
|
|
|
type Compiled struct {
|
|
Spec *Spec
|
|
Bindings map[string]*contract.Binder
|
|
Document []byte
|
|
Groups []string
|
|
}
|
|
|
|
var exportedName = regexp.MustCompile(`^[A-Z][A-Za-z0-9]*$`)
|
|
var goName = regexp.MustCompile(`^[a-zA-Z][A-Za-z0-9]*$`)
|
|
var lowerName = regexp.MustCompile(`^[a-z][A-Za-z0-9]*$`)
|
|
var serviceName = regexp.MustCompile(`^[a-zA-Z][a-zA-Z0-9-]*$`)
|
|
var staticSegment = regexp.MustCompile(`^[A-Za-z0-9._~-]+$`)
|
|
|
|
func Compile(spec *Spec) (*Compiled, error) {
|
|
if spec == nil || len(spec.Routes) == 0 || !serviceName.MatchString(spec.Service) {
|
|
return nil, errors.New("api: valid service and routes required")
|
|
}
|
|
var prepareErr error
|
|
spec, prepareErr = prepareMetadata(spec)
|
|
if prepareErr != nil {
|
|
return nil, prepareErr
|
|
}
|
|
if err := expandFields(spec); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := validateDocumentCatalog(spec); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := spec.RequestPolicy.Validate(); err != nil {
|
|
return nil, err
|
|
}
|
|
definitions := map[string]Type{}
|
|
for _, t := range spec.Types {
|
|
if t.Name == "File" {
|
|
return nil, errors.New("File is reserved for multipart uploads")
|
|
}
|
|
if t.Name == "ContractScalars" && len(spec.Scalars) > 0 {
|
|
return nil, errors.New("ContractScalars is reserved when declaring scalars")
|
|
}
|
|
if !exportedName.MatchString(t.Name) {
|
|
return nil, fmt.Errorf("%s: type names must be UpperCamelCase", t.Position)
|
|
}
|
|
if _, ok := definitions[t.Name]; ok {
|
|
return nil, fmt.Errorf("%s: duplicate type %s", t.Position, t.Name)
|
|
}
|
|
definitions[t.Name] = t
|
|
}
|
|
scalarTypes, scalarDefs, err := compileScalars(spec, definitions)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
graph, err := newResponseGraph(spec, definitions, scalarTypes, scalarDefs)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for _, r := range spec.Routes {
|
|
if graph.recursive[r.Request] {
|
|
return nil, fmt.Errorf("%s: recursive request DTO %s is unsupported; recursive types are supported for responses only", r.Position, r.Request)
|
|
}
|
|
}
|
|
resolved := map[string]reflect.Type{}
|
|
var resolve func(string, int) (reflect.Type, error)
|
|
resolve = func(name string, depth int) (reflect.Type, error) {
|
|
if depth > 32 {
|
|
return nil, errors.New("type nesting exceeds 32")
|
|
}
|
|
if t, ok := resolved[name]; ok {
|
|
return t, nil
|
|
}
|
|
typ, ok := definitions[name]
|
|
if !ok {
|
|
return nil, fmt.Errorf("unknown type %s", name)
|
|
}
|
|
var fields []reflect.StructField
|
|
for i, f := range typ.Fields {
|
|
t, err := graph.expression(graph.exprs[name][i], depth+1, resolve)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%s.%s: %w", name, f.Name, err)
|
|
}
|
|
fields = append(fields, reflect.StructField{Name: f.Name, Type: t, Tag: reflect.StructTag(fieldTag(f))})
|
|
}
|
|
t := reflect.StructOf(fields)
|
|
resolved[name] = t
|
|
return t, nil
|
|
}
|
|
c := &Compiled{Spec: spec, Bindings: map[string]*contract.Binder{}}
|
|
for _, t := range spec.Types {
|
|
if graph.recursive[t.Name] {
|
|
if _, err := graph.component(t.Name); err != nil {
|
|
return nil, err
|
|
}
|
|
continue
|
|
}
|
|
rt, err := resolve(t.Name, 0)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
b, err := contract.Describe(rt, scalarDefs...)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%s: %w", t.Position, err)
|
|
}
|
|
c.Bindings[t.Name] = b
|
|
}
|
|
api, err := openapi.New(spec.Title, spec.Version)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
handlers, paths, shapes, groups := map[string]bool{}, map[string]bool{}, map[string]string{}, map[string]bool{}
|
|
middlewareSymbols := map[string]string{}
|
|
for routeIndex, r := range spec.Routes {
|
|
if !goName.MatchString(r.Handler) {
|
|
return nil, fmt.Errorf("%s: invalid handler name", r.Position)
|
|
}
|
|
if strings.EqualFold(r.Handler, "service") || strings.EqualFold(r.Handler, "logic") {
|
|
return nil, errors.New("handler name conflicts with the Logic constructor file")
|
|
}
|
|
if r.Group == "" {
|
|
return nil, errors.New("group required")
|
|
}
|
|
for _, part := range strings.Split(r.Group, "/") {
|
|
if !lowerName.MatchString(part) || gotoken.Lookup(part).IsKeyword() {
|
|
return nil, fmt.Errorf("invalid lowerCamelCase group %q", r.Group)
|
|
}
|
|
}
|
|
h := r.Group + "/" + strings.ToLower(r.Handler)
|
|
if handlers[h] {
|
|
return nil, fmt.Errorf("duplicate handler %s in group %s", r.Handler, r.Group)
|
|
}
|
|
handlers[h] = true
|
|
groups[r.Group] = true
|
|
mseen := map[string]bool{}
|
|
for _, m := range r.Middleware {
|
|
if !goName.MatchString(m) || mseen[m] {
|
|
return nil, fmt.Errorf("invalid or duplicate middleware %q", m)
|
|
}
|
|
mseen[m] = true
|
|
if strings.EqualFold(m, "defaults") || strings.EqualFold(m, "registryGen") {
|
|
return nil, fmt.Errorf("reserved middleware name %q", m)
|
|
}
|
|
if prior, ok := middlewareSymbols[strings.ToLower(m)]; ok && prior != m {
|
|
return nil, fmt.Errorf("middleware names %s and %s collide", prior, m)
|
|
}
|
|
middlewareSymbols[strings.ToLower(m)] = m
|
|
}
|
|
shape, err := routeShape(r.Path)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%s: %w", r.Position, err)
|
|
}
|
|
for _, prior := range spec.Routes {
|
|
if prior.Method != r.Method || prior.Path == r.Path {
|
|
continue
|
|
}
|
|
if conflictPrefix(prior.Path, r.Path) {
|
|
return nil, fmt.Errorf("conflicting parameter names at a shared route prefix: %s and %s", prior.Path, r.Path)
|
|
}
|
|
}
|
|
if prior, exists := shapes[shape]; exists && prior != r.Path {
|
|
return nil, fmt.Errorf("ambiguous route parameter names: %s and %s", prior, r.Path)
|
|
}
|
|
shapes[shape] = r.Path
|
|
pathKey := r.Method + " " + shape
|
|
if paths[pathKey] {
|
|
return nil, fmt.Errorf("duplicate route %s", pathKey)
|
|
}
|
|
paths[pathKey] = true
|
|
if r.Method != strings.ToLower(r.Method) {
|
|
return nil, errors.New("HTTP methods must be lowercase")
|
|
}
|
|
if r.Status != 200 && r.Status != 201 && r.Status != 202 && r.Status != 204 {
|
|
return nil, errors.New("@status supports 200, 201, 202 or 204")
|
|
}
|
|
if !r.Manual && r.Produces == "" && (r.Status == 204) != (r.Response == "") {
|
|
return nil, errors.New("204 requires no response; other success statuses require a response type")
|
|
}
|
|
if r.Method == "head" && r.Response != "" {
|
|
return nil, errors.New("HEAD must not declare a response body")
|
|
}
|
|
if err := validateRouteExtensions(r); err != nil {
|
|
return nil, fmt.Errorf("%s: %w", r.Position, err)
|
|
}
|
|
effectivePolicy := contract.MergePolicy(spec.RequestPolicy, r.RequestPolicy)
|
|
if (r.Method == "get" || r.Method == "head") && effectivePolicy.Body != "" && effectivePolicy.Body != "auto" && effectivePolicy.Body != "none" {
|
|
return nil, errors.New("GET/HEAD cannot inherit a JSON body policy; override body with auto or none")
|
|
}
|
|
op := openapi.Operation{Method: r.Method, Path: r.Path, OperationID: operationID(r), Summary: r.Summary, Responses: map[int]openapi.Response{}}
|
|
if r.Request != "" {
|
|
b, ok := c.Bindings[r.Request]
|
|
if !ok {
|
|
return nil, fmt.Errorf("unknown request %s", r.Request)
|
|
}
|
|
r.BoundMultipart = b.HasMultipart() && !r.CustomBinding && !r.Manual
|
|
spec.Routes[routeIndex].BoundMultipart = r.BoundMultipart
|
|
if b.HasMultipart() && r.BodyMode != "multipart" {
|
|
return nil, errors.New("multipart fields require @body (mode: multipart)")
|
|
}
|
|
op.Parameters = b.Parameters()
|
|
op.Request = b.BodySchema()
|
|
if op.Request != nil && (effectivePolicy.Body == "none" || effectivePolicy.Body == "emptyObject") {
|
|
return nil, errors.New("request policy conflicts with JSON fields")
|
|
}
|
|
if op.Request != nil && (r.BodyMode == "raw" || r.BodyMode == "multipart") {
|
|
return nil, errors.New("raw/multipart request DTOs may declare only path/form/header fields; use the generated body stream for content")
|
|
}
|
|
if op.Request != nil && (r.Method == "get" || r.Method == "head") {
|
|
return nil, errors.New("GET/HEAD requests cannot declare JSON bodies; use form/path/header tags")
|
|
}
|
|
}
|
|
var response *openapi.Schema
|
|
if r.Response != "" {
|
|
s, err := responseDataSchema(r.Response, c.Bindings, graph)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if r.ResponseFormat == "raw" {
|
|
response = s
|
|
} else {
|
|
response, err = openapi.EnvelopeWith(s, responseConfig(spec, r))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
}
|
|
op.Responses[r.Status] = openapi.Response{Description: "Success", Schema: response}
|
|
errorSchema, err := openapi.EnvelopeWith(nil, responseConfig(spec, r))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for _, code := range []int{400, 413, 415, 500, 501} {
|
|
op.Responses[code] = openapi.Response{Description: "Error", Schema: errorSchema}
|
|
}
|
|
if err = api.Handle(discardRoutes{}, op, func(*httpx.Context) error { return nil }); err != nil {
|
|
return nil, fmt.Errorf("%s: %w", r.Position, err)
|
|
}
|
|
}
|
|
for group := range groups {
|
|
c.Groups = append(c.Groups, group)
|
|
}
|
|
sort.Strings(c.Groups)
|
|
c.Document, err = api.JSON()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
// Decorate the document with explicit auth/error contracts. Credential
|
|
// validation remains in the linked application middleware.
|
|
var doc map[string]any
|
|
decoder := json.NewDecoder(bytes.NewReader(c.Document))
|
|
decoder.UseNumber()
|
|
if err = decoder.Decode(&doc); err != nil {
|
|
return nil, err
|
|
}
|
|
decorateDocument(doc, spec, c.Bindings)
|
|
if len(graph.components) > 0 {
|
|
components := map[string]any{}
|
|
for name, schema := range graph.components {
|
|
components[name] = schema
|
|
}
|
|
doc["components"].(map[string]any)["schemas"] = components
|
|
}
|
|
compactDocument(doc)
|
|
c.Document, err = json.MarshalIndent(doc, "", " ")
|
|
return c, err
|
|
}
|
|
|
|
type discardRoutes struct{}
|
|
|
|
func (discardRoutes) Handle(string, string, httpx.Handler, ...httpx.Middleware) {}
|
|
func operationID(r Route) string {
|
|
return strings.ReplaceAll(r.Group, "/", "_") + "_" + upper(r.Handler)
|
|
}
|
|
func upper(s string) string { return strings.ToUpper(s[:1]) + s[1:] }
|
|
func lower(s string) string { return strings.ToLower(s[:1]) + s[1:] }
|
|
func routeShape(path string) (string, error) {
|
|
if path == "/" {
|
|
return path, nil
|
|
}
|
|
if !strings.HasPrefix(path, "/") {
|
|
return "", errors.New("route must start with /")
|
|
}
|
|
parts := strings.Split(path[1:], "/")
|
|
seen := map[string]bool{}
|
|
for i, part := range parts {
|
|
if strings.HasPrefix(part, ":") {
|
|
name := part[1:]
|
|
if !lowerName.MatchString(name) || seen[name] {
|
|
return "", errors.New("invalid/duplicate path parameter")
|
|
}
|
|
seen[name] = true
|
|
parts[i] = ":"
|
|
} else if !staticSegment.MatchString(part) || part == "." || part == ".." {
|
|
return "", errors.New("invalid route segment")
|
|
}
|
|
}
|
|
return "/" + strings.Join(parts, "/"), nil
|
|
}
|
|
|
|
func conflictPrefix(left, right string) bool {
|
|
a, b := strings.Split(left, "/"), strings.Split(right, "/")
|
|
for i := 0; i < len(a) && i < len(b); i++ {
|
|
if strings.HasPrefix(a[i], ":") && strings.HasPrefix(b[i], ":") {
|
|
if a[i] != b[i] {
|
|
return true
|
|
}
|
|
continue
|
|
}
|
|
if a[i] != b[i] {
|
|
return false
|
|
}
|
|
}
|
|
return false
|
|
}
|