diff --git a/.claude/guides/ops/releasing.md b/.claude/guides/ops/releasing.md index ce1a772..8b7f9fe 100644 --- a/.claude/guides/ops/releasing.md +++ b/.claude/guides/ops/releasing.md @@ -7,13 +7,13 @@ Push to main branch triggers Woodpecker CI to build and deploy automatically: ```bash -# Just push - Woodpecker handles the rest +# Push to Gitea - Woodpecker handles the rest +# origin is now Gitea (CI) - push triggers Woodpecker automatically git push origin main -# Or push to both remotes +# If you need to push to GitHub backup: GITEA_TOKEN=$(kubectl get secret rdev-credentials -n rdev -o jsonpath='{.data.GITEA_TOKEN}' | base64 -d) git push https://jordan:${GITEA_TOKEN}@git.threesix.ai/jordan/rdev.git main -git push origin main ``` Images are built via kaniko and pushed to `registry.threesix.ai/rdev/*`. diff --git a/internal/adapter/templates/templates/components/service/cmd/server/main.go.tmpl b/internal/adapter/templates/templates/components/service/cmd/server/main.go.tmpl index 3226267..6f23b9b 100644 --- a/internal/adapter/templates/templates/components/service/cmd/server/main.go.tmpl +++ b/internal/adapter/templates/templates/components/service/cmd/server/main.go.tmpl @@ -2,6 +2,10 @@ package main import ( + "flag" + "fmt" + "os" + "{{GO_MODULE}}/pkg/app" "{{GO_MODULE}}/pkg/logging" "{{GO_MODULE}}/services/{{COMPONENT_NAME}}/internal/adapter/memory" @@ -10,6 +14,22 @@ import ( ) func main() { + // Parse flags + exportOpenAPI := flag.Bool("export-openapi", false, "Export OpenAPI spec to stdout and exit") + flag.Parse() + + // If exporting OpenAPI, generate spec and exit (used by CI for docs generation) + if *exportOpenAPI { + spec := api.NewServiceSpec() + jsonBytes, err := spec.JSON() + if err != nil { + fmt.Fprintf(os.Stderr, "failed to generate OpenAPI spec: %v\n", err) + os.Exit(1) + } + fmt.Println(string(jsonBytes)) + os.Exit(0) + } + // Create logger logger := logging.Default() diff --git a/internal/adapter/templates/templates/skeleton/.claude/commands/generate-docs.md b/internal/adapter/templates/templates/skeleton/.claude/commands/generate-docs.md new file mode 100644 index 0000000..b26ae3e --- /dev/null +++ b/internal/adapter/templates/templates/skeleton/.claude/commands/generate-docs.md @@ -0,0 +1,50 @@ +# Generate API Documentation + +Regenerate the Slate API documentation from OpenAPI specs. + +## Usage + +Run this command when you need to preview documentation changes locally or after modifying OpenAPI specs. + +## Steps + +1. **Ensure services are running locally** + ```bash + ./scripts/dev.sh + ``` + +2. **Generate markdown from OpenAPI specs** + ```bash + chmod +x docs/scripts/generate-docs.sh + ./docs/scripts/generate-docs.sh http://localhost + ``` + +3. **Build Slate documentation locally** (optional) + ```bash + cd docs + bundle install + bundle exec middleman serve + ``` + Then open http://localhost:4567 to preview. + +## What This Does + +1. Discovers all services with OpenAPI specs +2. Fetches `/openapi.json` from each running service +3. Converts specs to Slate markdown using Widdershins +4. Updates `docs/source/includes/` with service documentation + +## CI Pipeline + +In production, this process runs automatically: +- CI fetches OpenAPI specs from built service binaries +- Widdershins converts to Slate markdown +- Middleman builds static HTML +- Static files deployed to `docs.{domain}` + +## Troubleshooting + +- **"Connection refused"**: Ensure services are running on expected ports +- **"No OpenAPI spec found"**: Check that services expose `/openapi.json` +- **"widdershins not found"**: Run `npm install -g widdershins` +- **"bundle not found"**: Install Ruby and run `gem install bundler` diff --git a/internal/adapter/templates/templates/skeleton/README.md.tmpl b/internal/adapter/templates/templates/skeleton/README.md.tmpl index d76ba5f..431cb47 100644 --- a/internal/adapter/templates/templates/skeleton/README.md.tmpl +++ b/internal/adapter/templates/templates/skeleton/README.md.tmpl @@ -38,6 +38,28 @@ cd {{PROJECT_NAME}} | `./scripts/quality.sh` | Run quality checks on all components | | `./scripts/discover.sh` | List all components in the monorepo | +## API Documentation + +API documentation is automatically generated from OpenAPI specs and deployed to: + +- **Docs**: https://docs.{{DOMAIN}} +- **OpenAPI Spec**: Each service exposes `/openapi.json` + +To regenerate docs locally: + +```bash +# Start services locally +./scripts/dev.sh + +# Generate Slate markdown from OpenAPI specs +./docs/scripts/generate-docs.sh http://localhost + +# Preview docs (optional - requires Ruby) +cd docs && bundle install && bundle exec middleman serve +``` + +Documentation is automatically rebuilt on every push to `main`. + ## Adding Components Components are added via the rdev API: diff --git a/internal/adapter/templates/templates/skeleton/docs/.gitignore b/internal/adapter/templates/templates/skeleton/docs/.gitignore new file mode 100644 index 0000000..36adc7f --- /dev/null +++ b/internal/adapter/templates/templates/skeleton/docs/.gitignore @@ -0,0 +1,15 @@ +# Slate build output +build/ + +# Ruby/Bundler +.bundle/ +vendor/bundle/ +Gemfile.lock + +# Temp files +*.tmp +*.bak +*~ + +# Generated includes (except _errors.md) +source/includes/_*_spec.json diff --git a/internal/adapter/templates/templates/skeleton/docs/Dockerfile b/internal/adapter/templates/templates/skeleton/docs/Dockerfile new file mode 100644 index 0000000..5228366 --- /dev/null +++ b/internal/adapter/templates/templates/skeleton/docs/Dockerfile @@ -0,0 +1,27 @@ +# Slate documentation builder +# Used by CI to generate static HTML from OpenAPI specs + +FROM ruby:3.2-slim + +# Install build dependencies +RUN apt-get update && apt-get install -y \ + build-essential \ + nodejs \ + npm \ + git \ + && rm -rf /var/lib/apt/lists/* + +# Install widdershins globally for OpenAPI to Slate markdown conversion +RUN npm install -g widdershins + +WORKDIR /docs + +# Copy Gemfile first for layer caching +COPY Gemfile Gemfile.lock* ./ +RUN bundle install + +# Copy the rest of the docs source +COPY . . + +# Build static site +CMD ["bundle", "exec", "middleman", "build", "--clean"] diff --git a/internal/adapter/templates/templates/skeleton/docs/Dockerfile.nginx b/internal/adapter/templates/templates/skeleton/docs/Dockerfile.nginx new file mode 100644 index 0000000..8afb4b5 --- /dev/null +++ b/internal/adapter/templates/templates/skeleton/docs/Dockerfile.nginx @@ -0,0 +1,47 @@ +# Production nginx image for serving Slate documentation +# Built by CI after Slate generates static HTML + +FROM nginx:alpine + +# Remove default nginx content +RUN rm -rf /usr/share/nginx/html/* + +# Copy built static files from Slate +COPY build/ /usr/share/nginx/html/ + +# Custom nginx config for SPA-style routing +RUN cat > /etc/nginx/conf.d/default.conf << 'EOF' +server { + listen 80; + server_name _; + root /usr/share/nginx/html; + index index.html; + + # Enable gzip compression + gzip on; + gzip_types text/plain text/css application/json application/javascript text/xml application/xml text/javascript; + gzip_min_length 1000; + + # Cache static assets + location ~* \.(css|js|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ { + expires 1y; + add_header Cache-Control "public, immutable"; + } + + # Serve index.html for all routes (SPA fallback) + location / { + try_files $uri $uri/ /index.html; + } + + # Health check endpoint + location /health { + access_log off; + return 200 "OK"; + add_header Content-Type text/plain; + } +} +EOF + +EXPOSE 80 + +CMD ["nginx", "-g", "daemon off;"] diff --git a/internal/adapter/templates/templates/skeleton/docs/Gemfile b/internal/adapter/templates/templates/skeleton/docs/Gemfile new file mode 100644 index 0000000..64b039e --- /dev/null +++ b/internal/adapter/templates/templates/skeleton/docs/Gemfile @@ -0,0 +1,14 @@ +# Slate documentation dependencies +source 'https://rubygems.org' + +# Slate uses Middleman for static site generation +gem 'middleman', '~> 4.4' +gem 'middleman-autoprefixer', '~> 3.0' +gem 'middleman-syntax', '~> 3.2' +gem 'middleman-sprockets', '~> 4.1' + +# Slate-specific dependencies +gem 'rouge', '~> 4.0' +gem 'redcarpet', '~> 3.5' +gem 'nokogiri', '~> 1.15' +gem 'activesupport', '~> 7.0' diff --git a/internal/adapter/templates/templates/skeleton/docs/config.rb b/internal/adapter/templates/templates/skeleton/docs/config.rb new file mode 100644 index 0000000..8b28d41 --- /dev/null +++ b/internal/adapter/templates/templates/skeleton/docs/config.rb @@ -0,0 +1,56 @@ +# Slate/Middleman configuration +# Based on slatedocs/slate with customizations for monorepo API docs + +# Markdown rendering +set :markdown_engine, :redcarpet +set :markdown, + fenced_code_blocks: true, + smartypants: true, + disable_indented_code_blocks: true, + prettify: true, + strikethrough: true, + tables: true, + with_toc_data: true, + no_intra_emphasis: true + +# Syntax highlighting +activate :syntax +set :haml, { ugly: true } + +# Assets +set :css_dir, 'stylesheets' +set :js_dir, 'javascripts' +set :images_dir, 'images' +set :fonts_dir, 'fonts' + +# Build directory +set :build_dir, 'build' + +# Relative assets for static hosting +activate :relative_assets +set :relative_links, true + +# Build-specific configuration +configure :build do + activate :minify_css + activate :minify_javascript, ignore: [/all_.*\.js/] + activate :asset_hash +end + +# Development helpers +helpers do + def toc_data(page_content) + content = page_content.dup + toc = [] + + content.scan(/(.+?)<\/h\1>/m) do |level, id, text| + toc << { + level: level.to_i, + id: id, + text: text.gsub(/<[^>]+>/, '') + } + end + + toc + end +end diff --git a/internal/adapter/templates/templates/skeleton/docs/scripts/generate-docs.sh.tmpl b/internal/adapter/templates/templates/skeleton/docs/scripts/generate-docs.sh.tmpl new file mode 100644 index 0000000..f38a9eb --- /dev/null +++ b/internal/adapter/templates/templates/skeleton/docs/scripts/generate-docs.sh.tmpl @@ -0,0 +1,128 @@ +#!/bin/bash +# Generate Slate documentation from OpenAPI specs +# +# This script: +# 1. Discovers all services with OpenAPI specs +# 2. Uses widdershins to convert OpenAPI JSON to Slate markdown +# 3. Injects the generated includes into the main index +# +# Usage: ./docs/scripts/generate-docs.sh [base_url] +# base_url: Optional base URL for fetching specs (default: http://localhost) +# +# The script expects services to be running locally during CI/CD build. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +DOCS_DIR="$(dirname "$SCRIPT_DIR")" +PROJECT_ROOT="$(dirname "$DOCS_DIR")" +INCLUDES_DIR="$DOCS_DIR/source/includes" + +BASE_URL="${1:-http://localhost}" + +echo "==> Generating API documentation from OpenAPI specs" +echo " Base URL: $BASE_URL" +echo " Docs dir: $DOCS_DIR" + +# Ensure includes directory exists +mkdir -p "$INCLUDES_DIR" + +# Clean old service includes (keep _errors.md) +find "$INCLUDES_DIR" -name '_*.md' ! -name '_errors.md' -delete 2>/dev/null || true + +# Track which services we generate docs for +SERVICES=() + +# Discover services by looking for OpenAPI spec files or main.go +for service_dir in "$PROJECT_ROOT"/services/*/; do + [ -d "$service_dir" ] || continue + + service_name=$(basename "$service_dir") + [ "$service_name" = ".gitkeep" ] && continue + + echo "==> Processing service: $service_name" + + # Try to fetch OpenAPI spec from running service + spec_url="$BASE_URL/api/$service_name/openapi.json" + spec_file="$INCLUDES_DIR/_${service_name}_spec.json" + + if curl -sf --connect-timeout 5 "$spec_url" > "$spec_file" 2>/dev/null; then + echo " Fetched spec from $spec_url" + else + # Fallback: check for static spec file + if [ -f "$service_dir/openapi.json" ]; then + cp "$service_dir/openapi.json" "$spec_file" + echo " Using static spec from $service_dir/openapi.json" + else + echo " WARNING: No OpenAPI spec found for $service_name, skipping" + rm -f "$spec_file" + continue + fi + fi + + # Convert OpenAPI to Slate markdown using widdershins + output_file="$INCLUDES_DIR/_${service_name}.md" + + echo " Converting to markdown..." + widdershins \ + --language_tabs 'shell:curl' 'go:Go' \ + --summary \ + --omitHeader \ + --resolve \ + --shallowSchemas \ + "$spec_file" \ + -o "$output_file" + + # Clean up temp spec file + rm -f "$spec_file" + + SERVICES+=("$service_name") + echo " Generated: $output_file" +done + +# Update index.html.md with service includes +INDEX_FILE="$DOCS_DIR/source/index.html.md" +if [ -f "$INDEX_FILE" ]; then + echo "==> Updating index with service includes" + + # Build includes list + INCLUDES="" + for svc in "${SERVICES[@]}"; do + INCLUDES="$INCLUDES - $svc"$'\n' + done + + # Update the includes section in frontmatter + # This is a simple approach - insert after 'includes:' line + if grep -q "^includes:" "$INDEX_FILE"; then + # Create temp file with updated includes + awk -v services="$INCLUDES" ' + /^includes:/ { + print $0 + print " - errors" + # Add service includes + n = split(services, arr, "\n") + for (i = 1; i <= n; i++) { + if (arr[i] != "") print arr[i] + } + # Skip existing includes until next frontmatter key or --- + while ((getline line) > 0) { + if (line ~ /^[a-z_]+:/ || line == "---") { + print line + break + } + } + next + } + { print } + ' "$INDEX_FILE" > "$INDEX_FILE.tmp" && mv "$INDEX_FILE.tmp" "$INDEX_FILE" + fi + + echo " Updated includes: ${SERVICES[*]:-none}" +fi + +echo "" +echo "==> Documentation generation complete" +echo " Services documented: ${#SERVICES[@]}" +echo "" +echo "To build the static site:" +echo " cd $DOCS_DIR && bundle exec middleman build" diff --git a/internal/adapter/templates/templates/skeleton/docs/source/includes/_errors.md b/internal/adapter/templates/templates/skeleton/docs/source/includes/_errors.md new file mode 100644 index 0000000..475212e --- /dev/null +++ b/internal/adapter/templates/templates/skeleton/docs/source/includes/_errors.md @@ -0,0 +1,59 @@ +# Errors + +The API uses standard HTTP status codes to indicate success or failure. + +## Error Response Format + +```json +{ + "error": { + "code": "ERROR_CODE", + "message": "Human-readable error message" + }, + "meta": { + "request_id": "abc123" + } +} +``` + +## Common Error Codes + +| HTTP Status | Error Code | Description | +|-------------|------------|-------------| +| 400 | `BAD_REQUEST` | The request body is malformed or missing required fields | +| 401 | `UNAUTHORIZED` | Authentication is required but was not provided | +| 403 | `FORBIDDEN` | The authenticated user lacks permission for this action | +| 404 | `NOT_FOUND` | The requested resource does not exist | +| 409 | `CONFLICT` | The request conflicts with existing data (e.g., duplicate) | +| 422 | `VALIDATION_ERROR` | The request data failed validation rules | +| 429 | `RATE_LIMITED` | Too many requests; slow down | +| 500 | `INTERNAL_ERROR` | An unexpected server error occurred | +| 503 | `SERVICE_UNAVAILABLE` | The service is temporarily unavailable | + +## Validation Errors + +Validation errors (422) include field-specific details: + +```json +{ + "error": { + "code": "VALIDATION_ERROR", + "message": "Validation failed", + "details": { + "name": "name is required", + "email": "invalid email format" + } + } +} +``` + +## Rate Limiting + +When rate limited (429), the response includes headers indicating when you can retry: + +| Header | Description | +|--------|-------------| +| `X-RateLimit-Limit` | Maximum requests per window | +| `X-RateLimit-Remaining` | Requests remaining in current window | +| `X-RateLimit-Reset` | Unix timestamp when the window resets | +| `Retry-After` | Seconds to wait before retrying | diff --git a/internal/adapter/templates/templates/skeleton/docs/source/index.html.md.tmpl b/internal/adapter/templates/templates/skeleton/docs/source/index.html.md.tmpl new file mode 100644 index 0000000..cc0c79e --- /dev/null +++ b/internal/adapter/templates/templates/skeleton/docs/source/index.html.md.tmpl @@ -0,0 +1,84 @@ +--- +title: {{PROJECT_NAME}} API Documentation + +language_tabs: + - shell: curl + - go: Go + +toc_footers: + - {{PROJECT_NAME}} + - Documentation Powered by Slate + +includes: + - errors + +search: true + +code_clipboard: true + +meta: + - name: description + content: API documentation for {{PROJECT_NAME}} +--- + +# Introduction + +Welcome to the **{{PROJECT_NAME}}** API documentation. + +This documentation covers all services in the {{PROJECT_NAME}} monorepo. Each service provides a REST API with JSON responses. + +## Authentication + +Most endpoints require authentication via Bearer token: + +```shell +curl -X GET "https://{{DOMAIN}}/api/service-name/endpoint" \ + -H "Authorization: Bearer YOUR_TOKEN" +``` + +```go +req, _ := http.NewRequest("GET", "https://{{DOMAIN}}/api/service-name/endpoint", nil) +req.Header.Set("Authorization", "Bearer YOUR_TOKEN") +``` + +## Base URLs + +| Environment | URL | +|-------------|-----| +| Production | `https://{{DOMAIN}}` | +| Staging | `https://staging.{{DOMAIN}}` | + +## Response Format + +All API responses use a consistent envelope: + +```json +{ + "data": { ... }, + "meta": { + "request_id": "abc123" + } +} +``` + +Error responses include an error object: + +```json +{ + "error": { + "code": "NOT_FOUND", + "message": "Resource not found" + }, + "meta": { + "request_id": "abc123" + } +} +``` + +# Services + +The following services are available in this project. Click on a service name to jump to its API documentation. + + + + diff --git a/internal/adapter/templates/templates/skeleton/docs/source/layouts/layout.erb b/internal/adapter/templates/templates/skeleton/docs/source/layouts/layout.erb new file mode 100644 index 0000000..281708d --- /dev/null +++ b/internal/adapter/templates/templates/skeleton/docs/source/layouts/layout.erb @@ -0,0 +1,103 @@ + + + + + + + <%= current_page.data.title || "API Documentation" %> + + + + <%= stylesheet_link_tag :screen, media: :screen %> + <%= stylesheet_link_tag :print, media: :print %> + + <% if current_page.data.search %> + <%= javascript_include_tag "all" %> + <% else %> + <%= javascript_include_tag "all_nosearch" %> + <% end %> + + <% if current_page.data.code_clipboard %> + + <% end %> + + + + + + NAV + <%= image_tag('navbar.png') %> + + +
+ <% if language_tabs.any? %> +
+ <% language_tabs.each do |lang| %> + <% if lang.is_a? Hash %> + <%= lang.values.first %> + <% else %> + <%= lang %> + <% end %> + <% end %> +
+ <% end %> + <% if current_page.data.search %> + + + <% end %> + + <% if current_page.data.toc_footers %> + + <% end %> +
+
+
+
+ <%= page_content %> +
+
+ <% if language_tabs.any? %> +
+ <% language_tabs.each do |lang| %> + <% if lang.is_a? Hash %> + <%= lang.values.first %> + <% else %> + <%= lang %> + <% end %> + <% end %> +
+ <% end %> +
+
+ + diff --git a/internal/adapter/templates/templates/skeleton/docs/source/stylesheets/_variables.scss b/internal/adapter/templates/templates/skeleton/docs/source/stylesheets/_variables.scss new file mode 100644 index 0000000..cacaa3e --- /dev/null +++ b/internal/adapter/templates/templates/skeleton/docs/source/stylesheets/_variables.scss @@ -0,0 +1,34 @@ +// Custom variables for Slate theming +// Override Slate defaults to match project branding + +// Colors - dark theme +$nav-bg: #1e1e2e !default; +$nav-text: #cdd6f4 !default; +$nav-active-bg: #313244 !default; +$nav-active-text: #f5c2e7 !default; +$nav-hover-bg: #45475a !default; + +$content-bg: #1e1e2e !default; +$content-text: #cdd6f4 !default; +$code-bg: #181825 !default; +$code-text: #cdd6f4 !default; + +$border-color: #45475a !default; + +// Code highlighting (catppuccin-mocha inspired) +$code-annotation-bg: #313244 !default; +$code-annotation-text: #a6adc8 !default; + +// Fonts +$font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif !default; +$code-font-family: "SF Mono", Consolas, Monaco, "Andale Mono", monospace !default; + +// Sizes +$nav-width: 230px !default; +$max-content-width: 800px !default; + +// Language tabs +$lang-select-bg: #11111b !default; +$lang-select-text: #cdd6f4 !default; +$lang-select-active-bg: #313244 !default; +$lang-select-active-text: #f5c2e7 !default; diff --git a/internal/adapter/templates/templates/skeleton/docs/source/stylesheets/screen.css.scss b/internal/adapter/templates/templates/skeleton/docs/source/stylesheets/screen.css.scss new file mode 100644 index 0000000..7e218e0 --- /dev/null +++ b/internal/adapter/templates/templates/skeleton/docs/source/stylesheets/screen.css.scss @@ -0,0 +1,158 @@ +// Slate documentation styles +// Imports Slate defaults and applies custom variables + +@import 'variables'; + +// Base Slate styles +@import 'normalize'; +@import 'icon-font'; + +// Custom overrides +body { + background-color: $content-bg; + color: $content-text; + font-family: $font-family; +} + +// Navigation +.toc-wrapper { + background-color: $nav-bg; + width: $nav-width; + + .toc-link { + color: $nav-text; + + &.active { + background-color: $nav-active-bg; + color: $nav-active-text; + } + + &:hover { + background-color: $nav-hover-bg; + } + } + + .toc-h2 { + padding-left: 25px; + font-size: 12px; + } +} + +// Code blocks +pre { + background-color: $code-bg; + + code { + color: $code-text; + font-family: $code-font-family; + } +} + +// Inline code +code { + background-color: $code-bg; + color: $code-text; + padding: 2px 6px; + border-radius: 3px; + font-family: $code-font-family; +} + +// Language tabs +.lang-selector { + background-color: $lang-select-bg; + + a { + color: $lang-select-text; + + &.active { + background-color: $lang-select-active-bg; + color: $lang-select-active-text; + } + } +} + +// Tables +table { + margin-bottom: 1em; + border-collapse: collapse; + width: 100%; + + th, td { + padding: 8px 12px; + border: 1px solid $border-color; + } + + th { + background-color: $code-bg; + font-weight: 600; + } + + tr:nth-child(even) { + background-color: rgba(0, 0, 0, 0.1); + } +} + +// Headers +h1, h2, h3, h4, h5, h6 { + color: $content-text; +} + +h1 { + border-bottom: 1px solid $border-color; + padding-bottom: 0.5em; +} + +// Links +a { + color: $nav-active-text; + text-decoration: none; + + &:hover { + text-decoration: underline; + } +} + +// Content area +.page-wrapper { + margin-left: $nav-width; + max-width: $max-content-width; + + .content { + padding: 30px; + } +} + +// Code annotations +.code-annotation { + background-color: $code-annotation-bg; + color: $code-annotation-text; + padding: 4px 8px; + border-radius: 3px; + font-size: 12px; +} + +// Search +.search { + input { + background-color: $code-bg; + border: 1px solid $border-color; + color: $content-text; + + &::placeholder { + color: $code-annotation-text; + } + } +} + +// Responsive +@media (max-width: 930px) { + .toc-wrapper { + width: 100%; + position: relative; + height: auto; + } + + .page-wrapper { + margin-left: 0; + } +} diff --git a/internal/adapter/templates/templates/skeleton/pkg/app/app.go.tmpl b/internal/adapter/templates/templates/skeleton/pkg/app/app.go.tmpl index b9ec81b..07db737 100644 --- a/internal/adapter/templates/templates/skeleton/pkg/app/app.go.tmpl +++ b/internal/adapter/templates/templates/skeleton/pkg/app/app.go.tmpl @@ -292,8 +292,13 @@ func (a *App) ListenAddr() string { return a.serverConfig.Addr() } -// EnableDocs adds /docs and /openapi.json endpoints to the application. -// It mounts the Scalar UI at /docs and the OpenAPI JSON spec at /openapi.json. +// EnableDocs adds OpenAPI endpoints to the application. +// It mounts: +// - /openapi.json: The raw OpenAPI spec (consumed by CI for Slate doc generation) +// - /docs: Redirects to the Slate documentation site at docs.{domain} +// +// During CI builds, Widdershins converts /openapi.json to Slate markdown, +// which is then built into static HTML and deployed to docs.{domain}. // // Example: // @@ -302,7 +307,7 @@ func (a *App) ListenAddr() string { // application.EnableDocs(spec) func (a *App) EnableDocs(spec *openapi.OpenAPISpec) { openapi.Mount(a.router, spec) - a.logger.Info("API documentation enabled", "docs", "/docs", "spec", "/openapi.json") + a.logger.Info("API documentation enabled", "spec", "/openapi.json", "docs_redirect", "/docs") } // ServeHTTP implements http.Handler, allowing App to be used in tests. diff --git a/internal/adapter/templates/templates/skeleton/pkg/openapi/docs.go.tmpl b/internal/adapter/templates/templates/skeleton/pkg/openapi/docs.go.tmpl index 7ced3a6..09a81d4 100644 --- a/internal/adapter/templates/templates/skeleton/pkg/openapi/docs.go.tmpl +++ b/internal/adapter/templates/templates/skeleton/pkg/openapi/docs.go.tmpl @@ -1,16 +1,16 @@ package openapi import ( - "fmt" "net/http" "github.com/go-chi/chi/v5" - scalargo "github.com/bdpiprava/scalar-go" ) -// Mount registers /docs and /openapi.json endpoints on the router. +// Mount registers the /openapi.json endpoint on the router. +// The OpenAPI spec is consumed by CI to generate Slate documentation +// which is served at docs.{domain}. No interactive UI is mounted here. func Mount(r chi.Router, spec *OpenAPISpec) { - // Serve OpenAPI JSON + // Serve OpenAPI JSON spec (consumed by Widdershins during doc generation) r.Get("/openapi.json", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.Header().Set("Access-Control-Allow-Origin", "*") @@ -24,26 +24,19 @@ func Mount(r chi.Router, spec *OpenAPISpec) { _, _ = w.Write(specBytes) }) - // Serve Scalar docs UI + // /docs redirects to the Slate documentation site r.Get("/docs", func(w http.ResponseWriter, r *http.Request) { - scheme := "http" + // Construct docs URL from current host + // e.g., api.slug.threesix.ai -> docs.slug.threesix.ai + scheme := "https" if proto := r.Header.Get("X-Forwarded-Proto"); proto != "" { scheme = proto - } else if r.TLS != nil { - scheme = "https" - } - specURL := fmt.Sprintf("%s://%s/openapi.json", scheme, r.Host) - - html, err := scalargo.NewV2( - scalargo.WithSpecURL(specURL), - scalargo.WithDarkMode(), - ) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return } - w.Header().Set("Content-Type", "text/html; charset=utf-8") - _, _ = fmt.Fprint(w, html) + // Extract the base domain (remove service prefix if present) + host := r.Host + docsURL := scheme + "://docs." + host + + http.Redirect(w, r, docsURL, http.StatusTemporaryRedirect) }) } diff --git a/internal/service/project_infra.go b/internal/service/project_infra.go index 4624e88..7f7eec5 100644 --- a/internal/service/project_infra.go +++ b/internal/service/project_infra.go @@ -127,6 +127,9 @@ type CreateProjectResult struct { Domain string URL string + // API documentation URL (docs.{slug}.{domain}) + DocsURL string + // All domains associated with the project Domains []*domain.ProjectDomain diff --git a/internal/service/project_infra_crud.go b/internal/service/project_infra_crud.go index 55e8485..947cfb2 100644 --- a/internal/service/project_infra_crud.go +++ b/internal/service/project_infra_crud.go @@ -64,22 +64,25 @@ func (s *ProjectInfraService) CreateProject(ctx context.Context, req CreateProje // 5. Create custom subdomain if requested s.createCustomDNS(ctx, req, projectID, result) - // 6. Activate CI (Woodpecker) - Before seeding so the webhook is installed + // 6. Create docs subdomain for API documentation (docs.{slug}.{domain}) + s.createDocsDNS(ctx, slug, projectID, result) + + // 7. Activate CI (Woodpecker) - Before seeding so the webhook is installed ciActivated := s.activateCI(ctx, result) - // 7. Seed repository with template + // 8. Seed repository with template templateSeeded := s.seedTemplate(ctx, req, result) - // 8. Provision database and cache + // 9. Provision database and cache s.provisionResources(ctx, result) - // 9. Create initial K8s deployment (before triggering CI build) + // 10. Create initial K8s deployment (before triggering CI build) // This ensures the deployment exists for `kubectl set image` in CI pipeline if templateSeeded { s.createInitialDeployment(ctx, req, result) } - // 10. Trigger initial CI build if both CI and template are ready + // 11. Trigger initial CI build if both CI and template are ready if ciActivated && templateSeeded && s.ciProvider != nil { pipelineNum, err := s.ciProvider.TriggerBuild(ctx, result.GitRepoOwner, result.GitRepoName, "main") if err != nil { @@ -266,6 +269,51 @@ func (s *ProjectInfraService) createCustomDNS(ctx context.Context, req CreatePro } } +// createDocsDNS creates DNS record for docs.{slug}.{defaultDomain}. +// This enables Slate API documentation to be served at docs.{domain}. +func (s *ProjectInfraService) createDocsDNS(ctx context.Context, slug, projectID string, result *CreateProjectResult) { + if s.dns == nil { + return // DNS not configured, skip silently (not critical) + } + + log := logging.FromContext(ctx).WithService("project_infra") + docsSubdomain := "docs." + slug + docsDomain := docsSubdomain + "." + s.defaultDomain + + dnsRecord, err := s.dns.CreateRecord(ctx, domain.DNSRecord{ + Type: "A", + Name: docsSubdomain, + Content: s.clusterIP, + TTL: 1, + Proxied: false, + }) + if err != nil { + log.Warn("failed to create docs DNS record", logging.FieldError, err, "domain", docsDomain) + result.NextSteps = append(result.NextSteps, "Create docs DNS manually: "+docsDomain+" → "+s.clusterIP) + return + } + + // Store in project_domains table + if s.domainRepo != nil { + pd := &domain.ProjectDomain{ + ProjectID: projectID, + Domain: docsDomain, + Type: domain.DomainTypeAlias, // docs subdomain is an alias + DNSRecordID: dnsRecord.ID, + DNSRecordType: "A", + Verified: true, + } + if err := s.domainRepo.Create(ctx, pd); err != nil { + log.Warn("failed to store docs domain", logging.FieldError, err) + } else { + result.Domains = append(result.Domains, pd) + } + } + + result.DocsURL = "https://" + docsDomain + log.Info("docs DNS created", "domain", docsDomain) +} + func (s *ProjectInfraService) activateCI(ctx context.Context, result *CreateProjectResult) bool { log := logging.FromContext(ctx).WithService("project_infra") if s.ciProvider == nil {