Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 8 additions & 3 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ jobs:
# github_token: ${{ secrets.GITHUB_TOKEN }}

rust-validations:
name: Generated rust validations 🦀
name: Generated rust 🦀
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
Expand All @@ -86,5 +86,10 @@ jobs:
- name: Install rust toolchain
run: rustup component add rustfmt clippy

- name: Type check the generated validation package
run: poetry run make verify-validations-rust
- name: Install openapi-generator
# Without it, verify.sh skips the leg that regenerates both halves, and only
# the committed crates get checked.
run: npm install -g @openapitools/openapi-generator-cli

- name: Type check the generated rust
run: poetry run make verify-rust
11 changes: 8 additions & 3 deletions .github/workflows/pr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ jobs:
# github-token: ${{ secrets.GITHUB_TOKEN }}

rust-validations:
name: Generated rust validations 🦀
name: Generated rust 🦀
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
Expand All @@ -76,5 +76,10 @@ jobs:
- name: Install rust toolchain
run: rustup component add rustfmt clippy

- name: Type check the generated validation package
run: poetry run make verify-validations-rust
- name: Install openapi-generator
# Without it, verify.sh skips the leg that regenerates both halves, and only
# the committed crates get checked.
run: npm install -g @openapitools/openapi-generator-cli

- name: Type check the generated rust
run: poetry run make verify-rust
40 changes: 36 additions & 4 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,12 @@ RM := $(shell which RM)

CLIENT_OUTDIR := $(shell mktemp -d)
SERVER_CLIENT_OUTDIR := $(shell mktemp -d)
OPENAPI_GEN := $(shell which openapi-generator)
# brew installs it as openapi-generator, the npm wrapper as openapi-generator-cli.
OPENAPI_GEN := $(shell which openapi-generator || which openapi-generator-cli)
FIRESTONE := $(shell which firestone)

ADDRESSBOOK_DIR := examples/addressbook
SERVER_RS_DIR := examples/addressbook/server-rs
RESOURCES := ${ADDRESSBOOK_DIR}/addressbook.yaml,${ADDRESSBOOK_DIR}/person.yaml,${ADDRESSBOOK_DIR}/postal_codes.yaml
OPENAPI_DOC := ${ADDRESSBOOK_DIR}/openapi.yaml

Expand All @@ -17,16 +19,17 @@ CLIENT_PKG := addressbook.client
MAIN_FILE := ${ADDRESSBOOK_DIR}/main.py
STREAMLIT_FILE := ${ADDRESSBOOK_DIR}/addressbook/webui/pages.py

.PHONY: gen-openapi gen-server gen-client gen-cli gen-validations gen-validations-rust verify-validations-rust
.PHONY: gen-openapi gen-server gen-server-rust gen-client gen-cli gen-validations gen-validations-rust verify-rust

help:
@echo "gen-openapi: Generate OpenAPI file from resources."
@echo "gen-server: Generate FastAPI server code."
@echo "gen-server-rust: Generate the axum server and its implementation."
@echo "gen-client: Generate Python client code."
@echo "gen-cli: Generate CRUD Python (Click-based) CLI."
@echo "gen-validations: Generate the python server side validation package."
@echo "gen-validations-rust: Generate the rust server side validation package."
@echo "verify-validations-rust: Type check the generated rust validation package."
@echo "verify-rust: Type check firestone's generated rust."

gen-openapi: ${FIRESTONE}
${FIRESTONE} generate \
Expand Down Expand Up @@ -80,6 +83,32 @@ gen-validations: ${FIRESTONE}
validations \
--output-dir ${ADDRESSBOOK_DIR}/addressbook/validation

gen-server-rust: ${OPENAPI_GEN} ${FIRESTONE} gen-openapi
@echo "Generating the axum server from the OpenAPI document"
${OPENAPI_GEN} generate \
-i ${OPENAPI_DOC} \
-g rust-axum \
-o ${SERVER_RS_DIR}/api \
--skip-validate-spec \
-p packageName=addressbook_api,packageVersion=1.0.0

