mirror of
https://github.com/bitechdev/ResolveSpec.git
synced 2025-12-13 17:10:36 +00:00
Added openapi spec
This commit is contained in:
parent
0f05202438
commit
d188f49126
321
pkg/openapi/README.md
Normal file
321
pkg/openapi/README.md
Normal file
@ -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.
|
||||||
235
pkg/openapi/example.go
Normal file
235
pkg/openapi/example.go
Normal file
@ -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
|
||||||
|
}
|
||||||
513
pkg/openapi/generator.go
Normal file
513
pkg/openapi/generator.go
Normal file
@ -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:]
|
||||||
|
}
|
||||||
714
pkg/openapi/generator_test.go
Normal file
714
pkg/openapi/generator_test.go
Normal file
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
499
pkg/openapi/paths.go
Normal file
499
pkg/openapi/paths.go
Normal file
@ -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
|
||||||
|
}
|
||||||
@ -22,11 +22,12 @@ type FallbackHandler func(w common.ResponseWriter, r common.Request, params map[
|
|||||||
|
|
||||||
// Handler handles API requests using database and model abstractions
|
// Handler handles API requests using database and model abstractions
|
||||||
type Handler struct {
|
type Handler struct {
|
||||||
db common.Database
|
db common.Database
|
||||||
registry common.ModelRegistry
|
registry common.ModelRegistry
|
||||||
nestedProcessor *common.NestedCUDProcessor
|
nestedProcessor *common.NestedCUDProcessor
|
||||||
hooks *HookRegistry
|
hooks *HookRegistry
|
||||||
fallbackHandler FallbackHandler
|
fallbackHandler FallbackHandler
|
||||||
|
openAPIGenerator func() (string, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewHandler creates a new API handler with database and registry abstractions
|
// 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()
|
ctx := r.UnderlyingRequest().Context()
|
||||||
|
|
||||||
body, err := r.Body()
|
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"]
|
schema := params["schema"]
|
||||||
entity := params["entity"]
|
entity := params["entity"]
|
||||||
|
|
||||||
@ -1433,3 +1446,28 @@ func toSnakeCase(s string) string {
|
|||||||
}
|
}
|
||||||
return strings.ToLower(result.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
|
||||||
|
}
|
||||||
|
|||||||
@ -46,6 +46,16 @@ type MiddlewareFunc func(http.Handler) http.Handler
|
|||||||
// authMiddleware is optional - if provided, routes will be protected with the middleware
|
// 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) })
|
// Example: SetupMuxRoutes(router, handler, func(h http.Handler) http.Handler { return security.NewAuthHandler(securityList, h) })
|
||||||
func SetupMuxRoutes(muxRouter *mux.Router, handler *Handler, authMiddleware MiddlewareFunc) {
|
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
|
// Get all registered models from the registry
|
||||||
allModels := handler.registry.GetAllModels()
|
allModels := handler.registry.GetAllModels()
|
||||||
|
|
||||||
@ -201,12 +211,27 @@ func ExampleWithBun(bunDB *bun.DB) {
|
|||||||
func SetupBunRouterRoutes(bunRouter *router.StandardBunRouterAdapter, handler *Handler) {
|
func SetupBunRouterRoutes(bunRouter *router.StandardBunRouterAdapter, handler *Handler) {
|
||||||
r := bunRouter.GetBunRouter()
|
r := bunRouter.GetBunRouter()
|
||||||
|
|
||||||
// Get all registered models from the registry
|
|
||||||
allModels := handler.registry.GetAllModels()
|
|
||||||
|
|
||||||
// CORS config
|
// CORS config
|
||||||
corsConfig := common.DefaultCORSConfig()
|
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
|
// Loop through each registered model and create explicit routes
|
||||||
for fullName := range allModels {
|
for fullName := range allModels {
|
||||||
// Parse the full name (e.g., "public.users" or just "users")
|
// Parse the full name (e.g., "public.users" or just "users")
|
||||||
|
|||||||
@ -24,11 +24,12 @@ type FallbackHandler func(w common.ResponseWriter, r common.Request, params map[
|
|||||||
// Handler handles API requests using database and model abstractions
|
// Handler handles API requests using database and model abstractions
|
||||||
// This handler reads filters, columns, and options from HTTP headers
|
// This handler reads filters, columns, and options from HTTP headers
|
||||||
type Handler struct {
|
type Handler struct {
|
||||||
db common.Database
|
db common.Database
|
||||||
registry common.ModelRegistry
|
registry common.ModelRegistry
|
||||||
hooks *HookRegistry
|
hooks *HookRegistry
|
||||||
nestedProcessor *common.NestedCUDProcessor
|
nestedProcessor *common.NestedCUDProcessor
|
||||||
fallbackHandler FallbackHandler
|
fallbackHandler FallbackHandler
|
||||||
|
openAPIGenerator func() (string, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewHandler creates a new API handler with database and registry abstractions
|
// 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()
|
ctx := r.UnderlyingRequest().Context()
|
||||||
|
|
||||||
schema := params["schema"]
|
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"]
|
schema := params["schema"]
|
||||||
entity := params["entity"]
|
entity := params["entity"]
|
||||||
|
|
||||||
@ -2379,3 +2392,32 @@ func (h *Handler) extractTagValue(tag, key string) string {
|
|||||||
}
|
}
|
||||||
return ""
|
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
|
||||||
|
}
|
||||||
|
|||||||
@ -99,6 +99,16 @@ type MiddlewareFunc func(http.Handler) http.Handler
|
|||||||
// authMiddleware is optional - if provided, routes will be protected with the middleware
|
// 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) })
|
// Example: SetupMuxRoutes(router, handler, func(h http.Handler) http.Handler { return security.NewAuthHandler(securityList, h) })
|
||||||
func SetupMuxRoutes(muxRouter *mux.Router, handler *Handler, authMiddleware MiddlewareFunc) {
|
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
|
// Get all registered models from the registry
|
||||||
allModels := handler.registry.GetAllModels()
|
allModels := handler.registry.GetAllModels()
|
||||||
|
|
||||||
@ -264,12 +274,27 @@ func ExampleWithBun(bunDB *bun.DB) {
|
|||||||
func SetupBunRouterRoutes(bunRouter *router.StandardBunRouterAdapter, handler *Handler) {
|
func SetupBunRouterRoutes(bunRouter *router.StandardBunRouterAdapter, handler *Handler) {
|
||||||
r := bunRouter.GetBunRouter()
|
r := bunRouter.GetBunRouter()
|
||||||
|
|
||||||
// Get all registered models from the registry
|
|
||||||
allModels := handler.registry.GetAllModels()
|
|
||||||
|
|
||||||
// CORS config
|
// CORS config
|
||||||
corsConfig := common.DefaultCORSConfig()
|
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
|
// Loop through each registered model and create explicit routes
|
||||||
for fullName := range allModels {
|
for fullName := range allModels {
|
||||||
// Parse the full name (e.g., "public.users" or just "users")
|
// Parse the full name (e.g., "public.users" or just "users")
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user