RustyCMS

A file-based headless CMS written in Rust. Content types are defined as JSON5 schemas, content is stored as JSON5 files, and served via a REST API. Under development.

Features

  • Schema-first: Define content types in types/*.json5 fields, types, constraints, defaults
  • TypeScript-like type system: extends (including multiple), pick, omit, partial
  • Rich validation: minLength, maxLength, min, max, pattern, enum, unique, nullable, readonly, strict
  • File-based (default): No database setup everything is files, versionable with Git
  • Optional SQLite: Switch store via env same API, different storage layer
  • REST API: Full CRUD endpoints, auto-generated per content type
  • References & _resolve: Reference fields as { _type, _slug }; embed with ?_resolve=all
  • Reusable partials: reusable schemas and useFields for shared field groups (e.g. layout)
  • Optional API auth: Protect write access (POST/PUT/DELETE) with an API key via env
  • Admin UI: Next.js web interface for browsing collections, creating and editing content; manage types (list, create, edit, delete) with schema files and JSON Schema export updated on save
  • Swagger UI: Interactive API docs at /swagger-ui
  • Asset management: Upload, serve, and delete image files (jpg, png, webp, avif, gif, svg) stored in content/assets/ via GET/POST/DELETE /api/assets; served with immutable cache headers; combinable with /api/transform for on-the-fly resizing
  • Image transformation: GET /api/transform load image from external URL or local asset, optional resize (w, h), aspect-ratio crop (e.g. 1:1), fit (fill/contain/cover), output formats jpeg, png, webp, avif; with response cache
  • JSON5: Human-friendly format with comments, trailing commas, unquoted keys
  • Editor validation: Export JSON Schema from your types so VS Code/Cursor validate content/*.json5 while you edit
  • CORS: Open by default for frontend development

Requirements

Quick start

cd rustycms
cargo run

The server starts at http://127.0.0.1:3000.

Start API + Admin UI and open browser:

./dev.sh

Runs both the API and Admin UI, waits until ready, and opens http://localhost:2001 in the default browser. Press Ctrl+C to stop both.

Admin UI (optional)

A Next.js web interface for managing content. Start the API server first, then:

cd admin-ui
npm install
npm run dev

The Admin UI runs at http://localhost:2001 (different port to avoid conflict with the API).

Version control: Only a single demo type (types/demo.json5) and one demo content entry (content/de/demo/demo-welcome.json5) are tracked. All other files under types/ and content/ are ignored via .gitignore, so you can add your own types and content without committing them.

Admin UI: Dashboard → Types lists all content types; you can create a new type, edit (fields, description, category, strict, …) or delete a type. Edits are written to types/*.json5 and the corresponding schemas/*.schema.json is updated. Content is managed under Collections (list, new entry, edit, with reference fields and Markdown editor). Schema and data preview are available on the relevant pages.

Environment variables (in admin-ui/.env.local or as env vars):

Variable Default Description
NEXT_PUBLIC_RUSTYCMS_API_URL http://127.0.0.1:3000 RustyCMS API base URL
NEXT_PUBLIC_RUSTYCMS_API_KEY API key for write operations (same as RUSTYCMS_API_KEY). Optional if you use the Admin UI login.

API key setup (backend + Admin UI):

  1. Backend In the project root (next to Cargo.toml), create or edit .env:

    RUSTYCMS_API_KEY=dein-geheimer-schluessel
    

    Use any secret string (e.g. openssl rand -hex 32). Start the API with cargo run; the log should show that API key auth is enabled.

  2. Admin UI Either:

    • Option A: In admin-ui/.env.local set the same key so the UI can write without logging in:
      NEXT_PUBLIC_RUSTYCMS_API_URL=http://127.0.0.1:3000
      NEXT_PUBLIC_RUSTYCMS_API_KEY=dein-geheimer-schluessel
      
    • Option B: Leave NEXT_PUBLIC_RUSTYCMS_API_KEY unset. Open the Admin UI, click Login in the sidebar, enter the same API key, and submit. The key is stored in the browser (sessionStorage) until you log out or close the tab.
  3. Check: Without a key, GET /api/collections returns 200; POST/PUT/DELETE return 401. With header X-API-Key: dein-geheimer-schluessel (or Authorization: Bearer …), write requests succeed.

CLI options

Option Default Description
--types-dir ./types Directory with type definitions
--content-dir ./content Directory with content files
-p, --port 3000 Port
--host 127.0.0.1 Host address
cargo run -- --types-dir ./my-schemas --content-dir ./my-data -p 8080

Configuration (environment variables)

A .env in the project directory is loaded at startup. See .env.example.

Variable Default Description
RUSTYCMS_STORE file Store backend: file or sqlite
RUSTYCMS_DATABASE_URL sqlite:content.db When using sqlite: SQLite URL (fallback: DATABASE_URL)
RUSTYCMS_API_KEY Optional. Single key = full write access (Bearer or X-API-Key). GET stays public.
RUSTYCMS_API_KEYS Optional. Multiple keys with roles: key1:read_write,key2:read. Roles: read, read_write, admin. Overrides RUSTYCMS_API_KEY.
RUSTYCMS_ENVIRONMENTS Optional. Comma-separated (e.g. production,staging). File store only. Content per env; API uses ?_environment=staging.
RUSTYCMS_WEBHOOKS Optional. Comma-separated URLs. POST with JSON { event, collection?, slug?, ... } on content/asset/schema create/update/delete.
RUSTYCMS_CORS_ORIGIN all Optional. One allowed CORS origin (e.g. https://my-frontend.com). Empty or * = all allowed.
RUSTYCMS_CACHE_TTL_SECS 60 Optional. Response cache for GET /api/content in seconds. 0 = cache off.

Examples:

# File store (default), no auth
cargo run

# SQLite backend
RUSTYCMS_STORE=sqlite cargo run

# Write access only with API key
RUSTYCMS_API_KEY=your-secret-token cargo run

Project structure

rustycms/
├── admin-ui/                   # Next.js Admin UI (optional)
│   ├── src/
│   │   ├── app/               # Dashboard, content list/edit pages
│   │   └── components/        # ContentForm, MarkdownEditor, ReferenceArrayField, Sidebar
│   └── package.json
├── Cargo.toml
├── src/
│   ├── main.rs                  # Entry point, CLI, server
│   ├── schema/
│   │   ├── types.rs             # SchemaDefinition, FieldDefinition
│   │   ├── loader.rs            # Load JSON5, resolve extends/pick/omit/partial
│   │   ├── json_schema.rs       # Export JSON Schema for editor validation
│   │   ├── validator.rs         # Validation (constraints, unique, readonly, …)
│   │   └── mod.rs               # SchemaRegistry
│   ├── bin/
│   │   └── export_json_schema.rs # CLI: export schemas + optional .vscode/settings.json
│   ├── store/
│   │   ├── store.rs             # ContentStore trait (abstract storage layer)
│   │   ├── filesystem.rs        # FileStore  CRUD on JSON5 files
│   │   ├── sqlite.rs            # SqliteStore  optional SQLite backend
│   │   ├── query.rs             # Filter, sort, paginate
│   │   ├── slug.rs              # Slug normalisation & validation
│   │   └── mod.rs
│   └── api/
│       ├── handlers.rs          # REST handlers (CRUD + validation)
│       ├── routes.rs            # Axum router
│       ├── openapi.rs           # OpenAPI spec & Swagger UI
│       ├── auth.rs              # Optional API key check (POST/PUT/DELETE)
│       ├── error.rs             # API error types
│       ├── response.rs          # Reference formatting, _resolve
│       ├── transform.rs         # Image transformation (external URL → resize/crop/format)
│       └── mod.rs
├── types/                       # Type definitions (layer 1)
│   ├── blog_post.json5
│   ├── page.json5               # extends blog_post
│   └── product.json5            # strict mode, constraints, unique, pattern
├── scripts/
│   └── contentful-to-rustycms.mjs  # Migration: Contentful-Export → content/de (JSON5)
├── schemas/                     # Generated JSON Schema (optional, for editor)
└── content/                     # Content files (layer 2)
    ├── blog_post/
    ├── page/
    └── product/

Editor validation (JSON Schema)

You can export JSON Schema files from your type definitions so that VS Code / Cursor validate content/*.json5 (and *.json) while you edit. Invalid fields, wrong types, or constraint violations show as squiggles in the editor.

# Export one .schema.json per content type into ./schemas
cargo run --bin export-json-schema

# Also write .vscode/settings.json so content/<type>/*.json5 uses schemas/<type>.schema.json
cargo run --bin export-json-schema -- --vscode

Options: --types-dir (default ./types), --out-dir (default ./schemas). After running with --vscode, open any file under content/blog_post/, content/page/, etc. the editor will use the matching schema. Re-run the command after changing type definitions to refresh schemas and (with --vscode) the settings.

Defining types

Add a .json5 file under types/:

// types/blog_post.json5
{
  name: "blog_post",
  description: "Simple blog post with title, body, tags and publish status",
  tags: ["content", "blog"],
  category: "content",
  fields: {
    title: {
      type: "string",
      required: true,
      minLength: 1,
      maxLength: 200,
      description: "Title of the blog post",
    },
    body: {
      type: "richtext",
      required: true,
      description: "Main content (Markdown)",
    },
    author:     { type: "string" },
    tags:       { type: "array", items: { type: "string" } },
    published:  { type: "boolean", default: false },
    created_at: { type: "datetime", auto: true, readonly: true },
  }
}

Schema options

Option Type Description
name string Required. Content type name
description string Human-readable description (Admin UI, Swagger)
tags string[] Tags for grouping/filtering (e.g. ["content","blog"])
category string Category for Admin UI (e.g. "content", "components")
extends string / array Parent type(s) to inherit from
strict boolean Reject unknown fields
pick string[] Only inherit these fields from parent (Pick<T>)
omit string[] Exclude these fields from parent (Omit<T>)
partial boolean Make all inherited fields optional (Partial<T>)
reusable boolean Schema only as partial (useFields), no collection/API

Field options

Option Type Description
type string Required. Field type (see below)
required boolean Field must be present when creating
unique boolean Value must be unique in the collection
default any Default value if not provided
auto boolean Auto-generated (e.g. timestamp)
readonly boolean Field cannot be changed after creation
nullable boolean Field allows explicit null
enum array Allowed values
description string Field description (Swagger UI, Admin UI)
minLength number Minimum string length
maxLength number Maximum string length
pattern string Regex pattern for strings
min number Minimum numeric value
max number Maximum numeric value
minItems number Minimum array length
maxItems number Maximum array length
items object Item definition for array fields
fields object Nested fields for object type
collection string Target collection for reference type
useFields string For type: "object": use fields from this schema (partial)

Field types

Type JSON type Description
string string Plain text
richtext string Markdown / rich text
markdown string Markdown content
html string Raw HTML (safely embedded)
number number Integer or float
integer number Integer only
boolean boolean true / false
datetime string ISO 8601 date/time
array array List (optional items)
object object Nested object
reference string Reference to another type

Inheritance & type composition

Simple inheritance inherit all fields from parent:

{
  name: "page",
  description: "Page with layout, SEO and content rows",
  tags: ["content", "page"],
  category: "content",
  extends: "blog_post",
  fields: {
    nav_order: { type: "number", min: 0 },
    template:  { type: "string", enum: ["default", "landing", "docs"] },
  }
}

Multiple inheritance inherit from several types:

{
  name: "featured_article",
  description: "Featured article with expiration",
  tags: ["content", "blog"],
  category: "content",
  extends: ["blog_post", "seo_meta"],
  fields: {
    featured_until: { type: "datetime" },
  }
}

Pick only certain fields from parent:

{
  name: "blog_post_preview",
  extends: "blog_post",
  pick: ["title", "excerpt", "author", "created_at"],
}

Omit exclude certain fields:

{
  name: "blog_post_light",
  extends: "blog_post",
  omit: ["body"],
}

Partial make all inherited fields optional:

{
  name: "blog_post_patch",
  extends: "blog_post",
  partial: true,
}

Reusable partials (useFields)

Define shared field groups once and reuse in multiple types:

  1. Reusable schema e.g. types/component_layout.json5 with reusable: true and only the shared fields (e.g. mobile, tablet, desktop, spaceBottom).
  2. Reference in type on an object field: type: "object", useFields: "component_layout". On load, the partials fields are inlined.

This keeps layout in one place and reuses it in Markdown, Headline, etc.

Strict mode

In strict mode, unknown fields are rejected:

{
  name: "product",
  description: "Product with SKU, price and optional fields",
  tags: ["content", "ecommerce"],
  category: "content",
  strict: true,
  fields: {
    title: { type: "string", required: true },
    price: { type: "number", required: true, min: 0 },
    sku:   { type: "string", required: true, unique: true, pattern: "^[A-Z]{2,4}-\\d{3,6}$" },
  }
}

Creating content

Contentful-Migration

Ein Contentful-Export (z.B. contentful-export.json) kann nach content/de/ überführt werden:

node scripts/contentful-to-rustycms.mjs [Pfad-zum-Export]
# Default: ../www.windwiderstand.de/contentful-export.json

Es werden u.a. Pages, Posts, Markdown, Link-Listen, Fullwidth-Banner, Tags, Navigation, Footer, Page-Config und Campaigns geschrieben. Nur unterstützte Row-Komponenten (markdown, link_list, fullwidth_banner, post_overview, searchable_text) landen in row1Content.

Via files

Add a .json5 file under content/<type>/ or, for localized content, content/<locale>/<type>/ (e.g. content/de/page/). The filename (without extension) becomes the slug.

// content/product/laptop-pro.json5
{
  title: "Laptop Pro 16",
  sku: "EL-1001",
  price: 1499.99,
  category: "electronics",
  images: ["laptop-front.jpg", "laptop-side.jpg"],
}

Via API

curl -X POST http://localhost:3000/api/content/product \
  -H "Content-Type: application/json" \
  -d '{
    "_slug": "keyboard-mx",
    "title": "Keyboard MX",
    "sku": "EL-2000",
    "price": 79.99,
    "category": "electronics",
    "images": ["keyboard.jpg"]
  }'

_slug sets the filename. Fields with default or auto are set automatically.

Note: If RUSTYCMS_API_KEY is set, POST/PUT/DELETE (including schema and content) must send the key see section “Optional API auth”.

REST API

Overview

Method Endpoint Description
GET /api/collections List all content types
GET /api/collections/:type Get schema for a type
POST /api/schemas Create new type (Admin UI: New type)
PUT /api/schemas/:type Update type definition (Admin UI: Edit type)
DELETE /api/schemas/:type Delete type definition (Admin UI: Delete type)
GET /api/content/:type List all entries
GET /api/content/:type/:slug Get single entry
POST /api/content/:type Create entry
PUT /api/content/:type/:slug Update entry
DELETE /api/content/:type/:slug Delete entry
GET /api or /api/ API index (living docs: links to Swagger UI + endpoint overview)
GET /swagger-ui Swagger UI
GET /api-docs/openapi.json OpenAPI 3.0 spec
GET /api/assets List all uploaded image assets
POST /api/assets Upload an image asset (multipart/form-data)
GET /api/assets/:filename Serve an image asset
DELETE /api/assets/:filename Delete an image asset
GET /api/transform Transform image from external URL (resize, crop, format)
GET /health Health check (e.g. for K8s/Docker), always 200 + {"status":"ok"}

Asset management

Images and other media files can be stored in content/assets/ and served directly via the API. This is useful for logos, hero images, icons, or any static media that belongs to your content.

Allowed file types: jpg, jpeg, png, webp, avif, gif, svg

Files are stored at {content-dir}/assets/ (default: ./content/assets/). The directory is created automatically on first use.

List assets

GET /api/assets
{
  "assets": [
    { "filename": "hero.jpg", "url": "/api/assets/hero.jpg", "mime_type": "image/jpeg", "size": 102400 }
  ],
  "total": 1
}

Upload an asset

curl -X POST http://localhost:3000/api/assets \
  -H "X-API-Key: your-key" \
  -F "file=@/path/to/hero.jpg"

Response (201):

{ "filename": "hero.jpg", "url": "/api/assets/hero.jpg", "mime_type": "image/jpeg", "size": 102400 }
  • Auth required (POST/DELETE) when RUSTYCMS_API_KEY is set
  • Filenames are lowercased and sanitized (only letters, digits, -, _, .)
  • Uploading a file that already exists returns 409 Conflict

Serve an asset

GET /api/assets/hero.jpg

Returns the raw image with correct Content-Type. Response header:

Cache-Control: public, max-age=31536000, immutable

Browsers and CDNs cache the file for 1 year. Combine with /api/transform for on-the-fly resizing of uploaded assets:

GET /api/transform?url=http://localhost:3000/api/assets/hero.jpg&w=400&h=300&format=webp

Delete an asset

curl -X DELETE http://localhost:3000/api/assets/hero.jpg \
  -H "X-API-Key: your-key"

Returns 204 No Content.

Image transformation (external URLs)

GET /api/transform loads an image from an external URL and returns it transformed. Parameters:

Parameter Alias Description
url Required. External image URL (e.g. HTTPS).
width w Target width in pixels.
height h Target height in pixels.
aspect_ratio ar Aspect ratio before resize, e.g. 1:1 or 16:9 (center crop).
fit fill (exact w×h), contain (keep aspect ratio), cover (fill with crop). When w and h are set: default fill; otherwise default contain.
format Output: jpeg, png, webp or avif. Default: jpeg. WebP is lossy when quality is used.
quality Quality 1100 for JPEG and WebP (lossy). Default: 85.

Example: 50×50 square from any image:
GET /api/transform?url=https://example.com/image.jpg&w=50&h=50&ar=1:1

Caching: Identical requests (same URL + same params) are served from memory when response cache is enabled (RUSTYCMS_CACHE_TTL_SECS > 0) until TTL expires (no re-download/resize).

ETags (conditional GET)

GET /api/content/:type/:slug sets an ETag header (content hash). If the client sends If-None-Match with that value and the content is unchanged, the API responds with 304 Not Modified (no body) saves bandwidth and load.

Resolving references (_resolve)

Reference fields return only { "_type": "collection", "_slug": "slug" } by default. With the _resolve query parameter you can embed referenced entries:

# Resolve all references (e.g. page with Markdown blocks, banner)
GET /api/content/page/contact?_resolve=all

# Resolve only a specific field
GET /api/content/page/home?_resolve=row1Content

Response then includes the full object in row1Content instead of just { _type, _slug }.

Filtering

Exact match: ?field=value. Suffixes are also supported:

  • ?title_prefix=Hello title starts with “Hello”
  • ?body_contains=rust body contains “rust”
  • ?price_min=10 number/string >= 10
  • ?price_max=100 number/string <= 100
GET /api/content/blog_post?published=true
GET /api/content/blog_post?title_prefix=Intro
GET /api/content/product?category=electronics&price_min=50

Sorting

GET /api/content/blog_post?_sort=created_at&_order=desc

Pagination

GET /api/content/blog_post?_page=2&_per_page=10

Response:

{
  "items": [ ... ],
  "total": 42,
  "page": 2,
  "per_page": 10,
  "total_pages": 5
}

Validation

The API validates automatically against the schema:

POST with title: 123          → "Expected type 'string', got 'number'"
POST without required field  → "Field is required"
POST with sku: "bad"          → "Value does not match pattern '^[A-Z]{2,4}-\\d{3,6}$'"
POST with price: -5           → "Value -5 is below minimum 0"
POST with duplicate SKU      → "Value must be unique"
POST with unknown field      → "Unknown field (strict mode is enabled)"
PUT  with changed readonly  → "Field is readonly and cannot be changed"
POST with title: null        → "Field does not allow null"
POST with rating: null       → OK (nullable: true)
POST with images: []         → "Array too short (min 1 items)"
POST with excerpt (501 chars)→ "String too long (max 500 characters)"

Store: file vs SQLite

By default the file store is used: each collection is a subdirectory under content/, each entry a .json5 file.

You can switch to SQLite same REST API, same schemas, different backing store (one table with collection, slug, JSON data):

RUSTYCMS_STORE=sqlite RUSTYCMS_DATABASE_URL=sqlite:content.db cargo run

The database file is created when needed (create_if_missing). Useful for larger setups, concurrent writers, or when you want content in a database.

Optional API auth

If you set RUSTYCMS_API_KEY (e.g. in .env), POST, PUT and DELETE require a valid key. GET stays public (read without auth).

Send the key one of:

  • Header: Authorization: Bearer <your-api-key>
  • Header: X-API-Key: <your-api-key>

Example with curl:

# Without key → 401 Unauthorized
curl -X POST http://localhost:3000/api/content/tag -H "Content-Type: application/json" -d '{"_slug":"x","name":"y"}'

# With key → 201 Created (or validation error)
curl -X POST http://localhost:3000/api/content/tag \
  -H "Content-Type: application/json" \
  -H "X-API-Key: your-secret-token" \
  -d '{"_slug":"x","name":"y"}'

If RUSTYCMS_API_KEY is not set, all endpoints behave as before without auth.

Swagger UI

Auto-generated interactive API documentation:

http://localhost:3000/swagger-ui

The OpenAPI spec is generated at startup from the schemas including all constraints (minLength, pattern, min/max, nullable, readOnly, …). The spec can also be fetched directly; it is served with cache headers so clients can cache it:

http://localhost:3000/api-docs/openapi.json

Schema hot-reload: Changes in types/*.json5 are watched after saving, schemas and the OpenAPI spec are reloaded automatically without restarting the server.

Troubleshooting

Problem Possible cause
Collection 'xyz' not found Type does not exist or path typo. Check GET /api/collections.
Field '_slug' is required POST must include _slug in the JSON body.
Missing or invalid API key RUSTYCMS_API_KEY is set; POST/PUT/DELETE need header Authorization: Bearer <key> or X-API-Key: <key>.
Entry 'slug' not found Slug misspelled or entry not present in the collection.
Validation errors (type, pattern, unique, …) Check request body: types, required fields, patterns and uniqueness per schema.

Operation

  • Graceful shutdown: On SIGINT (Ctrl+C) or SIGTERM (e.g. K8s), in-flight requests are completed, then the server exits cleanly.

Logging

# Default (info)
cargo run

# Debug
RUST_LOG=rustycms=debug,tower_http=debug cargo run

# Warnings only
RUST_LOG=warn cargo run

License

MIT

Description
No description provided
Readme 741 KiB
Languages
TypeScript 51.2%
Rust 35.9%
JavaScript 11.8%
CSS 0.7%
Shell 0.2%
Other 0.2%