Files
rustycms/README.md

521 lines
20 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 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:
```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 1100. 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