Skip to main content

sova_graphql/
plugin.rs

1//! Plugin: outbound client (primary) + optional schema mount.
2
3use crate::client::GraphQlClient;
4use crate::fake::FakeGraphql;
5use sova_core::{App, Plugin};
6
7#[cfg(feature = "server")]
8use crate::server::{
9    default_graphiql_path, default_subscriptions_path, install_server, SchemaHandle,
10    ServerMountConfig,
11};
12#[cfg(feature = "server")]
13use async_graphql::{ObjectType, Schema, SubscriptionType};
14
15enum Mode {
16    Client {
17        endpoint: String,
18    },
19    Fake {
20        fake: FakeGraphql,
21        endpoint: String,
22    },
23    #[cfg(feature = "server")]
24    Server {
25        handle: SchemaHandle,
26    },
27}
28
29/// Optional outbound client installed together with server mount.
30enum Outbound {
31    Http(String),
32    FromEnv,
33}
34
35/// GraphQL plugin. Prefer [`GraphQl::client`] / [`GraphQl::fake`]; server mount is optional.
36pub struct GraphQl {
37    mode: Mode,
38    path: String,
39    path_explicit: bool,
40    graphiql: bool,
41    graphiql_explicit: bool,
42    enabled: bool,
43    #[cfg(feature = "server")]
44    outbound: Option<Outbound>,
45    #[cfg(feature = "server")]
46    graphiql_path: Option<String>,
47    #[cfg(feature = "server")]
48    graphiql_path_explicit: bool,
49    #[cfg(feature = "server")]
50    subscriptions_path: Option<String>,
51    #[cfg(feature = "server")]
52    subscriptions_disabled: bool,
53    #[cfg(feature = "server")]
54    allow_get_queries: bool,
55    #[cfg(feature = "server")]
56    sdl_path: Option<String>,
57    #[cfg(feature = "server")]
58    sdl_path_explicit: bool,
59}
60
61impl GraphQl {
62    fn server_defaults() -> Self {
63        Self {
64            mode: Mode::Client {
65                endpoint: String::new(),
66            },
67            path: "/graphql".into(),
68            path_explicit: false,
69            graphiql: cfg!(debug_assertions),
70            graphiql_explicit: false,
71            enabled: true,
72            #[cfg(feature = "server")]
73            outbound: None,
74            #[cfg(feature = "server")]
75            graphiql_path: None,
76            #[cfg(feature = "server")]
77            graphiql_path_explicit: false,
78            #[cfg(feature = "server")]
79            subscriptions_path: None,
80            #[cfg(feature = "server")]
81            subscriptions_disabled: false,
82            #[cfg(feature = "server")]
83            allow_get_queries: false,
84            #[cfg(feature = "server")]
85            sdl_path: None,
86            #[cfg(feature = "server")]
87            sdl_path_explicit: false,
88        }
89    }
90
91    /// Outbound client against a remote GraphQL HTTP endpoint.
92    pub fn client(endpoint: impl Into<String>) -> Self {
93        Self {
94            mode: Mode::Client {
95                endpoint: endpoint.into(),
96            },
97            path: "/graphql".into(),
98            path_explicit: false,
99            graphiql: false,
100            graphiql_explicit: true,
101            enabled: true,
102            #[cfg(feature = "server")]
103            outbound: None,
104            #[cfg(feature = "server")]
105            graphiql_path: None,
106            #[cfg(feature = "server")]
107            graphiql_path_explicit: false,
108            #[cfg(feature = "server")]
109            subscriptions_path: None,
110            #[cfg(feature = "server")]
111            subscriptions_disabled: true,
112            #[cfg(feature = "server")]
113            allow_get_queries: false,
114            #[cfg(feature = "server")]
115            sdl_path: None,
116            #[cfg(feature = "server")]
117            sdl_path_explicit: false,
118        }
119    }
120
121    /// Outbound client with in-memory stubs (tests).
122    pub fn fake(fake: FakeGraphql) -> Self {
123        Self {
124            mode: Mode::Fake {
125                fake,
126                endpoint: "fake://graphql".into(),
127            },
128            path: "/graphql".into(),
129            path_explicit: false,
130            graphiql: false,
131            graphiql_explicit: true,
132            enabled: true,
133            #[cfg(feature = "server")]
134            outbound: None,
135            #[cfg(feature = "server")]
136            graphiql_path: None,
137            #[cfg(feature = "server")]
138            graphiql_path_explicit: false,
139            #[cfg(feature = "server")]
140            subscriptions_path: None,
141            #[cfg(feature = "server")]
142            subscriptions_disabled: true,
143            #[cfg(feature = "server")]
144            allow_get_queries: false,
145            #[cfg(feature = "server")]
146            sdl_path: None,
147            #[cfg(feature = "server")]
148            sdl_path_explicit: false,
149        }
150    }
151
152    /// Mount an `async-graphql` schema (requires feature `server`).
153    #[cfg(feature = "server")]
154    pub fn server<Q, M, S>(schema: Schema<Q, M, S>) -> Self
155    where
156        Q: ObjectType + 'static,
157        M: ObjectType + 'static,
158        S: SubscriptionType + 'static,
159    {
160        let mut this = Self::server_defaults();
161        this.mode = Mode::Server {
162            handle: SchemaHandle::from_schema(schema),
163        };
164        this
165    }
166
167    /// Also install an outbound client (BFF: mount + remote GraphQL).
168    #[cfg(feature = "server")]
169    pub fn with_client(mut self, endpoint: impl Into<String>) -> Self {
170        self.outbound = Some(Outbound::Http(endpoint.into()));
171        self
172    }
173
174    /// Outbound client URL from `GRAPHQL_URL` (with server mount).
175    #[cfg(feature = "server")]
176    pub fn with_client_from_env(mut self) -> Self {
177        self.outbound = Some(Outbound::FromEnv);
178        self
179    }
180
181    pub fn path(mut self, path: impl Into<String>) -> Self {
182        self.path = path.into();
183        self.path_explicit = true;
184        self
185    }
186
187    pub fn graphiql(mut self, enabled: bool) -> Self {
188        self.graphiql = enabled;
189        self.graphiql_explicit = true;
190        self
191    }
192
193    /// GraphiQL UI path (default `/graphiql` when enabled).
194    #[cfg(feature = "server")]
195    pub fn graphiql_path(mut self, path: impl Into<String>) -> Self {
196        self.graphiql_path = Some(path.into());
197        self.graphiql_path_explicit = true;
198        self
199    }
200
201    /// WebSocket endpoint for subscriptions (default `{api_path}/ws`).
202    #[cfg(feature = "server")]
203    pub fn subscriptions(mut self, path: impl Into<String>) -> Self {
204        self.subscriptions_path = Some(path.into());
205        self.subscriptions_disabled = false;
206        self
207    }
208
209    /// Disable subscription WebSocket mount.
210    #[cfg(feature = "server")]
211    pub fn without_subscriptions(mut self) -> Self {
212        self.subscriptions_disabled = true;
213        self
214    }
215
216    /// Allow GraphQL queries over HTTP GET on the API path (`?query=`).
217    #[cfg(feature = "server")]
218    pub fn allow_get_queries(mut self, enabled: bool) -> Self {
219        self.allow_get_queries = enabled;
220        self
221    }
222
223    /// Expose schema SDL at GET path (e.g. `/graphql/sdl`).
224    #[cfg(feature = "server")]
225    pub fn sdl_path(mut self, path: impl Into<String>) -> Self {
226        self.sdl_path = Some(path.into());
227        self.sdl_path_explicit = true;
228        self
229    }
230
231    #[cfg(feature = "server")]
232    fn install_outbound(&self, app: &mut App) {
233        let Some(outbound) = &self.outbound else {
234            return;
235        };
236        let endpoint = match outbound {
237            Outbound::Http(url) => url.clone(),
238            Outbound::FromEnv => std::env::var("GRAPHQL_URL").unwrap_or_default(),
239        };
240        if !endpoint.is_empty() {
241            app.state(GraphQlClient::http(endpoint));
242        }
243    }
244
245    #[cfg(feature = "server")]
246    fn server_mount_config(&self) -> ServerMountConfig {
247        let graphiql_path = if self.graphiql_path_explicit {
248            self.graphiql_path
249                .clone()
250                .unwrap_or_else(|| default_graphiql_path(&self.path))
251        } else {
252            default_graphiql_path(&self.path)
253        };
254        let subscriptions_path = if self.subscriptions_disabled {
255            None
256        } else if let Some(p) = &self.subscriptions_path {
257            Some(p.clone())
258        } else {
259            Some(default_subscriptions_path(&self.path))
260        };
261        let sdl_path = if self.sdl_path_explicit {
262            self.sdl_path.clone()
263        } else {
264            None
265        };
266        ServerMountConfig {
267            path: self.path.clone(),
268            graphiql: self.graphiql,
269            graphiql_path,
270            subscriptions_path,
271            allow_get_queries: self.allow_get_queries,
272            sdl_path,
273        }
274    }
275}
276
277impl Plugin for GraphQl {
278    fn id(&self) -> &'static str {
279        "graphql"
280    }
281
282    fn meta(&self) -> sova_core::PluginMeta {
283        sova_core::PluginMeta::new("GraphQL")
284            .description("Outbound GraphQL client (+ optional schema mount)")
285            .version(env!("CARGO_PKG_VERSION"))
286    }
287
288    fn install(mut self, app: &mut App) {
289        if let Some(doc) = app.config_doc() {
290            if let Some(section) = doc.section("graphql") {
291                if let Some(en) = section.get("enabled").and_then(|v| v.as_bool()) {
292                    self.enabled = en;
293                }
294                if !self.path_explicit {
295                    if let Some(p) = section.get("path").and_then(|v| v.as_str()) {
296                        self.path = p.to_string();
297                    }
298                }
299                if !self.graphiql_explicit {
300                    if let Some(g) = section.get("graphiql").and_then(|v| v.as_bool()) {
301                        self.graphiql = g;
302                    }
303                }
304                #[cfg(feature = "server")]
305                {
306                    if !self.graphiql_path_explicit {
307                        if let Some(p) = section.get("graphiql_path").and_then(|v| v.as_str()) {
308                            self.graphiql_path = Some(p.to_string());
309                            self.graphiql_path_explicit = true;
310                        }
311                    }
312                    if self.subscriptions_path.is_none() && !self.subscriptions_disabled {
313                        if let Some(p) = section.get("subscriptions_path").and_then(|v| v.as_str())
314                        {
315                            self.subscriptions_path = Some(p.to_string());
316                        }
317                    }
318                    if let Some(v) = section.get("allow_get_queries").and_then(|v| v.as_bool()) {
319                        self.allow_get_queries = v;
320                    }
321                    if !self.sdl_path_explicit {
322                        if let Some(p) = section.get("sdl_path").and_then(|v| v.as_str()) {
323                            self.sdl_path = Some(p.to_string());
324                            self.sdl_path_explicit = true;
325                        }
326                    }
327                }
328                if let Mode::Client { endpoint } = &mut self.mode {
329                    if endpoint.is_empty() {
330                        if let Some(u) = section.get("url").and_then(|v| v.as_str()) {
331                            *endpoint = u.to_string();
332                        }
333                    }
334                }
335            }
336        }
337
338        if !self.enabled {
339            return;
340        }
341
342        #[cfg(feature = "server")]
343        if matches!(self.mode, Mode::Server { .. }) {
344            self.install_outbound(app);
345        }
346
347        #[cfg(feature = "server")]
348        let server_cfg = if matches!(&self.mode, Mode::Server { .. }) {
349            Some(self.server_mount_config())
350        } else {
351            None
352        };
353
354        match self.mode {
355            Mode::Client { endpoint } => {
356                let endpoint = if endpoint.is_empty() {
357                    std::env::var("GRAPHQL_URL").unwrap_or_default()
358                } else {
359                    endpoint
360                };
361                app.state(GraphQlClient::http(endpoint));
362            }
363            Mode::Fake { fake, endpoint } => {
364                app.state(GraphQlClient::with_fake(endpoint, fake));
365            }
366            #[cfg(feature = "server")]
367            Mode::Server { handle } => {
368                install_server(app, handle, server_cfg.expect("server cfg"));
369            }
370        }
371    }
372}