// Package openapi registers routes and their OpenAPI 3.0.3 contracts together. package openapi import ( "bytes" "encoding/json" "errors" "fmt" "git.yuntuo.hk/k2safe/go-omni/httpx" "git.yuntuo.hk/k2safe/go-omni/response" "net/http" "reflect" "regexp" "strings" "sync" "time" ) type Schema struct { MaxBytes *int64 `json:"x-omni-max-bytes,omitempty"` Example any `json:"example,omitempty"` EnumDescriptions []string `json:"x-enum-descriptions,omitempty"` Type string `json:"type,omitempty"` Format string `json:"format,omitempty"` Description string `json:"description,omitempty"` Properties map[string]*Schema `json:"properties,omitempty"` Required []string `json:"required,omitempty"` Items *Schema `json:"items,omitempty"` AdditionalProperties *Schema `json:"additionalProperties,omitempty"` AdditionalPropertiesAllowed *bool `json:"-"` Nullable bool `json:"nullable,omitempty"` Minimum *float64 `json:"minimum,omitempty"` Maximum *float64 `json:"maximum,omitempty"` ExclusiveMinimum bool `json:"exclusiveMinimum,omitempty"` ExclusiveMaximum bool `json:"exclusiveMaximum,omitempty"` MinLength *int `json:"minLength,omitempty"` MaxLength *int `json:"maxLength,omitempty"` MinItems *int `json:"minItems,omitempty"` MaxItems *int `json:"maxItems,omitempty"` Enum []any `json:"enum,omitempty"` Default any `json:"default,omitempty"` Not *Schema `json:"not,omitempty"` AnyOf []*Schema `json:"anyOf,omitempty"` } type Parameter struct { Name string `json:"name"` In string `json:"in"` Required bool `json:"required"` Description string `json:"description,omitempty"` Schema *Schema `json:"schema"` } type Response struct { Description string Schema *Schema } type Operation struct { Method, Path, OperationID, Summary string Parameters []Parameter Request *Schema Responses map[int]Response BearerAuth bool } type Registrar interface { Handle(string, string, httpx.Handler, ...httpx.Middleware) } type API struct { mu sync.RWMutex document map[string]any paths map[string]map[string]any ids map[string]bool } var identifier = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_]*$`) func New(title, version string) (*API, error) { if title == "" || version == "" { return nil, errors.New("openapi: title and version required") } paths := make(map[string]map[string]any) return &API{paths: paths, ids: make(map[string]bool), document: map[string]any{"openapi": "3.0.3", "info": map[string]string{"title": title, "version": version}, "paths": paths, "components": map[string]any{"securitySchemes": map[string]any{"bearerAuth": map[string]string{"type": "http", "scheme": "bearer"}}}}}, nil } // Handle registers an absolute application route. To document a group, include // its full prefix here and pass middleware explicitly. Schemas describe wire // responses; Envelope wraps a data schema in GoOmni's standard JSON envelope. func (a *API) Handle(router Registrar, op Operation, handler httpx.Handler, middleware ...httpx.Middleware) error { if router == nil || handler == nil || !identifier.MatchString(op.OperationID) || !strings.HasPrefix(op.Path, "/") || strings.ContainsAny(op.Path, "*{}?# ") { return errors.New("openapi: invalid route") } method := strings.ToLower(op.Method) switch method { case "get", "post", "put", "patch", "delete", "head", "options": default: return errors.New("openapi: unsupported HTTP method") } parts := strings.Split(op.Path, "/") params := make(map[string]bool) for n, part := range parts { if strings.HasPrefix(part, ":") { name := part[1:] if !identifier.MatchString(name) || params[name] { return errors.New("openapi: invalid path parameter") } parts[n] = "{" + name + "}" params[name] = true } } path := strings.Join(parts, "/") seen := make(map[string]bool) for _, p := range op.Parameters { if p.Name == "" || p.Schema == nil || seen[p.In+":"+p.Name] { return errors.New("openapi: invalid or duplicate parameter") } seen[p.In+":"+p.Name] = true switch p.In { case "path": if !params[p.Name] || !p.Required { return errors.New("openapi: path parameter must match and be required") } delete(params, p.Name) case "query", "header", "cookie": default: return errors.New("openapi: invalid parameter location") } } if len(params) != 0 || len(op.Responses) == 0 { return errors.New("openapi: declare all path parameters and at least one response") } responses := map[string]any{} for code, response := range op.Responses { if code < 100 || code > 599 || response.Description == "" { return errors.New("openapi: invalid response") } item := map[string]any{"description": response.Description} if response.Schema != nil { item["content"] = map[string]any{"application/json": map[string]any{"schema": response.Schema}} } responses[fmt.Sprint(code)] = item } operation := map[string]any{"operationId": op.OperationID, "summary": op.Summary, "responses": responses} if len(op.Parameters) > 0 { operation["parameters"] = op.Parameters } if op.Request != nil { operation["requestBody"] = map[string]any{"required": true, "content": map[string]any{"application/json": map[string]any{"schema": op.Request}}} } if op.BearerAuth { operation["security"] = []map[string][]string{{"bearerAuth": {}}} } // Detach caller-owned maps/slices before concurrent document reads. encoded, err := json.Marshal(operation) if err != nil { return err } var frozen any decoder := json.NewDecoder(bytes.NewReader(encoded)) decoder.UseNumber() if err = decoder.Decode(&frozen); err != nil { return err } a.mu.Lock() defer a.mu.Unlock() if a.ids[op.OperationID] || a.paths[path][method] != nil { return errors.New("openapi: duplicate operation ID or route") } router.Handle(strings.ToUpper(method), op.Path, handler, middleware...) if a.paths[path] == nil { a.paths[path] = make(map[string]any) } a.paths[path][method] = frozen a.ids[op.OperationID] = true return nil } func (a *API) JSON() ([]byte, error) { a.mu.RLock() defer a.mu.RUnlock() return json.MarshalIndent(a.document, "", " ") } func (a *API) Handler() httpx.Handler { return func(c *httpx.Context) error { data, err := a.JSON() if err != nil { return err } return c.WriteResponse(200, http.Header{"Content-Type": {"application/json; charset=utf-8"}}, data) } } func Envelope(data *Schema) *Schema { schema, _ := EnvelopeWith(data, response.Config{}) return schema } func SchemaOf[T any]() (*Schema, error) { return schema(reflect.TypeOf((*T)(nil)).Elem(), map[reflect.Type]bool{}, 0) } func schema(t reflect.Type, stack map[reflect.Type]bool, depth int) (*Schema, error) { if depth > 32 || stack[t] { return nil, errors.New("openapi: recursive DTO requires an explicit schema") } if t.Kind() == reflect.Pointer { s, err := schema(t.Elem(), stack, depth+1) if err == nil { s.Nullable = true } return s, err } if t == reflect.TypeOf(time.Time{}) { return &Schema{Type: "string", Format: "date-time"}, nil } if t == reflect.TypeOf(json.RawMessage{}) { return &Schema{}, nil } if t == reflect.TypeOf(json.Number("")) { return &Schema{Type: "number"}, nil } marshaler := reflect.TypeOf((*json.Marshaler)(nil)).Elem() if t.Implements(marshaler) || reflect.PointerTo(t).Implements(marshaler) { return nil, errors.New("openapi: custom JSON marshaler requires an explicit schema") } switch t.Kind() { case reflect.String: return &Schema{Type: "string"}, nil case reflect.Bool: return &Schema{Type: "boolean"}, nil case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: return &Schema{Type: "integer"}, nil case reflect.Float32, reflect.Float64: return &Schema{Type: "number"}, nil case reflect.Slice, reflect.Array: if t.Elem().Kind() == reflect.Uint8 && t.Kind() == reflect.Slice { return &Schema{Type: "string", Format: "byte"}, nil } s, err := schema(t.Elem(), stack, depth+1) return &Schema{Type: "array", Items: s}, err case reflect.Map: if t.Key().Kind() != reflect.String { return nil, errors.New("openapi: map keys must be strings") } s, err := schema(t.Elem(), stack, depth+1) return &Schema{Type: "object", AdditionalProperties: s}, err case reflect.Interface: return &Schema{}, nil case reflect.Struct: stack[t] = true defer delete(stack, t) s := &Schema{Type: "object", Properties: map[string]*Schema{}} for n := 0; n < t.NumField(); n++ { f := t.Field(n) if f.PkgPath != "" { continue } tag := strings.Split(f.Tag.Get("json"), ",") if tag[0] == "-" { continue } if f.Anonymous && tag[0] == "" { return nil, errors.New("openapi: embedded fields require an explicit JSON name") } name := tag[0] if name == "" { name = f.Name } child, err := schema(f.Type, stack, depth+1) if err != nil { return nil, err } if s.Properties[name] != nil { return nil, errors.New("openapi: duplicate JSON field") } s.Properties[name] = child optional := false for _, option := range tag[1:] { if option == "string" { child.Type = "string" child.Format = "" } if option == "omitempty" || option == "omitzero" { optional = true } } if !optional { s.Required = append(s.Required, name) } } return s, nil default: return nil, fmt.Errorf("openapi: unsupported DTO type %s", t) } }