RustyCMS: File-based headless CMS with REST API, admin UI, multilingual support
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
520
README.md
Normal file
520
README.md
Normal file
@@ -0,0 +1,520 @@
|
||||
# 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**: `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
|
||||
- **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/*.json5` while you edit
|
||||
- **CORS**: Open by default for frontend development
|
||||
|
||||
## Requirements
|
||||
|
||||
- [Rust](https://www.rust-lang.org/tools/install) (1.70+)
|
||||
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
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 |
|
||||
|
||||
```bash
|
||||
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:**
|
||||
|
||||
```bash
|
||||
# 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.
|
||||
|
||||
```bash
|
||||
# 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/`:
|
||||
|
||||
```json5
|
||||
// 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:
|
||||
|
||||
```json5
|
||||
{
|
||||
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:
|
||||
|
||||
```json5
|
||||
{
|
||||
name: "featured_article",
|
||||
extends: ["blog_post", "seo_meta"],
|
||||
fields: {
|
||||
featured_until: { type: "datetime" },
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Pick** – only certain fields from parent:
|
||||
|
||||
```json5
|
||||
{
|
||||
name: "blog_post_preview",
|
||||
extends: "blog_post",
|
||||
pick: ["title", "excerpt", "author", "created_at"],
|
||||
}
|
||||
```
|
||||
|
||||
**Omit** – exclude certain fields:
|
||||
|
||||
```json5
|
||||
{
|
||||
name: "blog_post_light",
|
||||
extends: "blog_post",
|
||||
omit: ["body"],
|
||||
}
|
||||
```
|
||||
|
||||
**Partial** – make all inherited fields optional:
|
||||
|
||||
```json5
|
||||
{
|
||||
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 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:
|
||||
|
||||
```json5
|
||||
{
|
||||
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:
|
||||
|
||||
```json5
|
||||
// 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
|
||||
|
||||
```bash
|
||||
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:
|
||||
|
||||
```bash
|
||||
# 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
|
||||
|
||||
```bash
|
||||
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
|
||||
|
||||
```bash
|
||||
GET /api/content/blog_post?_sort=created_at&_order=desc
|
||||
```
|
||||
|
||||
### Pagination
|
||||
|
||||
```bash
|
||||
GET /api/content/blog_post?_page=2&_per_page=10
|
||||
```
|
||||
|
||||
Response:
|
||||
|
||||
```json
|
||||
{
|
||||
"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`):
|
||||
|
||||
```bash
|
||||
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:**
|
||||
|
||||
```bash
|
||||
# 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
|
||||
|
||||
```bash
|
||||
# Default (info)
|
||||
cargo run
|
||||
|
||||
# Debug
|
||||
RUST_LOG=rustycms=debug,tower_http=debug cargo run
|
||||
|
||||
# Warnings only
|
||||
RUST_LOG=warn cargo run
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
Reference in New Issue
Block a user