📄 blog-api-spec.yaml 22,453 bytes Apr 20, 2026 📋 Raw

openapi: 3.0.3
info:
title: HoffDesk Blog API
description: |
REST API for the HoffDesk blog module. Provides read access to published
blog posts (public) and full CRUD for post management (authenticated via
Cloudflare Access).

**Public endpoints** are unauthenticated and cached at the Cloudflare edge.
**Admin endpoints** require Cloudflare Access authentication (Email OTP).

version: 1.0.0
contact:
name: Socrates

servers:
- url: http://localhost:8000
description: Local development (Beelink)
- url: https://api.hoffdesk.com
description: Production (Cloudflare Tunnel)

tags:
- name: Blog - Public
description: Read-only endpoints for published blog content
- name: Blog - Admin
description: Write endpoints for post management (CF Access required)

paths:
# ─── PUBLIC ENDPOINTS ────────────────────────────────────────────────

/api/blog/posts:
get:
tags: [Blog - Public]
summary: List published blog posts
description: |
Returns a paginated list of published blog posts, ordered by
published_at descending (newest first). Supports filtering by
category and tag.
operationId: listBlogPosts
parameters:
- name: page
in: query
description: Page number (1-based)
schema:
type: integer
minimum: 1
default: 1
- name: per_page
in: query
description: Posts per page (max 50)
schema:
type: integer
minimum: 1
maximum: 50
default: 10
- name: category
in: query
description: Filter by category slug
schema:
type: string
example: engineering
- name: tag
in: query
description: Filter by tag name
schema:
type: string
example: openclaw
- name: featured
in: query
description: Only return featured posts
schema:
type: boolean
default: false
responses:
'200':
description: List of published posts
content:
application/json:
schema:
$ref: '#/components/schemas/PostListResponse'

/api/blog/posts/{slug}:
get:
tags: [Blog - Public]
summary: Get a single published post
description: |
Returns the full content of a published blog post by slug. Includes
rendered HTML body and metadata. Returns 404 for drafts or archived posts.
operationId: getBlogPost
parameters:
- name: slug
in: path
required: true
description: URL slug of the post
schema:
type: string
pattern: '^[a-z0-9]+(?:-[a-z0-9]+)*$'
responses:
'200':
description: Full post with rendered content
content:
application/json:
schema:
$ref: '#/components/schemas/PostDetailResponse'
'404':
description: Post not found or not published
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'

/api/blog/posts/{slug}/related:
get:
tags: [Blog - Public]
summary: Get related posts
description: |
Returns up to 3 related published posts based on shared categories and tags.
Posts with more tag overlap are ranked higher.
operationId: getRelatedPosts
parameters:
- name: slug
in: path
required: true
description: Slug of the reference post
schema:
type: string
- name: limit
in: query
description: Maximum number of related posts to return
schema:
type: integer
minimum: 1
maximum: 10
default: 3
responses:
'200':
description: List of related posts
content:
application/json:
schema:
$ref: '#/components/schemas/PostListResponse'

/api/blog/categories:
get:
tags: [Blog - Public]
summary: List all categories
description: |
Returns all blog categories with the count of published posts in each.
Useful for building navigation and archive pages.
operationId: listCategories
responses:
'200':
description: List of categories with post counts
content:
application/json:
schema:
$ref: '#/components/schemas/CategoryListResponse'

/api/blog/tags:
get:
tags: [Blog - Public]
summary: List all tags
description: |
Returns all tags with the count of published posts using each tag.
Useful for tag clouds and filter UIs.
operationId: listTags
responses:
'200':
description: List of tags with post counts
content:
application/json:
schema:
$ref: '#/components/schemas/TagListResponse'

# ─── ADMIN ENDPOINTS ────────────────────────────────────────────────

/api/blog/admin/posts:
post:
tags: [Blog - Admin]
summary: Create a new blog post
description: |
Creates a new blog post. The post body can be provided as Markdown
(preferred) or plain text. If status is 'published', the post is
immediately published and static HTML is regenerated.

    **Requires Cloudflare Access authentication.**
  operationId: createBlogPost
  requestBody:
    required: true
    content:
      application/json:
        schema:
          $ref: '#/components/schemas/CreatePostRequest'
  responses:
    '201':
      description: Post created successfully
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/PostDetailResponse'
    '400':
      description: Validation error (duplicate slug, invalid data)
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
    '401':
      description: Not authenticated (CF Access)
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'

/api/blog/admin/posts/{slug}:
patch:
tags: [Blog - Admin]
summary: Update a blog post
description: |
Partial update of a blog post. Only fields included in the request
body are updated. If a published post is updated, static HTML is
regenerated automatically.

    **Requires Cloudflare Access authentication.**
  operationId: updateBlogPost
  parameters:
    - name: slug
      in: path
      required: true
      description: URL slug of the post to update
      schema:
        type: string
  requestBody:
    required: true
    content:
      application/json:
        schema:
          $ref: '#/components/schemas/UpdatePostRequest'
  responses:
    '200':
      description: Post updated successfully
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/PostDetailResponse'
    '404':
      description: Post not found
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
    '401':
      description: Not authenticated (CF Access)
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'

