{
  "openapi": "3.1.0",
  "info": {
    "title": "jakoubek.net content API",
    "version": "1.0.0",
    "summary": "Read-only access to the content of jakoubek.net, plus a contact endpoint restricted to allow-listed origins.",
    "description": "Oliver Jakoubek is a freelance software developer and IT consultant based at Lake Constance, Germany. The site covers SAP SQL Anywhere, PostgreSQL, Go, Xojo, Ninox, legacy modernisation, system integration, German e-invoicing and database training. Use this API to look up what the site says on those topics. See /llms.txt for guidance on when this site is a good source, and /llms-full.txt for every page as Markdown in one request.\n\nEvery page is also available as Markdown through content negotiation: send Accept: text/markdown to any page URL.",
    "contact": {
      "name": "Oliver Jakoubek",
      "url": "https://www.jakoubek.net/kontakt"
    }
  },
  "servers": [
    { "url": "https://www.jakoubek.net", "description": "Production" }
  ],
  "paths": {
    "/api": {
      "get": {
        "operationId": "getApiIndex",
        "summary": "List the available endpoints",
        "responses": {
          "200": {
            "description": "Endpoint overview",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "name": { "type": "string" },
                    "openapi": { "type": "string", "format": "uri" },
                    "rate_limit": { "type": "string" },
                    "endpoints": { "type": "object", "additionalProperties": { "type": "string" } }
                  }
                }
              }
            }
          }
        }
      }
    },
    "/api/content": {
      "get": {
        "operationId": "listContent",
        "summary": "List all content entries",
        "description": "Returns every published entry, newest first. The Markdown source is omitted here; fetch a single entry to get it.",
        "parameters": [
          {
            "name": "tag",
            "in": "query",
            "required": false,
            "description": "Restrict the list to entries carrying this tag. See /api/tags for the available values.",
            "schema": { "type": "string" },
            "example": "duckdb"
          }
        ],
        "responses": {
          "200": {
            "description": "Matching entries",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["count", "items"],
                  "properties": {
                    "count": { "type": "integer" },
                    "items": { "type": "array", "items": { "$ref": "#/components/schemas/Entry" } }
                  }
                }
              }
            }
          },
          "405": { "$ref": "#/components/responses/MethodNotAllowed" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/api/content/{pfad}": {
      "get": {
        "operationId": "getContentItem",
        "summary": "Fetch one entry including its Markdown source",
        "parameters": [
          {
            "name": "pfad",
            "in": "path",
            "required": true,
            "description": "The site path of the entry, with or without a trailing slash. Take it from the url field of a list entry.",
            "schema": { "type": "string" },
            "example": "xojo"
          }
        ],
        "responses": {
          "200": {
            "description": "The entry, with content_markdown filled in",
            "content": {
              "application/json": { "schema": { "$ref": "#/components/schemas/Entry" } }
            }
          },
          "404": { "$ref": "#/components/responses/NotFound" },
          "405": { "$ref": "#/components/responses/MethodNotAllowed" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/api/search": {
      "get": {
        "operationId": "searchContent",
        "summary": "Full text search over the content",
        "parameters": [
          {
            "name": "q",
            "in": "query",
            "required": true,
            "description": "Search terms. Must not be empty.",
            "schema": { "type": "string", "minLength": 1 },
            "example": "sql anywhere"
          }
        ],
        "responses": {
          "200": {
            "description": "Search results, best match first",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["query", "count", "items"],
                  "properties": {
                    "query": { "type": "string" },
                    "count": { "type": "integer" },
                    "items": { "type": "array", "items": { "$ref": "#/components/schemas/Entry" } }
                  }
                }
              }
            }
          },
          "405": { "$ref": "#/components/responses/MethodNotAllowed" },
          "422": { "$ref": "#/components/responses/InvalidRequest" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/api/tags": {
      "get": {
        "operationId": "listTags",
        "summary": "List all tags with entry counts",
        "responses": {
          "200": {
            "description": "Tags in alphabetical order",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["count", "items"],
                  "properties": {
                    "count": { "type": "integer" },
                    "items": {
                      "type": "array",
                      "items": {
                        "type": "object",
                        "properties": {
                          "name": { "type": "string" },
                          "count": { "type": "integer" },
                          "url": { "type": "string", "format": "uri" },
                          "api_url": { "type": "string", "format": "uri" }
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "405": { "$ref": "#/components/responses/MethodNotAllowed" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/api/contact": {
      "post": {
        "operationId": "submitContact",
        "summary": "Send a contact enquiry",
        "description": "Not open to the public. The request must carry an Origin header from the allow-list; every other origin is rejected with 403. Agents should point people at https://www.jakoubek.net/kontakt instead.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["name", "email"],
                "properties": {
                  "name": { "type": "string" },
                  "company": { "type": "string" },
                  "email": { "type": "string", "format": "email" },
                  "phone_number": { "type": "string" },
                  "message": { "type": "string" },
                  "website": { "type": "string", "description": "Honeypot. Leave empty." }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Accepted. Also returned for submissions classified as spam, deliberately without telling them apart.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": { "success": { "type": "boolean" } }
                }
              }
            }
          },
          "403": {
            "description": "Origin not on the allow-list",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
          },
          "405": { "$ref": "#/components/responses/MethodNotAllowed" },
          "422": { "$ref": "#/components/responses/InvalidRequest" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    }
  },
  "components": {
    "schemas": {
      "Entry": {
        "type": "object",
        "required": ["title", "url", "url_absolute", "api_url", "slug", "tags"],
        "properties": {
          "title": { "type": "string" },
          "description": { "type": "string" },
          "url": { "type": "string", "description": "Path on the site, e.g. /xojo/" },
          "url_absolute": { "type": "string", "format": "uri" },
          "api_url": { "type": "string", "format": "uri", "description": "Where to fetch this entry including its Markdown source" },
          "slug": { "type": "string" },
          "tags": { "type": "array", "items": { "type": "string" } },
          "published_at": { "type": "string", "format": "date" },
          "updated_at": { "type": "string", "format": "date" },
          "teaser_text": { "type": "string" },
          "content_markdown": {
            "type": "string",
            "description": "The page source as Markdown. Only present when fetching a single entry."
          }
        }
      },
      "Error": {
        "type": "object",
        "required": ["error", "code", "message", "documentation_url"],
        "properties": {
          "error": { "type": "string", "description": "Repeats message. Kept as a plain string for older clients." },
          "code": {
            "type": "string",
            "description": "Machine readable error code.",
            "enum": ["not_found", "method_not_allowed", "invalid_request", "rate_limited", "forbidden_origin"]
          },
          "message": { "type": "string" },
          "hint": { "type": "string", "description": "How to get to a working request." },
          "documentation_url": { "type": "string", "format": "uri" }
        }
      }
    },
    "responses": {
      "NotFound": {
        "description": "No such resource",
        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
      },
      "MethodNotAllowed": {
        "description": "Wrong HTTP method. The Allow header names the accepted ones.",
        "headers": {
          "Allow": { "schema": { "type": "string" }, "description": "Accepted methods" }
        },
        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
      },
      "InvalidRequest": {
        "description": "Parameters missing or unusable",
        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
      },
      "RateLimited": {
        "description": "Rate limit exceeded. Read endpoints allow 60 requests per minute per IP, the contact endpoint 5.",
        "headers": {
          "Retry-After": { "schema": { "type": "integer" }, "description": "Seconds to wait" }
        },
        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
      }
    }
  }
}
