Readme
This commit is contained in:
1 parent
536d5b14d8
commit
427a824e45
2 files changed
+263
-2
No files matched your search
@@ -0,0 +1,250 @@
|
|||||||
|
# Dynamic Schema Records API
|
||||||
|
|
||||||
|
A lightweight, high-performance JSON records service built with **Rust**, **Axum**, and **SQLite**.
|
||||||
|
|
||||||
|
The service validates incoming JSON payloads against a dynamic schema specification defined in a JSON configuration file. Schemas support validation modes (`strict` vs. `loose`), data type constraints, and exact forced values (e.g., pinning API payload versions). Schemas can be reloaded at runtime without restarting the server.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Dynamic Schema Validation**:
|
||||||
|
- Enforce required types: `string`, `integer`, `number`, `boolean`, `object`, `array`, `null`.
|
||||||
|
- Force exact values on any field (e.g., pin `"version": 2`).
|
||||||
|
- Validation modes:
|
||||||
|
- **`strict`**: All defined fields are required, and unknown fields are rejected.
|
||||||
|
- **`loose`**: All defined fields are required, but additional unknown fields are accepted.
|
||||||
|
- **Hot-Reloadable Configuration**: Reload `config.json` via an HTTP admin endpoint with atomic swap and rollback on error.
|
||||||
|
- **Persistent Storage**: Validated records are stored with a generated UUID and ISO-8601 UTC timestamp in an SQLite database (`messages.db`).
|
||||||
|
- **Structured Logging**: Built with `tracing` outputting to standard output.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- [Rust toolchain](https://rustup.rs/) (1.75+ recommended)
|
||||||
|
- SQLite development libraries (if linking dynamically)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
The service looks for a configuration file at the path specified by the `APP_CONFIG` environment variable, defaulting to `config.json` in the current working directory.
|
||||||
|
|
||||||
|
### Example: `config.json`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mode": "strict",
|
||||||
|
"fields": {
|
||||||
|
"version": {
|
||||||
|
"type": "integer",
|
||||||
|
"value": 2
|
||||||
|
},
|
||||||
|
"message": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"enabled": {
|
||||||
|
"type": "boolean"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Schema Specification
|
||||||
|
|
||||||
|
| Property | Type | Description |
|
||||||
|
| :--- | :--- | :--- |
|
||||||
|
| `mode` | `string` | `"strict"` or `"loose"`. |
|
||||||
|
| `fields` | `object` | Map of field names to validation specifications. Must contain at least one field. |
|
||||||
|
| `fields.<name>.type` | `string` | Expected JSON type: `string`, `integer`, `number`, `boolean`, `object`, `array`, `null`. |
|
||||||
|
| `fields.<name>.value` | *any* *(optional)* | Expected exact value. Rejects any request where the value does not strictly match. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
### 1. Build and Run
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Clone the repository and enter the directory
|
||||||
|
cd your-project-name
|
||||||
|
|
||||||
|
# Create your config file
|
||||||
|
cat <<EOF > config.json
|
||||||
|
{
|
||||||
|
"mode": "strict",
|
||||||
|
"fields": {
|
||||||
|
"version": {
|
||||||
|
"type": "integer",
|
||||||
|
"value": 1
|
||||||
|
},
|
||||||
|
"message": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# Run the server (default: 127.0.0.1:3000)
|
||||||
|
cargo run
|
||||||
|
```
|
||||||
|
|
||||||
|
To specify a custom configuration file path:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
APP_CONFIG=custom_config.json cargo run
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API Reference
|
||||||
|
|
||||||
|
### Health / Service Info
|
||||||
|
- **Endpoint:** `GET /` or `GET /api/now`
|
||||||
|
- **Description:** Returns server timestamp and status payload.
|
||||||
|
- **Example Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"uuid": "4fc736f8-45e0-47da-8da9-6c30fef6a72e",
|
||||||
|
"date": "2026-10-25T10:19:00Z",
|
||||||
|
"data": {
|
||||||
|
"version": 1,
|
||||||
|
"message": "Hello from Rust!"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Create Record
|
||||||
|
- **Endpoint:** `POST /api/records`
|
||||||
|
- **Headers:** `Content-Type: application/json`
|
||||||
|
- **Request Body:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"data": {
|
||||||
|
"version": 1,
|
||||||
|
"message": "Hello, world!"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- **Response (`201 Created`):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"uuid": "f3b392b9-dc81-42cb-b9ef-dc7e90aa7ce5",
|
||||||
|
"date": "2026-10-25T10:20:15Z",
|
||||||
|
"data": {
|
||||||
|
"version": 1,
|
||||||
|
"message": "Hello, world!"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- **Error Responses (`400 Bad Request`):**
|
||||||
|
- Missing field: `Missing required field: message`
|
||||||
|
- Wrong type: `Field 'version' must be of type integer`
|
||||||
|
- Value mismatch: `Field 'version' must equal 1`
|
||||||
|
- Unknown field (in strict mode): `Unknown field: extra_field`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### List Records
|
||||||
|
- **Endpoint:** `GET /api/records`
|
||||||
|
- **Description:** Returns up to 100 most recent records ordered by `date DESC`.
|
||||||
|
- **Response (`200 OK`):**
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"uuid": "f3b392b9-dc81-42cb-b9ef-dc7e90aa7ce5",
|
||||||
|
"date": "2026-10-25T10:20:15Z",
|
||||||
|
"data": {
|
||||||
|
"version": 1,
|
||||||
|
"message": "Hello, world!"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Get Active Configuration
|
||||||
|
- **Endpoint:** `GET /api/admin/config`
|
||||||
|
- **Description:** Inspect the currently running schema and mode.
|
||||||
|
- **Response (`200 OK`):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mode": "strict",
|
||||||
|
"fields": {
|
||||||
|
"version": {
|
||||||
|
"type": "integer",
|
||||||
|
"value": 1
|
||||||
|
},
|
||||||
|
"message": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Hot-Reload Configuration
|
||||||
|
- **Endpoint:** `POST /api/admin/reload-config`
|
||||||
|
- **Description:** Reads and validates the file at `APP_CONFIG`. If valid, atomically updates the active schema in memory.
|
||||||
|
- **Response (`200 OK`):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "ok",
|
||||||
|
"mode": "strict",
|
||||||
|
"fields": 2
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- **Error Response (`422 Unprocessable Entity`):** Returned if the configuration file is unreadable, invalid JSON, or contains no fields. The active schema remains untouched.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Example Usage Workflow
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Create a valid record
|
||||||
|
curl -i -X POST http://127.0.0.1:3000/api/records \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"data": {"version": 1, "message": "First record"}}'
|
||||||
|
|
||||||
|
# 2. Modify config.json (e.g. bump required version to 2)
|
||||||
|
# Update "value": 2 in config.json...
|
||||||
|
|
||||||
|
# 3. Reload config without downtime
|
||||||
|
curl -i -X POST http://127.0.0.1:3000/api/admin/reload-config
|
||||||
|
|
||||||
|
# 4. Old version requests are now rejected
|
||||||
|
curl -i -X POST http://127.0.0.1:3000/api/records \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"data": {"version": 1, "message": "Rejected"}}'
|
||||||
|
# -> HTTP 400: Field 'version' must equal 2
|
||||||
|
|
||||||
|
# 5. New version requests succeed
|
||||||
|
curl -i -X POST http://127.0.0.1:3000/api/records \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"data": {"version": 2, "message": "Accepted"}}'
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Database Architecture
|
||||||
|
|
||||||
|
On startup, the application creates a local SQLite database named `messages.db` with the following schema:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE IF NOT EXISTS records (
|
||||||
|
uuid TEXT PRIMARY KEY,
|
||||||
|
date TEXT NOT NULL,
|
||||||
|
data TEXT NOT NULL
|
||||||
|
);
|
||||||
|
```
|
||||||
|
This project is licensed under the [MIT License](LICENSE) (or choose your preferred license).
|
||||||
|
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
This project is licensed under the MIT License.
|
||||||
+13
-2
@@ -1,4 +1,3 @@
|
|||||||
|
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::{rejection::JsonRejection, State},
|
extract::{rejection::JsonRejection, State},
|
||||||
http::StatusCode,
|
http::StatusCode,
|
||||||
@@ -34,6 +33,9 @@ enum ValidationMode {
|
|||||||
struct FieldSpec {
|
struct FieldSpec {
|
||||||
#[serde(rename = "type")]
|
#[serde(rename = "type")]
|
||||||
data_type: DataType,
|
data_type: DataType,
|
||||||
|
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
value: Option<Value>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
@@ -122,6 +124,15 @@ fn validate_data(data: &Value, schema: &SchemaConfig) -> Result<(), String> {
|
|||||||
type_name(&spec.data_type)
|
type_name(&spec.data_type)
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let Some(expected) = &spec.value {
|
||||||
|
if value != expected {
|
||||||
|
return Err(format!(
|
||||||
|
"Field '{key}' must equal {}",
|
||||||
|
expected
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if matches!(schema.mode, ValidationMode::Strict) {
|
if matches!(schema.mode, ValidationMode::Strict) {
|
||||||
@@ -405,4 +416,4 @@ async fn main() {
|
|||||||
axum::serve(listener, app)
|
axum::serve(listener, app)
|
||||||
.await
|
.await
|
||||||
.expect("HTTP server failed");
|
.expect("HTTP server failed");
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user