Skip to main content

apollo_federation/link/
mod.rs

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