Skip to main content

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;