Skip to main content

origincloud_core/names/
mod.rs

1//! Full resource names (AIP-122), the platform's identifiers:
2//! `//{service}.origincloudapis.eu/{relative name}`, such as
3//! `//pvs.origincloudapis.eu/projects/p/zones/eu-central1-az1/volumes/vol-1`.
4//!
5//! A resource's `name` is its relative name; its full name puts the host of
6//! the service that owns it in front, with no scheme and no API version.
7//! References in request bodies are full names. A request's path fields
8//! (`name`, `parent`) take either: a client given a full name of its own
9//! service sends its relative name, and refuses one of another service
10//! before sending anything.
11//!
12//! ```
13//! use origincloud_core::names::FullName;
14//!
15//! let volume: FullName =
16//!     "//pvs.origincloudapis.eu/projects/p/zones/eu-central1-az1/volumes/vol-1".parse()?;
17//! assert_eq!(volume.service(), "pvs");
18//! assert_eq!(volume.host(), "pvs.origincloudapis.eu");
19//! assert_eq!(volume.relative(), "projects/p/zones/eu-central1-az1/volumes/vol-1");
20//! assert_eq!(
21//!     volume.url("v1beta1")?,
22//!     "https://pvs.origincloudapis.eu/v1beta1/projects/p/zones/eu-central1-az1/volumes/vol-1"
23//! );
24//! assert_eq!(
25//!     FullName::from_relative("pvs", "projects/p/zones/eu-central1-az1/volumes/vol-1")?,
26//!     volume
27//! );
28//! # Ok::<(), origincloud_core::names::FullNameError>(())
29//! ```
30
31use std::fmt;
32use std::str::FromStr;
33
34use serde::de::{self, Deserializer, Visitor};
35use serde::{Deserialize, Serialize, Serializer};
36
37include!("generated/services.rs");
38
39/// The domain every service's host is under.
40pub const DOMAIN: &str = "origincloudapis.eu";
41
42/// A full resource name: `//`, the global host of the service that owns the
43/// resource, `/` and its relative name.
44///
45/// Built only by [`FullName::parse`] (the full form) or
46/// [`FullName::from_relative`] (a service and a relative name), both of
47/// which refuse anything but the one canonical spelling, so two names are
48/// equal exactly when they name the same resource. `Display`, `FromStr`
49/// and serde use the full form, and only it.
50#[derive(Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
51pub struct FullName {
52    /// `//{service}.{DOMAIN}/{relative}`, kept whole.
53    full: String,
54    /// The length of the service's short code.
55    service_len: usize,
56}
57
58/// Why a string is not a full resource name, or not one that can be used
59/// where it was given.
60#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
61#[non_exhaustive]
62pub enum FullNameError {
63    /// An empty string.
64    #[error("an empty string names no resource")]
65    Empty,
66    /// A URN, `urn:…`.
67    #[error(
68        "{0:?} is a URN; a resource is named by its full resource name, //{{service}}.{DOMAIN}/{{name}}"
69    )]
70    Urn(String),
71    /// A URL, or anything else with a scheme.
72    #[error("{0:?} is a URL; a full resource name has no scheme, //{{service}}.{DOMAIN}/{{name}}")]
73    Scheme(String),
74    /// A relative name where a full one is expected.
75    #[error("{0:?} is a relative resource name; a full one starts with //{{service}}.{DOMAIN}/")]
76    Relative(String),
77    /// A bare id, with no collection.
78    #[error(
79        "{0:?} is an id; a resource is named by its full resource name, //{{service}}.{DOMAIN}/{{name}}"
80    )]
81    BareId(String),
82    /// A host that is no service's.
83    #[error("{name:?} names no resource: {host} is not the host of one of the platform's APIs")]
84    UnknownService {
85        /// What was given.
86        name: String,
87        /// The host it names.
88        host: String,
89    },
90    /// A regional or zonal host, a front door rather than the service.
91    #[error(
92        "{name:?} names {host}; a full resource name has the service's global host, {service}.{DOMAIN}"
93    )]
94    NotGlobalHost {
95        /// What was given.
96        name: String,
97        /// The host it names.
98        host: String,
99        /// The short code of the service whose front door it is.
100        service: String,
101    },
102    /// An API version in the name.
103    #[error("{name:?} carries the API version {version:?}; a full resource name has none")]
104    Version {
105        /// What was given.
106        name: String,
107        /// The version.
108        version: String,
109    },
110    /// A service's host with nothing after it.
111    #[error("{0:?} has no relative name after its service")]
112    NoRelativeName(String),
113    /// A full name where a relative one is expected.
114    #[error("{0:?} is a full resource name; a relative one is expected here")]
115    NotRelative(String),
116    /// A name ending in `/`.
117    #[error("{0:?} ends with a '/'")]
118    TrailingSlash(String),
119    /// A name with an empty segment.
120    #[error("{0:?} has an empty segment")]
121    EmptySegment(String),
122    /// A name with a `.` or `..` segment.
123    #[error("{name:?} has the segment {segment:?}, which a resource name can't")]
124    DotSegment {
125        /// What was given.
126        name: String,
127        /// The segment.
128        segment: String,
129    },
130    /// A segment with a character a name can't hold.
131    #[error(
132        "{name:?} has the segment {segment:?}: a segment is letters, digits and '-', '.', '_' or '~'"
133    )]
134    InvalidSegment {
135        /// What was given.
136        name: String,
137        /// The segment.
138        segment: String,
139    },
140    /// [`FullName::url`] given something that is no API version.
141    #[error("{0:?} is not an API version, such as v1 or v1beta1")]
142    NotAVersion(String),
143}
144
145impl FullName {
146    /// Parses the full form, `//{host}/{relative}`, and nothing else.
147    ///
148    /// Refused: a URN, a URL (any scheme), a relative name, a bare id, a
149    /// host that isn't the global host of one of [`RESOURCE_SERVICES`] (a
150    /// regional or zonal one included), an API version as the first
151    /// segment, a trailing `/`, and an empty, `.`, `..` or otherwise
152    /// invalid segment.
153    pub fn parse(name: &str) -> Result<FullName, FullNameError> {
154        let Some(rest) = name.strip_prefix("//") else {
155            return Err(not_full(name));
156        };
157        let Some((host, relative)) = rest.split_once('/') else {
158            return Err(match service_of_host(name, rest) {
159                Ok(_) => FullNameError::NoRelativeName(name.to_owned()),
160                Err(e) => e,
161            });
162        };
163        let service = service_of_host(name, host)?;
164        if relative.is_empty() {
165            return Err(FullNameError::NoRelativeName(name.to_owned()));
166        }
167        check_relative(name, relative)?;
168        Ok(FullName {
169            full: name.to_owned(),
170            service_len: service.len(),
171        })
172    }
173
174    /// The full name of the resource `relative` (its `name`) of `service`,
175    /// a short code such as `pvs`: `//{service}.origincloudapis.eu/` +
176    /// `relative`.
177    ///
178    /// Refused: a service that isn't one of [`RESOURCE_SERVICES`], a full
179    /// name given as the relative one, an API version as the first segment,
180    /// a trailing `/`, and an empty, `.`, `..` or otherwise invalid
181    /// segment.
182    pub fn from_relative(service: &str, relative: &str) -> Result<FullName, FullNameError> {
183        if relative.starts_with("//") {
184            return Err(FullNameError::NotRelative(relative.to_owned()));
185        }
186        let host = format!("{service}.{DOMAIN}");
187        if !RESOURCE_SERVICES.contains(&service) {
188            return Err(FullNameError::UnknownService {
189                name: relative.to_owned(),
190                host,
191            });
192        }
193        if relative.is_empty() {
194            return Err(FullNameError::NoRelativeName(relative.to_owned()));
195        }
196        check_relative(relative, relative)?;
197        Ok(FullName {
198            full: format!("//{host}/{relative}"),
199            service_len: service.len(),
200        })
201    }
202
203    /// The short code of the service that owns the resource (`pvs`).
204    pub fn service(&self) -> &str {
205        &self.full[2..2 + self.service_len]
206    }
207
208    /// The service's host (`pvs.origincloudapis.eu`).
209    pub fn host(&self) -> &str {
210        &self.full[2..self.host_end()]
211    }
212
213    /// The relative name, the resource's `name`: the full name without
214    /// `//{host}/`.
215    pub fn relative(&self) -> &str {
216        &self.full[self.host_end() + 1..]
217    }
218
219    /// The full form, as `Display` writes it.
220    pub fn as_str(&self) -> &str {
221        &self.full
222    }
223
224    /// The URL that calls the resource,
225    /// `https://{host}/{version}/{relative}`, for an API version such as
226    /// `v1beta1`.
227    pub fn url(&self, version: &str) -> Result<String, FullNameError> {
228        if !is_version(version) {
229            return Err(FullNameError::NotAVersion(version.to_owned()));
230        }
231        Ok(format!(
232            "https://{}/{version}/{}",
233            self.host(),
234            self.relative()
235        ))
236    }
237
238    /// The principal the name is, if it is one: a user, a service account,
239    /// a service agent or an internal platform service, each an iam name.
240    pub fn principal(&self) -> Option<Principal<'_>> {
241        if self.service() != "iam" {
242            return None;
243        }
244        let segments: Vec<&str> = self.relative().split('/').collect();
245        match segments.as_slice() {
246            ["users", user] => Some(Principal::User { user }),
247            [
248                "organizations",
249                organization,
250                "serviceAccounts",
251                service_account,
252            ] => Some(Principal::ServiceAccount {
253                organization,
254                service_account,
255            }),
256            ["serviceAgents", service_agent] => Some(Principal::ServiceAgent { service_agent }),
257            ["services", service] => Some(Principal::Service { service }),
258            _ => None,
259        }
260    }
261
262    /// The role's id if the name is a role,
263    /// `//iam.origincloudapis.eu/roles/{role}`: `iam.editor`. A role's id
264    /// writes its parts with dots; `roles/iam/editor` is no role.
265    pub fn role(&self) -> Option<&str> {
266        if self.service() != "iam" {
267            return None;
268        }
269        self.relative()
270            .strip_prefix("roles/")
271            .filter(|role| !role.contains('/'))
272    }
273
274    /// The full name of the role `role`, such as `iam.editor`:
275    /// `//iam.origincloudapis.eu/roles/iam.editor`.
276    pub fn of_role(role: &str) -> Result<FullName, FullNameError> {
277        if role.contains('/') {
278            return Err(FullNameError::InvalidSegment {
279                name: role.to_owned(),
280                segment: role.to_owned(),
281            });
282        }
283        FullName::from_relative("iam", &format!("roles/{role}"))
284    }
285
286    /// Where the host ends in `full`.
287    fn host_end(&self) -> usize {
288        2 + self.service_len + 1 + DOMAIN.len()
289    }
290}
291
292/// Who acts: a principal, as a member of a role binding or the actor of an
293/// audit entry names it. Each is an iam full name.
294///
295/// An organisation acting on its own has no full name yet, and so no
296/// variant.
297#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
298#[non_exhaustive]
299pub enum Principal<'a> {
300    /// A user: `//iam.origincloudapis.eu/users/{user}`.
301    User {
302        /// The user's id.
303        user: &'a str,
304    },
305    /// An organization's service account:
306    /// `//iam.origincloudapis.eu/organizations/{organization}/serviceAccounts/{service_account}`.
307    ServiceAccount {
308        /// The organization's id.
309        organization: &'a str,
310        /// The service account's id.
311        service_account: &'a str,
312    },
313    /// A service agent, a platform service acting in a customer's project:
314    /// `//iam.origincloudapis.eu/serviceAgents/{service_agent}`.
315    ServiceAgent {
316        /// The service agent's name.
317        service_agent: &'a str,
318    },
319    /// An internal platform service:
320    /// `//iam.origincloudapis.eu/services/{service}`.
321    Service {
322        /// The service's name.
323        service: &'a str,
324    },
325}
326
327impl Principal<'_> {
328    /// The principal's full name.
329    pub fn full_name(&self) -> Result<FullName, FullNameError> {
330        let (relative, parts) = match *self {
331            Principal::User { user } => (format!("users/{user}"), vec![user]),
332            Principal::ServiceAccount {
333                organization,
334                service_account,
335            } => (
336                format!("organizations/{organization}/serviceAccounts/{service_account}"),
337                vec![organization, service_account],
338            ),
339            Principal::ServiceAgent { service_agent } => (
340                format!("serviceAgents/{service_agent}"),
341                vec![service_agent],
342            ),
343            Principal::Service { service } => (format!("services/{service}"), vec![service]),
344        };
345        // A part with a '/' would make another name, of no principal.
346        if let Some(part) = parts.iter().find(|p| p.contains('/')) {
347            return Err(FullNameError::InvalidSegment {
348                name: relative.clone(),
349                segment: (*part).to_owned(),
350            });
351        }
352        FullName::from_relative("iam", &relative)
353    }
354}
355
356/// The short code of the service whose global host is `host`, or why there
357/// is none.
358fn service_of_host<'h>(name: &str, host: &'h str) -> Result<&'h str, FullNameError> {
359    let code = host.strip_suffix(DOMAIN).and_then(|h| h.strip_suffix('.'));
360    if let Some(code) = code.filter(|c| RESOURCE_SERVICES.contains(c)) {
361        return Ok(code);
362    }
363    // `{service}.{region or zone}.origincloudapis.eu`: a front door, not
364    // the service's name.
365    let front_door = code
366        .and_then(|h| h.split_once('.'))
367        .filter(|(_, location)| !location.is_empty())
368        .map(|(code, _)| code)
369        .filter(|code| RESOURCE_SERVICES.contains(code));
370    Err(match front_door {
371        Some(service) => FullNameError::NotGlobalHost {
372            name: name.to_owned(),
373            host: host.to_owned(),
374            service: service.to_owned(),
375        },
376        None => FullNameError::UnknownService {
377            name: name.to_owned(),
378            host: host.to_owned(),
379        },
380    })
381}
382
383/// Why `name`, which doesn't start with `//`, is not a full name.
384fn not_full(name: &str) -> FullNameError {
385    let owned = name.to_owned();
386    if name.is_empty() {
387        FullNameError::Empty
388    } else if name
389        .get(..4)
390        .is_some_and(|p| p.eq_ignore_ascii_case("urn:"))
391    {
392        FullNameError::Urn(owned)
393    } else if name.contains("://") {
394        FullNameError::Scheme(owned)
395    } else if name.contains('/') {
396        FullNameError::Relative(owned)
397    } else {
398        FullNameError::BareId(owned)
399    }
400}
401
402/// Checks a non-empty relative name; `name` is what the error reports.
403fn check_relative(name: &str, relative: &str) -> Result<(), FullNameError> {
404    if relative.ends_with('/') {
405        return Err(FullNameError::TrailingSlash(name.to_owned()));
406    }
407    let mut segments = relative.split('/').peekable();
408    if let Some(first) = segments.peek()
409        && is_version(first)
410    {
411        return Err(FullNameError::Version {
412            name: name.to_owned(),
413            version: (*first).to_owned(),
414        });
415    }
416    for segment in segments {
417        check_segment(name, segment)?;
418    }
419    Ok(())
420}
421
422/// A segment is non-empty, neither `.` nor `..`, and made of RFC 3986's
423/// unreserved characters, so a name is a URL path as it is.
424fn check_segment(name: &str, segment: &str) -> Result<(), FullNameError> {
425    if segment.is_empty() {
426        return Err(FullNameError::EmptySegment(name.to_owned()));
427    }
428    if segment == "." || segment == ".." {
429        return Err(FullNameError::DotSegment {
430            name: name.to_owned(),
431            segment: segment.to_owned(),
432        });
433    }
434    let unreserved = |c: char| c.is_ascii_alphanumeric() || matches!(c, '-' | '.' | '_' | '~');
435    if !segment.chars().all(unreserved) {
436        return Err(FullNameError::InvalidSegment {
437            name: name.to_owned(),
438            segment: segment.to_owned(),
439        });
440    }
441    Ok(())
442}
443
444/// Whether `segment` reads as an API version (AIP-185): `v{major}`,
445/// optionally followed by `alpha` or `beta` and a number.
446fn is_version(segment: &str) -> bool {
447    let Some(rest) = segment.strip_prefix('v') else {
448        return false;
449    };
450    let major_len = rest.chars().take_while(char::is_ascii_digit).count();
451    if major_len == 0 || rest.starts_with('0') {
452        return false;
453    }
454    let stability = &rest[major_len..];
455    let number = stability
456        .strip_prefix("alpha")
457        .or_else(|| stability.strip_prefix("beta"));
458    match number {
459        None => stability.is_empty(),
460        Some(number) => number.chars().all(|c| c.is_ascii_digit()),
461    }
462}
463
464/// Every resource service, in the services a path field takes: a field
465/// referring to any type (`google.api.resource_reference`'s `type: "*"`).
466pub const ANY_SERVICE: &str = "*";
467
468/// The relative name to send in a request's path for `value`, the path
469/// field `field` of a call to the service `own` (a short code), which takes
470/// full names of `services`: the services of the type it refers to,
471/// [`ANY_SERVICE`] for any, or none for `own`.
472///
473/// `value` is sent as it is when it isn't a full name, and as its relative
474/// name when it is one of those services'. A full name of another service,
475/// a URN, a URL or no valid full name is an error.
476pub(crate) fn path_name<'v>(
477    own: &str,
478    services: &[&str],
479    field: &str,
480    value: &'v str,
481) -> Result<&'v str, String> {
482    let own = [own];
483    let services = if services.is_empty() {
484        &own[..]
485    } else {
486        services
487    };
488    let refused = |e: FullNameError| format!("{field} must be a name on {}: {e}", hosts(services));
489    if !value.starts_with("//") {
490        return match not_full(value) {
491            e @ (FullNameError::Urn(_) | FullNameError::Scheme(_)) => Err(refused(e)),
492            _ => Ok(value),
493        };
494    }
495    let name = FullName::parse(value).map_err(refused)?;
496    if !services.contains(&ANY_SERVICE) && !services.contains(&name.service()) {
497        return Err(format!(
498            "{field} must be a name on {}, got {value:?}, a name on {}",
499            hosts(services),
500            name.host()
501        ));
502    }
503    Ok(&value[value.len() - name.relative().len()..])
504}
505
506/// The hosts of `services`, for an error: `a.origincloudapis.eu`, `one of
507/// a.origincloudapis.eu, b.origincloudapis.eu`, or any API's for
508/// [`ANY_SERVICE`].
509fn hosts(services: &[&str]) -> String {
510    if services.contains(&ANY_SERVICE) {
511        return "the host of one of the platform's APIs".to_owned();
512    }
513    let hosts: Vec<String> = services.iter().map(|s| format!("{s}.{DOMAIN}")).collect();
514    match hosts.as_slice() {
515        [one] => one.clone(),
516        _ => format!("one of {}", hosts.join(", ")),
517    }
518}
519
520impl fmt::Display for FullName {
521    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
522        f.write_str(&self.full)
523    }
524}
525
526impl fmt::Debug for FullName {
527    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
528        f.debug_tuple("FullName").field(&self.full).finish()
529    }
530}
531
532impl FromStr for FullName {
533    type Err = FullNameError;
534
535    fn from_str(s: &str) -> Result<Self, Self::Err> {
536        FullName::parse(s)
537    }
538}
539
540impl AsRef<str> for FullName {
541    fn as_ref(&self) -> &str {
542        &self.full
543    }
544}
545
546impl From<FullName> for String {
547    fn from(name: FullName) -> String {
548        name.full
549    }
550}
551
552/// The full form, as a string.
553impl Serialize for FullName {
554    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
555        serializer.serialize_str(&self.full)
556    }
557}
558
559/// A string in the full form; anything [`FullName::parse`] refuses is an
560/// error.
561impl<'de> Deserialize<'de> for FullName {
562    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
563        struct FullNameVisitor;
564
565        impl Visitor<'_> for FullNameVisitor {
566            type Value = FullName;
567
568            fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
569                f.write_str("a full resource name, //{service}.origincloudapis.eu/{name}")
570            }
571
572            fn visit_str<E: de::Error>(self, value: &str) -> Result<FullName, E> {
573                FullName::parse(value).map_err(E::custom)
574            }
575        }
576
577        deserializer.deserialize_str(FullNameVisitor)
578    }
579}
580
581#[cfg(test)]
582mod tests;