use arrayvec::ArrayString;
use serde::{Deserialize, Serialize};
use std::fmt::Display;
use std::str::FromStr;
use vitaminc::random::{Generatable, SafeRand};
const ACTOR_ID_BYTE_LEN: usize = 10;
const ACTOR_ID_ENCODED_LEN: usize = 16;
const ALPHABET: base32::Alphabet = base32::Alphabet::Rfc4648 { padding: false };
type ActorIdString = ArrayString<ACTOR_ID_ENCODED_LEN>;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, utoipa::ToSchema)]
#[serde(rename_all = "lowercase")]
pub enum ActorKind {
App,
Agent,
}
impl ActorKind {
pub fn as_str(&self) -> &'static str {
match self {
Self::App => "app",
Self::Agent => "agent",
}
}
pub fn parse(s: &str) -> Option<Self> {
match s {
"app" => Some(Self::App),
"agent" => Some(Self::Agent),
_ => None,
}
}
}
#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash, Serialize, Deserialize)]
#[serde(transparent)]
#[cfg_attr(
feature = "server",
derive(diesel::expression::AsExpression, diesel::deserialize::FromSqlRow)
)]
#[cfg_attr(feature = "server", diesel(sql_type = diesel::sql_types::Text))]
pub struct ActorId(ActorIdString);
impl ActorId {
pub fn generate() -> Result<Self, vitaminc::random::RandomError> {
let mut rng = SafeRand::from_entropy()?;
let buf: [u8; ACTOR_ID_BYTE_LEN] = Generatable::random(&mut rng)?;
let encoded = base32::encode(ALPHABET, &buf);
let mut id = ActorIdString::new();
id.push_str(&encoded);
Ok(Self(id))
}
pub fn as_str(&self) -> &str {
self.0.as_str()
}
}
impl Display for ActorId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
impl TryFrom<&str> for ActorId {
type Error = InvalidActorId;
fn try_from(value: &str) -> Result<Self, Self::Error> {
if is_valid_actor_id(value) {
let mut id = ActorIdString::new();
id.push_str(value);
Ok(Self(id))
} else {
Err(InvalidActorId(value.to_string()))
}
}
}
impl TryFrom<String> for ActorId {
type Error = InvalidActorId;
fn try_from(value: String) -> Result<Self, Self::Error> {
Self::try_from(value.as_str())
}
}
impl FromStr for ActorId {
type Err = InvalidActorId;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Self::try_from(s)
}
}
impl PartialEq<&str> for ActorId {
fn eq(&self, other: &&str) -> bool {
self.0.as_str() == *other
}
}
#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)]
pub struct ActorIdentifier {
kind: ActorKind,
id: ActorId,
}
impl ActorIdentifier {
pub fn new(kind: ActorKind, id: ActorId) -> Self {
Self { kind, id }
}
pub fn generate(kind: ActorKind) -> Result<Self, vitaminc::random::RandomError> {
let id = ActorId::generate()?;
Ok(Self { kind, id })
}
pub fn kind(&self) -> ActorKind {
self.kind
}
pub fn id(&self) -> ActorId {
self.id
}
}
impl Display for ActorIdentifier {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}-{}", self.kind.as_str(), self.id)
}
}
impl TryFrom<&str> for ActorIdentifier {
type Error = InvalidActorId;
fn try_from(value: &str) -> Result<Self, Self::Error> {
let (prefix, id_str) = value
.split_once('-')
.ok_or_else(|| InvalidActorId(value.to_string()))?;
let kind = ActorKind::parse(prefix).ok_or_else(|| InvalidActorId(value.to_string()))?;
let id = ActorId::try_from(id_str)?;
Ok(Self { kind, id })
}
}
impl FromStr for ActorIdentifier {
type Err = InvalidActorId;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Self::try_from(s)
}
}
impl Serialize for ActorIdentifier {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
serializer.serialize_str(&self.to_string())
}
}
impl<'de> Deserialize<'de> for ActorIdentifier {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let s = String::deserialize(deserializer)?;
Self::try_from(s.as_str()).map_err(serde::de::Error::custom)
}
}
fn is_valid_actor_id(id: &str) -> bool {
base32::decode(ALPHABET, id)
.map(|bytes| bytes.len() == ACTOR_ID_BYTE_LEN)
.unwrap_or(false)
}
#[derive(Debug, thiserror::Error)]
#[error("Invalid actor ID: {0}")]
pub struct InvalidActorId(String);
#[cfg(feature = "server")]
mod sql_types {
use super::ActorId;
use diesel::{
backend::Backend,
deserialize::{self, FromSql},
serialize::{self, Output, ToSql},
sql_types::Text,
};
impl<DB> ToSql<Text, DB> for ActorId
where
DB: Backend,
str: ToSql<Text, DB>,
{
fn to_sql<'b>(&'b self, out: &mut Output<'b, '_, DB>) -> serialize::Result {
self.0.to_sql(out)
}
}
impl<DB> FromSql<Text, DB> for ActorId
where
DB: Backend,
String: FromSql<Text, DB>,
{
fn from_sql(bytes: DB::RawValue<'_>) -> deserialize::Result<Self> {
let raw = String::from_sql(bytes)?;
let actor_id = ActorId::try_from(raw)?;
Ok(actor_id)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
mod actor_id {
use super::*;
#[test]
fn generate_produces_valid_id() {
let id = ActorId::generate().unwrap();
assert_eq!(id.as_str().len(), 16, "base32 ID should be 16 chars");
}
#[test]
fn round_trips_through_serde() {
let id = ActorId::generate().unwrap();
let json = serde_json::to_value(id).unwrap();
let parsed: ActorId = serde_json::from_value(json).unwrap();
assert_eq!(parsed, id);
}
#[test]
fn from_str_round_trips() {
let id = ActorId::generate().unwrap();
let s = id.to_string();
let parsed: ActorId = s.parse().unwrap();
assert_eq!(parsed, id);
}
#[test]
fn rejects_invalid_base32() {
assert!(ActorId::try_from("!!!INVALID!!!").is_err());
}
#[test]
fn rejects_wrong_length() {
assert!(ActorId::try_from("AAAA").is_err());
}
}
mod identifier_app {
use super::*;
#[test]
fn generate_produces_valid_identifier() {
let ident = ActorIdentifier::generate(ActorKind::App).unwrap();
assert_eq!(ident.kind(), ActorKind::App, "kind should be App");
assert_eq!(
ident.id().as_str().len(),
16,
"base32 ID should be 16 chars"
);
}
#[test]
fn serializes_with_prefix() {
let ident = ActorIdentifier::generate(ActorKind::App).unwrap();
let serialized = serde_json::to_value(ident).unwrap();
let s = serialized.as_str().unwrap();
assert!(s.starts_with("app-"), "should start with 'app-', got: {s}");
assert_eq!(s.len(), 20, "app-<16 chars> = 20 chars, got: {s}");
}
#[test]
fn round_trips_through_serde() {
let ident = ActorIdentifier::generate(ActorKind::App).unwrap();
let json = serde_json::to_value(ident).unwrap();
let parsed: ActorIdentifier = serde_json::from_value(json).unwrap();
assert_eq!(parsed, ident, "should round-trip through serde");
}
}
mod identifier_agent {
use super::*;
#[test]
fn generate_produces_valid_identifier() {
let ident = ActorIdentifier::generate(ActorKind::Agent).unwrap();
assert_eq!(ident.kind(), ActorKind::Agent, "kind should be Agent");
assert_eq!(
ident.id().as_str().len(),
16,
"base32 ID should be 16 chars"
);
}
#[test]
fn serializes_with_prefix() {
let ident = ActorIdentifier::generate(ActorKind::Agent).unwrap();
let serialized = serde_json::to_value(ident).unwrap();
let s = serialized.as_str().unwrap();
assert!(
s.starts_with("agent-"),
"should start with 'agent-', got: {s}"
);
assert_eq!(s.len(), 22, "agent-<16 chars> = 22 chars, got: {s}");
}
#[test]
fn round_trips_through_serde() {
let ident = ActorIdentifier::generate(ActorKind::Agent).unwrap();
let json = serde_json::to_value(ident).unwrap();
let parsed: ActorIdentifier = serde_json::from_value(json).unwrap();
assert_eq!(parsed, ident, "should round-trip through serde");
}
}
mod identifier_invalid {
use super::*;
#[test]
fn rejects_unknown_prefix() {
let json = json!("unknown-JBSWY3DPEHPK3PXP");
let result: Result<ActorIdentifier, _> = serde_json::from_value(json);
assert!(result.is_err(), "should reject unknown actor kind");
}
#[test]
fn rejects_missing_delimiter() {
let json = json!("appJBSWY3DPEHPK3PXP");
let result: Result<ActorIdentifier, _> = serde_json::from_value(json);
assert!(result.is_err(), "should reject missing delimiter");
}
#[test]
fn rejects_invalid_base32() {
let json = json!("app-!!!INVALID!!!");
let result: Result<ActorIdentifier, _> = serde_json::from_value(json);
assert!(result.is_err(), "should reject invalid base32");
}
#[test]
fn rejects_wrong_length() {
let json = json!("app-AAAA");
let result: Result<ActorIdentifier, _> = serde_json::from_value(json);
assert!(result.is_err(), "should reject wrong-length ID");
}
#[test]
fn rejects_empty_string() {
let json = json!("");
let result: Result<ActorIdentifier, _> = serde_json::from_value(json);
assert!(result.is_err(), "should reject empty string");
}
}
#[test]
fn from_str_round_trips() {
let ident = ActorIdentifier::generate(ActorKind::App).unwrap();
let s = ident.to_string();
let parsed: ActorIdentifier = s.parse().unwrap();
assert_eq!(parsed, ident);
}
#[test]
fn from_str_rejects_invalid() {
assert!("not-valid".parse::<ActorIdentifier>().is_err());
}
#[test]
fn display_matches_serialize() {
let ident = ActorIdentifier::generate(ActorKind::App).unwrap();
let display = ident.to_string();
let serialized = serde_json::to_value(ident).unwrap();
assert_eq!(
display,
serialized.as_str().unwrap(),
"Display and Serialize should produce the same string"
);
}
#[test]
fn new_constructs_from_parts() {
let id = ActorId::generate().unwrap();
let ident = ActorIdentifier::new(ActorKind::App, id);
assert_eq!(ident.kind(), ActorKind::App);
assert_eq!(ident.id(), id);
}
}