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);
|
||||
}
|
||||
}
|
||||
+67
-43
@@ -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<Self, Self::Err> {
|
||||
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<Self, Self::Err> {
|
||||
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::<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.
|
||||
|
||||
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};
|
||||
|
||||
@@ -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