Co-authored-by: Cursor <cursoragent@cursor.com>
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.
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:
reusableschemas anduseFieldsfor shared field groups (e.g. layout) - Optional API auth: Protect write access (POST/PUT/DELETE) with an API key via env
- Swagger UI: Interactive API docs at
/swagger-ui - Image transformation:
GET /api/transform– load image from external URL, 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/*.json5while you edit - CORS: Open by default for frontend development
Requirements
- Rust (1.70+)
Quick start
cd rustycms
cargo run
The server starts at http://127.0.0.1:3000.
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. When set: POST/PUT/DELETE require this key (Bearer or X-API-Key). GET stays public. |
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/
├── 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
├── 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",
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 |
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 | Description (shown in Swagger 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 |
number |
number | Integer or float |
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",
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",
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:
- Reusable schema – e.g.
types/component_layout.json5withreusable: trueand only the shared fields (e.g.mobile,tablet,desktop,spaceBottom). - Reference in type – on an object field:
type: "object",useFields: "component_layout". On load, the partial’s 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",
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
Via files
Add a .json5 file under content/<type>/. The filename 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 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 |
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/transform |
Transform image from external URL (resize, crop, format) |
GET |
/health |
Health check (e.g. for K8s/Docker), always 200 + {"status":"ok"} |
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 = lossless; AVIF at default quality.) |
quality |
– | JPEG quality 1–100. 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