{
  "openapi": "3.1.0",
  "info": {
    "title": "rowan-kendal.com API",
    "version": "1.0.0",
    "description": "Public API for rowan-kendal.com, the personal site of Rowan Kendal (Senior QA Automation Engineer). No authentication is required. All error responses are structured JSON with `error` (message), `code` (machine-readable), and optional `hint` fields. Write endpoints are rate limited per client IP and return RFC RateLimit headers (RateLimit-Limit, RateLimit-Remaining, RateLimit-Reset, RateLimit-Policy) on every response, plus Retry-After on HTTP 429. Versioning: this is API v1, signaled via an `X-API-Version: 1` header on every /api/* response; breaking changes are announced with `Deprecation` and `Sunset` headers (RFC 8594) at least 90 days before removal and marked deprecated in this spec, while additive changes (new fields) may happen at any time. An MCP server (Streamable HTTP) is available at https://www.rowan-kendal.com/api/mcp, and an official CLI at https://www.npmjs.com/package/rowan-kendal-cli. Human documentation: https://www.rowan-kendal.com/docs",
    "contact": {
      "name": "Rowan Kendal",
      "email": "rowan.kendal@gmail.com",
      "url": "https://www.rowan-kendal.com/contact"
    }
  },
  "servers": [{ "url": "https://www.rowan-kendal.com" }],
  "paths": {
    "/api/dutch-weather": {
      "get": {
        "operationId": "getDutchWeather",
        "summary": "Weather for the unlisted trip page",
        "description": "Current conditions and a 7-day forecast (peak daily rain chance) for the towns on the private /dutch trip page, proxied from Open-Meteo and cached for 30 minutes. Rate limited to 60 requests per minute per IP.",
        "responses": {
          "200": {
            "description": "Weather per town: fetchedAt, source, and towns[] with current {temp, label, emoji} and daily[] {date, label, emoji, tempMax, tempMin, rainChance, rainMm}."
          },
          "429": {
            "description": "Rate limited",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
          },
          "502": {
            "description": "Upstream forecast unavailable",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
          }
        }
      }
    },
    "/api/pipeline-scores": {
      "get": {
        "operationId": "listPipelineScores",
        "summary": "Get the Pipeline Defense leaderboard",
        "description": "Returns the top 10 scores for the Pipeline Defense tower-defense game (https://www.rowan-kendal.com/pipeline-defence), sorted by score descending. Returns an empty list when the leaderboard store is unavailable.",
        "tags": ["leaderboard"],
        "responses": {
          "200": {
            "description": "The current leaderboard.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/Leaderboard" }
              }
            }
          }
        }
      },
      "post": {
        "operationId": "submitPipelineScore",
        "summary": "Submit a Pipeline Defense score",
        "description": "Adds a score to the leaderboard and returns the updated top 10. Rate limited to 20 requests per minute per IP; every response carries RFC RateLimit headers.",
        "tags": ["leaderboard"],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": { "$ref": "#/components/schemas/ScoreSubmission" }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Score accepted; the updated leaderboard.",
            "headers": {
              "RateLimit-Limit": { "$ref": "#/components/headers/RateLimitLimit" },
              "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimitRemaining" },
              "RateLimit-Reset": { "$ref": "#/components/headers/RateLimitReset" },
              "RateLimit-Policy": { "$ref": "#/components/headers/RateLimitPolicy" }
            },
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/Leaderboard" }
              }
            }
          },
          "400": {
            "description": "Malformed JSON or invalid payload.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/Error" }
              }
            }
          },
          "429": {
            "description": "Rate limit exceeded. Respect the Retry-After header.",
            "headers": {
              "Retry-After": {
                "description": "Seconds to wait before retrying.",
                "schema": { "type": "integer" }
              },
              "RateLimit-Limit": { "$ref": "#/components/headers/RateLimitLimit" },
              "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimitRemaining" },
              "RateLimit-Reset": { "$ref": "#/components/headers/RateLimitReset" },
              "RateLimit-Policy": { "$ref": "#/components/headers/RateLimitPolicy" }
            },
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/Error" }
              }
            }
          },
          "503": {
            "description": "Leaderboard storage is not configured.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/Error" }
              }
            }
          }
        }
      }
    }
  },
  "components": {
    "headers": {
      "RateLimitLimit": {
        "description": "Requests allowed in the current window.",
        "schema": { "type": "integer" }
      },
      "RateLimitRemaining": {
        "description": "Requests remaining in the current window.",
        "schema": { "type": "integer" }
      },
      "RateLimitReset": {
        "description": "Seconds until the current window resets.",
        "schema": { "type": "integer" }
      },
      "RateLimitPolicy": {
        "description": "Quota policy, e.g. \"20;w=60\" (20 requests per 60-second window).",
        "schema": { "type": "string" }
      }
    },
    "schemas": {
      "ScoreEntry": {
        "type": "object",
        "description": "One leaderboard row.",
        "properties": {
          "name": { "type": "string", "description": "Player name.", "maxLength": 50 },
          "score": { "type": "integer", "description": "Final score.", "minimum": 0 },
          "level": {
            "type": "string",
            "description": "Game level played.",
            "examples": ["STARTUP", "COMPANY", "CORPORATION"]
          },
          "date": { "type": "string", "format": "date", "description": "Date achieved (YYYY-MM-DD)." },
          "id": { "type": "integer", "description": "Server-assigned entry id." }
        },
        "required": ["name", "score"]
      },
      "Leaderboard": {
        "type": "object",
        "properties": {
          "scores": {
            "type": "array",
            "description": "Top 10 scores, highest first.",
            "items": { "$ref": "#/components/schemas/ScoreEntry" }
          }
        },
        "required": ["scores"]
      },
      "ScoreSubmission": {
        "type": "object",
        "description": "A score to add to the leaderboard.",
        "properties": {
          "name": { "type": "string", "description": "Player name (trimmed to 50 chars).", "minLength": 1 },
          "score": {
            "type": "integer",
            "description": "Final score.",
            "minimum": 0,
            "maximum": 10000000
          },
          "level": { "type": "string", "description": "Game level played (trimmed to 30 chars)." },
          "date": { "type": "string", "format": "date", "description": "Date achieved; defaults to today." }
        },
        "required": ["name", "score"]
      },
      "Error": {
        "type": "object",
        "description": "Structured error response returned by every API failure, including unknown API paths (HTTP 404, code \"not_found\").",
        "properties": {
          "error": { "type": "string", "description": "Human-readable message." },
          "code": {
            "type": "string",
            "description": "Machine-readable error code.",
            "examples": ["invalid_json", "invalid_payload", "rate_limited", "not_found", "storage_unconfigured"]
          },
          "hint": { "type": "string", "description": "How to resolve the error, when applicable." }
        },
        "required": ["error", "code"]
      }
    }
  }
}