@echo "Generating the implementation of the traits it declares"
@# --force: this example is generator output, so it is refreshed wholesale.
@# A real project leaves it off, and keeps the Backend it wrote.
${FIRESTONE} generate \
--title 'Example person and addressbook API' \
--description 'Example person and addressbook API' \
--resources ${RESOURCES} \
--version 1.0 \
server \
--pkg addressbook_server \
--api-pkg addressbook_api \
--output-dir ${SERVER_RS_DIR}/app \
--force

@echo "Formatting"
cd ${SERVER_RS_DIR} && cargo fmt --all

gen-validations-rust: ${FIRESTONE}
${FIRESTONE} generate \
--title 'Example person and addressbook API' \
Expand All @@ -90,7 +119,7 @@ gen-validations-rust: ${FIRESTONE}
--language rust \
--output-dir ${ADDRESSBOOK_DIR}/validation-rs/src/validation

verify-validations-rust: ${FIRESTONE}
verify-rust: ${FIRESTONE}
FIRESTONE=${FIRESTONE} test/rust/verify.sh

gen-cli: $(FIRESTONE)
Expand All @@ -117,6 +146,9 @@ gen-cli: $(FIRESTONE)
--output-dir ${ADDRESSBOOK_DIR}/addressbook/cli \
--as-modules

@echo "Formatting the generated CLI"
black ${MAIN_FILE} ${ADDRESSBOOK_DIR}/addressbook/cli

gen-streamlit: $(FIRESTONE)
@echo "Creating directory for ${STREAMLIT_FILE}"
${MKDIR} -pv $(shell ${DIRNAME} ${STREAMLIT_FILE})
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ Firestone lives alongside a couple of sibling projects that share tooling and de
| Python CLI | `firestone generate … cli` | Click-based CRUD utilities (`main.py` or modules) | Internal tooling, scripted batch jobs |
| Streamlit UI | `firestone generate … streamlit` | Streamlit pages/modules | Lightweight admin dashboards over your API |
| Validations | `firestone generate … validations` | Server side validation package (Python or Rust) | Enforcing cross-resource rules declared in the schema |
| Server impl | `firestone generate … server` | Implementation of an `openapi-generator -g rust-axum` server (Rust) | Standing an API up from the schema, rules enforced in the handlers |

## Quick Start

Expand Down
1 change: 1 addition & 0 deletions docs/site/content/generation-guides/_index.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ From one resource schema, you can create:

- **OpenAPI Specifications** - REST API documentation and contracts
- **AsyncAPI Specifications** - WebSocket and event-driven API docs
- **Servers** - The implementation of a rust-axum server, with your rules enforced in the handlers
- **Validation Packages** - Server side enforcement of the rules declared in your resources
- **CLI Tools** - Command-line interfaces with full CRUD operations
- **Streamlit UIs** - Interactive web interfaces for your APIs
Expand Down
22 changes: 22 additions & 0 deletions docs/site/content/generation-guides/server/_index.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
+++
title = "Server Generation"
weight = 45
description = "Generate a runnable axum server from your resource definitions, with the rules already enforced."
+++

# Server Generation

## Two Generators, One Document

A resource file already says what the paths are, which methods each exposes, what a body looks like, which operations need authentication and what rules the data has to satisfy. Turning that into a running API takes two steps, and firestone only owns one of them:

1. **`openapi-generator -g rust-axum`** turns the OpenAPI document firestone produces into the server: the router, the typed models, per-operation authentication and request validation. That is not firestone's job to duplicate.
2. **`firestone generate server`** implements the traits that server leaves behind - one required method per operation - wiring each to a `Backend` trait and enforcing the resources' validation rules on the way past.

Both read the same document, so they cannot disagree about an operation id, a model name or a response variant. What is left for you is the `Backend`, which is the one thing neither generator can know.

