# 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 - [Rust](https://www.rust-lang.org/tools/install) (1.70+) - [Node.js](https://nodejs.org/) (for Admin UI, optional) ## Quick start ```bash cd rustycms cargo run ``` The server starts at `http://127.0.0.1:3000`. **Start API + Admin UI and open browser:** ```bash ./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: ```bash 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`: ```bash 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: ```bash 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 | ```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. 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:** ```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/ ├── 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. ```bash # Export one .schema.json per content type into ./schemas cargo run --bin export-json-schema # Also write .vscode/settings.json so content//*.json5 uses schemas/.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", 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`) | | `omit` | string[] | Exclude these fields from parent (`Omit`) | | `partial` | boolean | Make all inherited fields optional (`Partial`) | | `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: ```json5 { 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: ```json5 { 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: ```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", 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: ```bash 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//` or, for localized content, `content///` (e.g. `content/de/page/`). The filename (without extension) 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 (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 ```bash GET /api/assets ``` ```json { "assets": [ { "filename": "hero.jpg", "url": "/api/assets/hero.jpg", "mime_type": "image/jpeg", "size": 102400 } ], "total": 1 } ``` #### Upload an asset ```bash curl -X POST http://localhost:3000/api/assets \ -H "X-API-Key: your-key" \ -F "file=@/path/to/hero.jpg" ``` Response (201): ```json { "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 ```bash 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: ```bash GET /api/transform?url=http://localhost:3000/api/assets/hero.jpg&w=400&h=300&format=webp ``` #### Delete an asset ```bash 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 1–100 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: ```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 ` - Header: `X-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 ` or `X-API-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