delete:
  tags: [Blog - Admin]
  summary: Delete a blog post
  description: |
    Soft-deletes a blog post by setting status to 'archived'. The post 
    is no longer visible in public listings but is retained in the 
    database. Static HTML is removed from the generated output.

    **Requires Cloudflare Access authentication.**
  operationId: deleteBlogPost
  parameters:
    - name: slug
      in: path
      required: true
      description: URL slug of the post to delete
      schema:
        type: string
  responses:
    '200':
      description: Post archived successfully
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/DeletePostResponse'
    '404':
      description: Post not found
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'

/api/blog/admin/posts/{slug}/publish:
post:
tags: [Blog - Admin]
summary: Publish a draft
description: |
Changes a draft post's status to 'published', sets published_at to
now, and triggers static HTML generation. Returns the updated post.

    **Requires Cloudflare Access authentication.**
  operationId: publishBlogPost
  parameters:
    - name: slug
      in: path
      required: true
      description: URL slug of the draft to publish
      schema:
        type: string
  responses:
    '200':
      description: Post published successfully
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/PostDetailResponse'
    '404':
      description: Post not found
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
    '409':
      description: Post is already published
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'

/api/blog/admin/posts/{slug}/unpublish:
post:
tags: [Blog - Admin]
summary: Unpublish a post
description: |
Reverts a published post to draft status. Removes the post from
public listings and deletes the generated static HTML. Sets
published_at to null.

    **Requires Cloudflare Access authentication.**
  operationId: unpublishBlogPost
  parameters:
    - name: slug
      in: path
      required: true
      description: URL slug of the post to unpublish
      schema:
        type: string
  responses:
    '200':
      description: Post unpublished successfully
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/PostDetailResponse'
    '404':
      description: Post not found
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
    '409':
      description: Post is already a draft
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'

/api/blog/admin/posts/{slug}/regenerate:
post:
tags: [Blog - Admin]
summary: Regenerate static HTML for a post
description: |
Forces regeneration of the static HTML for a single published post.
Useful after template changes or when the generated output is stale.

    **Requires Cloudflare Access authentication.**
  operationId: regenerateBlogPost
  parameters:
    - name: slug
      in: path
      required: true
      description: URL slug of the post to regenerate
      schema:
        type: string
  responses:
    '200':
      description: Post regenerated successfully
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/RegenerateResponse'
    '404':
      description: Post not found
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'

/api/blog/admin/rebuild:
post:
tags: [Blog - Admin]
summary: Full rebuild of blog index and static files
description: |
Triggers a full rebuild: re-parses all Markdown files, rebuilds the
SQLite metadata database, and regenerates all static HTML. This is
a heavyweight operation — use sparingly.

    **Requires Cloudflare Access authentication.**
  operationId: rebuildBlog
  responses:
    '200':
      description: Rebuild initiated successfully
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/RebuildResponse'

components:
schemas:
# ─── COMMON ─────────────────────────────────────────────────────────

PostSummary:
  type: object
  required:
    - slug
    - title
    - category
    - author
    - published_at
    - excerpt
    - reading_time
    - featured
  properties:
    slug:
      type: string
      description: URL-friendly identifier
      example: "building-multi-agent-home-dashboard"
    title:
      type: string
      example: "Building a Multi-Agent Home Dashboard"
    category:
      type: string
      description: Category slug
      example: "engineering"
    author:
      type: string
      example: "Socrates"
    published_at:
      type: string
      format: date-time
      description: ISO 8601 timestamp of publication
      example: "2026-04-20T12:00:00-05:00"
    excerpt:
      type: string
      description: Short summary or first paragraph
      example: "How we built a family dashboard with AI agents..."
    cover_image:
      type: string
      nullable: true
      description: URL or path to the cover image
      example: "/blog/images/dashboard-hero.jpg"
    reading_time:
      type: integer
      description: Estimated reading time in minutes
      example: 8
    featured:
      type: boolean
      description: Whether this post is featured
      example: false
    tags:
      type: array
      items:
        type: string
      description: List of tag names
      example: ["openclaw", "fastapi", "home-automation"]

PostDetail:
  allOf:
    - $ref: '#/components/schemas/PostSummary'
    - type: object
      required:
        - content_html
        - content_md
        - created_at
        - updated_at
      properties:
        content_html:
          type: string
          description: Rendered HTML content of the post body
          example: "<h2>Section Title</h2><p>Content goes here...</p>"
        content_md:
          type: string
          description: Raw Markdown content
          example: "## Section Title\n\nContent goes here..."
        created_at:
          type: string
          format: date-time
          example: "2026-04-20T10:00:00-05:00"
        updated_at:
          type: string
          format: date-time
          example: "2026-04-20T14:30:00-05:00"

