289 lines
10 KiB
Go
289 lines
10 KiB
Go
// 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)
|
|
}
|
|
}
|