diff --git a/crates/domain/src/authority.rs b/crates/domain/src/authority.rs new file mode 100644 index 0000000..299b1a1 --- /dev/null +++ b/crates/domain/src/authority.rs @@ -0,0 +1,86 @@ +use serde::{Deserialize, Serialize}; + +use crate::{AuthorityId, LocalizedLabel}; + +/// The kind of authority record. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum AuthorityKind { + Person, + Organisation, + Place, +} + +impl AuthorityKind { + pub fn as_str(&self) -> &'static str { + match self { + AuthorityKind::Person => "person", + AuthorityKind::Organisation => "organisation", + AuthorityKind::Place => "place", + } + } + + pub fn from_db(s: &str) -> Option { + match s { + "person" => Some(AuthorityKind::Person), + "organisation" => Some(AuthorityKind::Organisation), + "place" => Some(AuthorityKind::Place), + _ => None, + } + } +} + +/// An authority record (person / organisation / place), with multilingual labels. +#[derive(Debug, Clone, PartialEq)] +pub struct Authority { + pub id: AuthorityId, + pub kind: AuthorityKind, + pub external_uri: Option, + pub labels: Vec, +} + +/// An authority to be created. +#[derive(Debug, Clone, PartialEq)] +pub struct NewAuthority { + pub kind: AuthorityKind, + pub external_uri: Option, + pub labels: Vec, +} + +/// A reference to an authority confirmed to exist (carries its kind). +/// +/// Obtain via `db::authority::resolve_authority`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct AuthorityRef { + authority_id: AuthorityId, + kind: AuthorityKind, +} + +impl AuthorityRef { + pub fn new(authority_id: AuthorityId, kind: AuthorityKind) -> Self { + Self { authority_id, kind } + } + pub fn authority_id(&self) -> AuthorityId { + self.authority_id + } + pub fn kind(&self) -> AuthorityKind { + self.kind + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn kind_round_trips_via_db_string() { + for k in [ + AuthorityKind::Person, + AuthorityKind::Organisation, + AuthorityKind::Place, + ] { + assert_eq!(AuthorityKind::from_db(k.as_str()), Some(k)); + } + assert_eq!(AuthorityKind::from_db("ufo"), None); + } +} diff --git a/crates/domain/src/id.rs b/crates/domain/src/id.rs index 648e315..7990fb2 100644 --- a/crates/domain/src/id.rs +++ b/crates/domain/src/id.rs @@ -1,53 +1,69 @@ -use std::fmt; -use std::str::FromStr; +//! Strongly-typed identifiers. -use serde::{Deserialize, Serialize}; -use uuid::Uuid; +/// Define a UUID newtype identifier with the standard constructors and conversions. +macro_rules! id_newtype { + ($(#[$meta:meta])* $name:ident) => { + $(#[$meta])* + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] + #[serde(transparent)] + pub struct $name(uuid::Uuid); -/// Identifier for an organization (tenant). -/// -/// A newtype over [`Uuid`] so it can never be confused with another entity's id. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] -#[serde(transparent)] -pub struct OrgId(Uuid); + impl $name { + /// Generate a fresh random id. + #[must_use] + pub fn new() -> Self { + Self(uuid::Uuid::new_v4()) + } -impl OrgId { - /// Generate a fresh random id. - #[must_use = "generating an OrgId and discarding it is almost certainly a mistake"] - pub fn new() -> Self { - Self(Uuid::new_v4()) - } + /// Wrap an existing [`uuid::Uuid`]. + pub fn from_uuid(uuid: uuid::Uuid) -> Self { + Self(uuid) + } - /// Wrap an existing [`Uuid`]. - pub fn from_uuid(uuid: Uuid) -> Self { - Self(uuid) - } + /// The underlying [`uuid::Uuid`]. + pub fn to_uuid(&self) -> uuid::Uuid { + self.0 + } + } - /// Return the underlying [`Uuid`]. - pub fn to_uuid(&self) -> Uuid { - self.0 - } + impl Default for $name { + fn default() -> Self { + Self::new() + } + } + + impl std::fmt::Display for $name { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + std::fmt::Display::fmt(&self.0, f) + } + } + + impl std::str::FromStr for $name { + type Err = uuid::Error; + + fn from_str(s: &str) -> Result { + Ok(Self(uuid::Uuid::parse_str(s)?)) + } + } + }; } -impl Default for OrgId { - fn default() -> Self { - Self::new() - } -} - -impl fmt::Display for OrgId { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - fmt::Display::fmt(&self.0, f) - } -} - -impl FromStr for OrgId { - type Err = uuid::Error; - - fn from_str(s: &str) -> Result { - Ok(Self(Uuid::parse_str(s)?)) - } -} +id_newtype!( + /// Identifier for an organization (tenant). + OrgId +); +id_newtype!( + /// Identifier for a controlled vocabulary (term source). + VocabularyId +); +id_newtype!( + /// Identifier for a term within a vocabulary. + TermId +); +id_newtype!( + /// Identifier for an authority record (person, organisation, or place). + AuthorityId +); #[cfg(test)] mod tests { @@ -64,4 +80,12 @@ mod tests { fn rejects_invalid_uuid() { assert!("not-a-uuid".parse::().is_err()); } + + #[test] + fn distinct_id_types_parse_independently() { + let text = "550e8400-e29b-41d4-a716-446655440000"; + assert_eq!(text.parse::().unwrap().to_string(), text); + assert_eq!(text.parse::().unwrap().to_string(), text); + assert_eq!(text.parse::().unwrap().to_string(), text); + } } diff --git a/crates/domain/src/label.rs b/crates/domain/src/label.rs new file mode 100644 index 0000000..45fd981 --- /dev/null +++ b/crates/domain/src/label.rs @@ -0,0 +1,48 @@ +use serde::{Deserialize, Serialize}; + +/// A label in a specific language (BCP-47 tag, e.g. "sv", "en"). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct LocalizedLabel { + pub lang: String, + pub label: String, +} + +/// Pick the best label for `lang`, falling back to `fallback`, then the first. +pub fn pick_label<'a>(labels: &'a [LocalizedLabel], lang: &str, fallback: &str) -> Option<&'a str> { + labels + .iter() + .find(|l| l.lang == lang) + .or_else(|| labels.iter().find(|l| l.lang == fallback)) + .or_else(|| labels.first()) + .map(|l| l.label.as_str()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sample() -> Vec { + vec![ + LocalizedLabel { + lang: "sv".into(), + label: "trä".into(), + }, + LocalizedLabel { + lang: "en".into(), + label: "wood".into(), + }, + ] + } + + #[test] + fn prefers_requested_language() { + assert_eq!(pick_label(&sample(), "sv", "en"), Some("trä")); + } + + #[test] + fn falls_back_then_first() { + assert_eq!(pick_label(&sample(), "de", "en"), Some("wood")); + assert_eq!(pick_label(&sample(), "de", "fr"), Some("trä")); + assert_eq!(pick_label(&[], "sv", "en"), None); + } +} diff --git a/crates/domain/src/lib.rs b/crates/domain/src/lib.rs index 8a20e46..d5809c4 100644 --- a/crates/domain/src/lib.rs +++ b/crates/domain/src/lib.rs @@ -1,7 +1,13 @@ //! Core domain types and invariants. No I/O dependencies. mod audit; +mod authority; mod id; +mod label; +mod vocabulary; pub use audit::{AuditAction, AuditActor, AuditEntry, FieldChange, NewAuditEvent}; -pub use id::OrgId; +pub use authority::{Authority, AuthorityKind, AuthorityRef, NewAuthority}; +pub use id::{AuthorityId, OrgId, TermId, VocabularyId}; +pub use label::{LocalizedLabel, pick_label}; +pub use vocabulary::{NewTerm, Term, TermRef, Vocabulary}; diff --git a/crates/domain/src/vocabulary.rs b/crates/domain/src/vocabulary.rs new file mode 100644 index 0000000..2be29b7 --- /dev/null +++ b/crates/domain/src/vocabulary.rs @@ -0,0 +1,52 @@ +use serde::{Deserialize, Serialize}; + +use crate::{LocalizedLabel, TermId, VocabularyId}; + +/// A controlled vocabulary (term source), e.g. "material" or "object_name". +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Vocabulary { + pub id: VocabularyId, + pub key: String, +} + +/// A term within a vocabulary, with its multilingual labels. +#[derive(Debug, Clone, PartialEq)] +pub struct Term { + pub id: TermId, + pub vocabulary_id: VocabularyId, + pub external_uri: Option, + pub labels: Vec, +} + +/// A term to be created. +#[derive(Debug, Clone, PartialEq)] +pub struct NewTerm { + pub vocabulary_id: VocabularyId, + pub external_uri: Option, + pub labels: Vec, +} + +/// A reference to a term confirmed to exist in a given vocabulary. +/// +/// Obtain via `db::vocab::resolve_term`; do not construct ad hoc for +/// values that haven't been resolved. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct TermRef { + term_id: TermId, + vocabulary_id: VocabularyId, +} + +impl TermRef { + pub fn new(term_id: TermId, vocabulary_id: VocabularyId) -> Self { + Self { + term_id, + vocabulary_id, + } + } + pub fn term_id(&self) -> TermId { + self.term_id + } + pub fn vocabulary_id(&self) -> VocabularyId { + self.vocabulary_id + } +}