async_graphql/registry/
export_sdl.rs

1use std::{collections::HashMap, fmt::Write};
2
3use crate::registry::{Deprecation, MetaField, MetaInputValue, MetaType, Registry};
4
5const SYSTEM_SCALARS: &[&str] = &["Int", "Float", "String", "Boolean", "ID"];
6const FEDERATION_SCALARS: &[&str] = &["Any"];
7
8/// Options for SDL export
9#[derive(Debug, Copy, Clone, Default)]
10pub struct SDLExportOptions {
11    sorted_fields: bool,
12    sorted_arguments: bool,
13    sorted_enum_values: bool,
14    federation: bool,
15    prefer_single_line_descriptions: bool,
16    include_specified_by: bool,
17    compose_directive: bool,
18}
19
20impl SDLExportOptions {
21    /// Create a `SDLExportOptions`
22    #[inline]
23    pub fn new() -> Self {
24        Default::default()
25    }
26
27    /// Export sorted fields
28    #[inline]
29    #[must_use]
30    pub fn sorted_fields(self) -> Self {
31        Self {
32            sorted_fields: true,
33            ..self
34        }
35    }
36
37    /// Export sorted field arguments
38    #[inline]
39    #[must_use]
40    pub fn sorted_arguments(self) -> Self {
41        Self {
42            sorted_arguments: true,
43            ..self
44        }
45    }
46
47    /// Export sorted enum items
48    #[inline]
49    #[must_use]
50    pub fn sorted_enum_items(self) -> Self {
51        Self {
52            sorted_enum_values: true,
53            ..self
54        }
55    }
56
57    /// Export as Federation SDL(Schema Definition Language)
58    #[inline]
59    #[must_use]
60    pub fn federation(self) -> Self {
61        Self {
62            federation: true,
63            ..self
64        }
65    }
66
67    /// When possible, write one-line instead of three-line descriptions
68    #[inline]
69    #[must_use]
70    pub fn prefer_single_line_descriptions(self) -> Self {
71        Self {
72            prefer_single_line_descriptions: true,
73            ..self
74        }
75    }
76
77    /// Includes `specifiedBy` directive in SDL
78    pub fn include_specified_by(self) -> Self {
79        Self {
80            include_specified_by: true,
81            ..self
82        }
83    }
84
85    /// Enable `composeDirective` if federation is enabled
86    pub fn compose_directive(self) -> Self {
87        Self {
88            compose_directive: true,
89            ..self
90        }
91    }
92}
93
94impl Registry {
95    pub(crate) fn export_sdl(&self, options: SDLExportOptions) -> String {
96        let mut sdl = String::new();
97
98        for ty in self.types.values() {
99            if ty.name().starts_with("__") {
100                continue;
101            }
102
103            if options.federation {
104                const FEDERATION_TYPES: &[&str] = &["_Any", "_Entity", "_Service"];
105                if FEDERATION_TYPES.contains(&ty.name()) {
106                    continue;
107                }
108            }
109
110            self.export_type(ty, &mut sdl, &options);
111            writeln!(sdl).ok();
112        }
113
114        self.directives.values().for_each(|directive| {
115            // Filter out deprecated directive from SDL if it is not used
116            if directive.name == "deprecated"
117                && !self.types.values().any(|ty| match ty {
118                    MetaType::Object { fields, .. } => fields
119                        .values()
120                        .any(|field| field.deprecation.is_deprecated()),
121                    MetaType::Enum { enum_values, .. } => enum_values
122                        .values()
123                        .any(|value| value.deprecation.is_deprecated()),
124                    _ => false,
125                })
126            {
127                return;
128            }
129
130            // Filter out specifiedBy directive from SDL if it is not used
131            if directive.name == "specifiedBy"
132                && !self.types.values().any(|ty| {
133                    matches!(
134                        ty,
135                        MetaType::Scalar {
136                            specified_by_url: Some(_),
137                            ..
138                        }
139                    )
140                })
141            {
142                return;
143            }
144
145            // Filter out oneOf directive from SDL if it is not used
146            if directive.name == "oneOf"
147                && !self
148                    .types
149                    .values()
150                    .any(|ty| matches!(ty, MetaType::InputObject { oneof: true, .. }))
151            {
152                return;
153            }
154
155            writeln!(sdl, "{}", directive.sdl()).ok();
156        });
157
158        if options.federation {
159            writeln!(sdl, "extend schema @link(").ok();
160            writeln!(sdl, "\turl: \"https://specs.apollo.dev/federation/v2.3\",").ok();
161            writeln!(sdl, "\timport: [\"@key\", \"@tag\", \"@shareable\", \"@inaccessible\", \"@override\", \"@external\", \"@provides\", \"@requires\", \"@composeDirective\", \"@interfaceObject\"]").ok();
162            writeln!(sdl, ")").ok();
163
164            if options.compose_directive {
165                writeln!(sdl).ok();
166                let mut compose_directives = HashMap::<&str, Vec<String>>::new();
167                self.directives
168                    .values()
169                    .filter_map(|d| {
170                        d.composable
171                            .as_ref()
172                            .map(|ext_url| (ext_url, format!("\"@{}\"", d.name)))
173                    })
174                    .for_each(|(ext_url, name)| {
175                        compose_directives.entry(ext_url).or_default().push(name)
176                    });
177                for (url, directives) in compose_directives {
178                    writeln!(sdl, "extend schema @link(").ok();
179                    writeln!(sdl, "\turl: \"{}\"", url).ok();
180                    writeln!(sdl, "\timport: [{}]", directives.join(",")).ok();
181                    writeln!(sdl, ")").ok();
182                    for name in directives {
183                        writeln!(sdl, "\t@composeDirective(name: {})", name).ok();
184                    }
185                    writeln!(sdl).ok();
186                }
187            }
188        } else {
189            writeln!(sdl, "schema {{").ok();
190            writeln!(sdl, "\tquery: {}", self.query_type).ok();
191            if let Some(mutation_type) = self.mutation_type.as_deref() {
192                writeln!(sdl, "\tmutation: {}", mutation_type).ok();
193            }
194            if let Some(subscription_type) = self.subscription_type.as_deref() {
195                writeln!(sdl, "\tsubscription: {}", subscription_type).ok();
196            }
197            writeln!(sdl, "}}").ok();
198        }
199
200        sdl
201    }
202
203    fn export_fields<'a, I: Iterator<Item = &'a MetaField>>(
204        sdl: &mut String,
205        it: I,
206        options: &SDLExportOptions,
207    ) {
208        let mut fields = it.collect::<Vec<_>>();
209
210        if options.sorted_fields {
211            fields.sort_by_key(|field| &field.name);
212        }
213
214        for field in fields {
215            if field.name.starts_with("__")
216                || (options.federation && matches!(&*field.name, "_service" | "_entities"))
217            {
218                continue;
219            }
220
221            if let Some(description) = &field.description {
222                write_description(sdl, options, 1, description);
223            }
224
225            if !field.args.is_empty() {
226                write!(sdl, "\t{}(", field.name).ok();
227
228                let mut args = field.args.values().collect::<Vec<_>>();
229                if options.sorted_arguments {
230                    args.sort_by_key(|value| &value.name);
231                }
232
233                let need_multiline = args.iter().any(|x| x.description.is_some());
234
235                for (i, arg) in args.into_iter().enumerate() {
236                    if i != 0 {
237                        sdl.push(',');
238                    }
239
240                    if let Some(description) = &arg.description {
241                        writeln!(sdl).ok();
242                        write_description(sdl, options, 2, description);
243                    }
244
245                    if need_multiline {
246                        write!(sdl, "\t\t").ok();
247                    } else if i != 0 {
248                        sdl.push(' ');
249                    }
250
251                    write_input_value(sdl, arg);
252
253                    if options.federation {
254                        if arg.inaccessible {
255                            write!(sdl, " @inaccessible").ok();
256                        }
257
258                        for tag in &arg.tags {
259                            write!(sdl, " @tag(name: \"{}\")", tag.replace('"', "\\\"")).ok();
260                        }
261                    }
262
263                    for directive in &arg.directive_invocations {
264                        write!(sdl, " {}", directive.sdl()).ok();
265                    }
266                }
267
268                if need_multiline {
269                    sdl.push_str("\n\t");
270                }
271                write!(sdl, "): {}", field.ty).ok();
272            } else {
273                write!(sdl, "\t{}: {}", field.name, field.ty).ok();
274            }
275
276            write_deprecated(sdl, &field.deprecation);
277
278            for directive in &field.directive_invocations {
279                write!(sdl, " {}", directive.sdl()).ok();
280            }
281
282            if options.federation {
283                if field.external {
284                    write!(sdl, " @external").ok();
285                }
286                if let Some(requires) = &field.requires {
287                    write!(sdl, " @requires(fields: \"{}\")", requires).ok();
288                }
289                if let Some(provides) = &field.provides {
290                    write!(sdl, " @provides(fields: \"{}\")", provides).ok();
291                }
292                if field.shareable {
293                    write!(sdl, " @shareable").ok();
294                }
295                if field.inaccessible {
296                    write!(sdl, " @inaccessible").ok();
297                }
298                for tag in &field.tags {
299                    write!(sdl, " @tag(name: \"{}\")", tag.replace('"', "\\\"")).ok();
300                }
301                if let Some(from) = &field.override_from {
302                    write!(sdl, " @override(from: \"{}\")", from).ok();
303                }
304            }
305
306            writeln!(sdl).ok();
307        }
308    }
309
310    fn export_type(&self, ty: &MetaType, sdl: &mut String, options: &SDLExportOptions) {
311        match ty {
312            MetaType::Scalar {
313                name,
314                description,
315                inaccessible,
316                tags,
317                specified_by_url,
318                directive_invocations,
319                ..
320            } => {
321                let mut export_scalar = !SYSTEM_SCALARS.contains(&name.as_str());
322                if options.federation && FEDERATION_SCALARS.contains(&name.as_str()) {
323                    export_scalar = false;
324                }
325                if export_scalar {
326                    if let Some(description) = description {
327                        write_description(sdl, options, 0, description);
328                    }
329                    write!(sdl, "scalar {}", name).ok();
330
331                    if options.include_specified_by {
332                        if let Some(specified_by_url) = specified_by_url {
333                            write!(
334                                sdl,
335                                " @specifiedBy(url: \"{}\")",
336                                specified_by_url.replace('"', "\\\"")
337                            )
338                            .ok();
339                        }
340                    }
341
342                    if options.federation {
343                        if *inaccessible {
344                            write!(sdl, " @inaccessible").ok();
345                        }
346                        for tag in tags {
347                            write!(sdl, " @tag(name: \"{}\")", tag.replace('"', "\\\"")).ok();
348                        }
349                    }
350
351                    for directive in directive_invocations {
352                        write!(sdl, " {}", directive.sdl()).ok();
353                    }
354
355                    writeln!(sdl).ok();
356                }
357            }
358            MetaType::Object {
359                name,
360                fields,
361                extends,
362                keys,
363                description,
364                shareable,
365                resolvable,
366                inaccessible,
367                interface_object,
368                tags,
369                directive_invocations: raw_directives,
370                ..
371            } => {
372                if Some(name.as_str()) == self.subscription_type.as_deref()
373                    && options.federation
374                    && !self.federation_subscription
375                {
376                    return;
377                }
378
379                if name.as_str() == self.query_type && options.federation {
380                    let mut field_count = 0;
381                    for field in fields.values() {
382                        if field.name.starts_with("__")
383                            || (options.federation
384                                && matches!(&*field.name, "_service" | "_entities"))
385                        {
386                            continue;
387                        }
388                        field_count += 1;
389                    }
390                    if field_count == 0 {
391                        // is empty query root type
392                        return;
393                    }
394                }
395
396                if let Some(description) = description {
397                    write_description(sdl, options, 0, description);
398                }
399
400                if options.federation && *extends {
401                    write!(sdl, "extend ").ok();
402                }
403
404                write!(sdl, "type {}", name).ok();
405                self.write_implements(sdl, name);
406
407                for directive_invocation in raw_directives {
408                    write!(sdl, " {}", directive_invocation.sdl()).ok();
409                }
410
411                if options.federation {
412                    if let Some(keys) = keys {
413                        for key in keys {
414                            write!(sdl, " @key(fields: \"{}\"", key).ok();
415                            if !resolvable {
416                                write!(sdl, ", resolvable: false").ok();
417                            }
418                            write!(sdl, ")").ok();
419                        }
420                    }
421                    if *shareable {
422                        write!(sdl, " @shareable").ok();
423                    }
424
425                    if *inaccessible {
426                        write!(sdl, " @inaccessible").ok();
427                    }
428
429                    if *interface_object {
430                        write!(sdl, " @interfaceObject").ok();
431                    }
432
433                    for tag in tags {
434                        write!(sdl, " @tag(name: \"{}\")", tag.replace('"', "\\\"")).ok();
435                    }
436                }
437
438                writeln!(sdl, " {{").ok();
439                Self::export_fields(sdl, fields.values(), options);
440                writeln!(sdl, "}}").ok();
441            }
442            MetaType::Interface {
443                name,
444                fields,
445                extends,
446                keys,
447                description,
448                inaccessible,
449                tags,
450                directive_invocations,
451                ..
452            } => {
453                if let Some(description) = description {
454                    write_description(sdl, options, 0, description);
455                }
456
457                if options.federation && *extends {
458                    write!(sdl, "extend ").ok();
459                }
460                write!(sdl, "interface {}", name).ok();
461
462                if options.federation {
463                    if let Some(keys) = keys {
464                        for key in keys {
465                            write!(sdl, " @key(fields: \"{}\")", key).ok();
466                        }
467                    }
468                    if *inaccessible {
469                        write!(sdl, " @inaccessible").ok();
470                    }
471
472                    for tag in tags {
473                        write!(sdl, " @tag(name: \"{}\")", tag.replace('"', "\\\"")).ok();
474                    }
475                }
476
477                for directive in directive_invocations {
478                    write!(sdl, " {}", directive.sdl()).ok();
479                }
480
481                self.write_implements(sdl, name);
482
483                writeln!(sdl, " {{").ok();
484                Self::export_fields(sdl, fields.values(), options);
485                writeln!(sdl, "}}").ok();
486            }
487            MetaType::Enum {
488                name,
489                enum_values,
490                description,
491                inaccessible,
492                tags,
493                directive_invocations,
494                ..
495            } => {
496                if let Some(description) = description {
497                    write_description(sdl, options, 0, description);
498                }
499
500                write!(sdl, "enum {}", name).ok();
501                if options.federation {
502                    if *inaccessible {
503                        write!(sdl, " @inaccessible").ok();
504                    }
505                    for tag in tags {
506                        write!(sdl, " @tag(name: \"{}\")", tag.replace('"', "\\\"")).ok();
507                    }
508                }
509
510                for directive in directive_invocations {
511                    write!(sdl, " {}", directive.sdl()).ok();
512                }
513
514                writeln!(sdl, " {{").ok();
515
516                let mut values = enum_values.values().collect::<Vec<_>>();
517                if options.sorted_enum_values {
518                    values.sort_by_key(|value| &value.name);
519                }
520
521                for value in values {
522                    if let Some(description) = &value.description {
523                        write_description(sdl, options, 1, description);
524                    }
525                    write!(sdl, "\t{}", value.name).ok();
526                    write_deprecated(sdl, &value.deprecation);
527
528                    if options.federation {
529                        if value.inaccessible {
530                            write!(sdl, " @inaccessible").ok();
531                        }
532
533                        for tag in &value.tags {
534                            write!(sdl, " @tag(name: \"{}\")", tag.replace('"', "\\\"")).ok();
535                        }
536                    }
537
538                    for directive in &value.directive_invocations {
539                        write!(sdl, " {}", directive.sdl()).ok();
540                    }
541
542                    writeln!(sdl).ok();
543                }
544
545                writeln!(sdl, "}}").ok();
546            }
547            MetaType::InputObject {
548                name,
549                input_fields,
550                description,
551                inaccessible,
552                tags,
553                oneof,
554                directive_invocations: raw_directives,
555                ..
556            } => {
557                if let Some(description) = description {
558                    write_description(sdl, options, 0, description);
559                }
560
561                write!(sdl, "input {}", name).ok();
562
563                if *oneof {
564                    write!(sdl, " @oneOf").ok();
565                }
566                if options.federation {
567                    if *inaccessible {
568                        write!(sdl, " @inaccessible").ok();
569                    }
570                    for tag in tags {
571                        write!(sdl, " @tag(name: \"{}\")", tag.replace('"', "\\\"")).ok();
572                    }
573                }
574
575                for directive in raw_directives {
576                    write!(sdl, " {}", directive.sdl()).ok();
577                }
578
579                writeln!(sdl, " {{").ok();
580
581                let mut fields = input_fields.values().collect::<Vec<_>>();
582                if options.sorted_fields {
583                    fields.sort_by_key(|value| &value.name);
584                }
585
586                for field in fields {
587                    if let Some(ref description) = &field.description {
588                        write_description(sdl, options, 1, description);
589                    }
590                    sdl.push('\t');
591                    write_input_value(sdl, field);
592                    if options.federation {
593                        if field.inaccessible {
594                            write!(sdl, " @inaccessible").ok();
595                        }
596                        for tag in &field.tags {
597                            write!(sdl, " @tag(name: \"{}\")", tag.replace('"', "\\\"")).ok();
598                        }
599                    }
600                    for directive in &field.directive_invocations {
601                        write!(sdl, " {}", directive.sdl()).ok();
602                    }
603                    writeln!(sdl).ok();
604                }
605
606                writeln!(sdl, "}}").ok();
607            }
608            MetaType::Union {
609                name,
610                possible_types,
611                description,
612                inaccessible,
613                tags,
614                directive_invocations,
615                ..
616            } => {
617                if let Some(description) = description {
618                    write_description(sdl, options, 0, description);
619                }
620
621                write!(sdl, "union {}", name).ok();
622                if options.federation {
623                    if *inaccessible {
624                        write!(sdl, " @inaccessible").ok();
625                    }
626                    for tag in tags {
627                        write!(sdl, " @tag(name: \"{}\")", tag.replace('"', "\\\"")).ok();
628                    }
629                }
630
631                for directive in directive_invocations {
632                    write!(sdl, " {}", directive.sdl()).ok();
633                }
634
635                write!(sdl, " =").ok();
636
637                for (idx, ty) in possible_types.iter().enumerate() {
638                    if idx == 0 {
639                        write!(sdl, " {}", ty).ok();
640                    } else {
641                        write!(sdl, " | {}", ty).ok();
642                    }
643                }
644                writeln!(sdl).ok();
645            }
646        }
647    }
648
649    fn write_implements(&self, sdl: &mut String, name: &str) {
650        if let Some(implements) = self.implements.get(name) {
651            if !implements.is_empty() {
652                write!(
653                    sdl,
654                    " implements {}",
655                    implements
656                        .iter()
657                        .map(AsRef::as_ref)
658                        .collect::<Vec<&str>>()
659                        .join(" & ")
660                )
661                .ok();
662            }
663        }
664    }
665}
666
667fn write_description(
668    sdl: &mut String,
669    options: &SDLExportOptions,
670    level: usize,
671    description: &str,
672) {
673    let tabs = "\t".repeat(level);
674
675    if options.prefer_single_line_descriptions && !description.contains('\n') {
676        let description = description.replace('"', r#"\""#);
677        writeln!(sdl, "{tabs}\"{description}\"").ok();
678    } else {
679        let description = description.replace('\n', &format!("\n{tabs}"));
680        writeln!(sdl, "{tabs}\"\"\"\n{tabs}{description}\n{tabs}\"\"\"").ok();
681    }
682}
683
684fn write_input_value(sdl: &mut String, input_value: &MetaInputValue) {
685    if let Some(default_value) = &input_value.default_value {
686        _ = write!(
687            sdl,
688            "{}: {} = {}",
689            input_value.name, input_value.ty, default_value
690        );
691    } else {
692        _ = write!(sdl, "{}: {}", input_value.name, input_value.ty);
693    }
694
695    write_deprecated(sdl, &input_value.deprecation);
696}
697
698fn write_deprecated(sdl: &mut String, deprecation: &Deprecation) {
699    if let Deprecation::Deprecated { reason } = deprecation {
700        let _ = match reason {
701            Some(reason) => write!(sdl, " @deprecated(reason: \"{}\")", escape_string(reason)).ok(),
702            None => write!(sdl, " @deprecated").ok(),
703        };
704    }
705}
706
707fn escape_string(s: &str) -> String {
708    let mut res = String::new();
709
710    for c in s.chars() {
711        let ec = match c {
712            '\\' => Some("\\\\"),
713            '\x08' => Some("\\b"),
714            '\x0c' => Some("\\f"),
715            '\n' => Some("\\n"),
716            '\r' => Some("\\r"),
717            '\t' => Some("\\t"),
718            _ => None,
719        };
720        match ec {
721            Some(ec) => {
722                res.write_str(ec).ok();
723            }
724            None => {
725                res.write_char(c).ok();
726            }
727        }
728    }
729
730    res
731}
732
733#[cfg(test)]
734mod tests {
735    use super::*;
736    use crate::{model::__DirectiveLocation, registry::MetaDirective};
737
738    #[test]
739    fn test_escape_string() {
740        assert_eq!(
741            escape_string("1\\\x08d\x0c3\n4\r5\t6"),
742            "1\\\\\\bd\\f3\\n4\\r5\\t6"
743        );
744    }
745
746    #[test]
747    fn test_compose_directive_dsl() {
748        let expected = r#"directive @custom_type_directive on FIELD_DEFINITION
749extend schema @link(
750	url: "https://specs.apollo.dev/federation/v2.3",
751	import: ["@key", "@tag", "@shareable", "@inaccessible", "@override", "@external", "@provides", "@requires", "@composeDirective", "@interfaceObject"]
752)
753
754extend schema @link(
755	url: "https://custom.spec.dev/extension/v1.0"
756	import: ["@custom_type_directive"]
757)
758	@composeDirective(name: "@custom_type_directive")
759
760"#;
761        let mut registry = Registry::default();
762        registry.add_directive(MetaDirective {
763            name: "custom_type_directive".to_string(),
764            description: None,
765            locations: vec![__DirectiveLocation::FIELD_DEFINITION],
766            args: Default::default(),
767            is_repeatable: false,
768            visible: None,
769            composable: Some("https://custom.spec.dev/extension/v1.0".to_string()),
770        });
771        let dsl = registry.export_sdl(SDLExportOptions::new().federation().compose_directive());
772        assert_eq!(dsl, expected)
773    }
774
775    #[test]
776    fn test_type_directive_sdl_without_federation() {
777        let expected = r#"directive @custom_type_directive(optionalWithoutDefault: String, optionalWithDefault: String = "DEFAULT") on FIELD_DEFINITION | OBJECT
778schema {
779	query: Query
780}
781"#;
782        let mut registry = Registry::default();
783        registry.add_directive(MetaDirective {
784            name: "custom_type_directive".to_string(),
785            description: None,
786            locations: vec![
787                __DirectiveLocation::FIELD_DEFINITION,
788                __DirectiveLocation::OBJECT,
789            ],
790            args: [
791                (
792                    "optionalWithoutDefault".to_string(),
793                    MetaInputValue {
794                        name: "optionalWithoutDefault".to_string(),
795                        description: None,
796                        ty: "String".to_string(),
797                        deprecation: Deprecation::NoDeprecated,
798                        default_value: None,
799                        visible: None,
800                        inaccessible: false,
801                        tags: vec![],
802                        is_secret: false,
803                        directive_invocations: vec![],
804                    },
805                ),
806                (
807                    "optionalWithDefault".to_string(),
808                    MetaInputValue {
809                        name: "optionalWithDefault".to_string(),
810                        description: None,
811                        ty: "String".to_string(),
812                        deprecation: Deprecation::NoDeprecated,
813                        default_value: Some("\"DEFAULT\"".to_string()),
814                        visible: None,
815                        inaccessible: false,
816                        tags: vec![],
817                        is_secret: false,
818                        directive_invocations: vec![],
819                    },
820                ),
821            ]
822            .into(),
823            is_repeatable: false,
824            visible: None,
825            composable: None,
826        });
827        registry.query_type = "Query".to_string();
828        let sdl = registry.export_sdl(SDLExportOptions::new());
829        assert_eq!(sdl, expected)
830    }
831}