use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use crate::manifest::sha256_hex;
pub const DEFAULT_PROJECT: &str = "default";
pub const POINTER_PREFIX: &str = "projectmeta/";
pub const SPEC_PREFIX: &str = "projectver/";
pub const OWNER_PREFIX: &str = "owner/";
pub fn pointer_key(project: &str) -> String {
format!("{POINTER_PREFIX}{project}")
}
pub fn spec_key(hash: &str) -> String {
format!("{SPEC_PREFIX}{hash}")
}
pub fn history_key(project: &str) -> String {
format!("project-history/{project}")
}
pub fn resource_prefix(project: &str) -> String {
format!("project/{project}/")
}
pub fn owner_key(kind: &str, name: &str) -> String {
format!("{OWNER_PREFIX}{kind}/{name}")
}
pub mod owner_kind {
pub const SITE: &str = "site";
pub const FUNCTION: &str = "function";
pub const COMPUTE: &str = "compute";
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct ProjectMeta {
#[serde(skip_serializing_if = "String::is_empty")]
pub display: String,
#[serde(skip_serializing_if = "String::is_empty")]
pub description: String,
#[serde(skip_serializing_if = "BTreeMap::is_empty")]
pub labels: BTreeMap<String, String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct ProjectConfig {
#[serde(skip_serializing_if = "Option::is_none")]
pub region: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Project {
#[serde(default = "crate::schema_version")]
pub version: u32,
pub name: String,
pub created_at: u64,
#[serde(default)]
pub meta: ProjectMeta,
#[serde(default)]
pub config: ProjectConfig,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub secrets_ref: Option<String>,
}
impl Project {
pub fn id(&self) -> String {
let canonical = serde_json::to_vec(self).expect("Project serializes");
sha256_hex(&canonical)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct DomainOwner {
pub project: String,
pub site: String,
}
impl DomainOwner {
pub fn new(project: impl Into<String>, site: impl Into<String>) -> Self {
Self {
project: project.into(),
site: site.into(),
}
}
pub fn to_bytes(&self) -> Vec<u8> {
serde_json::to_vec(self).expect("DomainOwner serializes")
}
pub fn from_bytes(bytes: &[u8]) -> Self {
if let Ok(owner) = serde_json::from_slice::<Self>(bytes) {
return owner;
}
Self::new(DEFAULT_PROJECT, String::from_utf8_lossy(bytes).into_owned())
}
}
impl<'de> Deserialize<'de> for DomainOwner {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
#[derive(Deserialize)]
#[serde(untagged)]
enum Raw {
Bare(String),
Full { project: String, site: String },
}
Ok(match Raw::deserialize(deserializer)? {
Raw::Bare(site) => Self {
project: DEFAULT_PROJECT.to_string(),
site,
},
Raw::Full { project, site } => Self { project, site },
})
}
}
#[cfg(test)]
mod tests {
use super::*;
fn sample() -> Project {
Project {
version: crate::SCHEMA_VERSION,
name: "acme".into(),
created_at: 1_700_000_000,
meta: ProjectMeta {
display: "Acme Corp".into(),
..Default::default()
},
config: ProjectConfig {
region: Some("eu-west".into()),
},
secrets_ref: None,
}
}
#[test]
fn project_id_is_stable_and_content_addressed() {
let a = sample();
let mut b = sample();
assert_eq!(a.id(), b.id(), "identical projects share an id");
b.name = "other".into();
assert_ne!(a.id(), b.id(), "a changed field changes the id");
assert_eq!(a.id().len(), 64);
}
#[test]
fn key_builders() {
assert_eq!(pointer_key("acme"), "projectmeta/acme");
assert_eq!(spec_key("deadbeef"), "projectver/deadbeef");
assert_eq!(history_key("acme"), "project-history/acme");
assert_eq!(resource_prefix("acme"), "project/acme/");
assert_eq!(owner_key(owner_kind::SITE, "blog"), "owner/site/blog");
}
#[test]
fn domain_owner_reads_bare_and_full() {
let bare: DomainOwner = serde_json::from_str("\"blog\"").unwrap();
assert_eq!(bare, DomainOwner::new(DEFAULT_PROJECT, "blog"));
let full: DomainOwner =
serde_json::from_str(r#"{"project":"acme","site":"shop"}"#).unwrap();
assert_eq!(full, DomainOwner::new("acme", "shop"));
let round: DomainOwner =
serde_json::from_slice(&serde_json::to_vec(&full).unwrap()).unwrap();
assert_eq!(round, full);
}
#[test]
fn domain_owner_from_bytes_tolerates_all_layouts() {
let owner = DomainOwner::new("acme", "shop");
assert_eq!(DomainOwner::from_bytes(&owner.to_bytes()), owner);
assert_eq!(
DomainOwner::from_bytes(b"\"blog\""),
DomainOwner::new(DEFAULT_PROJECT, "blog")
);
assert_eq!(
DomainOwner::from_bytes(b"blog"),
DomainOwner::new(DEFAULT_PROJECT, "blog")
);
assert_eq!(
DomainOwner::from_bytes(b"123"),
DomainOwner::new(DEFAULT_PROJECT, "123")
);
}
}