diff --git a/pkg/openapi/README.md b/pkg/openapi/README.md new file mode 100644 index 0000000..2c95dcd --- /dev/null +++ b/pkg/openapi/README.md @@ -0,0 +1,321 @@ +# OpenAPI Generator for ResolveSpec + +This package provides automatic OpenAPI 3.0 specification generation for ResolveSpec, RestheadSpec, and FuncSpec API frameworks. + +## Features + +- **Automatic Schema Generation**: Generates OpenAPI schemas from Go struct models +- **Multiple Framework Support**: Works with RestheadSpec, ResolveSpec, and FuncSpec +- **Dynamic Endpoint Discovery**: Automatically discovers all registered models and generates paths +- **Query Parameter Access**: Access spec via `?openapi` on any endpoint or via `/openapi` +- **Comprehensive Documentation**: Includes all request/response schemas, parameters, and security schemes + +## Quick Start + +### RestheadSpec Example + +```go +import ( + "github.com/bitechdev/ResolveSpec/pkg/openapi" + "github.com/bitechdev/ResolveSpec/pkg/restheadspec" + "github.com/gorilla/mux" +) + +func main() { + // 1. Create handler + handler := restheadspec.NewHandlerWithGORM(db) + + // 2. Register models + handler.registry.RegisterModel("public.users", User{}) + handler.registry.RegisterModel("public.products", Product{}) + + // 3. Configure OpenAPI generator + handler.SetOpenAPIGenerator(func() (string, error) { + generator := openapi.NewGenerator(openapi.GeneratorConfig{ + Title: "My API", + Description: "API documentation", + Version: "1.0.0", + BaseURL: "http://localhost:8080", + Registry: handler.registry.(*modelregistry.DefaultModelRegistry), + IncludeRestheadSpec: true, + IncludeResolveSpec: false, + IncludeFuncSpec: false, + }) + return generator.GenerateJSON() + }) + + // 4. Setup routes (automatically includes /openapi endpoint) + router := mux.NewRouter() + restheadspec.SetupMuxRoutes(router, handler, nil) + + // Start server + http.ListenAndServe(":8080", router) +} +``` + +### ResolveSpec Example + +```go +func main() { + // 1. Create handler + handler := resolvespec.NewHandlerWithGORM(db) + + // 2. Register models + handler.RegisterModel("public", "users", User{}) + handler.RegisterModel("public", "products", Product{}) + + // 3. Configure OpenAPI generator + handler.SetOpenAPIGenerator(func() (string, error) { + generator := openapi.NewGenerator(openapi.GeneratorConfig{ + Title: "My API", + Version: "1.0.0", + Registry: handler.registry.(*modelregistry.DefaultModelRegistry), + IncludeResolveSpec: true, + }) + return generator.GenerateJSON() + }) + + // 4. Setup routes + router := mux.NewRouter() + resolvespec.SetupMuxRoutes(router, handler, nil) + + http.ListenAndServe(":8080", router) +} +``` + +## Accessing the OpenAPI Specification + +Once configured, the OpenAPI spec is available in two ways: + +### 1. Global `/openapi` Endpoint + +```bash +curl http://localhost:8080/openapi +``` + +Returns the complete OpenAPI specification for all registered models. + +### 2. Query Parameter on Any Endpoint + +```bash +# RestheadSpec +curl http://localhost:8080/public/users?openapi + +# ResolveSpec +curl http://localhost:8080/resolve/public/users?openapi +``` + +Returns the same OpenAPI specification as `/openapi`. + +## Generated Endpoints + +### RestheadSpec + +For each registered model (e.g., `public.users`), the following paths are generated: + +- `GET /public/users` - List records with header-based filtering +- `POST /public/users` - Create a new record +- `GET /public/users/{id}` - Get a single record +- `PUT /public/users/{id}` - Update a record +- `PATCH /public/users/{id}` - Partially update a record +- `DELETE /public/users/{id}` - Delete a record +- `GET /public/users/metadata` - Get table metadata +- `OPTIONS /public/users` - CORS preflight + +### ResolveSpec + +For each registered model (e.g., `public.users`), the following paths are generated: + +- `POST /resolve/public/users` - Execute operations (read, create, meta) +- `POST /resolve/public/users/{id}` - Execute operations (update, delete) +- `GET /resolve/public/users` - Get metadata +- `OPTIONS /resolve/public/users` - CORS preflight + +## Schema Generation + +The generator automatically extracts information from your Go struct tags: + +```go +type User struct { + ID int `json:"id" gorm:"primaryKey" description:"User ID"` + Name string `json:"name" gorm:"not null" description:"User's full name"` + Email string `json:"email" gorm:"unique" description:"Email address"` + CreatedAt time.Time `json:"created_at" description:"Creation timestamp"` + Roles []string `json:"roles" description:"User roles"` +} +``` + +This generates an OpenAPI schema with: +- Property names from `json` tags +- Required fields from `gorm:"not null"` and non-pointer types +- Descriptions from `description` tags +- Proper type mappings (int → integer, time.Time → string with format: date-time, etc.) + +## RestheadSpec Headers + +The generator documents all RestheadSpec HTTP headers: + +- `X-Filters` - JSON array of filter conditions +- `X-Columns` - Comma-separated columns to select +- `X-Sort` - JSON array of sort specifications +- `X-Limit` - Maximum records to return +- `X-Offset` - Records to skip +- `X-Preload` - Relations to eager load +- `X-Expand` - Relations to expand (LEFT JOIN) +- `X-Distinct` - Enable DISTINCT queries +- `X-Response-Format` - Response format (detail, simple, syncfusion) +- `X-Clean-JSON` - Remove null/empty fields +- `X-Custom-SQL-Where` - Custom WHERE clause (AND) +- `X-Custom-SQL-Or` - Custom WHERE clause (OR) + +## ResolveSpec Request Body + +The generator documents the ResolveSpec request body structure: + +```json +{ + "operation": "read", + "data": {}, + "id": 123, + "options": { + "limit": 10, + "offset": 0, + "filters": [ + {"column": "status", "operator": "eq", "value": "active"} + ], + "sort": [ + {"column": "created_at", "direction": "desc"} + ] + } +} +``` + +## Security Schemes + +The generator automatically includes common security schemes: + +- **BearerAuth**: JWT Bearer token authentication +- **SessionToken**: Session token in Authorization header +- **CookieAuth**: Cookie-based session authentication +- **HeaderAuth**: Header-based user authentication (X-User-ID) + +## FuncSpec Custom Endpoints + +For FuncSpec, you can manually register custom SQL endpoints: + +```go +funcSpecEndpoints := map[string]openapi.FuncSpecEndpoint{ + "/api/reports/sales": { + Path: "/api/reports/sales", + Method: "GET", + Summary: "Get sales report", + Description: "Returns sales data for specified date range", + SQLQuery: "SELECT * FROM sales WHERE date BETWEEN [start_date] AND [end_date]", + Parameters: []string{"start_date", "end_date"}, + }, +} + +generator := openapi.NewGenerator(openapi.GeneratorConfig{ + // ... other config + IncludeFuncSpec: true, + FuncSpecEndpoints: funcSpecEndpoints, +}) +``` + +## Combining Multiple Frameworks + +You can generate a unified OpenAPI spec that includes multiple frameworks: + +```go +generator := openapi.NewGenerator(openapi.GeneratorConfig{ + Title: "Unified API", + Version: "1.0.0", + Registry: sharedRegistry, + IncludeRestheadSpec: true, + IncludeResolveSpec: true, + IncludeFuncSpec: true, + FuncSpecEndpoints: funcSpecEndpoints, +}) +``` + +This will generate a complete spec with all endpoints from all frameworks. + +## Advanced Customization + +You can customize the generated spec further: + +```go +handler.SetOpenAPIGenerator(func() (string, error) { + generator := openapi.NewGenerator(config) + + // Generate initial spec + spec, err := generator.Generate() + if err != nil { + return "", err + } + + // Add contact information + spec.Info.Contact = &openapi.Contact{ + Name: "API Support", + Email: "support@example.com", + URL: "https://example.com/support", + } + + // Add additional servers + spec.Servers = append(spec.Servers, openapi.Server{ + URL: "https://staging.example.com", + Description: "Staging Server", + }) + + // Convert back to JSON + data, _ := json.MarshalIndent(spec, "", " ") + return string(data), nil +}) +``` + +## Using with Swagger UI + +You can serve the generated OpenAPI spec with Swagger UI: + +1. Get the spec from `/openapi` +2. Load it in Swagger UI at `https://petstore.swagger.io/` +3. Or self-host Swagger UI and point it to your `/openapi` endpoint + +Example with self-hosted Swagger UI: + +```go +// Serve Swagger UI static files +router.PathPrefix("/swagger/").Handler( + http.StripPrefix("/swagger/", http.FileServer(http.Dir("./swagger-ui"))), +) + +// Configure Swagger UI to use /openapi +``` + +## Testing + +You can test the OpenAPI endpoint: + +```bash +# Get the full spec +curl http://localhost:8080/openapi | jq + +# Validate with openapi-generator +openapi-generator validate -i http://localhost:8080/openapi + +# Generate client SDKs +openapi-generator generate -i http://localhost:8080/openapi -g typescript-fetch -o ./client +``` + +## Complete Example + +See `example.go` in this package for complete, runnable examples including: +- Basic RestheadSpec setup +- Basic ResolveSpec setup +- Combining both frameworks +- Adding FuncSpec endpoints +- Advanced customization + +## License + +Part of the ResolveSpec project. diff --git a/pkg/openapi/example.go b/pkg/openapi/example.go new file mode 100644 index 0000000..11e880c --- /dev/null +++ b/pkg/openapi/example.go @@ -0,0 +1,235 @@ +package openapi + +import ( + "github.com/bitechdev/ResolveSpec/pkg/modelregistry" + "github.com/bitechdev/ResolveSpec/pkg/restheadspec" + "github.com/bitechdev/ResolveSpec/pkg/resolvespec" + "github.com/gorilla/mux" + "gorm.io/gorm" +) + +// ExampleRestheadSpec shows how to configure OpenAPI generation for RestheadSpec +func ExampleRestheadSpec(db *gorm.DB) { + // 1. Create registry and register models + registry := modelregistry.NewModelRegistry() + // registry.RegisterModel("public.users", User{}) + // registry.RegisterModel("public.products", Product{}) + + // 2. Create handler with custom registry + // import "github.com/bitechdev/ResolveSpec/pkg/common/adapters/database" + // gormAdapter := database.NewGormAdapter(db) + // handler := restheadspec.NewHandler(gormAdapter, registry) + // Or use the convenience function (creates its own registry): + handler := restheadspec.NewHandlerWithGORM(db) + + // 3. Configure OpenAPI generator + handler.SetOpenAPIGenerator(func() (string, error) { + generator := NewGenerator(GeneratorConfig{ + Title: "My API", + Description: "API documentation for my application", + Version: "1.0.0", + BaseURL: "http://localhost:8080", + Registry: registry, + IncludeRestheadSpec: true, + IncludeResolveSpec: false, + IncludeFuncSpec: false, + }) + return generator.GenerateJSON() + }) + + // 4. Setup routes (includes /openapi endpoint) + router := mux.NewRouter() + restheadspec.SetupMuxRoutes(router, handler, nil) + + // Now the following endpoints are available: + // GET /openapi - Full OpenAPI spec + // GET /public/users?openapi - OpenAPI spec + // GET /public/products?openapi - OpenAPI spec + // etc. +} + +// ExampleResolveSpec shows how to configure OpenAPI generation for ResolveSpec +func ExampleResolveSpec(db *gorm.DB) { + // 1. Create registry and register models + registry := modelregistry.NewModelRegistry() + // registry.RegisterModel("public.users", User{}) + // registry.RegisterModel("public.products", Product{}) + + // 2. Create handler with custom registry + // import "github.com/bitechdev/ResolveSpec/pkg/common/adapters/database" + // gormAdapter := database.NewGormAdapter(db) + // handler := resolvespec.NewHandler(gormAdapter, registry) + // Or use the convenience function (creates its own registry): + handler := resolvespec.NewHandlerWithGORM(db) + // Note: handler.RegisterModel("schema", "entity", model) can be used + + // 3. Configure OpenAPI generator + handler.SetOpenAPIGenerator(func() (string, error) { + generator := NewGenerator(GeneratorConfig{ + Title: "My API", + Description: "API documentation for my application", + Version: "1.0.0", + BaseURL: "http://localhost:8080", + Registry: registry, + IncludeRestheadSpec: false, + IncludeResolveSpec: true, + IncludeFuncSpec: false, + }) + return generator.GenerateJSON() + }) + + // 4. Setup routes (includes /openapi endpoint) + router := mux.NewRouter() + resolvespec.SetupMuxRoutes(router, handler, nil) + + // Now the following endpoints are available: + // GET /openapi - Full OpenAPI spec + // POST /resolve/public/users?openapi - OpenAPI spec + // POST /resolve/public/products?openapi - OpenAPI spec + // etc. +} + +// ExampleBothSpecs shows how to combine both RestheadSpec and ResolveSpec +func ExampleBothSpecs(db *gorm.DB) { + // Create shared registry + sharedRegistry := modelregistry.NewModelRegistry() + // Register models once + // sharedRegistry.RegisterModel("public.users", User{}) + // sharedRegistry.RegisterModel("public.products", Product{}) + + // Create handlers - they will have separate registries initially + restheadHandler := restheadspec.NewHandlerWithGORM(db) + resolveHandler := resolvespec.NewHandlerWithGORM(db) + + // Note: If you want to use a shared registry, create handlers manually: + // import "github.com/bitechdev/ResolveSpec/pkg/common/adapters/database" + // gormAdapter := database.NewGormAdapter(db) + // restheadHandler := restheadspec.NewHandler(gormAdapter, sharedRegistry) + // resolveHandler := resolvespec.NewHandler(gormAdapter, sharedRegistry) + + // Configure OpenAPI generator for both + generatorFunc := func() (string, error) { + generator := NewGenerator(GeneratorConfig{ + Title: "My Unified API", + Description: "Complete API documentation with both RestheadSpec and ResolveSpec endpoints", + Version: "1.0.0", + BaseURL: "http://localhost:8080", + Registry: sharedRegistry, + IncludeRestheadSpec: true, + IncludeResolveSpec: true, + IncludeFuncSpec: false, + }) + return generator.GenerateJSON() + } + + restheadHandler.SetOpenAPIGenerator(generatorFunc) + resolveHandler.SetOpenAPIGenerator(generatorFunc) + + // Setup routes + router := mux.NewRouter() + restheadspec.SetupMuxRoutes(router, restheadHandler, nil) + + // Add ResolveSpec routes under /resolve prefix + resolveRouter := router.PathPrefix("/resolve").Subrouter() + resolvespec.SetupMuxRoutes(resolveRouter, resolveHandler, nil) + + // Now you have both styles of API available: + // GET /openapi - Full OpenAPI spec (both styles) + // GET /public/users - RestheadSpec list endpoint + // POST /resolve/public/users - ResolveSpec operation endpoint + // GET /public/users?openapi - OpenAPI spec + // POST /resolve/public/users?openapi - OpenAPI spec +} + +// ExampleWithFuncSpec shows how to add FuncSpec endpoints to OpenAPI +func ExampleWithFuncSpec() { + // FuncSpec endpoints need to be registered manually since they don't use model registry + generatorFunc := func() (string, error) { + funcSpecEndpoints := map[string]FuncSpecEndpoint{ + "/api/reports/sales": { + Path: "/api/reports/sales", + Method: "GET", + Summary: "Get sales report", + Description: "Returns sales data for the specified date range", + SQLQuery: "SELECT * FROM sales WHERE date BETWEEN [start_date] AND [end_date]", + Parameters: []string{"start_date", "end_date"}, + }, + "/api/analytics/users": { + Path: "/api/analytics/users", + Method: "GET", + Summary: "Get user analytics", + Description: "Returns user activity analytics", + SQLQuery: "SELECT * FROM user_analytics WHERE user_id = [user_id]", + Parameters: []string{"user_id"}, + }, + } + + generator := NewGenerator(GeneratorConfig{ + Title: "My API with Custom Queries", + Description: "API with FuncSpec custom SQL endpoints", + Version: "1.0.0", + BaseURL: "http://localhost:8080", + Registry: modelregistry.NewModelRegistry(), + IncludeRestheadSpec: false, + IncludeResolveSpec: false, + IncludeFuncSpec: true, + FuncSpecEndpoints: funcSpecEndpoints, + }) + return generator.GenerateJSON() + } + + // Use this generator function with your handlers + _ = generatorFunc +} + +// ExampleCustomization shows advanced customization options +func ExampleCustomization() { + // Create registry and register models with descriptions using struct tags + registry := modelregistry.NewModelRegistry() + + // type User struct { + // ID int `json:"id" gorm:"primaryKey" description:"Unique user identifier"` + // Name string `json:"name" description:"User's full name"` + // Email string `json:"email" gorm:"unique" description:"User's email address"` + // } + // registry.RegisterModel("public.users", User{}) + + // Advanced configuration - create generator function + generatorFunc := func() (string, error) { + generator := NewGenerator(GeneratorConfig{ + Title: "My Advanced API", + Description: "Comprehensive API documentation with custom configuration", + Version: "2.1.0", + BaseURL: "https://api.myapp.com", + Registry: registry, + IncludeRestheadSpec: true, + IncludeResolveSpec: true, + IncludeFuncSpec: false, + }) + + // Generate the spec + // spec, err := generator.Generate() + // if err != nil { + // return "", err + // } + + // Customize the spec further if needed + // spec.Info.Contact = &Contact{ + // Name: "API Support", + // Email: "support@myapp.com", + // URL: "https://myapp.com/support", + // } + + // Add additional servers + // spec.Servers = append(spec.Servers, Server{ + // URL: "https://staging-api.myapp.com", + // Description: "Staging Server", + // }) + + // Convert back to JSON - or use GenerateJSON() for simple cases + return generator.GenerateJSON() + } + + // Use this generator function with your handlers + _ = generatorFunc +} diff --git a/pkg/openapi/generator.go b/pkg/openapi/generator.go new file mode 100644 index 0000000..d4a47df --- /dev/null +++ b/pkg/openapi/generator.go @@ -0,0 +1,513 @@ +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:] +} diff --git a/pkg/openapi/generator_test.go b/pkg/openapi/generator_test.go new file mode 100644 index 0000000..780a33a --- /dev/null +++ b/pkg/openapi/generator_test.go @@ -0,0 +1,714 @@ +package openapi + +import ( + "encoding/json" + "strings" + "testing" + "time" + + "github.com/bitechdev/ResolveSpec/pkg/modelregistry" +) + +// Test models +type TestUser struct { + ID int `json:"id" gorm:"primaryKey" description:"User ID"` + Name string `json:"name" gorm:"not null" description:"User's full name"` + Email string `json:"email" gorm:"unique" description:"Email address"` + Age int `json:"age" description:"User age"` + IsActive bool `json:"is_active" description:"Active status"` + CreatedAt time.Time `json:"created_at" description:"Creation timestamp"` + UpdatedAt *time.Time `json:"updated_at,omitempty" description:"Last update timestamp"` + Roles []string `json:"roles,omitempty" description:"User roles"` +} + +type TestProduct struct { + ID int `json:"id" gorm:"primaryKey"` + Name string `json:"name" gorm:"not null"` + Description string `json:"description"` + Price float64 `json:"price"` + InStock bool `json:"in_stock"` +} + +type TestOrder struct { + ID int `json:"id" gorm:"primaryKey"` + UserID int `json:"user_id" gorm:"not null"` + ProductID int `json:"product_id" gorm:"not null"` + Quantity int `json:"quantity"` + TotalPrice float64 `json:"total_price"` +} + +func TestNewGenerator(t *testing.T) { + registry := modelregistry.NewModelRegistry() + + tests := []struct { + name string + config GeneratorConfig + want string // expected title + }{ + { + name: "with all fields", + config: GeneratorConfig{ + Title: "Test API", + Description: "Test Description", + Version: "1.0.0", + BaseURL: "http://localhost:8080", + Registry: registry, + }, + want: "Test API", + }, + { + name: "with defaults", + config: GeneratorConfig{ + Registry: registry, + }, + want: "ResolveSpec API", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gen := NewGenerator(tt.config) + if gen == nil { + t.Fatal("NewGenerator returned nil") + } + if gen.config.Title != tt.want { + t.Errorf("Title = %v, want %v", gen.config.Title, tt.want) + } + }) + } +} + +func TestGenerateBasicSpec(t *testing.T) { + registry := modelregistry.NewModelRegistry() + err := registry.RegisterModel("public.users", TestUser{}) + if err != nil { + t.Fatalf("Failed to register model: %v", err) + } + + config := GeneratorConfig{ + Title: "Test API", + Version: "1.0.0", + Registry: registry, + IncludeRestheadSpec: true, + } + + gen := NewGenerator(config) + spec, err := gen.Generate() + if err != nil { + t.Fatalf("Generate failed: %v", err) + } + + // Test basic spec structure + if spec.OpenAPI != "3.0.0" { + t.Errorf("OpenAPI version = %v, want 3.0.0", spec.OpenAPI) + } + if spec.Info.Title != "Test API" { + t.Errorf("Title = %v, want Test API", spec.Info.Title) + } + if spec.Info.Version != "1.0.0" { + t.Errorf("Version = %v, want 1.0.0", spec.Info.Version) + } + + // Test that common schemas are added + if spec.Components.Schemas["Response"].Type != "object" { + t.Error("Response schema not found or invalid") + } + if spec.Components.Schemas["Metadata"].Type != "object" { + t.Error("Metadata schema not found or invalid") + } + + // Test that model schema is added + if _, exists := spec.Components.Schemas["Users"]; !exists { + t.Error("Users schema not found") + } + + // Test that security schemes are added + if len(spec.Components.SecuritySchemes) == 0 { + t.Error("Security schemes not added") + } +} + +func TestGenerateModelSchema(t *testing.T) { + registry := modelregistry.NewModelRegistry() + gen := NewGenerator(GeneratorConfig{Registry: registry}) + + schema := gen.generateModelSchema(TestUser{}) + + // Test basic properties + if schema.Type != "object" { + t.Errorf("Schema type = %v, want object", schema.Type) + } + + // Test that properties are generated + expectedProps := []string{"id", "name", "email", "age", "is_active", "created_at", "updated_at", "roles"} + for _, prop := range expectedProps { + if _, exists := schema.Properties[prop]; !exists { + t.Errorf("Property %s not found in schema", prop) + } + } + + // Test property types + if schema.Properties["id"].Type != "integer" { + t.Errorf("id type = %v, want integer", schema.Properties["id"].Type) + } + if schema.Properties["name"].Type != "string" { + t.Errorf("name type = %v, want string", schema.Properties["name"].Type) + } + if schema.Properties["is_active"].Type != "boolean" { + t.Errorf("is_active type = %v, want boolean", schema.Properties["is_active"].Type) + } + + // Test array type + if schema.Properties["roles"].Type != "array" { + t.Errorf("roles type = %v, want array", schema.Properties["roles"].Type) + } + if schema.Properties["roles"].Items.Type != "string" { + t.Errorf("roles items type = %v, want string", schema.Properties["roles"].Items.Type) + } + + // Test time.Time format + if schema.Properties["created_at"].Type != "string" { + t.Errorf("created_at type = %v, want string", schema.Properties["created_at"].Type) + } + if schema.Properties["created_at"].Format != "date-time" { + t.Errorf("created_at format = %v, want date-time", schema.Properties["created_at"].Format) + } + + // Test required fields (non-pointer, no omitempty) + requiredFields := map[string]bool{} + for _, field := range schema.Required { + requiredFields[field] = true + } + if !requiredFields["id"] { + t.Error("id should be required") + } + if !requiredFields["name"] { + t.Error("name should be required") + } + if requiredFields["updated_at"] { + t.Error("updated_at should not be required (pointer + omitempty)") + } + if requiredFields["roles"] { + t.Error("roles should not be required (omitempty)") + } + + // Test descriptions + if schema.Properties["id"].Description != "User ID" { + t.Errorf("id description = %v, want 'User ID'", schema.Properties["id"].Description) + } +} + +func TestGenerateRestheadSpecPaths(t *testing.T) { + registry := modelregistry.NewModelRegistry() + err := registry.RegisterModel("public.users", TestUser{}) + if err != nil { + t.Fatalf("Failed to register model: %v", err) + } + + config := GeneratorConfig{ + Title: "Test API", + Version: "1.0.0", + Registry: registry, + IncludeRestheadSpec: true, + } + + gen := NewGenerator(config) + spec, err := gen.Generate() + if err != nil { + t.Fatalf("Generate failed: %v", err) + } + + // Test that paths are generated + expectedPaths := []string{ + "/public/users", + "/public/users/{id}", + "/public/users/metadata", + } + + for _, path := range expectedPaths { + if _, exists := spec.Paths[path]; !exists { + t.Errorf("Path %s not found", path) + } + } + + // Test collection endpoint methods + usersPath := spec.Paths["/public/users"] + if usersPath.Get == nil { + t.Error("GET method not found for /public/users") + } + if usersPath.Post == nil { + t.Error("POST method not found for /public/users") + } + if usersPath.Options == nil { + t.Error("OPTIONS method not found for /public/users") + } + + // Test single record endpoint methods + userIDPath := spec.Paths["/public/users/{id}"] + if userIDPath.Get == nil { + t.Error("GET method not found for /public/users/{id}") + } + if userIDPath.Put == nil { + t.Error("PUT method not found for /public/users/{id}") + } + if userIDPath.Patch == nil { + t.Error("PATCH method not found for /public/users/{id}") + } + if userIDPath.Delete == nil { + t.Error("DELETE method not found for /public/users/{id}") + } + + // Test metadata endpoint + metadataPath := spec.Paths["/public/users/metadata"] + if metadataPath.Get == nil { + t.Error("GET method not found for /public/users/metadata") + } + + // Test operation details + getOp := usersPath.Get + if getOp.Summary == "" { + t.Error("GET operation summary is empty") + } + if getOp.OperationID == "" { + t.Error("GET operation ID is empty") + } + if len(getOp.Tags) == 0 { + t.Error("GET operation has no tags") + } + if len(getOp.Parameters) == 0 { + t.Error("GET operation has no parameters") + } + + // Test RestheadSpec headers + hasFiltersHeader := false + for _, param := range getOp.Parameters { + if param.Name == "X-Filters" && param.In == "header" { + hasFiltersHeader = true + break + } + } + if !hasFiltersHeader { + t.Error("X-Filters header parameter not found") + } +} + +func TestGenerateResolveSpecPaths(t *testing.T) { + registry := modelregistry.NewModelRegistry() + err := registry.RegisterModel("public.products", TestProduct{}) + if err != nil { + t.Fatalf("Failed to register model: %v", err) + } + + config := GeneratorConfig{ + Title: "Test API", + Version: "1.0.0", + Registry: registry, + IncludeResolveSpec: true, + } + + gen := NewGenerator(config) + spec, err := gen.Generate() + if err != nil { + t.Fatalf("Generate failed: %v", err) + } + + // Test that paths are generated + expectedPaths := []string{ + "/resolve/public/products", + "/resolve/public/products/{id}", + } + + for _, path := range expectedPaths { + if _, exists := spec.Paths[path]; !exists { + t.Errorf("Path %s not found", path) + } + } + + // Test collection endpoint methods + productsPath := spec.Paths["/resolve/public/products"] + if productsPath.Post == nil { + t.Error("POST method not found for /resolve/public/products") + } + if productsPath.Get == nil { + t.Error("GET method not found for /resolve/public/products") + } + if productsPath.Options == nil { + t.Error("OPTIONS method not found for /resolve/public/products") + } + + // Test POST operation has request body + postOp := productsPath.Post + if postOp.RequestBody == nil { + t.Error("POST operation has no request body") + } + if _, exists := postOp.RequestBody.Content["application/json"]; !exists { + t.Error("POST operation request body has no application/json content") + } + + // Test request body schema references ResolveSpecRequest + reqBodySchema := postOp.RequestBody.Content["application/json"].Schema + if reqBodySchema.Ref != "#/components/schemas/ResolveSpecRequest" { + t.Errorf("Request body schema ref = %v, want #/components/schemas/ResolveSpecRequest", reqBodySchema.Ref) + } +} + +func TestGenerateFuncSpecPaths(t *testing.T) { + registry := modelregistry.NewModelRegistry() + + funcSpecEndpoints := map[string]FuncSpecEndpoint{ + "/api/reports/sales": { + Path: "/api/reports/sales", + Method: "GET", + Summary: "Get sales report", + Description: "Returns sales data", + Parameters: []string{"start_date", "end_date"}, + }, + "/api/analytics/users": { + Path: "/api/analytics/users", + Method: "POST", + Summary: "Get user analytics", + Description: "Returns user activity", + Parameters: []string{"user_id"}, + }, + } + + config := GeneratorConfig{ + Title: "Test API", + Version: "1.0.0", + Registry: registry, + IncludeFuncSpec: true, + FuncSpecEndpoints: funcSpecEndpoints, + } + + gen := NewGenerator(config) + spec, err := gen.Generate() + if err != nil { + t.Fatalf("Generate failed: %v", err) + } + + // Test that FuncSpec paths are generated + salesPath := spec.Paths["/api/reports/sales"] + if salesPath.Get == nil { + t.Error("GET method not found for /api/reports/sales") + } + if salesPath.Get.Summary != "Get sales report" { + t.Errorf("GET summary = %v, want 'Get sales report'", salesPath.Get.Summary) + } + if len(salesPath.Get.Parameters) != 2 { + t.Errorf("GET has %d parameters, want 2", len(salesPath.Get.Parameters)) + } + + analyticsPath := spec.Paths["/api/analytics/users"] + if analyticsPath.Post == nil { + t.Error("POST method not found for /api/analytics/users") + } + if len(analyticsPath.Post.Parameters) != 1 { + t.Errorf("POST has %d parameters, want 1", len(analyticsPath.Post.Parameters)) + } +} + +func TestGenerateJSON(t *testing.T) { + registry := modelregistry.NewModelRegistry() + err := registry.RegisterModel("public.users", TestUser{}) + if err != nil { + t.Fatalf("Failed to register model: %v", err) + } + + config := GeneratorConfig{ + Title: "Test API", + Version: "1.0.0", + Registry: registry, + IncludeRestheadSpec: true, + } + + gen := NewGenerator(config) + jsonStr, err := gen.GenerateJSON() + if err != nil { + t.Fatalf("GenerateJSON failed: %v", err) + } + + // Test that it's valid JSON + var spec OpenAPISpec + if err := json.Unmarshal([]byte(jsonStr), &spec); err != nil { + t.Fatalf("Generated JSON is invalid: %v", err) + } + + // Test basic structure + if spec.OpenAPI != "3.0.0" { + t.Errorf("OpenAPI version = %v, want 3.0.0", spec.OpenAPI) + } + if spec.Info.Title != "Test API" { + t.Errorf("Title = %v, want Test API", spec.Info.Title) + } + + // Test that JSON contains expected fields + if !strings.Contains(jsonStr, `"openapi"`) { + t.Error("JSON doesn't contain 'openapi' field") + } + if !strings.Contains(jsonStr, `"paths"`) { + t.Error("JSON doesn't contain 'paths' field") + } + if !strings.Contains(jsonStr, `"components"`) { + t.Error("JSON doesn't contain 'components' field") + } +} + +func TestMultipleModels(t *testing.T) { + registry := modelregistry.NewModelRegistry() + registry.RegisterModel("public.users", TestUser{}) + registry.RegisterModel("public.products", TestProduct{}) + registry.RegisterModel("public.orders", TestOrder{}) + + config := GeneratorConfig{ + Title: "Test API", + Version: "1.0.0", + Registry: registry, + IncludeRestheadSpec: true, + } + + gen := NewGenerator(config) + spec, err := gen.Generate() + if err != nil { + t.Fatalf("Generate failed: %v", err) + } + + // Test that all model schemas are generated + expectedSchemas := []string{"Users", "Products", "Orders"} + for _, schemaName := range expectedSchemas { + if _, exists := spec.Components.Schemas[schemaName]; !exists { + t.Errorf("Schema %s not found", schemaName) + } + } + + // Test that all paths are generated + expectedPaths := []string{ + "/public/users", + "/public/products", + "/public/orders", + } + for _, path := range expectedPaths { + if _, exists := spec.Paths[path]; !exists { + t.Errorf("Path %s not found", path) + } + } +} + +func TestModelNameParsing(t *testing.T) { + tests := []struct { + name string + fullName string + wantSchema string + wantEntity string + }{ + { + name: "with schema", + fullName: "public.users", + wantSchema: "public", + wantEntity: "users", + }, + { + name: "without schema", + fullName: "users", + wantSchema: "public", + wantEntity: "users", + }, + { + name: "custom schema", + fullName: "custom.products", + wantSchema: "custom", + wantEntity: "products", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + schema, entity := parseModelName(tt.fullName) + if schema != tt.wantSchema { + t.Errorf("schema = %v, want %v", schema, tt.wantSchema) + } + if entity != tt.wantEntity { + t.Errorf("entity = %v, want %v", entity, tt.wantEntity) + } + }) + } +} + +func TestSchemaNameFormatting(t *testing.T) { + tests := []struct { + name string + schema string + entity string + wantName string + }{ + { + name: "public schema", + schema: "public", + entity: "users", + wantName: "Users", + }, + { + name: "custom schema", + schema: "custom", + entity: "products", + wantName: "CustomProducts", + }, + { + name: "multi-word entity", + schema: "public", + entity: "user_profiles", + wantName: "User_profiles", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + name := formatSchemaName(tt.schema, tt.entity) + if name != tt.wantName { + t.Errorf("formatSchemaName() = %v, want %v", name, tt.wantName) + } + }) + } +} + +func TestToTitleCase(t *testing.T) { + tests := []struct { + input string + want string + }{ + {"users", "Users"}, + {"products", "Products"}, + {"userProfiles", "UserProfiles"}, + {"a", "A"}, + {"", ""}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + got := toTitleCase(tt.input) + if got != tt.want { + t.Errorf("toTitleCase(%v) = %v, want %v", tt.input, got, tt.want) + } + }) + } +} + +func TestGenerateWithBaseURL(t *testing.T) { + registry := modelregistry.NewModelRegistry() + registry.RegisterModel("public.users", TestUser{}) + + config := GeneratorConfig{ + Title: "Test API", + Version: "1.0.0", + BaseURL: "https://api.example.com", + Registry: registry, + IncludeRestheadSpec: true, + } + + gen := NewGenerator(config) + spec, err := gen.Generate() + if err != nil { + t.Fatalf("Generate failed: %v", err) + } + + // Test that server is added + if len(spec.Servers) == 0 { + t.Fatal("No servers added") + } + if spec.Servers[0].URL != "https://api.example.com" { + t.Errorf("Server URL = %v, want https://api.example.com", spec.Servers[0].URL) + } + if spec.Servers[0].Description != "API Server" { + t.Errorf("Server description = %v, want 'API Server'", spec.Servers[0].Description) + } +} + +func TestGenerateCombinedFrameworks(t *testing.T) { + registry := modelregistry.NewModelRegistry() + registry.RegisterModel("public.users", TestUser{}) + + config := GeneratorConfig{ + Title: "Test API", + Version: "1.0.0", + Registry: registry, + IncludeRestheadSpec: true, + IncludeResolveSpec: true, + } + + gen := NewGenerator(config) + spec, err := gen.Generate() + if err != nil { + t.Fatalf("Generate failed: %v", err) + } + + // Test that both RestheadSpec and ResolveSpec paths are generated + restheadPath := "/public/users" + resolveSpecPath := "/resolve/public/users" + + if _, exists := spec.Paths[restheadPath]; !exists { + t.Errorf("RestheadSpec path %s not found", restheadPath) + } + if _, exists := spec.Paths[resolveSpecPath]; !exists { + t.Errorf("ResolveSpec path %s not found", resolveSpecPath) + } +} + +func TestNilRegistry(t *testing.T) { + config := GeneratorConfig{ + Title: "Test API", + Version: "1.0.0", + } + + gen := NewGenerator(config) + _, err := gen.Generate() + if err == nil { + t.Error("Expected error for nil registry, got nil") + } + if !strings.Contains(err.Error(), "registry") { + t.Errorf("Error message should mention registry, got: %v", err) + } +} + +func TestSecuritySchemes(t *testing.T) { + registry := modelregistry.NewModelRegistry() + config := GeneratorConfig{ + Registry: registry, + } + + gen := NewGenerator(config) + spec, err := gen.Generate() + if err != nil { + t.Fatalf("Generate failed: %v", err) + } + + // Test that all security schemes are present + expectedSchemes := []string{"BearerAuth", "SessionToken", "CookieAuth", "HeaderAuth"} + for _, scheme := range expectedSchemes { + if _, exists := spec.Components.SecuritySchemes[scheme]; !exists { + t.Errorf("Security scheme %s not found", scheme) + } + } + + // Test BearerAuth scheme details + bearerAuth := spec.Components.SecuritySchemes["BearerAuth"] + if bearerAuth.Type != "http" { + t.Errorf("BearerAuth type = %v, want http", bearerAuth.Type) + } + if bearerAuth.Scheme != "bearer" { + t.Errorf("BearerAuth scheme = %v, want bearer", bearerAuth.Scheme) + } + if bearerAuth.BearerFormat != "JWT" { + t.Errorf("BearerAuth format = %v, want JWT", bearerAuth.BearerFormat) + } + + // Test HeaderAuth scheme details + headerAuth := spec.Components.SecuritySchemes["HeaderAuth"] + if headerAuth.Type != "apiKey" { + t.Errorf("HeaderAuth type = %v, want apiKey", headerAuth.Type) + } + if headerAuth.In != "header" { + t.Errorf("HeaderAuth in = %v, want header", headerAuth.In) + } + if headerAuth.Name != "X-User-ID" { + t.Errorf("HeaderAuth name = %v, want X-User-ID", headerAuth.Name) + } +} diff --git a/pkg/openapi/paths.go b/pkg/openapi/paths.go new file mode 100644 index 0000000..d302d02 --- /dev/null +++ b/pkg/openapi/paths.go @@ -0,0 +1,499 @@ +package openapi + +import ( + "fmt" +) + +// generateRestheadSpecPaths generates OpenAPI paths for RestheadSpec endpoints +func (g *Generator) generateRestheadSpecPaths(spec *OpenAPISpec, schema, entity, schemaName string) { + basePath := fmt.Sprintf("/%s/%s", schema, entity) + idPath := fmt.Sprintf("/%s/%s/{id}", schema, entity) + metaPath := fmt.Sprintf("/%s/%s/metadata", schema, entity) + + // Collection endpoint: GET (list), POST (create) + spec.Paths[basePath] = PathItem{ + Get: &Operation{ + Summary: fmt.Sprintf("List %s records", entity), + Description: fmt.Sprintf("Retrieve a list of %s records with optional filtering, sorting, and pagination via headers", entity), + OperationID: fmt.Sprintf("listRestheadSpec%s%s", formatSchemaName(schema, ""), formatSchemaName("", entity)), + Tags: []string{fmt.Sprintf("%s (RestheadSpec)", entity)}, + Parameters: g.getRestheadSpecHeaders(), + Responses: map[string]Response{ + "200": { + Description: "Successful response", + Content: map[string]MediaType{ + "application/json": { + Schema: &Schema{ + Type: "object", + Properties: map[string]*Schema{ + "success": {Type: "boolean"}, + "data": {Type: "array", Items: &Schema{Ref: fmt.Sprintf("#/components/schemas/%s", schemaName)}}, + "metadata": {Ref: "#/components/schemas/Metadata"}, + }, + }, + }, + }, + }, + "401": g.errorResponse("Unauthorized"), + "500": g.errorResponse("Internal server error"), + }, + Security: g.securityRequirements(), + }, + Post: &Operation{ + Summary: fmt.Sprintf("Create %s record", entity), + Description: fmt.Sprintf("Create a new %s record", entity), + OperationID: fmt.Sprintf("createRestheadSpec%s%s", formatSchemaName(schema, ""), formatSchemaName("", entity)), + Tags: []string{fmt.Sprintf("%s (RestheadSpec)", entity)}, + RequestBody: &RequestBody{ + Required: true, + Description: fmt.Sprintf("%s object to create", entity), + Content: map[string]MediaType{ + "application/json": { + Schema: &Schema{Ref: fmt.Sprintf("#/components/schemas/%s", schemaName)}, + }, + }, + }, + Responses: map[string]Response{ + "201": { + Description: "Record created successfully", + Content: map[string]MediaType{ + "application/json": { + Schema: &Schema{ + Type: "object", + Properties: map[string]*Schema{ + "success": {Type: "boolean"}, + "data": {Ref: fmt.Sprintf("#/components/schemas/%s", schemaName)}, + }, + }, + }, + }, + }, + "400": g.errorResponse("Bad request"), + "401": g.errorResponse("Unauthorized"), + "500": g.errorResponse("Internal server error"), + }, + Security: g.securityRequirements(), + }, + Options: &Operation{ + Summary: "CORS preflight", + Description: "Handle CORS preflight requests", + OperationID: fmt.Sprintf("optionsRestheadSpec%s%s", formatSchemaName(schema, ""), formatSchemaName("", entity)), + Tags: []string{fmt.Sprintf("%s (RestheadSpec)", entity)}, + Responses: map[string]Response{ + "204": {Description: "No content"}, + }, + }, + } + + // Single record endpoint: GET (read), PUT/PATCH (update), DELETE + spec.Paths[idPath] = PathItem{ + Get: &Operation{ + Summary: fmt.Sprintf("Get %s record by ID", entity), + Description: fmt.Sprintf("Retrieve a single %s record by its ID", entity), + OperationID: fmt.Sprintf("getRestheadSpec%s%s", formatSchemaName(schema, ""), formatSchemaName("", entity)), + Tags: []string{fmt.Sprintf("%s (RestheadSpec)", entity)}, + Parameters: []Parameter{ + {Name: "id", In: "path", Required: true, Description: "Record ID", Schema: &Schema{Type: "integer"}}, + }, + Responses: map[string]Response{ + "200": { + Description: "Successful response", + Content: map[string]MediaType{ + "application/json": { + Schema: &Schema{ + Type: "object", + Properties: map[string]*Schema{ + "success": {Type: "boolean"}, + "data": {Ref: fmt.Sprintf("#/components/schemas/%s", schemaName)}, + }, + }, + }, + }, + }, + "404": g.errorResponse("Record not found"), + "401": g.errorResponse("Unauthorized"), + "500": g.errorResponse("Internal server error"), + }, + Security: g.securityRequirements(), + }, + Put: &Operation{ + Summary: fmt.Sprintf("Update %s record", entity), + Description: fmt.Sprintf("Update an existing %s record by ID", entity), + OperationID: fmt.Sprintf("updateRestheadSpec%s%s", formatSchemaName(schema, ""), formatSchemaName("", entity)), + Tags: []string{fmt.Sprintf("%s (RestheadSpec)", entity)}, + Parameters: []Parameter{ + {Name: "id", In: "path", Required: true, Description: "Record ID", Schema: &Schema{Type: "integer"}}, + }, + RequestBody: &RequestBody{ + Required: true, + Description: fmt.Sprintf("Updated %s object", entity), + Content: map[string]MediaType{ + "application/json": { + Schema: &Schema{Ref: fmt.Sprintf("#/components/schemas/%s", schemaName)}, + }, + }, + }, + Responses: map[string]Response{ + "200": { + Description: "Record updated successfully", + Content: map[string]MediaType{ + "application/json": { + Schema: &Schema{ + Type: "object", + Properties: map[string]*Schema{ + "success": {Type: "boolean"}, + "data": {Ref: fmt.Sprintf("#/components/schemas/%s", schemaName)}, + }, + }, + }, + }, + }, + "400": g.errorResponse("Bad request"), + "404": g.errorResponse("Record not found"), + "401": g.errorResponse("Unauthorized"), + "500": g.errorResponse("Internal server error"), + }, + Security: g.securityRequirements(), + }, + Patch: &Operation{ + Summary: fmt.Sprintf("Partially update %s record", entity), + Description: fmt.Sprintf("Partially update an existing %s record by ID", entity), + OperationID: fmt.Sprintf("patchRestheadSpec%s%s", formatSchemaName(schema, ""), formatSchemaName("", entity)), + Tags: []string{fmt.Sprintf("%s (RestheadSpec)", entity)}, + Parameters: []Parameter{ + {Name: "id", In: "path", Required: true, Description: "Record ID", Schema: &Schema{Type: "integer"}}, + }, + RequestBody: &RequestBody{ + Required: true, + Description: fmt.Sprintf("Partial %s object", entity), + Content: map[string]MediaType{ + "application/json": { + Schema: &Schema{Ref: fmt.Sprintf("#/components/schemas/%s", schemaName)}, + }, + }, + }, + Responses: map[string]Response{ + "200": { + Description: "Record updated successfully", + Content: map[string]MediaType{ + "application/json": { + Schema: &Schema{ + Type: "object", + Properties: map[string]*Schema{ + "success": {Type: "boolean"}, + "data": {Ref: fmt.Sprintf("#/components/schemas/%s", schemaName)}, + }, + }, + }, + }, + }, + "400": g.errorResponse("Bad request"), + "404": g.errorResponse("Record not found"), + "401": g.errorResponse("Unauthorized"), + "500": g.errorResponse("Internal server error"), + }, + Security: g.securityRequirements(), + }, + Delete: &Operation{ + Summary: fmt.Sprintf("Delete %s record", entity), + Description: fmt.Sprintf("Delete a %s record by ID", entity), + OperationID: fmt.Sprintf("deleteRestheadSpec%s%s", formatSchemaName(schema, ""), formatSchemaName("", entity)), + Tags: []string{fmt.Sprintf("%s (RestheadSpec)", entity)}, + Parameters: []Parameter{ + {Name: "id", In: "path", Required: true, Description: "Record ID", Schema: &Schema{Type: "integer"}}, + }, + Responses: map[string]Response{ + "200": { + Description: "Record deleted successfully", + Content: map[string]MediaType{ + "application/json": { + Schema: &Schema{ + Type: "object", + Properties: map[string]*Schema{ + "success": {Type: "boolean"}, + }, + }, + }, + }, + }, + "404": g.errorResponse("Record not found"), + "401": g.errorResponse("Unauthorized"), + "500": g.errorResponse("Internal server error"), + }, + Security: g.securityRequirements(), + }, + } + + // Metadata endpoint + spec.Paths[metaPath] = PathItem{ + Get: &Operation{ + Summary: fmt.Sprintf("Get %s metadata", entity), + Description: fmt.Sprintf("Retrieve metadata information for %s table", entity), + OperationID: fmt.Sprintf("metadataRestheadSpec%s%s", formatSchemaName(schema, ""), formatSchemaName("", entity)), + Tags: []string{fmt.Sprintf("%s (RestheadSpec)", entity)}, + Responses: map[string]Response{ + "200": { + Description: "Metadata retrieved successfully", + Content: map[string]MediaType{ + "application/json": { + Schema: &Schema{ + Type: "object", + Properties: map[string]*Schema{ + "success": {Type: "boolean"}, + "data": { + Type: "object", + Properties: map[string]*Schema{ + "schema": {Type: "string"}, + "table": {Type: "string"}, + "columns": {Type: "array", Items: &Schema{Type: "object"}}, + }, + }, + }, + }, + }, + }, + }, + "401": g.errorResponse("Unauthorized"), + "500": g.errorResponse("Internal server error"), + }, + Security: g.securityRequirements(), + }, + } +} + +// generateResolveSpecPaths generates OpenAPI paths for ResolveSpec endpoints +func (g *Generator) generateResolveSpecPaths(spec *OpenAPISpec, schema, entity, schemaName string) { + basePath := fmt.Sprintf("/resolve/%s/%s", schema, entity) + idPath := fmt.Sprintf("/resolve/%s/%s/{id}", schema, entity) + + // Collection endpoint: POST (operations) + spec.Paths[basePath] = PathItem{ + Post: &Operation{ + Summary: fmt.Sprintf("Perform operation on %s", entity), + Description: fmt.Sprintf("Execute read, create, or meta operations on %s records", entity), + OperationID: fmt.Sprintf("operateResolveSpec%s%s", formatSchemaName(schema, ""), formatSchemaName("", entity)), + Tags: []string{fmt.Sprintf("%s (ResolveSpec)", entity)}, + RequestBody: &RequestBody{ + Required: true, + Description: "Operation request with operation type and options", + Content: map[string]MediaType{ + "application/json": { + Schema: &Schema{Ref: "#/components/schemas/ResolveSpecRequest"}, + Example: map[string]interface{}{ + "operation": "read", + "options": map[string]interface{}{ + "limit": 10, + "filters": []map[string]interface{}{ + {"column": "status", "operator": "eq", "value": "active"}, + }, + }, + }, + }, + }, + }, + Responses: map[string]Response{ + "200": { + Description: "Operation completed successfully", + Content: map[string]MediaType{ + "application/json": { + Schema: &Schema{ + Type: "object", + Properties: map[string]*Schema{ + "success": {Type: "boolean"}, + "data": {Type: "array", Items: &Schema{Ref: fmt.Sprintf("#/components/schemas/%s", schemaName)}}, + "metadata": {Ref: "#/components/schemas/Metadata"}, + }, + }, + }, + }, + }, + "400": g.errorResponse("Bad request"), + "401": g.errorResponse("Unauthorized"), + "500": g.errorResponse("Internal server error"), + }, + Security: g.securityRequirements(), + }, + Get: &Operation{ + Summary: fmt.Sprintf("Get %s metadata", entity), + Description: fmt.Sprintf("Retrieve metadata for %s", entity), + OperationID: fmt.Sprintf("metadataResolveSpec%s%s", formatSchemaName(schema, ""), formatSchemaName("", entity)), + Tags: []string{fmt.Sprintf("%s (ResolveSpec)", entity)}, + Responses: map[string]Response{ + "200": { + Description: "Metadata retrieved successfully", + Content: map[string]MediaType{ + "application/json": { + Schema: &Schema{Ref: "#/components/schemas/Response"}, + }, + }, + }, + "401": g.errorResponse("Unauthorized"), + "500": g.errorResponse("Internal server error"), + }, + Security: g.securityRequirements(), + }, + Options: &Operation{ + Summary: "CORS preflight", + Description: "Handle CORS preflight requests", + OperationID: fmt.Sprintf("optionsResolveSpec%s%s", formatSchemaName(schema, ""), formatSchemaName("", entity)), + Tags: []string{fmt.Sprintf("%s (ResolveSpec)", entity)}, + Responses: map[string]Response{ + "204": {Description: "No content"}, + }, + }, + } + + // Single record endpoint: POST (update/delete) + spec.Paths[idPath] = PathItem{ + Post: &Operation{ + Summary: fmt.Sprintf("Update or delete %s record", entity), + Description: fmt.Sprintf("Execute update or delete operation on a specific %s record", entity), + OperationID: fmt.Sprintf("modifyResolveSpec%s%s", formatSchemaName(schema, ""), formatSchemaName("", entity)), + Tags: []string{fmt.Sprintf("%s (ResolveSpec)", entity)}, + Parameters: []Parameter{ + {Name: "id", In: "path", Required: true, Description: "Record ID", Schema: &Schema{Type: "integer"}}, + }, + RequestBody: &RequestBody{ + Required: true, + Description: "Operation request (update or delete)", + Content: map[string]MediaType{ + "application/json": { + Schema: &Schema{Ref: "#/components/schemas/ResolveSpecRequest"}, + Example: map[string]interface{}{ + "operation": "update", + "data": map[string]interface{}{ + "status": "inactive", + }, + }, + }, + }, + }, + Responses: map[string]Response{ + "200": { + Description: "Operation completed successfully", + Content: map[string]MediaType{ + "application/json": { + Schema: &Schema{ + Type: "object", + Properties: map[string]*Schema{ + "success": {Type: "boolean"}, + "data": {Ref: fmt.Sprintf("#/components/schemas/%s", schemaName)}, + }, + }, + }, + }, + }, + "400": g.errorResponse("Bad request"), + "404": g.errorResponse("Record not found"), + "401": g.errorResponse("Unauthorized"), + "500": g.errorResponse("Internal server error"), + }, + Security: g.securityRequirements(), + }, + } +} + +// generateFuncSpecPaths generates OpenAPI paths for FuncSpec endpoints +func (g *Generator) generateFuncSpecPaths(spec *OpenAPISpec) { + for path, endpoint := range g.config.FuncSpecEndpoints { + operation := &Operation{ + Summary: endpoint.Summary, + Description: endpoint.Description, + OperationID: fmt.Sprintf("funcSpec%s", sanitizeOperationID(path)), + Tags: []string{"FuncSpec"}, + Parameters: g.extractFuncSpecParameters(endpoint.Parameters), + Responses: map[string]Response{ + "200": { + Description: "Query executed successfully", + Content: map[string]MediaType{ + "application/json": { + Schema: &Schema{Ref: "#/components/schemas/Response"}, + }, + }, + }, + "400": g.errorResponse("Bad request"), + "401": g.errorResponse("Unauthorized"), + "500": g.errorResponse("Internal server error"), + }, + Security: g.securityRequirements(), + } + + pathItem := spec.Paths[path] + switch endpoint.Method { + case "GET": + pathItem.Get = operation + case "POST": + pathItem.Post = operation + case "PUT": + pathItem.Put = operation + case "DELETE": + pathItem.Delete = operation + } + spec.Paths[path] = pathItem + } +} + +// getRestheadSpecHeaders returns all RestheadSpec header parameters +func (g *Generator) getRestheadSpecHeaders() []Parameter { + return []Parameter{ + {Name: "X-Filters", In: "header", Description: "JSON array of filter conditions", Schema: &Schema{Type: "string"}}, + {Name: "X-Columns", In: "header", Description: "Comma-separated list of columns to select", Schema: &Schema{Type: "string"}}, + {Name: "X-Sort", In: "header", Description: "JSON array of sort specifications", Schema: &Schema{Type: "string"}}, + {Name: "X-Limit", In: "header", Description: "Maximum number of records to return", Schema: &Schema{Type: "integer"}}, + {Name: "X-Offset", In: "header", Description: "Number of records to skip", Schema: &Schema{Type: "integer"}}, + {Name: "X-Preload", In: "header", Description: "Relations to eager load (comma-separated)", Schema: &Schema{Type: "string"}}, + {Name: "X-Expand", In: "header", Description: "Relations to expand with LEFT JOIN (comma-separated)", Schema: &Schema{Type: "string"}}, + {Name: "X-Distinct", In: "header", Description: "Enable DISTINCT query (true/false)", Schema: &Schema{Type: "boolean"}}, + {Name: "X-Response-Format", In: "header", Description: "Response format", Schema: &Schema{Type: "string", Enum: []interface{}{"detail", "simple", "syncfusion"}}}, + {Name: "X-Clean-JSON", In: "header", Description: "Remove null/empty fields from response (true/false)", Schema: &Schema{Type: "boolean"}}, + {Name: "X-Custom-SQL-Where", In: "header", Description: "Custom SQL WHERE clause (AND)", Schema: &Schema{Type: "string"}}, + {Name: "X-Custom-SQL-Or", In: "header", Description: "Custom SQL WHERE clause (OR)", Schema: &Schema{Type: "string"}}, + } +} + +// extractFuncSpecParameters creates OpenAPI parameters from parameter names +func (g *Generator) extractFuncSpecParameters(paramNames []string) []Parameter { + params := []Parameter{} + for _, name := range paramNames { + params = append(params, Parameter{ + Name: name, + In: "query", + Description: fmt.Sprintf("Parameter: %s", name), + Schema: &Schema{Type: "string"}, + }) + } + return params +} + +// errorResponse creates a standard error response +func (g *Generator) errorResponse(description string) Response { + return Response{ + Description: description, + Content: map[string]MediaType{ + "application/json": { + Schema: &Schema{Ref: "#/components/schemas/APIError"}, + }, + }, + } +} + +// securityRequirements returns all security options (user can use any) +func (g *Generator) securityRequirements() []map[string][]string { + return []map[string][]string{ + {"BearerAuth": {}}, + {"SessionToken": {}}, + {"CookieAuth": {}}, + {"HeaderAuth": {}}, + } +} + +// sanitizeOperationID removes invalid characters from operation IDs +func sanitizeOperationID(path string) string { + result := "" + for _, char := range path { + if (char >= 'a' && char <= 'z') || (char >= 'A' && char <= 'Z') || (char >= '0' && char <= '9') { + result += string(char) + } + } + return result +} diff --git a/pkg/resolvespec/handler.go b/pkg/resolvespec/handler.go index e38d362..52fff72 100644 --- a/pkg/resolvespec/handler.go +++ b/pkg/resolvespec/handler.go @@ -22,11 +22,12 @@ type FallbackHandler func(w common.ResponseWriter, r common.Request, params map[ // Handler handles API requests using database and model abstractions type Handler struct { - db common.Database - registry common.ModelRegistry - nestedProcessor *common.NestedCUDProcessor - hooks *HookRegistry - fallbackHandler FallbackHandler + db common.Database + registry common.ModelRegistry + nestedProcessor *common.NestedCUDProcessor + hooks *HookRegistry + fallbackHandler FallbackHandler + openAPIGenerator func() (string, error) } // NewHandler creates a new API handler with database and registry abstractions @@ -75,6 +76,12 @@ func (h *Handler) Handle(w common.ResponseWriter, r common.Request, params map[s } }() + // Check for ?openapi query parameter + if r.UnderlyingRequest().URL.Query().Get("openapi") != "" { + h.HandleOpenAPI(w, r) + return + } + ctx := r.UnderlyingRequest().Context() body, err := r.Body() @@ -156,6 +163,12 @@ func (h *Handler) HandleGet(w common.ResponseWriter, r common.Request, params ma } }() + // Check for ?openapi query parameter + if r.UnderlyingRequest().URL.Query().Get("openapi") != "" { + h.HandleOpenAPI(w, r) + return + } + schema := params["schema"] entity := params["entity"] @@ -1433,3 +1446,28 @@ func toSnakeCase(s string) string { } return strings.ToLower(result.String()) } + +// HandleOpenAPI generates and returns the OpenAPI specification +func (h *Handler) HandleOpenAPI(w common.ResponseWriter, r common.Request) { + if h.openAPIGenerator == nil { + logger.Error("OpenAPI generator not configured") + h.sendError(w, http.StatusInternalServerError, "openapi_not_configured", "OpenAPI generation not configured", nil) + return + } + + spec, err := h.openAPIGenerator() + if err != nil { + logger.Error("Failed to generate OpenAPI spec: %v", err) + h.sendError(w, http.StatusInternalServerError, "openapi_generation_error", "Failed to generate OpenAPI specification", err) + return + } + + w.SetHeader("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write([]byte(spec)) +} + +// SetOpenAPIGenerator sets the OpenAPI generator function +func (h *Handler) SetOpenAPIGenerator(generator func() (string, error)) { + h.openAPIGenerator = generator +} diff --git a/pkg/resolvespec/resolvespec.go b/pkg/resolvespec/resolvespec.go index 3d9d734..6097cf7 100644 --- a/pkg/resolvespec/resolvespec.go +++ b/pkg/resolvespec/resolvespec.go @@ -46,6 +46,16 @@ type MiddlewareFunc func(http.Handler) http.Handler // authMiddleware is optional - if provided, routes will be protected with the middleware // Example: SetupMuxRoutes(router, handler, func(h http.Handler) http.Handler { return security.NewAuthHandler(securityList, h) }) func SetupMuxRoutes(muxRouter *mux.Router, handler *Handler, authMiddleware MiddlewareFunc) { + // Add global /openapi route + openAPIHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + corsConfig := common.DefaultCORSConfig() + respAdapter := router.NewHTTPResponseWriter(w) + common.SetCORSHeaders(respAdapter, corsConfig) + reqAdapter := router.NewHTTPRequest(r) + handler.HandleOpenAPI(respAdapter, reqAdapter) + }) + muxRouter.Handle("/openapi", openAPIHandler).Methods("GET", "OPTIONS") + // Get all registered models from the registry allModels := handler.registry.GetAllModels() @@ -201,12 +211,27 @@ func ExampleWithBun(bunDB *bun.DB) { func SetupBunRouterRoutes(bunRouter *router.StandardBunRouterAdapter, handler *Handler) { r := bunRouter.GetBunRouter() - // Get all registered models from the registry - allModels := handler.registry.GetAllModels() - // CORS config corsConfig := common.DefaultCORSConfig() + // Add global /openapi route + r.Handle("GET", "/openapi", func(w http.ResponseWriter, req bunrouter.Request) error { + respAdapter := router.NewHTTPResponseWriter(w) + common.SetCORSHeaders(respAdapter, corsConfig) + reqAdapter := router.NewHTTPRequest(req.Request) + handler.HandleOpenAPI(respAdapter, reqAdapter) + return nil + }) + + r.Handle("OPTIONS", "/openapi", func(w http.ResponseWriter, req bunrouter.Request) error { + respAdapter := router.NewHTTPResponseWriter(w) + common.SetCORSHeaders(respAdapter, corsConfig) + return nil + }) + + // Get all registered models from the registry + allModels := handler.registry.GetAllModels() + // Loop through each registered model and create explicit routes for fullName := range allModels { // Parse the full name (e.g., "public.users" or just "users") diff --git a/pkg/restheadspec/handler.go b/pkg/restheadspec/handler.go index 48eaf5e..a579dbb 100644 --- a/pkg/restheadspec/handler.go +++ b/pkg/restheadspec/handler.go @@ -24,11 +24,12 @@ type FallbackHandler func(w common.ResponseWriter, r common.Request, params map[ // Handler handles API requests using database and model abstractions // This handler reads filters, columns, and options from HTTP headers type Handler struct { - db common.Database - registry common.ModelRegistry - hooks *HookRegistry - nestedProcessor *common.NestedCUDProcessor - fallbackHandler FallbackHandler + db common.Database + registry common.ModelRegistry + hooks *HookRegistry + nestedProcessor *common.NestedCUDProcessor + fallbackHandler FallbackHandler + openAPIGenerator func() (string, error) } // NewHandler creates a new API handler with database and registry abstractions @@ -78,6 +79,12 @@ func (h *Handler) Handle(w common.ResponseWriter, r common.Request, params map[s } }() + // Check for ?openapi query parameter + if r.UnderlyingRequest().URL.Query().Get("openapi") != "" { + h.HandleOpenAPI(w, r) + return + } + ctx := r.UnderlyingRequest().Context() schema := params["schema"] @@ -208,6 +215,12 @@ func (h *Handler) HandleGet(w common.ResponseWriter, r common.Request, params ma } }() + // Check for ?openapi query parameter + if r.UnderlyingRequest().URL.Query().Get("openapi") != "" { + h.HandleOpenAPI(w, r) + return + } + schema := params["schema"] entity := params["entity"] @@ -2379,3 +2392,32 @@ func (h *Handler) extractTagValue(tag, key string) string { } return "" } + +// HandleOpenAPI generates and returns the OpenAPI specification +func (h *Handler) HandleOpenAPI(w common.ResponseWriter, r common.Request) { + // Import needed here to avoid circular dependency + // The import is done inline + // We'll use a factory function approach instead + if h.openAPIGenerator == nil { + logger.Error("OpenAPI generator not configured") + h.sendError(w, http.StatusInternalServerError, "openapi_not_configured", "OpenAPI generation not configured", nil) + return + } + + spec, err := h.openAPIGenerator() + if err != nil { + logger.Error("Failed to generate OpenAPI spec: %v", err) + h.sendError(w, http.StatusInternalServerError, "openapi_generation_error", "Failed to generate OpenAPI specification", err) + return + } + + w.SetHeader("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write([]byte(spec)) +} + +// SetOpenAPIGenerator sets the OpenAPI generator function +// This allows avoiding circular dependencies +func (h *Handler) SetOpenAPIGenerator(generator func() (string, error)) { + h.openAPIGenerator = generator +} diff --git a/pkg/restheadspec/restheadspec.go b/pkg/restheadspec/restheadspec.go index 3ebb9f4..8435138 100644 --- a/pkg/restheadspec/restheadspec.go +++ b/pkg/restheadspec/restheadspec.go @@ -99,6 +99,16 @@ type MiddlewareFunc func(http.Handler) http.Handler // authMiddleware is optional - if provided, routes will be protected with the middleware // Example: SetupMuxRoutes(router, handler, func(h http.Handler) http.Handler { return security.NewAuthHandler(securityList, h) }) func SetupMuxRoutes(muxRouter *mux.Router, handler *Handler, authMiddleware MiddlewareFunc) { + // Add global /openapi route + openAPIHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + corsConfig := common.DefaultCORSConfig() + respAdapter := router.NewHTTPResponseWriter(w) + common.SetCORSHeaders(respAdapter, corsConfig) + reqAdapter := router.NewHTTPRequest(r) + handler.HandleOpenAPI(respAdapter, reqAdapter) + }) + muxRouter.Handle("/openapi", openAPIHandler).Methods("GET", "OPTIONS") + // Get all registered models from the registry allModels := handler.registry.GetAllModels() @@ -264,12 +274,27 @@ func ExampleWithBun(bunDB *bun.DB) { func SetupBunRouterRoutes(bunRouter *router.StandardBunRouterAdapter, handler *Handler) { r := bunRouter.GetBunRouter() - // Get all registered models from the registry - allModels := handler.registry.GetAllModels() - // CORS config corsConfig := common.DefaultCORSConfig() + // Add global /openapi route + r.Handle("GET", "/openapi", func(w http.ResponseWriter, req bunrouter.Request) error { + respAdapter := router.NewHTTPResponseWriter(w) + common.SetCORSHeaders(respAdapter, corsConfig) + reqAdapter := router.NewBunRouterRequest(req) + handler.HandleOpenAPI(respAdapter, reqAdapter) + return nil + }) + + r.Handle("OPTIONS", "/openapi", func(w http.ResponseWriter, req bunrouter.Request) error { + respAdapter := router.NewHTTPResponseWriter(w) + common.SetCORSHeaders(respAdapter, corsConfig) + return nil + }) + + // Get all registered models from the registry + allModels := handler.registry.GetAllModels() + // Loop through each registered model and create explicit routes for fullName := range allModels { // Parse the full name (e.g., "public.users" or just "users")