mirror of
https://github.com/bitechdev/ResolveSpec.git
synced 2025-12-13 17:10:36 +00:00
514 lines
15 KiB
Go
514 lines
15 KiB
Go
package openapi
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"reflect"
|
|
"strings"
|
|
|
|
"github.com/bitechdev/ResolveSpec/pkg/modelregistry"
|
|
)
|
|
|
|
// OpenAPISpec represents the OpenAPI 3.0 specification structure
|
|
type OpenAPISpec struct {
|
|
OpenAPI string `json:"openapi"`
|
|
Info Info `json:"info"`
|
|
Servers []Server `json:"servers,omitempty"`
|
|
Paths map[string]PathItem `json:"paths"`
|
|
Components Components `json:"components"`
|
|
Security []map[string][]string `json:"security,omitempty"`
|
|
}
|
|
|
|
type Info struct {
|
|
Title string `json:"title"`
|
|
Description string `json:"description,omitempty"`
|
|
Version string `json:"version"`
|
|
Contact *Contact `json:"contact,omitempty"`
|
|
}
|
|
|
|
type Contact struct {
|
|
Name string `json:"name,omitempty"`
|
|
URL string `json:"url,omitempty"`
|
|
Email string `json:"email,omitempty"`
|
|
}
|
|
|
|
type Server struct {
|
|
URL string `json:"url"`
|
|
Description string `json:"description,omitempty"`
|
|
}
|
|
|
|
type PathItem struct {
|
|
Get *Operation `json:"get,omitempty"`
|
|
Post *Operation `json:"post,omitempty"`
|
|
Put *Operation `json:"put,omitempty"`
|
|
Patch *Operation `json:"patch,omitempty"`
|
|
Delete *Operation `json:"delete,omitempty"`
|
|
Options *Operation `json:"options,omitempty"`
|
|
}
|
|
|
|
type Operation struct {
|
|
Summary string `json:"summary,omitempty"`
|
|
Description string `json:"description,omitempty"`
|
|
OperationID string `json:"operationId,omitempty"`
|
|
Tags []string `json:"tags,omitempty"`
|
|
Parameters []Parameter `json:"parameters,omitempty"`
|
|
RequestBody *RequestBody `json:"requestBody,omitempty"`
|
|
Responses map[string]Response `json:"responses"`
|
|
Security []map[string][]string `json:"security,omitempty"`
|
|
}
|
|
|
|
type Parameter struct {
|
|
Name string `json:"name"`
|
|
In string `json:"in"` // "query", "header", "path", "cookie"
|
|
Description string `json:"description,omitempty"`
|
|
Required bool `json:"required,omitempty"`
|
|
Schema *Schema `json:"schema,omitempty"`
|
|
Example interface{} `json:"example,omitempty"`
|
|
}
|
|
|
|
type RequestBody struct {
|
|
Description string `json:"description,omitempty"`
|
|
Required bool `json:"required,omitempty"`
|
|
Content map[string]MediaType `json:"content"`
|
|
}
|
|
|
|
type MediaType struct {
|
|
Schema *Schema `json:"schema,omitempty"`
|
|
Example interface{} `json:"example,omitempty"`
|
|
}
|
|
|
|
type Response struct {
|
|
Description string `json:"description"`
|
|
Content map[string]MediaType `json:"content,omitempty"`
|
|
}
|
|
|
|
type Components struct {
|
|
Schemas map[string]Schema `json:"schemas,omitempty"`
|
|
SecuritySchemes map[string]SecurityScheme `json:"securitySchemes,omitempty"`
|
|
}
|
|
|
|
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"`
|
|
Items *Schema `json:"items,omitempty"`
|
|
Required []string `json:"required,omitempty"`
|
|
Ref string `json:"$ref,omitempty"`
|
|
Enum []interface{} `json:"enum,omitempty"`
|
|
Example interface{} `json:"example,omitempty"`
|
|
AdditionalProperties interface{} `json:"additionalProperties,omitempty"`
|
|
OneOf []*Schema `json:"oneOf,omitempty"`
|
|
AnyOf []*Schema `json:"anyOf,omitempty"`
|
|
}
|
|
|
|
type SecurityScheme struct {
|
|
Type string `json:"type"` // "apiKey", "http", "oauth2", "openIdConnect"
|
|
Description string `json:"description,omitempty"`
|
|
Name string `json:"name,omitempty"` // For apiKey
|
|
In string `json:"in,omitempty"` // For apiKey: "query", "header", "cookie"
|
|
Scheme string `json:"scheme,omitempty"` // For http: "basic", "bearer"
|
|
BearerFormat string `json:"bearerFormat,omitempty"` // For http bearer
|
|
}
|
|
|
|
// GeneratorConfig holds configuration for OpenAPI spec generation
|
|
type GeneratorConfig struct {
|
|
Title string
|
|
Description string
|
|
Version string
|
|
BaseURL string
|
|
Registry *modelregistry.DefaultModelRegistry
|
|
IncludeRestheadSpec bool
|
|
IncludeResolveSpec bool
|
|
IncludeFuncSpec bool
|
|
FuncSpecEndpoints map[string]FuncSpecEndpoint // path -> endpoint info
|
|
}
|
|
|
|
// FuncSpecEndpoint represents a FuncSpec endpoint for OpenAPI generation
|
|
type FuncSpecEndpoint struct {
|
|
Path string
|
|
Method string
|
|
Summary string
|
|
Description string
|
|
SQLQuery string
|
|
Parameters []string // Parameter names extracted from SQL
|
|
}
|
|
|
|
// Generator creates OpenAPI specifications
|
|
type Generator struct {
|
|
config GeneratorConfig
|
|
}
|
|
|
|
// NewGenerator creates a new OpenAPI generator
|
|
func NewGenerator(config GeneratorConfig) *Generator {
|
|
if config.Title == "" {
|
|
config.Title = "ResolveSpec API"
|
|
}
|
|
if config.Version == "" {
|
|
config.Version = "1.0.0"
|
|
}
|
|
return &Generator{config: config}
|
|
}
|
|
|
|
// Generate creates the complete OpenAPI specification
|
|
func (g *Generator) Generate() (*OpenAPISpec, error) {
|
|
spec := &OpenAPISpec{
|
|
OpenAPI: "3.0.0",
|
|
Info: Info{
|
|
Title: g.config.Title,
|
|
Description: g.config.Description,
|
|
Version: g.config.Version,
|
|
},
|
|
Paths: make(map[string]PathItem),
|
|
Components: Components{
|
|
Schemas: make(map[string]Schema),
|
|
SecuritySchemes: g.generateSecuritySchemes(),
|
|
},
|
|
}
|
|
|
|
if g.config.BaseURL != "" {
|
|
spec.Servers = []Server{
|
|
{URL: g.config.BaseURL, Description: "API Server"},
|
|
}
|
|
}
|
|
|
|
// Add common schemas
|
|
g.addCommonSchemas(spec)
|
|
|
|
// Generate paths and schemas from registered models
|
|
if err := g.generateFromModels(spec); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return spec, nil
|
|
}
|
|
|
|
// GenerateJSON generates OpenAPI spec as JSON string
|
|
func (g *Generator) GenerateJSON() (string, error) {
|
|
spec, err := g.Generate()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
data, err := json.MarshalIndent(spec, "", " ")
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to marshal spec: %w", err)
|
|
}
|
|
|
|
return string(data), nil
|
|
}
|
|
|
|
// generateSecuritySchemes creates security scheme definitions
|
|
func (g *Generator) generateSecuritySchemes() map[string]SecurityScheme {
|
|
return map[string]SecurityScheme{
|
|
"BearerAuth": {
|
|
Type: "http",
|
|
Scheme: "bearer",
|
|
BearerFormat: "JWT",
|
|
Description: "JWT Bearer token authentication",
|
|
},
|
|
"SessionToken": {
|
|
Type: "apiKey",
|
|
In: "header",
|
|
Name: "Authorization",
|
|
Description: "Session token authentication",
|
|
},
|
|
"CookieAuth": {
|
|
Type: "apiKey",
|
|
In: "cookie",
|
|
Name: "session_token",
|
|
Description: "Cookie-based session authentication",
|
|
},
|
|
"HeaderAuth": {
|
|
Type: "apiKey",
|
|
In: "header",
|
|
Name: "X-User-ID",
|
|
Description: "Header-based user authentication",
|
|
},
|
|
}
|
|
}
|
|
|
|
// addCommonSchemas adds common reusable schemas
|
|
func (g *Generator) addCommonSchemas(spec *OpenAPISpec) {
|
|
// Response wrapper schema
|
|
spec.Components.Schemas["Response"] = Schema{
|
|
Type: "object",
|
|
Properties: map[string]*Schema{
|
|
"success": {Type: "boolean", Description: "Indicates if the operation was successful"},
|
|
"data": {Description: "The response data"},
|
|
"metadata": {Ref: "#/components/schemas/Metadata"},
|
|
"error": {Ref: "#/components/schemas/APIError"},
|
|
},
|
|
}
|
|
|
|
// Metadata schema
|
|
spec.Components.Schemas["Metadata"] = Schema{
|
|
Type: "object",
|
|
Properties: map[string]*Schema{
|
|
"total": {Type: "integer", Description: "Total number of records"},
|
|
"count": {Type: "integer", Description: "Number of records in this response"},
|
|
"filtered": {Type: "integer", Description: "Number of records after filtering"},
|
|
"limit": {Type: "integer", Description: "Limit applied"},
|
|
"offset": {Type: "integer", Description: "Offset applied"},
|
|
"rowNumber": {Type: "integer", Description: "Row number for cursor pagination"},
|
|
},
|
|
}
|
|
|
|
// APIError schema
|
|
spec.Components.Schemas["APIError"] = Schema{
|
|
Type: "object",
|
|
Properties: map[string]*Schema{
|
|
"code": {Type: "string", Description: "Error code"},
|
|
"message": {Type: "string", Description: "Error message"},
|
|
"details": {Type: "string", Description: "Detailed error information"},
|
|
},
|
|
}
|
|
|
|
// RequestOptions schema
|
|
spec.Components.Schemas["RequestOptions"] = Schema{
|
|
Type: "object",
|
|
Properties: map[string]*Schema{
|
|
"preload": {
|
|
Type: "array",
|
|
Description: "Relations to eager load",
|
|
Items: &Schema{Ref: "#/components/schemas/PreloadOption"},
|
|
},
|
|
"columns": {
|
|
Type: "array",
|
|
Description: "Columns to select",
|
|
Items: &Schema{Type: "string"},
|
|
},
|
|
"omitColumns": {
|
|
Type: "array",
|
|
Description: "Columns to exclude",
|
|
Items: &Schema{Type: "string"},
|
|
},
|
|
"filters": {
|
|
Type: "array",
|
|
Description: "Filter conditions",
|
|
Items: &Schema{Ref: "#/components/schemas/FilterOption"},
|
|
},
|
|
"sort": {
|
|
Type: "array",
|
|
Description: "Sort specifications",
|
|
Items: &Schema{Ref: "#/components/schemas/SortOption"},
|
|
},
|
|
"limit": {Type: "integer", Description: "Maximum number of records"},
|
|
"offset": {Type: "integer", Description: "Number of records to skip"},
|
|
},
|
|
}
|
|
|
|
// FilterOption schema
|
|
spec.Components.Schemas["FilterOption"] = Schema{
|
|
Type: "object",
|
|
Properties: map[string]*Schema{
|
|
"column": {Type: "string", Description: "Column name"},
|
|
"operator": {Type: "string", Description: "Comparison operator", Enum: []interface{}{"eq", "neq", "gt", "lt", "gte", "lte", "like", "ilike", "in", "not_in", "between", "is_null", "is_not_null"}},
|
|
"value": {Description: "Filter value"},
|
|
"logicOperator": {Type: "string", Description: "Logic operator", Enum: []interface{}{"AND", "OR"}},
|
|
},
|
|
}
|
|
|
|
// SortOption schema
|
|
spec.Components.Schemas["SortOption"] = Schema{
|
|
Type: "object",
|
|
Properties: map[string]*Schema{
|
|
"column": {Type: "string", Description: "Column name"},
|
|
"direction": {Type: "string", Description: "Sort direction", Enum: []interface{}{"asc", "desc"}},
|
|
},
|
|
}
|
|
|
|
// PreloadOption schema
|
|
spec.Components.Schemas["PreloadOption"] = Schema{
|
|
Type: "object",
|
|
Properties: map[string]*Schema{
|
|
"relation": {Type: "string", Description: "Relation name"},
|
|
"columns": {
|
|
Type: "array",
|
|
Description: "Columns to select from related table",
|
|
Items: &Schema{Type: "string"},
|
|
},
|
|
},
|
|
}
|
|
|
|
// ResolveSpec RequestBody schema
|
|
spec.Components.Schemas["ResolveSpecRequest"] = Schema{
|
|
Type: "object",
|
|
Properties: map[string]*Schema{
|
|
"operation": {Type: "string", Description: "Operation type", Enum: []interface{}{"read", "create", "update", "delete", "meta"}},
|
|
"data": {Description: "Payload data (object or array)"},
|
|
"id": {Type: "integer", Description: "Record ID for single operations"},
|
|
"options": {Ref: "#/components/schemas/RequestOptions"},
|
|
},
|
|
}
|
|
}
|
|
|
|
// generateFromModels generates paths and schemas from registered models
|
|
func (g *Generator) generateFromModels(spec *OpenAPISpec) error {
|
|
if g.config.Registry == nil {
|
|
return fmt.Errorf("model registry is required")
|
|
}
|
|
|
|
models := g.config.Registry.GetAllModels()
|
|
|
|
for name, model := range models {
|
|
// Parse schema.entity from model name
|
|
schema, entity := parseModelName(name)
|
|
|
|
// Generate schema for this model
|
|
modelSchema := g.generateModelSchema(model)
|
|
schemaName := formatSchemaName(schema, entity)
|
|
spec.Components.Schemas[schemaName] = modelSchema
|
|
|
|
// Generate paths for different frameworks
|
|
if g.config.IncludeRestheadSpec {
|
|
g.generateRestheadSpecPaths(spec, schema, entity, schemaName)
|
|
}
|
|
|
|
if g.config.IncludeResolveSpec {
|
|
g.generateResolveSpecPaths(spec, schema, entity, schemaName)
|
|
}
|
|
}
|
|
|
|
// Generate FuncSpec paths if configured
|
|
if g.config.IncludeFuncSpec && len(g.config.FuncSpecEndpoints) > 0 {
|
|
g.generateFuncSpecPaths(spec)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// generateModelSchema creates an OpenAPI schema from a Go struct
|
|
func (g *Generator) generateModelSchema(model interface{}) Schema {
|
|
schema := Schema{
|
|
Type: "object",
|
|
Properties: make(map[string]*Schema),
|
|
Required: []string{},
|
|
}
|
|
|
|
modelType := reflect.TypeOf(model)
|
|
if modelType.Kind() == reflect.Ptr {
|
|
modelType = modelType.Elem()
|
|
}
|
|
if modelType.Kind() != reflect.Struct {
|
|
return schema
|
|
}
|
|
|
|
for i := 0; i < modelType.NumField(); i++ {
|
|
field := modelType.Field(i)
|
|
|
|
// Skip unexported fields
|
|
if !field.IsExported() {
|
|
continue
|
|
}
|
|
|
|
// Get JSON tag name
|
|
jsonTag := field.Tag.Get("json")
|
|
if jsonTag == "-" {
|
|
continue
|
|
}
|
|
|
|
fieldName := strings.Split(jsonTag, ",")[0]
|
|
if fieldName == "" {
|
|
fieldName = field.Name
|
|
}
|
|
|
|
// Generate property schema
|
|
propSchema := g.generatePropertySchema(field)
|
|
schema.Properties[fieldName] = propSchema
|
|
|
|
// Check if field is required (not a pointer and no omitempty)
|
|
if field.Type.Kind() != reflect.Ptr && !strings.Contains(jsonTag, "omitempty") {
|
|
schema.Required = append(schema.Required, fieldName)
|
|
}
|
|
}
|
|
|
|
return schema
|
|
}
|
|
|
|
// generatePropertySchema creates a schema for a struct field
|
|
func (g *Generator) generatePropertySchema(field reflect.StructField) *Schema {
|
|
schema := &Schema{}
|
|
|
|
fieldType := field.Type
|
|
if fieldType.Kind() == reflect.Ptr {
|
|
fieldType = fieldType.Elem()
|
|
}
|
|
|
|
// Get description from tag
|
|
if desc := field.Tag.Get("description"); desc != "" {
|
|
schema.Description = desc
|
|
}
|
|
|
|
switch fieldType.Kind() {
|
|
case reflect.String:
|
|
schema.Type = "string"
|
|
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64,
|
|
reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
|
|
schema.Type = "integer"
|
|
case reflect.Float32, reflect.Float64:
|
|
schema.Type = "number"
|
|
case reflect.Bool:
|
|
schema.Type = "boolean"
|
|
case reflect.Slice, reflect.Array:
|
|
schema.Type = "array"
|
|
elemType := fieldType.Elem()
|
|
if elemType.Kind() == reflect.Ptr {
|
|
elemType = elemType.Elem()
|
|
}
|
|
if elemType.Kind() == reflect.Struct {
|
|
// Complex type - would need recursive handling
|
|
schema.Items = &Schema{Type: "object"}
|
|
} else {
|
|
schema.Items = g.generatePropertySchema(reflect.StructField{Type: elemType})
|
|
}
|
|
case reflect.Struct:
|
|
// Check for time.Time
|
|
if fieldType.String() == "time.Time" {
|
|
schema.Type = "string"
|
|
schema.Format = "date-time"
|
|
} else {
|
|
schema.Type = "object"
|
|
}
|
|
default:
|
|
schema.Type = "string"
|
|
}
|
|
|
|
// Check for custom format from gorm/bun tags
|
|
if gormTag := field.Tag.Get("gorm"); gormTag != "" {
|
|
if strings.Contains(gormTag, "type:uuid") {
|
|
schema.Format = "uuid"
|
|
}
|
|
}
|
|
|
|
return schema
|
|
}
|
|
|
|
// parseModelName splits "schema.entity" or returns "public" and entity
|
|
func parseModelName(name string) (schema, entity string) {
|
|
parts := strings.Split(name, ".")
|
|
if len(parts) == 2 {
|
|
return parts[0], parts[1]
|
|
}
|
|
return "public", name
|
|
}
|
|
|
|
// formatSchemaName creates a component schema name
|
|
func formatSchemaName(schema, entity string) string {
|
|
if schema == "public" {
|
|
return toTitleCase(entity)
|
|
}
|
|
return toTitleCase(schema) + toTitleCase(entity)
|
|
}
|
|
|
|
// toTitleCase converts a string to title case (first letter uppercase)
|
|
func toTitleCase(s string) string {
|
|
if s == "" {
|
|
return ""
|
|
}
|
|
if len(s) == 1 {
|
|
return strings.ToUpper(s)
|
|
}
|
|
return strings.ToUpper(s[:1]) + s[1:]
|
|
}
|