tauri_plugin_graphql/lib.rs
1// Copyright 2022 Jonas Kruckenberg
2// SPDX-License-Identifier: MIT
3
4//! This crate contains a Tauri plugin used to expose a [`async_graphql`]
5//! GraphQL endpoint through Tauri's IPC system. This plugin can be used as
6//! safer alternative to Tauri's existing Command API since both the Rust and
7//! JavaScript side of the interface can be generated from a common schema.
8//!
9//! ## Rationale
10//!
11//! Especially in bigger projects that have specialized teams for the Frontend
12//! and Rust core the existing command API falls short of being an optimal
13//! solution. The Frontend is tightly coupled through `invoke()` calls to
14//! backend commands, but there is no type-safety to alert Frontend developers
15//! to changes in command signatures. This results in a very brittle interface
16//! where changes on the Rust side will inadvertently break code in the
17//! Frontend. This problem is similar exiting REST APIs, where the absence of a
18//! formal contract between the server and the frontend makes future changes
19//! very difficult.
20//!
21//! We can employ the same techniques used in traditional web development and
22//! use shared schema that governs which types, methods, etc. are
23//! available. GraphQL is such a schema language.
24//!
25//! ## Examples
26//!
27//! For the following examples, it is assumed you are familiar with [`Tauri
28//! Commands`][`Commands`], [`Events`] and [`GraphQL`].
29//!
30//! ### Queries
31//!
32//! An example app that implements a very simple read-only todo-app using
33//! GraphQL:
34//!
35//! ```rust
36//! use async_graphql::{Schema, EmptySubscription, EmptyMutation, Object, SimpleObject, Result as GraphQLResult};
37//!
38//! #[derive(SimpleObject, Debug, Clone)]
39//! struct ListItem {
40//! id: i32,
41//! text: String
42//! }
43//!
44//! impl ListItem {
45//! pub fn new(text: String) -> Self {
46//! Self {
47//! id: rand::random::<i32>(),
48//! text
49//! }
50//! }
51//! }
52//!
53//! struct Query;
54//!
55//! #[Object]
56//! impl Query {
57//! async fn list(&self) -> GraphQLResult<Vec<ListItem>> {
58//! let item = vec![
59//! ListItem::new("foo".to_string()),
60//! ListItem::new("bar".to_string())
61//! ];
62//!
63//! Ok(item)
64//! }
65//! }
66//!
67//! let schema = Schema::new(
68//! Query,
69//! EmptyMutation,
70//! EmptySubscription,
71//! );
72//!
73//! tauri::Builder::default()
74//! .plugin(tauri_plugin_graphql::init(schema));
75//! ```
76//!
77//! ### Mutations
78//!
79//! GraphQL mutations provide a way to update or create state in the Core.
80//!
81//! Similarly to queries, mutations have access to a context object and can
82//! manipulate windows, menus or global state.
83//!
84//! ```rust
85//! use async_graphql::{Schema, Object, Context, EmptySubscription, EmptyMutation, SimpleObject, Result as GraphQLObject};
86//! use tauri::{AppHandle, Manager};
87//! use std::sync::Mutex;
88//!
89//! #[derive(Debug, Default)]
90//! struct List(Mutex<Vec<ListItem>>);
91//!
92//! #[derive(SimpleObject, Debug, Clone)]
93//! struct ListItem {
94//! id: i32,
95//! text: String
96//! }
97//!
98//! impl ListItem {
99//! pub fn new(text: String) -> Self {
100//! Self {
101//! id: rand::random::<i32>(),
102//! text
103//! }
104//! }
105//! }
106//!
107//! struct Query;
108//!
109//! #[Object]
110//! impl Query {
111//! async fn list(&self, ctx: &Context<'_>) -> GraphQLObject<Vec<ListItem>> {
112//! let app = ctx.data::<AppHandle>().unwrap();
113//!
114//! let list = app.state::<List>();
115//! let list = list.0.lock().unwrap();
116//!
117//! let items = list.iter().cloned().collect::<Vec<_>>();
118//!
119//! Ok(items)
120//! }
121//! }
122//!
123//! struct Mutation;
124//!
125//! #[Object]
126//! impl Mutation {
127//! async fn add_entry(&self, ctx: &Context<'_>, text: String) -> GraphQLObject<ListItem> {
128//! let app = ctx.data::<AppHandle>().unwrap();
129//!
130//! let list = app.state::<List>();
131//! let mut list = list.0.lock().unwrap();
132//!
133//! let item = ListItem::new(text);
134//!
135//! list.push(item.clone());
136//!
137//! Ok(item)
138//! }
139//! }
140//!
141//! let schema = Schema::new(
142//! Query,
143//! Mutation,
144//! EmptySubscription,
145//! );
146//!
147//! tauri::Builder::default()
148//! .plugin(tauri_plugin_graphql::init(schema))
149//! .setup(|app| {
150//! app.manage(List::default());
151//!
152//! Ok(())
153//! });
154//! ```
155//!
156//! ### Subscriptions
157//!
158//! GraphQL subscriptions are a way to push real-time data to the Frontend.
159//! Similarly to queries, a client can request a set of fields, but instead of
160//! immediately returning a single answer, a new result is sent to the Frontend
161//! every time the Core sends one.
162//!
163//! Subscription resolvers should be async and must return a [`Stream`].
164//!
165//! ```rust
166//! use async_graphql::{
167//! futures_util::{self, stream::Stream},
168//! Schema, Object, Subscription, EmptySubscription,
169//! EmptyMutation, SimpleObject, Result as GraphQLResult
170//! };
171//!
172//! struct Query;
173//!
174//! #[Object]
175//! impl Query {
176//! async fn hello_world(&self) -> GraphQLResult<&str> {
177//! Ok("Hello World!")
178//! }
179//! }
180//!
181//! struct Subscription;
182//!
183//! #[Subscription]
184//! impl Subscription {
185//! async fn hello_world(&self) -> impl Stream<Item = &str> {
186//! futures_util::stream::iter(vec!["Hello", "World!"])
187//! }
188//! }
189//!
190//! let schema = Schema::new(
191//! Query,
192//! EmptyMutation,
193//! Subscription,
194//! );
195//!
196//! tauri::Builder::default()
197//! .plugin(tauri_plugin_graphql::init(schema));
198//! ```
199//!
200//! ## Stability
201//!
202//! To work around limitations with the current command system, this plugin
203//! directly implements an invoke handler instead of reyling on the
204//! [`tauri::generate_handler`] macro.
205//! Since the invoke handler implementation is not considered stable and might
206//! change between releases **this plugin has no backwards compatibility
207//! guarantees**.
208//!
209//! [`Stream`]: https://docs.rs/futures-util/latest/futures_util/stream/trait.Stream.html
210//! [`Commands`]: https://tauri.studio/docs/guides/command
211//! [`Events`]: https://tauri.studio/docs/guides/events
212//! [`GraphQL`]: https://graphql.org
213
214pub use async_graphql;
215use async_graphql::{
216 futures_util::StreamExt, BatchRequest, ObjectType, Request, Schema, SubscriptionType,
217};
218use serde::Deserialize;
219#[cfg(feature = "graphiql")]
220use std::net::SocketAddr;
221use tauri::{
222 plugin::{self, TauriPlugin},
223 Invoke, InvokeError, Manager, Runtime,
224};
225
226fn invoke_handler<R, Query, Mutation, Subscription>(
227 schema: Schema<Query, Mutation, Subscription>,
228) -> impl Fn(Invoke<R>)
229where
230 R: Runtime,
231 Query: ObjectType + 'static,
232 Mutation: ObjectType + 'static,
233 Subscription: SubscriptionType + 'static,
234{
235 move |invoke| {
236 let window = invoke.message.window();
237
238 let schema = schema.clone();
239
240 match invoke.message.command() {
241 "graphql" => invoke.resolver.respond_async(async move {
242 let req: BatchRequest = serde_json::from_value(invoke.message.payload().clone())
243 .map_err(InvokeError::from_serde_json)?;
244
245 let resp = schema
246 .execute_batch(req.data(window.app_handle()).data(window))
247 .await;
248
249 let str = serde_json::to_string(&resp).map_err(InvokeError::from_serde_json)?;
250
251 Ok((str, resp.is_ok()))
252 }),
253 "subscriptions" => invoke.resolver.respond_async(async move {
254 let req: SubscriptionRequest = serde_json::from_value(invoke.message.payload().clone())
255 .map_err(InvokeError::from_serde_json)?;
256
257 let subscription_window = window.clone();
258 let mut stream = schema.execute_stream(req.inner.data(window.app_handle()).data(window));
259
260 let event_id = &format!("graphql://{}", req.id);
261
262 while let Some(result) = stream.next().await {
263 let str = serde_json::to_string(&result).map_err(InvokeError::from_serde_json)?;
264
265 subscription_window.emit(event_id, str)?;
266 }
267 subscription_window.emit(event_id, Option::<()>::None)?;
268
269 Ok(())
270 }),
271 cmd => invoke.resolver.reject(format!(
272 "Invalid endpoint \"{}\". Valid endpoints are: \"graphql\", \"subscriptions\".",
273 cmd
274 )),
275 }
276 }
277}
278
279/// Initializes the GraphQL plugin.
280///
281/// This plugin exposes a async-graphql endpoint via Tauri's IPC system,
282/// allowing the frontend to invoke backend functionality through GraphQL.
283/// **This does not open a web server.**
284///
285/// The `schema` argument must be a valid [`async_graphql::Schema`].
286///
287/// ## Example
288///
289/// ```rust
290/// use async_graphql::{Schema, Object, EmptyMutation, EmptySubscription, SimpleObject, Result as GraphQLResult};
291///
292/// #[derive(SimpleObject)]
293/// struct User {
294/// id: i32,
295/// name: String
296/// }
297///
298/// struct Query;
299///
300/// // Implement resolvers for all possible queries.
301/// #[Object]
302/// impl Query {
303/// async fn me(&self) -> GraphQLResult<User> {
304/// Ok(User {
305/// id: 1,
306/// name: "Luke Skywalker".to_string(),
307/// })
308/// }
309/// }
310///
311/// let schema = Schema::new(
312/// Query,
313/// EmptyMutation,
314/// EmptySubscription,
315/// );
316///
317/// tauri::Builder::default()
318/// .plugin(tauri_plugin_graphql::init(schema));
319/// ```
320pub fn init<R, Query, Mutation, Subscription>(
321 schema: Schema<Query, Mutation, Subscription>,
322) -> TauriPlugin<R>
323where
324 R: Runtime,
325 Query: ObjectType + 'static,
326 Mutation: ObjectType + 'static,
327 Subscription: SubscriptionType + 'static,
328{
329 plugin::Builder::new("graphql")
330 .invoke_handler(invoke_handler(schema))
331 .build()
332}
333
334/// Initializes the GraphQL plugin and GraphiQL IDE.
335///
336/// This plugin exposes a async-graphql endpoint via Tauri's IPC system,
337/// allowing the frontend to invoke backend functionality through GraphQL.
338/// While the regular `init` function does not open a web server, the GraphiQL
339/// client is exposed over http.
340///
341/// The `schema` argument must be a valid [`async_graphql::Schema`].
342///
343/// ## Example
344///
345/// ```rust
346/// use async_graphql::{Schema, Object, EmptyMutation, EmptySubscription, SimpleObject, Result as GraphQLResult};
347///
348/// #[derive(SimpleObject)]
349/// struct User {
350/// id: i32,
351/// name: String
352/// }
353///
354/// struct Query;
355///
356/// // Implement resolvers for all possible queries.
357/// #[Object]
358/// impl Query {
359/// async fn me(&self) -> GraphQLResult<User> {
360/// Ok(User {
361/// id: 1,
362/// name: "Luke Skywalker".to_string(),
363/// })
364/// }
365/// }
366///
367/// let schema = Schema::new(
368/// Query,
369/// EmptyMutation,
370/// EmptySubscription,
371/// );
372///
373/// tauri::Builder::default()
374/// .plugin(tauri_plugin_graphql::init_with_graphiql(schema, ([127,0,0,1], 8080)));
375/// ```
376#[cfg(feature = "graphiql")]
377pub fn init_with_graphiql<R, Query, Mutation, Subscription>(
378 schema: Schema<Query, Mutation, Subscription>,
379 graphiql_addr: impl Into<SocketAddr>,
380) -> TauriPlugin<R>
381where
382 R: Runtime,
383 Query: ObjectType + 'static,
384 Mutation: ObjectType + 'static,
385 Subscription: SubscriptionType + 'static,
386{
387 use async_graphql::http::GraphiQLSource;
388 use async_graphql_warp::{GraphQLBadRequest, GraphQLResponse};
389 use http::StatusCode;
390 use std::convert::Infallible;
391 use warp::{http::Response as HttpResponse, Filter, Rejection};
392
393 let graphiql_addr: SocketAddr = graphiql_addr.into();
394
395 plugin::Builder::new("graphql")
396 .invoke_handler(invoke_handler(schema.clone()))
397 .setup(move |_| {
398 let graphql_post = async_graphql_warp::graphql(schema).and_then(
399 |(schema, request): (
400 Schema<Query, Mutation, Subscription>,
401 async_graphql::Request,
402 )| async move {
403 Ok::<_, Infallible>(GraphQLResponse::from(schema.execute(request).await))
404 },
405 );
406
407 let graphiql = warp::path::end().and(warp::get()).map(move || {
408 HttpResponse::builder()
409 .header("content-type", "text/html")
410 .body(
411 GraphiQLSource::build()
412 .endpoint(&graphiql_addr.to_string())
413 .finish(),
414 )
415 });
416
417 let routes = graphiql
418 .or(graphql_post)
419 .recover(|err: Rejection| async move {
420 if let Some(GraphQLBadRequest(err)) = err.find() {
421 return Ok::<_, Infallible>(warp::reply::with_status(
422 err.to_string(),
423 StatusCode::BAD_REQUEST,
424 ));
425 }
426
427 Ok(warp::reply::with_status(
428 "INTERNAL_SERVER_ERROR".to_string(),
429 StatusCode::INTERNAL_SERVER_ERROR,
430 ))
431 });
432
433 println!("GraphiQL IDE: {}", graphiql_addr);
434
435 tauri::async_runtime::spawn(warp::serve(routes).run(graphiql_addr));
436
437 Ok(())
438 })
439 .build()
440}
441
442#[derive(Debug, Deserialize)]
443struct SubscriptionRequest {
444 #[serde(flatten)]
445 inner: Request,
446 id: u32,
447}