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        if let Some(alias) = self.alias {
270            Value::Object(vec![
271                (
272                    IMPORT_NAME_ARGUMENT,
273                    Node::new(Value::String(self.element.to_string())),
274                ),
275                (
276                    IMPORT_AS_ARGUMENT,
277                    Node::new(Value::String(alias.to_string())),
278                ),
279            ])
280        } else {
281            Value::String(self.element.to_string())
282        }
283    }
284}
285
286#[derive(Clone, Debug, Eq, PartialEq)]
287pub struct Link {
288    pub url: Url,
289    pub spec_alias: Option<Name>,
290    pub imports: Vec<Arc<Import>>,
291    pub purpose: Option<Purpose>,
292}
293
294impl Link {
295    pub fn spec_name_in_schema(&self) -> &Name {
296        self.spec_alias.as_ref().unwrap_or(&self.url.identity.name)
297    }
298
299    pub fn directive_name_in_schema(&self, name: &Name) -> Name {
300        // If the directive is imported, then it's name in schema is whatever name it is
301        // imported under. Otherwise, it is usually fully qualified by the spec name (so,
302        // something like 'federation__key'), but there is a special case for directives
303        // whose name match the one of the spec: those don't get qualified.
304        if let Some(import) = self.imports.iter().find(|i| i.element == *name) {
305            import.alias.clone().unwrap_or_else(|| name.clone())
306        } else if name == self.url.identity.name.as_str() {
307            self.spec_name_in_schema().clone()
308        } else {
309            // Both sides are `Name`s and we just add valid characters in between.
310            Name::new_unchecked(&format!("{}__{}", self.spec_name_in_schema(), name))
311        }
312    }
313
314    pub(crate) fn directive_name_in_schema_for_core_arguments(
315        spec_url: &Url,
316        spec_name_in_schema: &Name,
317        imports: &[Import],
318        directive_name_in_spec: &Name,
319    ) -> Name {
320        if let Some(element_import) = imports
321            .iter()
322            .find(|i| i.element == *directive_name_in_spec)
323        {
324            element_import.imported_name().clone()
325        } else if spec_url.identity.name == *directive_name_in_spec {
326            spec_name_in_schema.clone()
327        } else {
328            Name::new_unchecked(format!("{spec_name_in_schema}__{directive_name_in_spec}").as_str())
329        }
330    }
331
332    pub fn type_name_in_schema(&self, name: &Name) -> Name {
333        // Similar to directives, but the special case of a directive name matching the spec
334        // name does not apply to types.
335        if let Some(import) = self.imports.iter().find(|i| i.element == *name) {
336            import.alias.clone().unwrap_or_else(|| name.clone())
337        } else {
338            // Both sides are `Name`s and we just add valid characters in between.
339            Name::new_unchecked(&format!("{}__{}", self.spec_name_in_schema(), name))
340        }
341    }
342
343    pub fn from_directive_application(directive: &Node<Directive>) -> Result<Link, LinkError> {
344        let (url, is_link) = if let Some(value) = directive.specified_argument_by_name("url") {
345            (value, true)
346        } else if let Some(value) = directive.specified_argument_by_name("feature") {
347            // XXX(@goto-bus-stop): @core compatibility is primarily to support old tests--should be
348            // removed when those are updated.
349            (value, false)
350        } else {
351            return Err(LinkError::BootstrapError(
352                "the `url` argument for @link is mandatory".to_string(),
353            ));
354        };
355
356        let (directive_name, arg_name) = if is_link {
357            ("link", "url")
358        } else {
359            ("core", "feature")
360        };
361
362        let url = url.as_str().ok_or_else(|| {
363            LinkError::BootstrapError(format!(
364                "the `{arg_name}` argument for @{directive_name} must be a String"
365            ))
366        })?;
367        let url: Url = url.parse::<Url>().map_err(|e| {
368            LinkError::BootstrapError(format!("invalid `{arg_name}` argument (reason: {e})"))
369        })?;
370
371        let spec_alias = directive
372            .specified_argument_by_name("as")
373            .and_then(|arg| arg.as_str())
374            .map(Name::new)
375            .transpose()?;
376        let purpose = if let Some(value) = directive.specified_argument_by_name("for") {
377            Some(Purpose::from_value(value)?)
378        } else {
379            None
380        };
381
382        let imports = if is_link {
383            directive
384                .specified_argument_by_name("import")
385                .and_then(|arg| arg.as_list())
386                .unwrap_or(&[])
387                .iter()
388                .map(|value| Ok(Arc::new(Import::from_value(value)?)))
389                .collect::<Result<Vec<Arc<Import>>, LinkError>>()?
390        } else {
391            Default::default()
392        };
393
394        Ok(Link {
395            url,
396            spec_alias,
397            imports,
398            purpose,
399        })
400    }
401
402    pub fn for_identity<'schema>(
403        schema: &'schema Schema,
404        identity: &Identity,
405    ) -> Option<(Self, &'schema Component<Directive>)> {
406        schema
407            .schema_definition
408            .directives
409            .iter()
410            .find_map(|directive| {
411                let link = Link::from_directive_application(directive).ok()?;
412                if link.url.identity == *identity {
413                    Some((link, directive))
414                } else {
415                    None
416                }
417            })
418    }
419}
420
421impl fmt::Display for Link {
422    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
423        let imported_types: Vec<String> = self
424            .imports
425            .iter()
426            .map(|import| import.to_string())
427            .collect::<Vec<String>>();
428        let imports = if imported_types.is_empty() {
429            "".to_string()
430        } else {
431            format!(r#", import: [{}]"#, imported_types.join(", "))
432        };
433        let alias = self
434            .spec_alias
435            .as_ref()
436            .map(|a| format!(r#", as: "{a}""#))
437            .unwrap_or("".to_string());
438        let purpose = self
439            .purpose
440            .as_ref()
441            .map(|p| format!(r#", for: {p}"#))
442            .unwrap_or("".to_string());
443        write!(f, r#"@link(url: "{}"{alias}{imports}{purpose})"#, self.url)
444    }
445}
446
447#[derive(Clone, Debug)]
448pub struct LinkedElement {
449    pub link: Arc<Link>,
450    pub import: Option<Arc<Import>>,
451}
452
453#[derive(Clone, Default, Eq, PartialEq, Debug)]
454pub struct LinksMetadata {
455    pub(crate) links: Vec<Arc<Link>>,
456    pub(crate) by_identity: IndexMap<Identity, Arc<Link>>,
457    pub(crate) by_name_in_schema: IndexMap<Name, Arc<Link>>,
458    pub(crate) types_by_imported_name: IndexMap<Name, (Arc<Link>, Arc<Import>)>,
459    pub(crate) directives_by_imported_name: IndexMap<Name, (Arc<Link>, Arc<Import>)>,
460    pub(crate) directives_by_original_name: IndexMap<Name, (Arc<Link>, Arc<Import>)>,
461}
462
463impl LinksMetadata {
464    // PORT_NOTE: Call this as a replacement for `CoreFeatures.coreItself` from JS.
465    pub(crate) fn link_spec_definition(
466        &self,
467    ) -> Result<&'static LinkSpecDefinition, FederationError> {
468        if let Some(link_link) = self.for_identity(&Identity::link_identity()) {
469            LINK_VERSIONS.find(&link_link.url.version).ok_or_else(|| {
470                SingleFederationError::Internal {
471                    message: format!("Unexpected link spec version {}", link_link.url.version),
472                }
473                .into()
474            })
475        } else if let Some(core_link) = self.for_identity(&Identity::core_identity()) {
476            CORE_VERSIONS.find(&core_link.url.version).ok_or_else(|| {
477                SingleFederationError::Internal {
478                    message: format!("Unexpected core spec version {}", core_link.url.version),
479                }
480                .into()
481            })
482        } else {
483            Err(SingleFederationError::Internal {
484                message: "Unexpectedly could not find core/link spec".to_owned(),
485            }
486            .into())
487        }
488    }
489
490    pub fn all_links(&self) -> &[Arc<Link>] {
491        self.links.as_ref()
492    }
493
494    pub fn for_identity(&self, identity: &Identity) -> Option<Arc<Link>> {
495        self.by_identity.get(identity).cloned()
496    }
497
498    pub fn source_link_of_type(&self, type_name: &Name) -> Option<LinkedElement> {
499        // For types, it's either an imported name or it must be fully qualified
500
501        if let Some((link, import)) = self.types_by_imported_name.get(type_name) {
502            Some(LinkedElement {
503                link: Arc::clone(link),
504                import: Some(Arc::clone(import)),
505            })
506        } else {
507            type_name.split_once("__").and_then(|(spec_name, _)| {
508                self.by_name_in_schema
509                    .get(spec_name)
510                    .map(|link| LinkedElement {
511                        link: Arc::clone(link),
512                        import: None,
513                    })
514            })
515        }
516    }
517
518    pub fn source_link_of_directive(&self, directive_name: &Name) -> Option<LinkedElement> {
519        // For directives, it can be either:
520        //   1. be an imported name,
521        //   2. be the "imported" name of a linked spec (special case of a directive named like the
522        //      spec),
523        //   3. or it must be fully qualified.
524        if let Some((link, import)) = self.directives_by_imported_name.get(directive_name) {
525            return Some(LinkedElement {
526                link: Arc::clone(link),
527                import: Some(Arc::clone(import)),
528            });
529        }
530
531        if let Some(link) = self.by_name_in_schema.get(directive_name) {
532            return Some(LinkedElement {
533                link: Arc::clone(link),
534                import: None,
535            });
536        }
537
538        directive_name.split_once("__").and_then(|(spec_name, _)| {
539            self.by_name_in_schema
540                .get(spec_name)
541                .map(|link| LinkedElement {
542                    link: Arc::clone(link),
543                    import: None,
544                })
545        })
546    }
547
548    pub(crate) fn import_to_feature_url_map(&self) -> HashMap<String, Url> {
549        let directive_entries = self
550            .directives_by_imported_name
551            .iter()
552            .map(|(name, (link, _))| (name.to_string(), link.url.clone()));
553        let type_entries = self
554            .types_by_imported_name
555            .iter()
556            .map(|(name, (link, _))| (name.to_string(), link.url.clone()));
557
558        directive_entries.chain(type_entries).collect()
559    }
560}