readysite / website / internal / content / api / openapi.go
19.1 KB
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, "", "  ")
}
← Back