feat: add independent platform modules and multi-provider storage

This commit is contained in:
2026-09-23 14:51:14 +08:00
parent f91034477b
commit 208a9c3fbd
57 changed files with 4890 additions and 48 deletions

267
openapi/openapi.go Normal file
View 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)
}
}