feat: add independent platform modules and multi-provider storage
This commit is contained in:
267
openapi/openapi.go
Normal file
267
openapi/openapi.go
Normal file
@@ -0,0 +1,267 @@
|
||||
// Package openapi registers routes and their OpenAPI 3.0.3 contracts together.
|
||||
package openapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"git.yuntuo.hk/k2safe/go-omni/httpx"
|
||||
"net/http"
|
||||
"reflect"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Schema struct {
|
||||
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"`
|
||||
Nullable bool `json:"nullable,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
|
||||
if err = json.Unmarshal(encoded, &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 {
|
||||
return &Schema{Type: "object", Required: []string{"code", "message"}, Properties: map[string]*Schema{"code": {Type: "string"}, "message": {Type: "string"}, "requestId": {Type: "string"}, "data": data}}
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user