ocre/graphql.rs
1//! GraphQL support (feature `graphql`).
2//!
3//! Built on [async-graphql](https://docs.rs/async-graphql): [`routes`] serves
4//! `POST /graphql` and GraphiQL on `GET /graphql`; `ocre g api Post ...
5//! --graphql` generates `posts(limit, offset)`, `post(id)`, `createPost`,
6//! `updatePost` and `deletePost` resolvers that call the model, so they share
7//! its rules with the JSON API.
8//!
9//! Opt-in because it costs on the free plan: the WebAssembly binary grows by
10//! about 1.1 MB and each new Worker instance spends 20-60 ms of CPU loading
11//! it and building the schema (measured with `wrangler tail`; the free plan
12//! allows 10 ms per request, with tolerance for infrequent overruns). Build
13//! the schema once per instance and keep resolvers thin; each resolver's D1
14//! queries cost the same rows read and written as in a JSON handler.
15//!
16//! Resolvers get the request context with `ctx.data::<ocre::Ctx>()?`, and
17//! Ocre errors convert with `?`: clients see `Not found` or the bad-request
18//! message with `extensions.status` (and `extensions.fields` for validation
19//! errors), never internal details, which are logged.
20//!
21//! ```no_run
22//! use std::sync::LazyLock;
23//! use async_graphql::{Context, EmptyMutation, EmptySubscription, Object, Schema};
24//! use axum::Router;
25//! use ocre::{Ctx, Error};
26//!
27//! struct Query;
28//!
29//! #[Object]
30//! impl Query {
31//! async fn post_count(&self, ctx: &Context<'_>) -> async_graphql::Result<i64> {
32//! let db = ctx.data::<Ctx>()?.db()?;
33//! # let _ = db;
34//! // ... SELECT COUNT(*) FROM posts
35//! Err(Error::NotFound.into())
36//! }
37//! }
38//!
39//! type AppSchema = Schema<Query, EmptyMutation, EmptySubscription>;
40//! static SCHEMA: LazyLock<AppSchema> = LazyLock::new(|| Schema::new(Query, EmptyMutation, EmptySubscription));
41//!
42//! fn routes() -> Router<Ctx> {
43//! Router::new().merge(ocre::graphql::routes(|| &*SCHEMA))
44//! }
45//! # let _ = routes;
46//! ```
47
48use std::any::Any;
49
50use async_graphql::{ErrorExtensions, ObjectType, Schema, SubscriptionType};
51use axum::{
52 http::{StatusCode, header},
53 response::{Html, IntoResponse, Response},
54};
55
56use crate::Error;
57
58pub use crate::runtime::graphql_routes as routes;
59pub use async_graphql;
60
61impl From<Error> for async_graphql::Error {
62 fn from(err: Error) -> Self {
63 let public = err.into_public();
64 if let Some(internal) = &public.internal {
65 crate::error::log_internal(internal);
66 }
67 let fields = (!public.fields.is_empty()).then(|| public.fields_json());
68 async_graphql::Error::new(public.message).extend_with(|_, extensions| {
69 extensions.set("status", public.status.as_u16());
70 if let Some(fields) = fields {
71 extensions.set("fields", async_graphql::Value::from_json(fields).expect("field errors are plain JSON"));
72 }
73 })
74 }
75}
76
77/// Renders GraphiQL, the in-browser query editor, pointed at `/graphql`.
78///
79/// [`routes`] serves it on `GET /graphql`; use it directly to mount the
80/// editor elsewhere or behind a check. The page loads GraphiQL's scripts
81/// from a CDN.
82///
83/// # Examples
84///
85/// ```
86/// let page = ocre::graphql::graphiql();
87/// assert!(page.0.contains("/graphql"));
88/// ```
89pub fn graphiql() -> Html<String> {
90 Html(async_graphql::http::GraphiQLSource::build().endpoint("/graphql").finish())
91}
92
93/// Executes a `POST /graphql` JSON body against `schema` and returns the JSON response.
94///
95/// `data` is available to resolvers through `ctx.data::<T>()` ([`routes`]
96/// passes the [`Ctx`](crate::Ctx)). A body that is not a GraphQL request
97/// (`{"query": ..., "variables": ...}`) gets a 400 with
98/// `{"errors": [{"message": "invalid GraphQL request: ..."}]}`. Otherwise the
99/// status is 200 and resolver errors are in the body's `errors`, as GraphQL
100/// expects.
101///
102/// # Examples
103///
104/// ```
105/// use async_graphql::{EmptyMutation, EmptySubscription, Object, Schema};
106///
107/// struct Query;
108///
109/// #[Object]
110/// impl Query {
111/// async fn hello(&self) -> &'static str {
112/// "world"
113/// }
114/// }
115///
116/// let schema = Schema::new(Query, EmptyMutation, EmptySubscription);
117/// let response = pollster::block_on(ocre::graphql::respond(&schema, br#"{"query": "{ hello }"}"#, ()));
118/// assert_eq!(response.status(), 200);
119/// let body = pollster::block_on(axum::body::to_bytes(response.into_body(), usize::MAX)).unwrap();
120/// assert_eq!(body, r#"{"data":{"hello":"world"}}"#);
121///
122/// let response = pollster::block_on(ocre::graphql::respond(&schema, b"not json", ()));
123/// assert_eq!(response.status(), 400);
124/// ```
125pub async fn respond<Q, M, S>(schema: &Schema<Q, M, S>, body: &[u8], data: impl Any + Send + Sync) -> Response
126where
127 Q: ObjectType + 'static,
128 M: ObjectType + 'static,
129 S: SubscriptionType + 'static,
130{
131 let request: async_graphql::Request = match serde_json::from_slice(body) {
132 Ok(request) => request,
133 Err(err) => {
134 let body = serde_json::json!({ "errors": [{ "message": format!("invalid GraphQL request: {err}") }] });
135 return (StatusCode::BAD_REQUEST, axum::Json(body)).into_response();
136 }
137 };
138 let response = schema.execute(request.data(data)).await;
139 let body = serde_json::to_string(&response).expect("GraphQL responses serialize");
140 ([(header::CONTENT_TYPE, "application/json")], body).into_response()
141}
142
143#[cfg(test)]
144#[path = "../tests/graphql.rs"]
145mod tests;