### 1. [Generating a Server](./generating)
Both commands, the crates they write, and what to run.

### 2. [Implementing a Backend](./backend)
The one trait you write, and how the validation rules reach your storage through it.
103 changes: 103 additions & 0 deletions docs/site/content/generation-guides/server/backend.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
+++
title = "Implementing a Backend"
linkTitle = "Backend"
weight = 2
description = "The one trait the generated server leaves you to write, and how the validation rules reach your storage through it."
+++

## The One Trait

Firestone knows every route, model and rule in your API. It cannot know your storage, and should not guess, so that is the whole of what you write:

```rust
#[async_trait]
pub trait Backend: Send + Sync + 'static {
async fn list(&self, resource: &str) -> Result<Vec<Value>, Error>;
async fn get(&self, resource: &str, key: &str) -> Result<Option<Value>, Error>;
async fn create(&self, resource: &str, body: Value) -> Result<(String, Value), Error>;
async fn replace(&self, resource: &str, key: &str, body: Value) -> Result<Value, Error>;
async fn patch(&self, resource: &str, key: &str, body: Value) -> Result<Value, Error>;
async fn delete(&self, resource: &str, key: &str) -> Result<(), Error>;

/// Find the first resource whose dotted `path` holds `value`.
async fn find(&self, resource: &str, path: &str, value: &Value)
-> Result<Option<Value>, Error>;
}
```

It is deliberately resource-agnostic: `resource` is the `kind` from the schema, so one implementation serves every resource rather than one per kind. Bodies have already been through the generated models by the time they arrive, so a malformed body was refused before your code ran.

`InMemory` is generated alongside the trait and wired into `main.rs`, so the server runs as it is. Swap it out and nothing else in the crate changes:

```rust
let api = Api::new(Arc::new(MyPostgres::new(pool)));
```

## `find` Is What the Rules Use

`find` is the one method that is not plain CRUD. It is how a validation rule reaches your storage:

```yaml
person:
references:
kind: persons
key: first_name
value: person.first_name
```

becomes `find("persons", "first_name", "Ann")` when an address naming Ann is created. `path` is a **path into the resource**, not a column name — `person.first_name` is a perfectly ordinary rule — so translating it into a query is your job and belongs in one mapping table:

```rust
const LOOKUPS: &[((&str, &str), &str)] = &[
(("persons", "first_name"), "first_name = $1"),
(("addressbook", "person.first_name"), "person->>'first_name' = $1"),
];
```

Raise on a pair you have not mapped rather than returning `None`, otherwise a typo in a schema becomes a rule that quietly rejects everything.

## Transactions

The generated handler validates and then writes, and those are two separate calls. A referenced resource can disappear in between, so for anything that matters:

- Build the backend per request around the transaction that performs the write, so the lookups and the mutation see one snapshot.
- Take the locks the check implies, or run `SERIALIZABLE` and retry.
- Keep the foreign keys. Validation turns a constraint violation into a clear 422 naming the rule; it is not the guarantee itself.

## Errors

Return `Error` and the response shape is handled for you:

| Variant | Status |
|---------|--------|
| `NotFound` | 404 |
| `Validation` | whatever the rule asked for, usually 422 |
| `NotImplemented` | 501 |
| `Internal` | 500 |

A 401 is not in that list: the generated server refuses an unauthenticated request
to a secured operation before a handler runs, from the `security` block in the
schema, so it never reaches your backend.

All of them leave as an RFC 9457 problem document served as `application/problem+json`, which is what the generated OpenAPI document declares:

```json
{
"type": "about:blank",
"title": "Validation failed",
"status": 422,
"detail": "No persons found with first_name 'Ann'.",
"violations": [
{
"rule": "person_must_exist",
"resource": "addressbook",
"field": "person.first_name",
"message": "No persons found with first_name 'Ann'."
}
]
}
```

## Next Steps

- **[Generating a Server](./generating)** - both commands, and adding your own routes
Loading
Loading