async_graphql/http/
altair_source.rs

1use std::collections::HashMap;
2
3use handlebars::Handlebars;
4use schemars::JsonSchema;
5use serde::{Deserialize, Serialize};
6
7/// A builder for constructing an Altair HTML page.
8#[derive(Default, Serialize)]
9pub struct AltairSource<'a> {
10    #[serde(default, skip_serializing_if = "Option::is_none")]
11    title: Option<&'a str>,
12    #[serde(default, skip_serializing_if = "Option::is_none")]
13    options: Option<serde_json::Value>,
14}
15impl<'a> AltairSource<'a> {
16    /// Creates a builder for constructing an Altair HTML page.
17    pub fn build() -> AltairSource<'a> {
18        Default::default()
19    }
20
21    /// Sets the html document title.
22    pub fn title(self, title: &'a str) -> AltairSource<'a> {
23        AltairSource {
24            title: Some(title),
25            ..self
26        }
27    }
28
29    /// Sets the [Altair options](https://github.com/altair-graphql/altair?tab=readme-ov-file#configuration-options).
30    ///
31    /// # Examples
32    ///
33    /// With on-the-fly options:
34    /// ```rust
35    /// use async_graphql::http::*;
36    /// use serde_json::json;
37    ///
38    /// AltairSource::build()
39    ///     .options(json!({
40    ///         "endpointURL": "/",
41    ///         "subscriptionsEndpoint": "/ws",
42    ///         "subscriptionsProtocol": "wss",
43    ///     }))
44    ///     .finish();
45    /// ```
46    ///
47    /// With strongly-typed [AltairConfigOptions], useful when reading options
48    /// from config files: ```rust
49    /// use async_graphql::http::*;
50    ///
51    /// AltairSource::build()
52    ///     .options(AltairConfigOptions {
53    ///         window_options: Some(AltairWindowOptions {
54    ///             endpoint_url: Some("/".to_owned()),
55    ///             subscriptions_endpoint: Some("/ws".to_owned()),
56    ///             subscriptions_protocol: Some("wss".to_owned()),
57    ///             ..Default::default()
58    ///         }),
59    ///         ..Default::default()
60    ///     })
61    ///     .finish();
62    /// ```
63    pub fn options<T: Serialize>(self, options: T) -> AltairSource<'a> {
64        AltairSource {
65            options: Some(serde_json::to_value(options).expect("Failed to serialize options")),
66            ..self
67        }
68    }
69
70    /// Returns an Altair HTML page.
71    pub fn finish(self) -> String {
72        let mut handlebars = Handlebars::new();
73
74        handlebars.register_helper("toJson", Box::new(ToJsonHelper));
75
76        handlebars
77            .register_template_string("altair_source", include_str!("./altair_source.hbs"))
78            .expect("Failed to register template");
79
80        handlebars
81            .render("altair_source", &self)
82            .expect("Failed to render template")
83    }
84}
85
86/// Altair window [options](https://github.com/altair-graphql/altair/blob/master/packages/altair-core/src/config.ts#L10)
87#[derive(Default, Serialize, Deserialize, JsonSchema)]
88#[serde(rename_all = "camelCase")]
89pub struct AltairWindowOptions {
90    /// Initial name of the window
91    #[serde(default, skip_serializing_if = "Option::is_none")]
92    pub initial_name: Option<String>,
93    /// URL to set as the server endpoint
94    #[serde(
95        rename = "endpointURL",
96        default,
97        skip_serializing_if = "Option::is_none"
98    )]
99    pub endpoint_url: Option<String>,
100    /// URL to set as the subscription endpoint. This can be relative or
101    /// absolute.
102    #[serde(default, skip_serializing_if = "Option::is_none")]
103    pub subscriptions_endpoint: Option<String>,
104    /// URL protocol for the subscription endpoint. This is used if the
105    /// specified subscriptions endpoint is relative.
106    ///
107    /// e.g. wss
108    #[serde(default, skip_serializing_if = "Option::is_none")]
109    pub subscriptions_protocol: Option<String>,
110    /// Initial query to be added
111    #[serde(default, skip_serializing_if = "Option::is_none")]
112    pub initial_query: Option<String>,
113    /// Initial variables to be added
114    #[serde(default, skip_serializing_if = "Option::is_none")]
115    pub initial_variables: Option<String>,
116    /// Initial pre-request script to be added
117    #[serde(default, skip_serializing_if = "Option::is_none")]
118    pub initial_pre_request_script: Option<String>,
119    /// Initial post-request script to be added
120    #[serde(default, skip_serializing_if = "Option::is_none")]
121    pub initial_post_request_script: Option<String>,
122    /// Initial authorization type and data
123    #[serde(default, skip_serializing_if = "Option::is_none")]
124    pub initial_authorization: Option<AltairAuthorizationProviderInput>,
125    /// Initial headers object to be added
126    /// ```js
127    /// {
128    ///  'X-GraphQL-Token': 'asd7-237s-2bdk-nsdk4'
129    /// }
130    /// ```
131    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
132    pub initial_headers: HashMap<String, String>,
133    /// Initial subscriptions connection params
134    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
135    pub initial_subscriptions_payload: HashMap<String, String>,
136    /// HTTP method to use for making requests
137    #[serde(default, skip_serializing_if = "Option::is_none")]
138    pub initial_http_method: Option<AltairHttpVerb>,
139}
140
141/// Altair config [options](https://github.com/altair-graphql/altair/blob/master/packages/altair-core/src/config.ts#L79)
142#[derive(Default, Serialize, Deserialize, JsonSchema)]
143#[serde(rename_all = "camelCase")]
144pub struct AltairConfigOptions {
145    /// Options to be applied on every new window (including the initial)
146    #[serde(default, flatten, skip_serializing_if = "Option::is_none")]
147    pub window_options: Option<AltairWindowOptions>,
148    /// Initial Environments to be added
149    /// ```js
150    ///  {
151    ///    base: {
152    ///     title: 'Environment',
153    ///     variables: {}
154    ///   },
155    ///   subEnvironments: [
156    ///     {
157    ///       title: 'sub-1',
158    ///       variables: {}
159    ///     }
160    ///   ]
161    /// }
162    /// ```
163    #[serde(default, skip_serializing_if = "Option::is_none")]
164    pub initial_environments: Option<AltairInitialEnvironments>,
165    /// Namespace for storing the data for the altair instance.
166    ///
167    /// Use this when you have multiple altair instances running on the same
168    /// domain.
169    ///
170    /// e.g. altair_dev_
171    #[serde(default, skip_serializing_if = "Option::is_none")]
172    pub instance_storage_namespace: Option<String>,
173    /// Initial app settings to use
174    #[serde(default, skip_serializing_if = "Option::is_none")]
175    pub initial_settings: Option<AltairSettingsState>,
176    /// Indicates if the state should be preserved for subsequent app loads
177    /// (default true)
178    #[serde(default, skip_serializing_if = "Option::is_none")]
179    pub preserve_state: Option<bool>,
180    /// List of options for windows to be loaded
181    #[serde(default, skip_serializing_if = "Vec::is_empty")]
182    pub initial_windows: Vec<AltairWindowOptions>,
183    /// Persisted settings for the app. The settings will be merged with the app
184    /// settings.
185    #[serde(default, skip_serializing_if = "Option::is_none")]
186    pub persisted_settings: Option<AltairSettingsState>,
187    /// Disable the account and remote syncing functionality
188    #[serde(default, skip_serializing_if = "Option::is_none")]
189    pub disable_account: Option<bool>,
190}
191
192/// Altair supported HTTP verbs
193#[derive(Serialize, Deserialize, JsonSchema)]
194#[allow(missing_docs)]
195pub enum AltairHttpVerb {
196    POST,
197    GET,
198    PUT,
199    DELETE,
200}
201
202/// Altair initial environments setup
203#[derive(Default, Serialize, Deserialize, JsonSchema)]
204#[serde(rename_all = "camelCase")]
205pub struct AltairInitialEnvironments {
206    /// Base environment
207    #[serde(default, skip_serializing_if = "Option::is_none")]
208    pub base: Option<AltairInitialEnvironmentState>,
209    /// Other sub environments
210    #[serde(default, skip_serializing_if = "Vec::is_empty")]
211    pub sub_environments: Vec<AltairInitialEnvironmentState>,
212}
213
214/// Altair initial environment state
215#[derive(Default, Serialize, Deserialize, JsonSchema)]
216#[serde(rename_all = "camelCase")]
217pub struct AltairInitialEnvironmentState {
218    /// Environment identifier
219    #[serde(default, skip_serializing_if = "Option::is_none")]
220    pub id: Option<String>,
221    /// Environment title
222    #[serde(default, skip_serializing_if = "Option::is_none")]
223    pub title: Option<String>,
224    /// Environment variables
225    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
226    pub variables: HashMap<String, String>,
227}
228
229/// Altair authorization provider input
230#[derive(Serialize, Deserialize, JsonSchema)]
231#[serde(tag = "type", content = "data")]
232pub enum AltairAuthorizationProviderInput {
233    /// Api key authorization
234    #[serde(rename = "api-key")]
235    ApiKey {
236        /// Header name
237        header_name: String,
238        /// Header value
239        header_value: String,
240    },
241    /// Basic authorization
242    #[serde(rename = "basic")]
243    Basic {
244        /// Password
245        password: String,
246        /// Username
247        username: String,
248    },
249    /// Bearer token authorization
250    #[serde(rename = "bearer")]
251    Bearer {
252        /// Token
253        token: String,
254    },
255    /// OAuth2 access token authorization
256    #[serde(rename = "oauth2")]
257    OAuth2 {
258        /// Access token response
259        access_token_response: String,
260    },
261}
262
263/// Altair application settings state
264#[derive(Default, Serialize, Deserialize, JsonSchema)]
265#[serde(rename_all = "camelCase")]
266pub struct AltairSettingsState {
267    /// Theme
268    #[serde(default, skip_serializing_if = "Option::is_none")]
269    pub theme: Option<String>,
270    /// Theme for dark mode
271    #[serde(
272        rename = "theme.dark",
273        default,
274        skip_serializing_if = "Option::is_none"
275    )]
276    pub theme_dark: Option<String>,
277    /// Language
278    #[serde(default, skip_serializing_if = "Option::is_none")]
279    pub language: Option<AltairSettingsLanguage>,
280    /// 'Add query' functionality depth
281    #[serde(default, skip_serializing_if = "Option::is_none")]
282    pub add_query_depth_limit: Option<usize>,
283    /// Editor tab size
284    #[serde(default, skip_serializing_if = "Option::is_none")]
285    pub tab_size: Option<usize>,
286    /// Enable experimental features.
287    ///
288    /// Note: Might be unstable
289    #[serde(default, skip_serializing_if = "Option::is_none")]
290    pub enable_experimental: Option<bool>,
291    /// Base Font Size
292    ///
293    /// default: 24
294    #[serde(
295        rename = "theme.fontsize",
296        default,
297        skip_serializing_if = "Option::is_none"
298    )]
299    pub theme_font_size: Option<usize>,
300    /// Editor Font Family
301    #[serde(
302        rename = "theme.editorFontFamily",
303        default,
304        skip_serializing_if = "Option::is_none"
305    )]
306    pub theme_editor_font_family: Option<String>,
307    /// Editor Font Size
308    #[serde(
309        rename = "theme.editorFontSize",
310        default,
311        skip_serializing_if = "Option::is_none"
312    )]
313    pub theme_editor_font_size: Option<usize>,
314    /// Disable push notifications
315    #[serde(default, skip_serializing_if = "Option::is_none")]
316    pub disable_push_notification: Option<bool>,
317    /// Enabled plugins
318    #[serde(rename = "plugin.list", default, skip_serializing_if = "Vec::is_empty")]
319    pub plugin_list: Vec<String>,
320    /// Send requests with credentials (cookies)
321    #[serde(
322        rename = "request.withCredentials",
323        default,
324        skip_serializing_if = "Option::is_none"
325    )]
326    pub request_with_credentials: Option<bool>,
327    /// Reload schema on app start
328    #[serde(
329        rename = "schema.reloadOnStart",
330        default,
331        skip_serializing_if = "Option::is_none"
332    )]
333    pub schema_reload_on_start: Option<bool>,
334    /// Disable update notification
335    #[serde(
336        rename = "alert.disableUpdateNotification",
337        default,
338        skip_serializing_if = "Option::is_none"
339    )]
340    pub alert_disable_update_notification: Option<bool>,
341    /// Disable warning alerts
342    #[serde(
343        rename = "alert.disableWarnings",
344        default,
345        skip_serializing_if = "Option::is_none"
346    )]
347    pub alert_disable_warnings: Option<bool>,
348    /// Number of items allowed in history pane
349    #[serde(default, skip_serializing_if = "Option::is_none")]
350    pub history_depth: Option<usize>,
351    /// Disable line numbers
352    #[serde(default, skip_serializing_if = "Option::is_none")]
353    pub disable_line_numbers: Option<bool>,
354    /// Theme config object
355    #[serde(default, skip_serializing_if = "Option::is_none")]
356    pub theme_config: Option<serde_json::Value>,
357    /// Theme config object for dark mode
358    #[serde(
359        rename = "themeConfig.dark",
360        default,
361        skip_serializing_if = "Option::is_none"
362    )]
363    pub theme_config_dark: Option<serde_json::Value>,
364    /// Hides extensions object
365    #[serde(
366        rename = "response.hideExtensions",
367        default,
368        skip_serializing_if = "Option::is_none"
369    )]
370    pub response_hide_extensions: Option<bool>,
371    /// Contains shortcut to action mapping
372    #[serde(
373        rename = "editor.shortcuts",
374        default,
375        skip_serializing_if = "HashMap::is_empty"
376    )]
377    pub editor_shortcuts: HashMap<String, String>,
378    /// Disable new editor beta
379    #[serde(
380        rename = "beta.disable.newEditor",
381        default,
382        skip_serializing_if = "Option::is_none"
383    )]
384    pub beta_disable_new_editor: Option<bool>,
385    /// Disable new script beta
386    #[serde(
387        rename = "beta.disable.newScript",
388        default,
389        skip_serializing_if = "Option::is_none"
390    )]
391    pub beta_disable_new_script: Option<bool>,
392    /// List of cookies to be accessible in the pre-request script
393    ///
394    /// e.g. ['cookie1', 'cookie2']
395    #[serde(
396        rename = "script.allowedCookies",
397        default,
398        skip_serializing_if = "Vec::is_empty"
399    )]
400    pub script_allowed_cookies: Vec<String>,
401    /// Enable the scrollbar in the tab list
402    #[serde(default, skip_serializing_if = "Option::is_none")]
403    pub enable_tablist_scrollbar: Option<bool>,
404}
405
406/// Altair supported languages
407#[derive(Serialize, Deserialize, JsonSchema)]
408#[allow(missing_docs)]
409pub enum AltairSettingsLanguage {
410    #[serde(rename = "en-US")]
411    English,
412    #[serde(rename = "fr-FR")]
413    French,
414    #[serde(rename = "es-ES")]
415    EspaƱol,
416    #[serde(rename = "cs-CZ")]
417    Czech,
418    #[serde(rename = "de-DE")]
419    German,
420    #[serde(rename = "pt-BR")]
421    Brazilian,
422    #[serde(rename = "ru-RU")]
423    Russian,
424    #[serde(rename = "uk-UA")]
425    Ukrainian,
426    #[serde(rename = "zh-CN")]
427    ChineseSimplified,
428    #[serde(rename = "ja-JP")]
429    Japanese,
430    #[serde(rename = "sr-SP")]
431    Serbian,
432    #[serde(rename = "it-IT")]
433    Italian,
434    #[serde(rename = "pl-PL")]
435    Polish,
436    #[serde(rename = "ko-KR")]
437    Korean,
438    #[serde(rename = "ro-RO")]
439    Romanian,
440    #[serde(rename = "vi-VN")]
441    Vietnamese,
442}
443
444struct ToJsonHelper;
445impl handlebars::HelperDef for ToJsonHelper {
446    #[allow(unused_assignments)]
447    fn call_inner<'reg: 'rc, 'rc>(
448        &self,
449        h: &handlebars::Helper<'rc>,
450        r: &'reg handlebars::Handlebars<'reg>,
451        _: &'rc handlebars::Context,
452        _: &mut handlebars::RenderContext<'reg, 'rc>,
453    ) -> std::result::Result<handlebars::ScopedJson<'rc>, handlebars::RenderError> {
454        let mut param_idx = 0;
455        let obj = h
456            .param(param_idx)
457            .and_then(|x| {
458                if r.strict_mode() && x.is_value_missing() {
459                    None
460                } else {
461                    Some(x.value())
462                }
463            })
464            .ok_or_else(|| {
465                handlebars::RenderErrorReason::ParamNotFoundForName("toJson", "obj".to_string())
466            })
467            .and_then(|x| {
468                x.as_object().ok_or_else(|| {
469                    handlebars::RenderErrorReason::ParamTypeMismatchForName(
470                        "toJson",
471                        "obj".to_string(),
472                        "object".to_string(),
473                    )
474                })
475            })?;
476        param_idx += 1;
477        let result = if obj.is_empty() {
478            "{}".to_owned()
479        } else {
480            serde_json::to_string(&obj).expect("Failed to serialize json")
481        };
482        Ok(handlebars::ScopedJson::Derived(
483            handlebars::JsonValue::from(result),
484        ))
485    }
486}
487
488#[cfg(test)]
489mod tests {
490    use serde_json::json;
491
492    use super::*;
493
494    #[test]
495    fn test_without_options() {
496        let altair_source = AltairSource::build().title("Custom Title").finish();
497
498        assert_eq!(
499            altair_source,
500            r#"<!DOCTYPE html>
501<html>
502
503  <head>
504    <meta charset="utf-8">
505
506    <title>Custom Title</title>
507
508    <base href="https://unpkg.com/altair-static@latest/build/dist/">
509
510    <meta name="viewport" content="width=device-width, initial-scale=1">
511    <link rel="icon" type="image/x-icon" href="favicon.ico">
512    <link rel="stylesheet" href="styles.css">
513  </head>
514
515  <body>
516    <script>
517      document.addEventListener('DOMContentLoaded', () => {
518        AltairGraphQL.init();
519      });
520    </script>
521    <app-root>
522      <style>
523        .loading-screen {
524          /*Prevents the loading screen from showing until CSS is downloaded*/
525          display: none;
526        }
527      </style>
528      <div class="loading-screen styled">
529        <div class="loading-screen-inner">
530          <div class="loading-screen-logo-container">
531            <img src="assets/img/logo_350.svg" alt="Altair">
532          </div>
533          <div class="loading-screen-loading-indicator">
534            <span class="loading-indicator-dot"></span>
535            <span class="loading-indicator-dot"></span>
536            <span class="loading-indicator-dot"></span>
537          </div>
538        </div>
539      </div>
540    </app-root>
541    <script type="text/javascript" src="runtime.js"></script>
542    <script type="text/javascript" src="polyfills.js"></script>
543    <script type="text/javascript" src="main.js"></script>
544  </body>
545
546</html>"#
547        )
548    }
549
550    #[test]
551    fn test_with_dynamic() {
552        let altair_source = AltairSource::build()
553            .options(json!({
554                "endpointURL": "/",
555                "subscriptionsEndpoint": "/ws",
556            }))
557            .finish();
558
559        assert_eq!(
560            altair_source,
561            r#"<!DOCTYPE html>
562<html>
563
564  <head>
565    <meta charset="utf-8">
566
567    <title>Altair</title>
568
569    <base href="https://unpkg.com/altair-static@latest/build/dist/">
570
571    <meta name="viewport" content="width=device-width, initial-scale=1">
572    <link rel="icon" type="image/x-icon" href="favicon.ico">
573    <link rel="stylesheet" href="styles.css">
574  </head>
575
576  <body>
577    <script>
578      document.addEventListener('DOMContentLoaded', () => {
579        AltairGraphQL.init({"endpointURL":"/","subscriptionsEndpoint":"/ws"});
580      });
581    </script>
582    <app-root>
583      <style>
584        .loading-screen {
585          /*Prevents the loading screen from showing until CSS is downloaded*/
586          display: none;
587        }
588      </style>
589      <div class="loading-screen styled">
590        <div class="loading-screen-inner">
591          <div class="loading-screen-logo-container">
592            <img src="assets/img/logo_350.svg" alt="Altair">
593          </div>
594          <div class="loading-screen-loading-indicator">
595            <span class="loading-indicator-dot"></span>
596            <span class="loading-indicator-dot"></span>
597            <span class="loading-indicator-dot"></span>
598          </div>
599        </div>
600      </div>
601    </app-root>
602    <script type="text/javascript" src="runtime.js"></script>
603    <script type="text/javascript" src="polyfills.js"></script>
604    <script type="text/javascript" src="main.js"></script>
605  </body>
606
607</html>"#
608        )
609    }
610
611    #[test]
612    fn test_with_static() {
613        let altair_source = AltairSource::build()
614            .options(AltairConfigOptions {
615                window_options: Some(AltairWindowOptions {
616                    endpoint_url: Some("/".to_owned()),
617                    subscriptions_endpoint: Some("/ws".to_owned()),
618                    ..Default::default()
619                }),
620                ..Default::default()
621            })
622            .finish();
623
624        assert_eq!(
625            altair_source,
626            r#"<!DOCTYPE html>
627<html>
628
629  <head>
630    <meta charset="utf-8">
631
632    <title>Altair</title>
633
634    <base href="https://unpkg.com/altair-static@latest/build/dist/">
635
636    <meta name="viewport" content="width=device-width, initial-scale=1">
637    <link rel="icon" type="image/x-icon" href="favicon.ico">
638    <link rel="stylesheet" href="styles.css">
639  </head>
640
641  <body>
642    <script>
643      document.addEventListener('DOMContentLoaded', () => {
644        AltairGraphQL.init({"endpointURL":"/","subscriptionsEndpoint":"/ws"});
645      });
646    </script>
647    <app-root>
648      <style>
649        .loading-screen {
650          /*Prevents the loading screen from showing until CSS is downloaded*/
651          display: none;
652        }
653      </style>
654      <div class="loading-screen styled">
655        <div class="loading-screen-inner">
656          <div class="loading-screen-logo-container">
657            <img src="assets/img/logo_350.svg" alt="Altair">
658          </div>
659          <div class="loading-screen-loading-indicator">
660            <span class="loading-indicator-dot"></span>
661            <span class="loading-indicator-dot"></span>
662            <span class="loading-indicator-dot"></span>
663          </div>
664        </div>
665      </div>
666    </app-root>
667    <script type="text/javascript" src="runtime.js"></script>
668    <script type="text/javascript" src="polyfills.js"></script>
669    <script type="text/javascript" src="main.js"></script>
670  </body>
671
672</html>"#
673        )
674    }
675}