openapi.go
// Package api provides API-related functionality including OpenAPI spec generation,
// CORS headers, and rate limiting helpers.
package api
import (
"encoding/json"
"github.com/readysite/readysite/website/internal/content/schema"
"github.com/readysite/readysite/website/models"
)
// OpenAPISpec represents an OpenAPI 3.0 specification.
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,omitempty"`
Security []map[string][]string `json:"security,omitempty"`
Tags []Tag `json:"tags,omitempty"`
}
// Info contains API metadata.
type Info struct {
Title string `json:"title"`
Description string `json:"description,omitempty"`
Version string `json:"version"`
Contact *Contact `json:"contact,omitempty"`
License *License `json:"license,omitempty"`
}
// Contact information.
type Contact struct {
Name string `json:"name,omitempty"`
URL string `json:"url,omitempty"`
Email string `json:"email,omitempty"`
}
// License information.
type License struct {
Name string `json:"name"`
URL string `json:"url,omitempty"`
}
// Server information.
type Server struct {
URL string `json:"url"`
Description string `json:"description,omitempty"`
}
// Tag for grouping operations.
type Tag struct {
Name string `json:"name"`
Description string `json:"description,omitempty"`
}
// PathItem describes operations for a path.
type PathItem struct {
Get *Operation `json:"get,omitempty"`
Post *Operation `json:"post,omitempty"`
Patch *Operation `json:"patch,omitempty"`
Delete *Operation `json:"delete,omitempty"`
Options *Operation `json:"options,omitempty"`
}
// Operation describes an API operation.
type Operation struct {
Tags []string `json:"tags,omitempty"`
Summary string `json:"summary,omitempty"`
Description string `json:"description,omitempty"`
OperationID string `json:"operationId,omitempty"`
Parameters []Parameter `json:"parameters,omitempty"`
RequestBody *RequestBody `json:"requestBody,omitempty"`
Responses map[string]Response `json:"responses"`
Security []map[string][]string `json:"security,omitempty"`
}
// Parameter describes an operation parameter.
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"`
}
// RequestBody describes a request body.
type RequestBody struct {
Description string `json:"description,omitempty"`
Required bool `json:"required,omitempty"`
Content map[string]MediaType `json:"content"`
}
// Response describes an operation response.
type Response struct {
Description string `json:"description"`
Content map[string]MediaType `json:"content,omitempty"`
Headers map[string]Header `json:"headers,omitempty"`
}
// Header describes a response header.
type Header struct {
Description string `json:"description,omitempty"`
Schema *Schema `json:"schema,omitempty"`
}
// MediaType describes a media type.
type MediaType struct {
Schema *Schema `json:"schema,omitempty"`
Example any `json:"example,omitempty"`
}
// Schema describes a JSON schema.
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"`
Enum []any `json:"enum,omitempty"`
Default any `json:"default,omitempty"`
Minimum *float64 `json:"minimum,omitempty"`
Maximum *float64 `json:"maximum,omitempty"`
MinLength *int `json:"minLength,omitempty"`
MaxLength *int `json:"maxLength,omitempty"`
Ref string `json:"$ref,omitempty"`
AdditionalProperties any `json:"additionalProperties,omitempty"`
}
// Components holds reusable schema definitions.
type Components struct {
Schemas map[string]*Schema `json:"schemas,omitempty"`
SecuritySchemes map[string]SecurityScheme `json:"securitySchemes,omitempty"`
}
// SecurityScheme describes a security mechanism.
type SecurityScheme struct {
Type string `json:"type"`
Scheme string `json:"scheme,omitempty"`
BearerFormat string `json:"bearerFormat,omitempty"`
Description string `json:"description,omitempty"`
}
// GenerateSpec generates an OpenAPI specification for all collections.
func GenerateSpec(baseURL string) (*OpenAPISpec, error) {
collections, err := models.Collections.All()
if err != nil {
return nil, err
}
spec := &OpenAPISpec{
OpenAPI: "3.0.3",
Info: Info{
Title: "ReadySite Collections API",
Description: "REST API for managing collection records. Provides CRUD operations with filtering, sorting, pagination, and real-time subscriptions.",
Version: "1.0.0",
},
Servers: []Server{
{URL: baseURL, Description: "API Server"},
},
Paths: make(map[string]PathItem),
Components: Components{
Schemas: make(map[string]*Schema),
SecuritySchemes: make(map[string]SecurityScheme),
},
Security: []map[string][]string{
{"bearerAuth": {}},
},
}
// Add security scheme
spec.Components.SecuritySchemes["bearerAuth"] = SecurityScheme{
Type: "http",
Scheme: "bearer",
BearerFormat: "JWT",
Description: "JWT token obtained from authentication endpoint",
}
// Add common schemas
addCommonSchemas(spec)
// Add tags for each collection
var tags []Tag
for _, col := range collections {
tags = append(tags, Tag{
Name: col.Name,
Description: "Operations for " + col.Name + " collection",
})
// Generate paths for this collection
addCollectionPaths(spec, col)
}
spec.Tags = tags
return spec, nil
}
// addCommonSchemas adds common schema definitions.
func addCommonSchemas(spec *OpenAPISpec) {
// Error response
spec.Components.Schemas["Error"] = &Schema{
Type: "object",
Properties: map[string]*Schema{
"status": {Type: "integer", Description: "HTTP status code"},
"message": {Type: "string", Description: "Error message"},
},
Required: []string{"status", "message"},
}
// List response
spec.Components.Schemas["ListResponse"] = &Schema{
Type: "object",
Properties: map[string]*Schema{
"page": {Type: "integer", Description: "Current page number"},
"perPage": {Type: "integer", Description: "Items per page"},
"totalItems": {Type: "integer", Description: "Total number of items"},
"totalPages": {Type: "integer", Description: "Total number of pages"},
"items": {Type: "array", Items: &Schema{Type: "object"}},
},
}
// Base record schema
spec.Components.Schemas["BaseRecord"] = &Schema{
Type: "object",
Properties: map[string]*Schema{
"id": {Type: "string", Description: "Record ID"},
"collectionId": {Type: "string", Description: "Collection ID"},
"collectionName": {Type: "string", Description: "Collection name"},
"created": {Type: "string", Format: "date-time", Description: "Creation timestamp"},
"updated": {Type: "string", Format: "date-time", Description: "Last update timestamp"},
},
}
}
// addCollectionPaths adds API paths for a collection.
func addCollectionPaths(spec *OpenAPISpec, col *models.Collection) {
basePath := "/api/collections/" + col.ID + "/records"
recordPath := basePath + "/{recordId}"
// Generate schema for this collection
recordSchema := generateRecordSchema(col)
schemaName := sanitizeName(col.Name) + "Record"
spec.Components.Schemas[schemaName] = recordSchema
// Common parameters
filterParam := Parameter{
Name: "filter",
In: "query",
Description: "Filter expression (e.g., \"status = 'published' && views > 100\")",
Schema: &Schema{Type: "string"},
}
sortParam := Parameter{
Name: "sort",
In: "query",
Description: "Sort fields (e.g., \"-created,title\" for descending created, ascending title)",
Schema: &Schema{Type: "string"},
}
pageParam := Parameter{
Name: "page",
In: "query",
Description: "Page number (default 1)",
Schema: &Schema{Type: "integer", Default: 1},
}
perPageParam := Parameter{
Name: "perPage",
In: "query",
Description: "Items per page (default 20, max 100)",
Schema: &Schema{Type: "integer", Default: 20},
}
fieldsParam := Parameter{
Name: "fields",
In: "query",
Description: "Comma-separated list of fields to include",
Schema: &Schema{Type: "string"},
}
expandParam := Parameter{
Name: "expand",
In: "query",
Description: "Comma-separated relation fields to expand (e.g., \"author,comments.user\")",
Schema: &Schema{Type: "string"},
}
skipTotalParam := Parameter{
Name: "skipTotal",
In: "query",
Description: "Skip counting total items for faster queries",
Schema: &Schema{Type: "boolean"},
}
recordIDParam := Parameter{
Name: "recordId",
In: "path",
Description: "Record ID",
Required: true,
Schema: &Schema{Type: "string"},
}
// List records
spec.Paths[basePath] = PathItem{
Get: &Operation{
Tags: []string{col.Name},
Summary: "List " + col.Name + " records",
Description: "Returns a paginated list of records from the " + col.Name + " collection.",
OperationID: "list" + sanitizeName(col.Name),
Parameters: []Parameter{filterParam, sortParam, pageParam, perPageParam, fieldsParam, expandParam, skipTotalParam},
Responses: map[string]Response{
"200": {
Description: "List of records",
Content: map[string]MediaType{
"application/json": {Schema: &Schema{Ref: "#/components/schemas/ListResponse"}},
},
Headers: rateLimitHeaders(),
},
"401": {Description: "Authentication required", Content: errorContent()},
"403": {Description: "Access denied", Content: errorContent()},
"429": {Description: "Too many requests", Content: errorContent()},
},
},
Post: &Operation{
Tags: []string{col.Name},
Summary: "Create " + col.Name + " record",
Description: "Creates a new record in the " + col.Name + " collection.",
OperationID: "create" + sanitizeName(col.Name),
RequestBody: &RequestBody{
Required: true,
Description: "Record data",
Content: map[string]MediaType{
"application/json": {Schema: recordSchema},
},
},
Responses: map[string]Response{
"201": {
Description: "Created record",
Content: map[string]MediaType{
"application/json": {Schema: &Schema{Ref: "#/components/schemas/" + schemaName}},
},
Headers: rateLimitHeaders(),
},
"400": {Description: "Invalid request body", Content: errorContent()},
"401": {Description: "Authentication required", Content: errorContent()},
"403": {Description: "Access denied", Content: errorContent()},
"429": {Description: "Too many requests", Content: errorContent()},
},
},
}
// Single record operations
spec.Paths[recordPath] = PathItem{
Get: &Operation{
Tags: []string{col.Name},
Summary: "Get " + col.Name + " record",
Description: "Returns a single record by ID from the " + col.Name + " collection.",
OperationID: "get" + sanitizeName(col.Name),
Parameters: []Parameter{recordIDParam, fieldsParam, expandParam},
Responses: map[string]Response{
"200": {
Description: "Record data",
Content: map[string]MediaType{
"application/json": {Schema: &Schema{Ref: "#/components/schemas/" + schemaName}},
},
Headers: rateLimitHeaders(),
},
"404": {Description: "Record not found", Content: errorContent()},
"429": {Description: "Too many requests", Content: errorContent()},
},
},
Patch: &Operation{
Tags: []string{col.Name},
Summary: "Update " + col.Name + " record",
Description: "Updates an existing record in the " + col.Name + " collection. Supports partial updates.",
OperationID: "update" + sanitizeName(col.Name),
Parameters: []Parameter{recordIDParam},
RequestBody: &RequestBody{
Required: true,
Description: "Fields to update",
Content: map[string]MediaType{
"application/json": {Schema: recordSchema},
},
},
Responses: map[string]Response{
"200": {
Description: "Updated record",
Content: map[string]MediaType{
"application/json": {Schema: &Schema{Ref: "#/components/schemas/" + schemaName}},
},
Headers: rateLimitHeaders(),
},
"400": {Description: "Invalid request body", Content: errorContent()},
"401": {Description: "Authentication required", Content: errorContent()},
"403": {Description: "Access denied", Content: errorContent()},
"404": {Description: "Record not found", Content: errorContent()},
"409": {Description: "Conflict - record was modified", Content: errorContent()},
"429": {Description: "Too many requests", Content: errorContent()},
},
},
Delete: &Operation{
Tags: []string{col.Name},
Summary: "Delete " + col.Name + " record",
Description: "Deletes a record from the " + col.Name + " collection.",
OperationID: "delete" + sanitizeName(col.Name),
Parameters: []Parameter{recordIDParam},
Responses: map[string]Response{
"204": {Description: "Record deleted"},
"401": {Description: "Authentication required", Content: errorContent()},
"403": {Description: "Access denied", Content: errorContent()},
"404": {Description: "Record not found", Content: errorContent()},
"429": {Description: "Too many requests", Content: errorContent()},
},
},
}
// Subscribe endpoint
subscribePath := "/api/collections/" + col.ID + "/subscribe"
spec.Paths[subscribePath] = PathItem{
Get: &Operation{
Tags: []string{col.Name},
Summary: "Subscribe to " + col.Name + " changes",
Description: "Server-Sent Events stream for real-time record changes.",
OperationID: "subscribe" + sanitizeName(col.Name),
Parameters: []Parameter{filterParam},
Responses: map[string]Response{
"200": {
Description: "SSE stream of events (create, update, delete)",
Content: map[string]MediaType{
"text/event-stream": {},
},
},
"401": {Description: "Authentication required"},
"403": {Description: "Access denied"},
},
},
}
}
// generateRecordSchema generates a JSON schema from collection fields.
func generateRecordSchema(col *models.Collection) *Schema {
s := &Schema{
Type: "object",
Properties: make(map[string]*Schema),
}
// Add base record fields
s.Properties["id"] = &Schema{Type: "string", Description: "Record ID (read-only)"}
s.Properties["collectionId"] = &Schema{Type: "string", Description: "Collection ID (read-only)"}
s.Properties["collectionName"] = &Schema{Type: "string", Description: "Collection name (read-only)"}
s.Properties["created"] = &Schema{Type: "string", Format: "date-time", Description: "Creation timestamp (read-only)"}
s.Properties["updated"] = &Schema{Type: "string", Format: "date-time", Description: "Last update timestamp (read-only)"}
// Add custom fields from schema
fields, err := schema.GetFields(col)
if err != nil {
return s
}
var required []string
for _, f := range fields {
fieldSchema := fieldToSchema(f)
s.Properties[f.Name] = fieldSchema
if f.Required {
required = append(required, f.Name)
}
}
if len(required) > 0 {
s.Required = required
}
return s
}
// fieldToSchema converts a schema field to an OpenAPI schema.
func fieldToSchema(f schema.Field) *Schema {
s := &Schema{
Description: f.Name,
}
switch f.Type {
case schema.Text:
s.Type = "string"
if min, ok := f.Options["min"].(float64); ok {
minInt := int(min)
s.MinLength = &minInt
}
if max, ok := f.Options["max"].(float64); ok {
maxInt := int(max)
s.MaxLength = &maxInt
}
case schema.Number:
s.Type = "number"
if min, ok := f.Options["min"].(float64); ok {
s.Minimum = &min
}
if max, ok := f.Options["max"].(float64); ok {
s.Maximum = &max
}
case schema.Bool:
s.Type = "boolean"
case schema.Date:
s.Type = "string"
s.Format = "date-time"
case schema.Email:
s.Type = "string"
s.Format = "email"
case schema.URL:
s.Type = "string"
s.Format = "uri"
case schema.JSON:
s.Type = "object"
s.AdditionalProperties = true
case schema.Relation:
if multiple, _ := f.Options["multiple"].(bool); multiple {
s.Type = "array"
s.Items = &Schema{Type: "string", Description: "Related record ID"}
} else {
s.Type = "string"
s.Description = "Related record ID"
}
case schema.File:
if multiple, _ := f.Options["multiple"].(bool); multiple {
s.Type = "array"
s.Items = &Schema{Type: "string", Description: "File URL"}
} else {
s.Type = "string"
s.Description = "File URL"
}
case schema.Select:
s.Type = "string"
if options, ok := f.Options["values"].([]any); ok {
for _, opt := range options {
if str, ok := opt.(string); ok {
s.Enum = append(s.Enum, str)
}
}
}
case schema.Autodate:
s.Type = "string"
s.Format = "date-time"
s.Description = "Auto-generated timestamp (read-only)"
case schema.Editor:
s.Type = "string"
s.Description = "Rich text/HTML content"
default:
s.Type = "string"
}
return s
}
// rateLimitHeaders returns standard rate limit response headers.
func rateLimitHeaders() map[string]Header {
return map[string]Header{
"X-RateLimit-Limit": {
Description: "Maximum requests allowed per window",
Schema: &Schema{Type: "integer"},
},
"X-RateLimit-Remaining": {
Description: "Remaining requests in current window",
Schema: &Schema{Type: "integer"},
},
"X-RateLimit-Reset": {
Description: "Unix timestamp when window resets",
Schema: &Schema{Type: "integer"},
},
}
}
// errorContent returns error response content.
func errorContent() map[string]MediaType {
return map[string]MediaType{
"application/json": {Schema: &Schema{Ref: "#/components/schemas/Error"}},
}
}
// sanitizeName converts a collection name to a valid identifier.
func sanitizeName(name string) string {
// Capitalize first letter and remove spaces
if len(name) == 0 {
return "Unknown"
}
result := make([]byte, 0, len(name))
capitalizeNext := true
for i := 0; i < len(name); i++ {
c := name[i]
if c == ' ' || c == '-' || c == '_' {
capitalizeNext = true
continue
}
if capitalizeNext && c >= 'a' && c <= 'z' {
c -= 32 // Convert to uppercase
}
capitalizeNext = false
result = append(result, c)
}
return string(result)
}
// ToJSON serializes the spec to JSON.
func (s *OpenAPISpec) ToJSON() ([]byte, error) {
return json.MarshalIndent(s, "", " ")
}