ocre/lib.rs
1//! Ocre: a Rails-like Rust web framework for Cloudflare Workers, built to run
2//! on the Workers free plan and to be written by AI agents.
3//!
4//! An Ocre app is one Worker compiled to WebAssembly. Requests go through
5//! [`serve`], which runs a plain [axum](https://docs.rs/axum) router with
6//! sessions, CSRF protection, CORS and security headers. Handlers reach the
7//! Worker's bindings (D1, KV, R2, Queues, Durable Objects, email) through the
8//! per-request [`Ctx`]. The `ocre` command-line tool (crate `ocre-cli`)
9//! generates the app, its models, scaffolds and migrations, and deploys it.
10//!
11//! Guides and the generated app's conventions live in the repository
12//! [README](https://github.com/tgeselle/ocre.rs#readme); a one-page list of
13//! every public item is in `docs/api-index.md`.
14//!
15//! # Design rules
16//!
17//! - **Plain axum handlers.** Every Ocre type is `Send`, so handlers never
18//! need `#[worker::send]`. Extractors ([`Session`], [`Flash`], [`Json`],
19//! [`Page`], [`storage::Multipart`], [`i18n::I18n`], [`cache::Conditional`])
20//! and responses ([`Created`], [`cache::CacheControl`], [`cache::ETag`])
21//! are ordinary axum types.
22//! - **One way to do each thing.** SQL with `?N` placeholders and
23//! [`params!`], askama templates compiled at build time, htmx for
24//! interactivity, a single [`Error`] type that knows its HTTP status.
25//! - **Errors name the fix.** A missing binding answers 500 and logs which
26//! `cloudflare.config.ts` entry to add; internal details are logged, never shown
27//! to users.
28//! - **Free plan first.** Nothing costs a request, a KV write or a database
29//! row unless the app asks for it: sessions live in an encrypted cookie
30//! (no storage), static files are served by Workers Static Assets before
31//! the Worker runs, R2 downloads stream without passing through
32//! WebAssembly, and features that add binary size or startup CPU
33//! (GraphQL, realtime) are opt-in cargo features. The free plan allows
34//! 10 ms of CPU per request; functions note their cost in D1 rows, KV
35//! operations, Queue operations, R2 operations or CPU where it matters.
36//!
37//! # Modules
38//!
39//! | Module | Contents |
40//! |---|---|
41//! | crate root | [`serve`], [`Ctx`], [`Db`], [`params!`] and [`Query`] / [`Paginated`] (D1), [`Error`] / [`Result`], [`Json`] / [`ApiError`] / [`Page`] (JSON APIs), [`Session`] / [`Flash`] / [`Cookies`], [`Validator`], [`NestedForm`] (bracketed form names), request helpers ([`Format`], [`RemoteIp`], [`RequestId`], [`redirect_back`]), `render` / `error_page` / `Htmx` / `HxRedirect` (feature `html`), serde helpers ([`optional`], [`patch`], [`bool_from_sql`], ...) |
42//! | [`bulk`] | Many rows in one D1 statement: `bulk::insert`, `upsert` and `update` (one JSON parameter, `json_each`) |
43//! | [`cache`] | Read-through values in Workers KV, `Cache-Control`, `ETag` and `304 Not Modified` |
44//! | [`config`] | Typed app settings from Worker variables and secrets (`ctx.config::<Settings>()`), the environment (development or production) |
45//! | [`encryption`] | Encrypted model columns (AES-256-GCM keyed from `SECRET_KEY_BASE`), deterministic for lookups |
46//! | [`errors`] | Error reporting (Rails' `Rails.error`): `ctx.errors().report / handle / record`, subscribers such as Sentry |
47//! | [`events`] | Structured events (Rails' `Rails.event`): `ctx.events().notify(name, payload)`, tags, context, subscribers |
48#![cfg_attr(
49 feature = "html",
50 doc = "| [`filters`] | Ocre's view helpers as askama filters: `{{ price\\|number_to_currency(\"$\") }}` (feature `html`) |"
51)]
52#![cfg_attr(
53 not(feature = "html"),
54 doc = "| `filters` | View helpers as askama filters (feature `html`, off in this build) |"
55)]
56#![cfg_attr(
57 feature = "graphql",
58 doc = "| [`graphql`] | `/graphql` endpoint and GraphiQL for an async-graphql schema (feature `graphql`) |"
59)]
60#![cfg_attr(
61 not(feature = "graphql"),
62 doc = "| `graphql` | `/graphql` endpoint and GraphiQL (feature `graphql`, off in this build) |"
63)]
64//! | [`helpers`] | Rails' view helpers: numbers (`number_to_currency`...), times (`time_ago_in_words`, `strftime`), text (`excerpt`, `highlight`) |
65//! | [`i18n`] | Translations from `locales/*.yml`, plurals, the request's locale |
66//! | [`jobs`] | Background jobs on Cloudflare Queues, scheduled tasks on Cron Triggers |
67//! | [`jwt`] | HS256 JSON Web Tokens for API clients |
68//! | [`log`] | Structured logging to Workers Logs: levels, request-scoped fields (`ctx.log()`), JSON lines |
69//! | [`mail`] | Sending email (log, Resend, Cloudflare adapters) and receiving it from Email Routing |
70//! | [`oauth`] | "Sign in with GitHub / Google": OAuth 2.0 code flow with PKCE |
71//! | [`password`] | PBKDF2-HMAC-SHA256 password digests |
72#![cfg_attr(
73 feature = "push",
74 doc = "| [`push`] | Web push notifications: VAPID keys, RFC 8291 encryption, sending (feature `push`) |"
75)]
76#![cfg_attr(not(feature = "push"), doc = "| `push` | Web push notifications (feature `push`, off in this build) |")]
77#![cfg_attr(
78 feature = "realtime",
79 doc = "| [`realtime`] | WebSocket channels on a Durable Object, htmx broadcasts (feature `realtime`) |"
80)]
81#![cfg_attr(
82 not(feature = "realtime"),
83 doc = "| `realtime` | WebSocket channels on a Durable Object (feature `realtime`, off in this build) |"
84)]
85//! | [`replicas`] | D1 read replicas: reads from a nearby copy, each visitor still reading their own writes (`D1_REPLICAS=on`) |
86//! | [`security`] | Content-Security-Policy (nonces), Permissions-Policy, rate limits, safe redirects, `sanitize` / `strip_tags`, log filtering, HTTP Basic auth |
87//! | [`seo`] | JSON-LD (`seo::json_ld`), `/sitemap.xml` (`Sitemap`, with `hreflang` alternates) and `/llms.txt` (`LlmsTxt`) |
88//! | [`sse`] | Server-Sent Events: stream events to the browser as they happen |
89//! | [`storage`] | Files in Cloudflare R2: multipart uploads, attachments, streamed downloads |
90//! | [`token`] | Random tokens for emailed links and API keys, stored as SHA-256 digests |
91//! | [`webhooks`] | Signed webhooks: HMAC-SHA256 and Standard Webhooks verification, signing outgoing calls, each event processed once |
92//!
93//! # A complete app
94//!
95//! A generated app's `src/lib.rs` (crate type `cdylib`) is the Worker entry
96//! point plus an axum router whose state is [`Ctx`]:
97//!
98//! ```no_run
99//! use axum::{Router, extract::{Path, State}, routing::get};
100//! use ocre::{ApiResult, Ctx, Json, OptionExt, params};
101//! use serde::{Deserialize, Serialize};
102//! use worker::{Context, Env, HttpRequest, event};
103//!
104//! #[event(fetch)]
105//! async fn fetch(req: HttpRequest, env: Env, _ctx: Context) -> worker::Result<worker::web_sys::Response> {
106//! ocre::serve(routes(), req, env).await
107//! }
108//!
109//! fn routes() -> Router<Ctx> {
110//! Router::new().route("/up", get(up)).route("/posts/{id}", get(show))
111//! }
112//!
113//! async fn up() -> &'static str {
114//! "OK"
115//! }
116//!
117//! #[derive(Serialize, Deserialize)]
118//! struct Post {
119//! id: i64,
120//! title: String,
121//! }
122//!
123//! // GET /posts/1: the row as JSON, or a JSON 404 when there is none. `ApiResult`
124//! // answers errors as JSON; HTML pages return `ocre::Result` (feature `html`).
125//! async fn show(State(ctx): State<Ctx>, Path(id): Path<i64>) -> ApiResult<Json<Post>> {
126//! let post = ctx.db()?.first::<Post>("SELECT id, title FROM posts WHERE id = ?1", params![id]).await?;
127//! Ok(Json(post.or_404()?))
128//! }
129//! # fn main() {}
130//! ```
131//!
132//! `cloudflare.config.ts` binds the D1 database as `DB`; `ocre new` writes it, and
133//! `ocre dev` / `ocre deploy` run it.
134//!
135//! # Cargo features
136//!
137//! | Feature | Default | Enables |
138//! |---|---|---|
139//! | `html` | yes | askama templates (`render`), HTML error pages, the `Htmx` extractor. API-only apps (`ocre new --api`) turn it off |
140//! | `graphql` | no | The `graphql` module (async-graphql). About 1.1 MB more WebAssembly and 20-60 ms of CPU when a Worker instance starts |
141//! | `realtime` | no | The `realtime` module and the exported `OcreChannel` Durable Object class (WebSocket Hibernation) |
142#![cfg_attr(docsrs, feature(doc_cfg))]
143#![warn(missing_docs)]
144#![warn(rustdoc::broken_intra_doc_links)]
145
146mod api;
147pub mod bulk;
148pub mod cache;
149mod clock;
150pub mod config;
151mod cookies;
152pub mod encryption;
153mod error;
154pub mod errors;
155pub mod events;
156mod fields;
157#[cfg(feature = "html")]
158#[cfg_attr(docsrs, doc(cfg(feature = "html")))]
159pub mod filters;
160mod form;
161#[cfg(feature = "graphql")]
162#[cfg_attr(docsrs, doc(cfg(feature = "graphql")))]
163pub mod graphql;
164pub mod helpers;
165#[cfg(feature = "html")]
166mod htmx;
167pub mod i18n;
168mod instrument;
169pub mod jobs;
170pub mod jwt;
171pub mod log;
172pub mod mail;
173mod names;
174pub mod oauth;
175pub mod password;
176mod protect;
177#[cfg(feature = "push")]
178#[cfg_attr(docsrs, doc(cfg(feature = "push")))]
179pub mod push;
180mod query;
181#[cfg(feature = "realtime")]
182#[cfg_attr(docsrs, doc(cfg(feature = "realtime")))]
183pub mod realtime;
184pub mod replicas;
185mod request;
186mod runtime;
187pub mod security;
188pub mod seo;
189mod session;
190mod sql;
191pub mod sse;
192pub mod storage;
193#[cfg(test)]
194#[path = "../tests/support.rs"]
195mod support;
196#[cfg(all(feature = "testing", not(target_arch = "wasm32")))]
197#[cfg_attr(docsrs, doc(cfg(feature = "testing")))]
198pub mod testing;
199pub mod token;
200mod validate;
201#[cfg(feature = "html")]
202mod view;
203pub mod webhooks;
204
205pub use api::{ApiError, ApiResult, Created, Json, Page, PageLinks};
206pub use clock::now;
207pub use cookies::Cookies;
208pub use error::{Error, OptionExt, Result};
209pub use fields::{optional, patch, patch_json};
210pub use form::NestedForm;
211#[cfg(feature = "html")]
212#[cfg_attr(docsrs, doc(cfg(feature = "html")))]
213pub use htmx::{Htmx, HxRedirect};
214pub use protect::{ALLOWED_HOSTS, ALLOWED_ORIGINS};
215pub use query::{Batches, Direction, Paginated, Query, escape_like};
216pub use request::{Format, Markdown, RemoteIp, RequestId, encode_path, redirect_back, remote_ip};
217pub use runtime::{Ctx, Db, serve, sleep};
218/// JSON values (`serde_json::Value`, the `json!` macro) for `json` fields,
219/// without adding `serde_json` to the app.
220pub use serde_json;
221pub use session::{Flash, SECRET_KEY_BASE, SECRET_KEY_BASE_PREVIOUS, SESSION_COOKIE, Session};
222pub use sql::{IntoParam, MAX_SAFE_INTEGER, Param, Statement, bool_from_sql, json_from_sql, optional_json_from_sql};
223pub use validate::{FieldError, Validator};
224#[cfg(feature = "html")]
225#[cfg_attr(docsrs, doc(cfg(feature = "html")))]
226pub use view::{ErrorPage, error_page, render};
227
228/// Builds the parameter list of a [`Db`] query: `params![title, id]`.
229///
230/// Each value goes through [`IntoParam`], so strings, integers, floats,
231/// booleans, `Option`s of those (`None` binds `NULL`) and JSON values can be
232/// mixed. Values bind to the `?1`, `?2`, ... placeholders in order. The result
233/// is a `Vec<Param>`, the type every [`Db`] method and [`Statement::new`]
234/// take; `params![]` binds nothing.
235///
236/// # Examples
237///
238/// ```
239/// use ocre::{Param, params};
240///
241/// let title = "Hello";
242/// let published: Option<bool> = None;
243/// let params: Vec<Param> = params![title, 42, published];
244/// assert_eq!(params.len(), 3);
245/// let none: Vec<Param> = params![];
246/// assert!(none.is_empty());
247/// ```
248#[macro_export]
249macro_rules! params {
250 ($($value:expr),* $(,)?) => {
251 ::std::vec![$($crate::IntoParam::into_param($value)),*]
252 };
253}