# ─── RESPONSES ──────────────────────────────────────────────────────

PostListResponse:
  type: object
  required:
    - posts
    - total
    - page
    - per_page
    - total_pages
  properties:
    posts:
      type: array
      items:
        $ref: '#/components/schemas/PostSummary'
    total:
      type: integer
      description: Total number of published posts matching filters
      example: 42
    page:
      type: integer
      example: 1
    per_page:
      type: integer
      example: 10
    total_pages:
      type: integer
      example: 5

PostDetailResponse:
  type: object
  required:
    - post
  properties:
    post:
      $ref: '#/components/schemas/PostDetail'

CategoryListResponse:
  type: object
  required:
    - categories
  properties:
    categories:
      type: array
      items:
        type: object
        required:
          - slug
          - name
          - post_count
        properties:
          slug:
            type: string
            example: "engineering"
          name:
            type: string
            description: Display name
            example: "Engineering"
          description:
            type: string
            nullable: true
            example: "Technical deep-dives and behind-the-scenes"
          post_count:
            type: integer
            example: 12

TagListResponse:
  type: object
  required:
    - tags
  properties:
    tags:
      type: array
      items:
        type: object
        required:
          - name
          - post_count
        properties:
          name:
            type: string
            example: "openclaw"
          post_count:
            type: integer
            example: 7

DeletePostResponse:
  type: object
  required:
    - message
    - slug
  properties:
    message:
      type: string
      example: "Post archived successfully"
    slug:
      type: string
      example: "building-multi-agent-home-dashboard"

RegenerateResponse:
  type: object
  required:
    - message
    - slug
    - regenerated_at
  properties:
    message:
      type: string
      example: "Static HTML regenerated"
    slug:
      type: string
    regenerated_at:
      type: string
      format: date-time

RebuildResponse:
  type: object
  required:
    - message
    - posts_processed
    - started_at
  properties:
    message:
      type: string
      example: "Full rebuild initiated"
    posts_processed:
      type: integer
      description: Number of posts processed
      example: 42
    started_at:
      type: string
      format: date-time

# ─── REQUESTS ────────────────────────────────────────────────────────

CreatePostRequest:
  type: object
  required:
    - title
    - content_md
  properties:
    title:
      type: string
      description: Post title (required)
      example: "Building a Multi-Agent Home Dashboard"
    slug:
      type: string
      description: |
        URL slug. Auto-generated from title if not provided. 
        Must be unique. Alphanumeric + hyphens only.
      example: "building-multi-agent-home-dashboard"
    category:
      type: string
      description: Category slug (defaults to 'general')
      default: general
      example: "engineering"
    tags:
      type: array
      items:
        type: string
      description: List of tag names
      example: ["openclaw", "fastapi"]
    author:
      type: string
      description: Author name (defaults to 'HoffDesk Team')
      default: "HoffDesk Team"
      example: "Socrates"
    excerpt:
      type: string
      description: |
        Short summary. Auto-generated from first paragraph if not provided.
      example: "How we built a family dashboard with AI agents..."
    status:
      type: string
      description: Initial publication status
      enum: [draft, published]
      default: draft
    featured:
      type: boolean
      description: Mark as featured post
      default: false
    cover_image:
      type: string
      nullable: true
      description: Path or URL to cover image
    content_md:
      type: string
      description: |
        Full Markdown content of the post. Will be stored as-is and 
        rendered to HTML on read/generate.
      example: "## Why We Built This\n\nContent goes here..."

UpdatePostRequest:
  type: object
  description: |
    Partial update  only include fields you want to change. 
    Omitted fields are left unchanged.
  properties:
    title:
      type: string
      example: "Building a Multi-Agent Home Dashboard (Updated)"
    category:
      type: string
      example: "engineering"
    tags:
      type: array
      items:
        type: string
      example: ["openclaw", "fastapi", "home-automation"]
    author:
      type: string
      example: "Socrates"
    excerpt:
      type: string
      example: "Updated summary..."
    featured:
      type: boolean
    cover_image:
      type: string
      nullable: true
    content_md:
      type: string
      description: Updated Markdown content (full replacement)

ErrorResponse:
  type: object
  required:
    - error
    - code
  properties:
    error:
      type: string
      description: Human-readable error message
      example: "Post not found"
    code:
      type: string
      description: Machine-readable error code
      example: "POST_NOT_FOUND"
      enum:
        - POST_NOT_FOUND
        - SLUG_EXISTS
        - SLUG_RESERVED
        - ALREADY_PUBLISHED
        - ALREADY_DRAFT
        - VALIDATION_ERROR
        - UNAUTHORIZED
        - REBUILD_FAILED
    details:
      type: object
      nullable: true
      description: Additional context for debugging