apollo_federation/link/
mod.rs

1use std::collections::HashMap;
2use std::fmt;
3use std::str;
4use std::sync::Arc;
5
6use apollo_compiler::InvalidNameError;
7use apollo_compiler::Name;
8use apollo_compiler::Node;
9use apollo_compiler::Schema;
10use apollo_compiler::ast::Directive;
11use apollo_compiler::ast::Value;
12use apollo_compiler::collections::IndexMap;
13use apollo_compiler::name;
14use apollo_compiler::schema::Component;
15use thiserror::Error;
16
17use crate::error::FederationError;
18use crate::error::SingleFederationError;
19use crate::link::link_spec_definition::CORE_VERSIONS;
20use crate::link::link_spec_definition::LINK_VERSIONS;
21use crate::link::link_spec_definition::LinkSpecDefinition;
22use crate::link::spec::Identity;
23use crate::link::spec::Url;
24
25pub(crate) mod argument;
26pub(crate) mod authenticated_spec_definition;
27pub(crate) mod cache_tag_spec_definition;
28pub(crate) mod context_spec_definition;
29pub mod cost_spec_definition;
30pub mod database;
31pub(crate) mod federation_spec_definition;
32pub(crate) mod graphql_definition;
33pub(crate) mod inaccessible_spec_definition;
34pub(crate) mod join_spec_definition;
35pub(crate) mod link_spec_definition;
36pub(crate) mod policy_spec_definition;
37pub(crate) mod requires_scopes_spec_definition;
38pub mod spec;
39pub(crate) mod spec_definition;
40pub(crate) mod tag_spec_definition;
41
42pub const DEFAULT_LINK_NAME: Name = name!("link");
43pub const DEFAULT_IMPORT_SCALAR_NAME: Name = name!("Import");
44pub const DEFAULT_PURPOSE_ENUM_NAME: Name = name!("Purpose");
45pub(crate) const IMPORT_AS_ARGUMENT: Name = name!("as");
46pub(crate) const IMPORT_NAME_ARGUMENT: Name = name!("name");
47
48// TODO: we should provide proper "diagnostic" here, linking to ast, accumulating more than one
49// error and whatnot.
50#[derive(Error, Debug, PartialEq)]
51pub enum LinkError {
52    #[error(transparent)]
53    InvalidName(#[from] InvalidNameError),
54    #[error("Invalid use of @link in schema: {0}")]
55    BootstrapError(String),
56    #[error("Unknown import: {0}")]
57    InvalidImport(String),
58}
59
60// TODO: Replace LinkError usages with FederationError.
61impl From<LinkError> for FederationError {
62    fn from(value: LinkError) -> Self {
63        SingleFederationError::InvalidLinkDirectiveUsage {
64            message: value.to_string(),
65        }
66        .into()
67    }
68}
69
70#[derive(Clone, Copy, Eq, PartialEq, Debug)]
71pub enum Purpose {
72    SECURITY,
73    EXECUTION,
74}
75
76impl Purpose {
77    pub fn from_value(value: &Value) -> Result<Purpose, LinkError> {
78        value
79            .as_enum()
80            .ok_or_else(|| {
81                LinkError::BootstrapError("invalid `purpose` value, should be an enum".to_string())
82            })
83            .and_then(|value| value.parse())
84    }
85}
86
87impl str::FromStr for Purpose {
88    type Err = LinkError;
89
90    fn from_str(s: &str) -> Result<Self, Self::Err> {
91        match s {
92            "SECURITY" => Ok(Purpose::SECURITY),
93            "EXECUTION" => Ok(Purpose::EXECUTION),
94            _ => Err(LinkError::BootstrapError(format!(
95                "invalid/unrecognized `purpose` value '{s}'"
96            ))),
97        }
98    }
99}
100
101impl fmt::Display for Purpose {
102    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
103        match self {
104            Purpose::SECURITY => f.write_str("SECURITY"),
105            Purpose::EXECUTION => f.write_str("EXECUTION"),
106        }
107    }
108}
109
110impl From<&Purpose> for Name {
111    fn from(value: &Purpose) -> Self {
112        match value {
113            Purpose::SECURITY => name!("SECURITY"),
114            Purpose::EXECUTION => name!("EXECUTION"),
115        }
116    }
117}
118
119#[derive(Eq, PartialEq, Debug)]
120pub struct Import {
121    /// The name of the element that is being imported.
122    ///
123    /// Note that this will never start with '@': whether or not this is the name of a directive is
124    /// entirely reflected by the value of `is_directive`.
125    pub element: Name,
126
127    /// Whether the imported element is a directive (if it is not, then it is an imported type).
128    pub is_directive: bool,
129
130    /// The optional alias under which the element is imported.
131    pub alias: Option<Name>,
132}
133
134impl Import {
135    pub fn from_value(value: &Value) -> Result<Import, LinkError> {
136        // TODO: it could be nice to include the broken value in the error messages of this method
137        // (especially since @link(import:) is a list), but `Value` does not implement `Display`
138        // currently, so a bit annoying.
139        match value {
140            Value::String(str) => {
141                if let Some(directive_name) = str.strip_prefix('@') {
142                    Ok(Import {
143                        element: Name::new(directive_name)?,
144                        is_directive: true,
145                        alias: None,
146                    })
147                } else {
148                    Ok(Import {
149                        element: Name::new(str)?,
150                        is_directive: false,
151                        alias: None,
152                    })
153                }
154            }
155            Value::Object(fields) => {
156                let mut name: Option<&str> = None;
157                let mut alias: Option<&str> = None;
158                for (k, v) in fields {
159                    match k.as_str() {
160                        "name" => {
161                            name = Some(v.as_str().ok_or_else(|| {
162                                LinkError::BootstrapError(format!(r#"in "{}", invalid value for `name` field in @link(import:) argument: must be a string"#, value.serialize().no_indent()))
163                            })?)
164                        },
165                        "as" => {
166                            alias = Some(v.as_str().ok_or_else(|| {
167                                LinkError::BootstrapError(format!(r#"in "{}", invalid value for `as` field in @link(import:) argument: must be a string"#, value.serialize().no_indent()))
168                            })?)
169                        },
170                        _ => Err(LinkError::BootstrapError(format!(r#"in "{}", unknown field `{k}` in @link(import:) argument"#, value.serialize().no_indent())))?
171                    }
172                }
173                let Some(element) = name else {
174                    return Err(LinkError::BootstrapError(format!(
175                        r#"in "{}", invalid entry in @link(import:) argument, missing mandatory `name` field"#,
176                        value.serialize().no_indent()
177                    )));
178                };
179                if let Some(directive_name) = element.strip_prefix('@') {
180                    if let Some(alias_str) = alias.as_ref() {
181                        let Some(alias_str) = alias_str.strip_prefix('@') else {
182                            return Err(LinkError::BootstrapError(format!(
183                                r#"in "{}", invalid alias '{alias_str}' for import name '{element}': should start with '@' since the imported name does"#,
184                                value.serialize().no_indent()
185                            )));
186                        };
187                        alias = Some(alias_str);
188                    }
189                    Ok(Import {
190                        element: Name::new(directive_name)?,
191                        is_directive: true,
192                        alias: alias.map(Name::new).transpose()?,
193                    })
194                } else {
195                    if let Some(alias) = &alias
196                        && alias.starts_with('@')
197                    {
198                        return Err(LinkError::BootstrapError(format!(
199                            r#"in "{}", invalid alias '{alias}' for import name '{element}': should not start with '@' (or, if {element} is a directive, then the name should start with '@')"#,
200                            value.serialize().no_indent()
201                        )));
202                    }
203                    Ok(Import {
204                        element: Name::new(element)?,
205                        is_directive: false,
206                        alias: alias.map(Name::new).transpose()?,
207                    })
208                }
209            }
210            _ => Err(LinkError::BootstrapError(format!(
211                r#"in "{}", invalid sub-value for @link(import:) argument: values should be either strings or input object values of the form {{ name: "<importedElement>", as: "<alias>" }}."#,
212                value.serialize().no_indent()
213            ))),
214        }
215    }
216
217    pub fn element_display_name(&self) -> impl fmt::Display {
218        DisplayName {
219            name: &self.element,
220            is_directive: self.is_directive,
221        }
222    }
223
224    pub fn imported_name(&self) -> &Name {
225        self.alias.as_ref().unwrap_or(&self.element)
226    }
227
228    pub fn imported_display_name(&self) -> impl fmt::Display {
229        DisplayName {
230            name: self.imported_name(),
231            is_directive: self.is_directive,
232        }
233    }
234}
235
236/// A [`fmt::Display`]able wrapper for name strings that adds an `@` in front for directive names.
237struct DisplayName<'s> {
238    name: &'s str,
239    is_directive: bool,
240}
241
242impl fmt::Display for DisplayName<'_> {
243    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
244        if self.is_directive {
245            f.write_str("@")?;
246        }
247        f.write_str(self.name)
248    }
249}
250
251impl fmt::Display for Import {
252    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
253        if self.alias.is_some() {
254            write!(
255                f,
256                r#"{{ name: "{}", as: "{}" }}"#,
257                self.element_display_name(),
258                self.imported_display_name()
259            )
260        } else {
261            write!(f, r#""{}""#, self.imported_display_name())
262        }
263    }
264}
265
266#[allow(clippy::from_over_into)]
267impl Into<Value> for Import {
268    fn into(self) -> Value {
269        let element_string = if self.is_directive {
270            format!("@{}", self.element)
271        } else {
272            self.element.to_string()
273        };
274
275        if let Some(alias) = self.alias {
276            let alias_string = if self.is_directive {
277                format!("@{}", alias)
278            } else {
279                alias.to_string()
280            };
281            Value::Object(vec![
282                (
283                    IMPORT_NAME_ARGUMENT,
284                    Node::new(Value::String(element_string)),
285                ),
286                (IMPORT_AS_ARGUMENT, Node::new(Value::String(alias_string))),
287            ])
288        } else {
289            Value::String(element_string)
290        }
291    }
292}
293
294#[derive(Clone, Debug, Eq, PartialEq)]
295pub struct Link {
296    pub url: Url,
297    pub spec_alias: Option<Name>,
298    pub imports: Vec<Arc<Import>>,
299    pub purpose: Option<Purpose>,
300}
301
302impl Link {
303    pub fn spec_name_in_schema(&self) -> &Name {
304        self.spec_alias.as_ref().unwrap_or(&self.url.identity.name)
305    }
306
307    pub fn directive_name_in_schema(&self, name: &Name) -> Name {
308        // If the directive is imported, then it's name in schema is whatever name it is
309        // imported under. Otherwise, it is usually fully qualified by the spec name (so,
310        // something like 'federation__key'), but there is a special case for directives
311        // whose name match the one of the spec: those don't get qualified.
312        if let Some(import) = self.imports.iter().find(|i| i.element == *name) {
313            import.alias.clone().unwrap_or_else(|| name.clone())
314        } else if name == self.url.identity.name.as_str() {
315            self.spec_name_in_schema().clone()
316        } else {
317            // Both sides are `Name`s and we just add valid characters in between.
318            Name::new_unchecked(&format!("{}__{}", self.spec_name_in_schema(), name))
319        }
320    }
321
322    pub(crate) fn directive_name_in_schema_for_core_arguments(
323        spec_url: &Url,
324        spec_name_in_schema: &Name,
325        imports: &[Import],
326        directive_name_in_spec: &Name,
327    ) -> Name {
328        if let Some(element_import) = imports
329            .iter()
330            .find(|i| i.element == *directive_name_in_spec)
331        {
332            element_import.imported_name().clone()
333        } else if spec_url.identity.name == *directive_name_in_spec {
334            spec_name_in_schema.clone()
335        } else {
336            Name::new_unchecked(format!("{spec_name_in_schema}__{directive_name_in_spec}").as_str())
337        }
338    }
339
340    pub fn type_name_in_schema(&self, name: &Name) -> Name {
341        // Similar to directives, but the special case of a directive name matching the spec
342        // name does not apply to types.
343        if let Some(import) = self.imports.iter().find(|i| i.element == *name) {
344            import.alias.clone().unwrap_or_else(|| name.clone())
345        } else {
346            // Both sides are `Name`s and we just add valid characters in between.
347            Name::new_unchecked(&format!("{}__{}", self.spec_name_in_schema(), name))
348        }
349    }
350
351    pub fn from_directive_application(directive: &Node<Directive>) -> Result<Link, LinkError> {
352        let (url, is_link) = if let Some(value) = directive.specified_argument_by_name("url") {
353            (value, true)
354        } else if let Some(value) = directive.specified_argument_by_name("feature") {
355            // XXX(@goto-bus-stop): @core compatibility is primarily to support old tests--should be
356            // removed when those are updated.
357            (value, false)
358        } else {
359            return Err(LinkError::BootstrapError(
360                "the `url` argument for @link is mandatory".to_string(),
361            ));
362        };
363
364        let (directive_name, arg_name) = if is_link {
365            ("link", "url")
366        } else {
367            ("core", "feature")
368        };
369
370        let url = url.as_str().ok_or_else(|| {
371            LinkError::BootstrapError(format!(
372                "the `{arg_name}` argument for @{directive_name} must be a String"
373            ))
374        })?;
375        let url: Url = url.parse::<Url>().map_err(|e| {
376            LinkError::BootstrapError(format!("invalid `{arg_name}` argument (reason: {e})"))
377        })?;
378
379        let spec_alias = directive
380            .specified_argument_by_name("as")
381            .and_then(|arg| arg.as_str())
382            .map(Name::new)
383            .transpose()?;
384        let purpose = if let Some(value) = directive.specified_argument_by_name("for") {
385            Some(Purpose::from_value(value)?)
386        } else {
387            None
388        };
389
390        let imports = if is_link {
391            directive
392                .specified_argument_by_name("import")
393                .and_then(|arg| arg.as_list())
394                .unwrap_or(&[])
395                .iter()
396                .map(|value| Ok(Arc::new(Import::from_value(value)?)))
397                .collect::<Result<Vec<Arc<Import>>, LinkError>>()?
398        } else {
399            Default::default()
400        };
401
402        Ok(Link {
403            url,
404            spec_alias,
405            imports,
406            purpose,
407        })
408    }
409
410    pub fn for_identity<'schema>(
411        schema: &'schema Schema,
412        identity: &Identity,
413    ) -> Option<(Self, &'schema Component<Directive>)> {
414        schema
415            .schema_definition
416            .directives
417            .iter()
418            .find_map(|directive| {
419                let link = Link::from_directive_application(directive).ok()?;
420                if link.url.identity == *identity {
421                    Some((link, directive))
422                } else {
423                    None
424                }
425            })
426    }
427
428    /// Returns true if this link has an import assigning an alias to the given element.
429    pub(crate) fn renames(&self, element: &Name) -> bool {
430        self.imports
431            .iter()
432            .find(|import| &import.element == element)
433            .is_some_and(|import| *import.imported_name() != *element)
434    }
435}
436
437impl fmt::Display for Link {
438    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
439        let imported_types: Vec<String> = self
440            .imports
441            .iter()
442            .map(|import| import.to_string())
443            .collect::<Vec<String>>();
444        let imports = if imported_types.is_empty() {
445            "".to_string()
446        } else {
447            format!(r#", import: [{}]"#, imported_types.join(", "))
448        };
449        let alias = self
450            .spec_alias
451            .as_ref()
452            .map(|a| format!(r#", as: "{a}""#))
453            .unwrap_or("".to_string());
454        let purpose = self
455            .purpose
456            .as_ref()
457            .map(|p| format!(r#", for: {p}"#))
458            .unwrap_or("".to_string());
459        write!(f, r#"@link(url: "{}"{alias}{imports}{purpose})"#, self.url)
460    }
461}
462
463#[derive(Clone, Debug)]
464pub struct LinkedElement {
465    pub link: Arc<Link>,
466    pub import: Option<Arc<Import>>,
467}
468
469#[derive(Clone, Default, Eq, PartialEq, Debug)]
470pub struct LinksMetadata {
471    pub(crate) links: Vec<Arc<Link>>,
472    pub(crate) by_identity: IndexMap<Identity, Arc<Link>>,
473    pub(crate) by_name_in_schema: IndexMap<Name, Arc<Link>>,
474    pub(crate) types_by_imported_name: IndexMap<Name, (Arc<Link>, Arc<Import>)>,
475    pub(crate) directives_by_imported_name: IndexMap<Name, (Arc<Link>, Arc<Import>)>,
476    pub(crate) directives_by_original_name: IndexMap<Name, (Arc<Link>, Arc<Import>)>,
477}
478
479impl LinksMetadata {
480    // PORT_NOTE: Call this as a replacement for `CoreFeatures.coreItself` from JS.
481    pub(crate) fn link_spec_definition(
482        &self,
483    ) -> Result<&'static LinkSpecDefinition, FederationError> {
484        if let Some(link_link) = self.for_identity(&Identity::link_identity()) {
485            LINK_VERSIONS.find(&link_link.url.version).ok_or_else(|| {
486                SingleFederationError::Internal {
487                    message: format!("Unexpected link spec version {}", link_link.url.version),
488                }
489                .into()
490            })
491        } else if let Some(core_link) = self.for_identity(&Identity::core_identity()) {
492            CORE_VERSIONS.find(&core_link.url.version).ok_or_else(|| {
493                SingleFederationError::Internal {
494                    message: format!("Unexpected core spec version {}", core_link.url.version),
495                }
496                .into()
497            })
498        } else {
499            Err(SingleFederationError::Internal {
500                message: "Unexpectedly could not find core/link spec".to_owned(),
501            }
502            .into())
503        }
504    }
505
506    pub fn all_links(&self) -> &[Arc<Link>] {
507        self.links.as_ref()
508    }
509
510    pub fn for_identity(&self, identity: &Identity) -> Option<Arc<Link>> {
511        self.by_identity.get(identity).cloned()
512    }
513
514    pub fn source_link_of_type(&self, type_name: &Name) -> Option<LinkedElement> {
515        // For types, it's either an imported name or it must be fully qualified
516
517        if let Some((link, import)) = self.types_by_imported_name.get(type_name) {
518            Some(LinkedElement {
519                link: Arc::clone(link),
520                import: Some(Arc::clone(import)),
521            })
522        } else {
523            type_name.split_once("__").and_then(|(spec_name, _)| {
524                self.by_name_in_schema
525                    .get(spec_name)
526                    .map(|link| LinkedElement {
527                        link: Arc::clone(link),
528                        import: None,
529                    })
530            })
531        }
532    }
533
534    pub fn source_link_of_directive(&self, directive_name: &Name) -> Option<LinkedElement> {
535        // For directives, it can be either:
536        //   1. be an imported name,
537        //   2. be the "imported" name of a linked spec (special case of a directive named like the
538        //      spec),
539        //   3. or it must be fully qualified.
540        if let Some((link, import)) = self.directives_by_imported_name.get(directive_name) {
541            return Some(LinkedElement {
542                link: Arc::clone(link),
543                import: Some(Arc::clone(import)),
544            });
545        }
546
547        if let Some(link) = self.by_name_in_schema.get(directive_name) {
548            return Some(LinkedElement {
549                link: Arc::clone(link),
550                import: None,
551            });
552        }
553
554        directive_name.split_once("__").and_then(|(spec_name, _)| {
555            self.by_name_in_schema
556                .get(spec_name)
557                .map(|link| LinkedElement {
558                    link: Arc::clone(link),
559                    import: None,
560                })
561        })
562    }
563
564    pub(crate) fn import_to_feature_url_map(&self) -> HashMap<String, Url> {
565        let directive_entries = self
566            .directives_by_imported_name
567            .iter()
568            .map(|(name, (link, _))| (name.to_string(), link.url.clone()));
569        let type_entries = self
570            .types_by_imported_name
571            .iter()
572            .map(|(name, (link, _))| (name.to_string(), link.url.clone()));
573
574        directive_entries.chain(type_entries).collect()
575    }
576}