feat(domain): id macro + vocabulary/authority/label value types
This commit is contained in:
@@ -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<Self> {
|
||||||
|
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<String>,
|
||||||
|
pub labels: Vec<LocalizedLabel>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An authority to be created.
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub struct NewAuthority {
|
||||||
|
pub kind: AuthorityKind,
|
||||||
|
pub external_uri: Option<String>,
|
||||||
|
pub labels: Vec<LocalizedLabel>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+47
-23
@@ -1,53 +1,69 @@
|
|||||||
use std::fmt;
|
//! Strongly-typed identifiers.
|
||||||
use std::str::FromStr;
|
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
/// Define a UUID newtype identifier with the standard constructors and conversions.
|
||||||
use uuid::Uuid;
|
macro_rules! id_newtype {
|
||||||
|
($(#[$meta:meta])* $name:ident) => {
|
||||||
/// Identifier for an organization (tenant).
|
$(#[$meta])*
|
||||||
///
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
|
||||||
/// 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)]
|
#[serde(transparent)]
|
||||||
pub struct OrgId(Uuid);
|
pub struct $name(uuid::Uuid);
|
||||||
|
|
||||||
impl OrgId {
|
impl $name {
|
||||||
/// Generate a fresh random id.
|
/// Generate a fresh random id.
|
||||||
#[must_use = "generating an OrgId and discarding it is almost certainly a mistake"]
|
#[must_use]
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
Self(Uuid::new_v4())
|
Self(uuid::Uuid::new_v4())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Wrap an existing [`Uuid`].
|
/// Wrap an existing [`uuid::Uuid`].
|
||||||
pub fn from_uuid(uuid: Uuid) -> Self {
|
pub fn from_uuid(uuid: uuid::Uuid) -> Self {
|
||||||
Self(uuid)
|
Self(uuid)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Return the underlying [`Uuid`].
|
/// The underlying [`uuid::Uuid`].
|
||||||
pub fn to_uuid(&self) -> Uuid {
|
pub fn to_uuid(&self) -> uuid::Uuid {
|
||||||
self.0
|
self.0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for OrgId {
|
impl Default for $name {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self::new()
|
Self::new()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl fmt::Display for OrgId {
|
impl std::fmt::Display for $name {
|
||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
fmt::Display::fmt(&self.0, f)
|
std::fmt::Display::fmt(&self.0, f)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl FromStr for OrgId {
|
impl std::str::FromStr for $name {
|
||||||
type Err = uuid::Error;
|
type Err = uuid::Error;
|
||||||
|
|
||||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||||
Ok(Self(Uuid::parse_str(s)?))
|
Ok(Self(uuid::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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
@@ -64,4 +80,12 @@ mod tests {
|
|||||||
fn rejects_invalid_uuid() {
|
fn rejects_invalid_uuid() {
|
||||||
assert!("not-a-uuid".parse::<OrgId>().is_err());
|
assert!("not-a-uuid".parse::<OrgId>().is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn distinct_id_types_parse_independently() {
|
||||||
|
let text = "550e8400-e29b-41d4-a716-446655440000";
|
||||||
|
assert_eq!(text.parse::<VocabularyId>().unwrap().to_string(), text);
|
||||||
|
assert_eq!(text.parse::<TermId>().unwrap().to_string(), text);
|
||||||
|
assert_eq!(text.parse::<AuthorityId>().unwrap().to_string(), text);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<LocalizedLabel> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,13 @@
|
|||||||
//! Core domain types and invariants. No I/O dependencies.
|
//! Core domain types and invariants. No I/O dependencies.
|
||||||
|
|
||||||
mod audit;
|
mod audit;
|
||||||
|
mod authority;
|
||||||
mod id;
|
mod id;
|
||||||
|
mod label;
|
||||||
|
mod vocabulary;
|
||||||
|
|
||||||
pub use audit::{AuditAction, AuditActor, AuditEntry, FieldChange, NewAuditEvent};
|
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};
|
||||||
|
|||||||
@@ -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<String>,
|
||||||
|
pub labels: Vec<LocalizedLabel>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A term to be created.
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub struct NewTerm {
|
||||||
|
pub vocabulary_id: VocabularyId,
|
||||||
|
pub external_uri: Option<String>,
|
||||||
|
pub labels: Vec<LocalizedLabel>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user