Something went wrong. Try again.
atproto Thingiverse but good
Something went wrong. Try again.
Rust
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551//! PM-43 publish-draft data model.//!//! A draft is a server-side, non-public, in-progress publish composition. It is//! persisted as a [`DraftThingInput`]: a mirror of the generated//! [`ThingInput`](polymodel_api::space_polymodel::library::publish_thing::ThingInput)//! with every field optional, so a partial draft (saved before a name, license,//! or any model exists) round-trips losslessly. The same camelCase field names//! keep the payload shape aligned with the publishable record.//!//! Leaf media/reference types (`Image`, `File`, `Bbox`, `StrongRef`) are reused//! from the generated API at their default owned-string type, so an uploaded//! image/file ref stored in a draft is byte-identical to what `publishThing`//! consumes.//!//! [`DraftThingInput::assemble`] is the single conversion point from a draft to//! a publishable `ThingInput`: it validates required structure (which is//! enforced *structurally* by the typed record — `name`/`license`/`models`,//! `name`/`parts`, `name`+`file` are not optional there) and then defers the//! length/grapheme limits to the generated [`LexiconSchema::validate`], so draft//! validation cannot drift from the lexicon.
use jacquard_common::DefaultStr;use jacquard_lexicon::schema::LexiconSchema;use polymodel_api::com_atproto::repo::strong_ref::StrongRef;use polymodel_api::space_polymodel::library::publish_thing::{ModelInput, PartInput, ThingInput};use polymodel_api::space_polymodel::library::{Bbox, File, Image};use serde::{Deserialize, Serialize};
/// The owned string type the generated records use (`DefaultStr`).type S = DefaultStr;
// ----------------------------------------------------------------------------// Draft endpoint wire types//// Defined here (cross-target) rather than in the server-only `appview::drafts`// module so the wasm client can share them with the server.// ----------------------------------------------------------------------------
/// Response for create/upsert: the opaque draft id the client should keep.#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]pub struct DraftIdResponse { pub draft_id: String,}
/// A draft listing row, rendered from denormalized columns (no payload parse).#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]#[serde(rename_all = "camelCase")]pub struct DraftSummary { pub draft_id: String, pub name: Option<String>, pub model_count: i64, pub created_at: i64, pub updated_at: i64,}
/// Response for delete: whether a row actually existed.#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]pub struct DeleteDraftResponse { pub deleted: bool,}
/// Response for image upload: a fully-formed `#image` ready to attach.#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]pub struct ImageUploadResponse { pub image: Image,}
/// A `ThingInput`-shaped draft payload with every field optional.#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq)]#[serde(rename_all = "camelCase")]pub struct DraftThingInput { #[serde(default, skip_serializing_if = "Option::is_none")] pub name: Option<String>, #[serde(default, skip_serializing_if = "Option::is_none")] pub license: Option<String>, #[serde(default, skip_serializing_if = "Option::is_none")] pub summary: Option<String>, #[serde(default, skip_serializing_if = "Option::is_none")] pub tags: Option<Vec<String>>, #[serde(default, skip_serializing_if = "Option::is_none")] pub instructions: Option<Vec<String>>, #[serde(default, skip_serializing_if = "Option::is_none")] pub cover: Option<Vec<Image>>, #[serde(default, skip_serializing_if = "Option::is_none")] pub previews: Option<Vec<Image>>, /// Out of scope for the PM-43 UI, carried as an optional pass-through. #[serde(default, skip_serializing_if = "Option::is_none")] pub derived_from: Option<StrongRef>, /// Out of scope for the PM-43 UI, carried as an optional pass-through. #[serde(default, skip_serializing_if = "Option::is_none")] pub intended_to_work_with: Option<Vec<StrongRef>>, #[serde(default)] pub models: Vec<DraftModelInput>,}
/// A model within a draft. `parts` are ordered; an empty draft model is allowed/// (it simply fails publish validation until it has a name and at least one part).#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq)]#[serde(rename_all = "camelCase")]pub struct DraftModelInput { #[serde(default, skip_serializing_if = "Option::is_none")] pub name: Option<String>, #[serde(default, skip_serializing_if = "Option::is_none")] pub summary: Option<String>, /// Per-model license override. Out of scope for the PM-43 UI. #[serde(default, skip_serializing_if = "Option::is_none")] pub license: Option<String>, #[serde(default, skip_serializing_if = "Option::is_none")] pub tags: Option<Vec<String>>, #[serde(default, skip_serializing_if = "Option::is_none")] pub instructions: Option<Vec<String>>, #[serde(default, skip_serializing_if = "Option::is_none")] pub cover: Option<Vec<Image>>, #[serde(default, skip_serializing_if = "Option::is_none")] pub previews: Option<Vec<Image>>, #[serde(default, skip_serializing_if = "Option::is_none")] pub derived_from: Option<StrongRef>, #[serde(default, skip_serializing_if = "Option::is_none")] pub intended_to_work_with: Option<Vec<StrongRef>>, #[serde(default)] pub parts: Vec<DraftPartInput>,}
/// A part within a draft model. A part is publishable once it has a name and a/// successfully-staged file (an inline `file` manifest or a stage `upload_id`).#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq)]#[serde(rename_all = "camelCase")]pub struct DraftPartInput { #[serde(default, skip_serializing_if = "Option::is_none")] pub name: Option<String>, /// stageFile upload id; the server resolves it to a `File` manifest at publish. #[serde(default, skip_serializing_if = "Option::is_none")] pub upload_id: Option<String>, /// Inline file manifest (alternative to `upload_id`). #[serde(default, skip_serializing_if = "Option::is_none")] pub file: Option<File>, #[serde(default, skip_serializing_if = "Option::is_none")] pub format: Option<String>, #[serde(default, skip_serializing_if = "Option::is_none")] pub notes: Option<String>, /// Out of scope for the PM-43 UI, carried as an optional pass-through. #[serde(default, skip_serializing_if = "Option::is_none")] pub units: Option<String>, #[serde(default, skip_serializing_if = "Option::is_none")] pub print_settings: Option<Vec<String>>, #[serde(default, skip_serializing_if = "Option::is_none")] pub dimensions: Option<Bbox>, #[serde(default, skip_serializing_if = "Option::is_none")] pub previews: Option<Vec<Image>>, /// Out of scope for the PM-43 UI, carried as an optional pass-through. #[serde(default, skip_serializing_if = "Option::is_none")] pub derived_from: Option<StrongRef>, /// Out of scope for the PM-43 UI, carried as an optional pass-through. #[serde(default, skip_serializing_if = "Option::is_none")] pub intended_to_work_with: Option<Vec<StrongRef>>,}
/// One actionable validation problem, addressed to a draft field path.#[derive(Debug, Clone, PartialEq, Eq)]pub struct ValidationIssue { pub field: String, pub message: String,}
/// All validation problems found while assembling a draft into a `ThingInput`.#[derive(Debug, Clone, PartialEq, Eq, Default)]pub struct ValidationErrors { pub issues: Vec<ValidationIssue>,}
impl ValidationErrors { fn push(&mut self, field: impl Into<String>, message: impl Into<String>) { self.issues.push(ValidationIssue { field: field.into(), message: message.into(), }); }
pub fn is_empty(&self) -> bool { self.issues.is_empty() }
/// Whether any issue targets the given field path exactly. pub fn has_field(&self, field: &str) -> bool { self.issues.iter().any(|i| i.field == field) }}
impl std::fmt::Display for ValidationErrors { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { let mut first = true; for issue in &self.issues { if !first { write!(f, "; ")?; } write!(f, "{}: {}", issue.field, issue.message)?; first = false; } Ok(()) }}
fn nonempty(value: &Option<String>) -> bool { value .as_deref() .map(|v| !v.trim().is_empty()) .unwrap_or(false)}
fn into_s(value: &str) -> S { S::from(value.trim())}
fn opt_s(value: &Option<String>) -> Option<S> { value .as_deref() .map(str::trim) .filter(|v| !v.is_empty()) .map(S::from)}
fn opt_vec_s(value: &Option<Vec<String>>) -> Option<Vec<S>> { value .as_ref() .map(|items| items.iter().map(|s| S::from(s.trim())).collect::<Vec<_>>())}
/// Convert a freeform multi-line text field (stored raw, one entry per line)/// into a list of non-empty steps. Whitespace-only lines are dropped here, at/// assembly, so the live textarea can keep spaces and blank lines while typing.fn clean_lines(value: &Option<Vec<String>>) -> Option<Vec<S>> { let lines: Vec<S> = value .as_ref()? .iter() .map(|line| line.trim_end()) .filter(|line| !line.trim().is_empty()) .map(S::from) .collect(); if lines.is_empty() { None } else { Some(lines) }}
impl DraftPartInput { /// A part is backed by a staged file when it has either an inline manifest /// or a non-empty upload id. pub fn has_file(&self) -> bool { self.file.is_some() || nonempty(&self.upload_id) }
/// A part is publishable when it has a non-empty name and a staged file. pub fn is_publishable(&self) -> bool { nonempty(&self.name) && self.has_file() }}
impl DraftModelInput { /// A model is publishable when it has a non-empty name and at least one /// publishable part. pub fn is_publishable(&self) -> bool { nonempty(&self.name) && !self.parts.is_empty() && self.parts.iter().all(|p| p.is_publishable()) }}
impl DraftThingInput { /// Display name for draft listings, if set. pub fn display_name(&self) -> Option<&str> { self.name .as_deref() .map(str::trim) .filter(|v| !v.is_empty()) }
/// Number of models (for the denormalized `model_count` listing column). pub fn model_count(&self) -> i64 { self.models.len() as i64 }
/// Basics is complete once the thing has a name. License has an effective /// default and is chosen in the details step, so it never gates Basics. pub fn basics_complete(&self) -> bool { nonempty(&self.name) }
/// Step 2 (Files & parts) is complete when there is at least one model and /// every model is publishable. pub fn files_complete(&self) -> bool { !self.models.is_empty() && self.models.iter().all(|m| m.is_publishable()) }
/// Validate the draft and convert it into a publishable `ThingInput`. /// /// Required structure is checked here (the draft is all-optional); the /// length/grapheme limits are delegated to the generated /// [`LexiconSchema::validate`] on the assembled records so this code never /// re-declares the lexicon's constraints. pub fn assemble(&self) -> Result<ThingInput<S>, ValidationErrors> { let mut errors = ValidationErrors::default();
if !nonempty(&self.name) { errors.push("name", "A name is required to publish."); } if self.models.is_empty() { errors.push("models", "Add at least one model with a file."); }
for (mi, model) in self.models.iter().enumerate() { if !nonempty(&model.name) { errors.push(format!("models[{mi}].name"), "Each model needs a name."); } if model.parts.is_empty() { errors.push( format!("models[{mi}].parts"), "Each model needs at least one part with a file.", ); } for (pi, part) in model.parts.iter().enumerate() { if !nonempty(&part.name) { errors.push( format!("models[{mi}].parts[{pi}].name"), "Each part needs a name.", ); } if !part.has_file() { errors.push( format!("models[{mi}].parts[{pi}].file"), "Upload a file for this part.", ); } } }
if !errors.is_empty() { return Err(errors); }
// Presence is satisfied above; this pass only constructs typed records. let models = self .models .iter() .map(|model| { let parts = model .parts .iter() .map(|part| PartInput { name: into_s(part.name.as_deref().unwrap_or_default()), upload_id: opt_s(&part.upload_id), file: part.file.clone(), format: opt_s(&part.format), notes: opt_s(&part.notes), units: opt_s(&part.units), print_settings: opt_vec_s(&part.print_settings), dimensions: part.dimensions.clone(), previews: part.previews.clone(), derived_from: part.derived_from.clone(), intended_to_work_with: part.intended_to_work_with.clone(), ..Default::default() }) .collect(); ModelInput { cover: model.cover.clone(), derived_from: model.derived_from.clone(), instructions: opt_vec_s(&model.instructions), intended_to_work_with: model.intended_to_work_with.clone(), license: opt_s(&model.license), name: into_s(model.name.as_deref().unwrap_or_default()), parts, previews: model.previews.clone(), summary: opt_s(&model.summary), tags: opt_vec_s(&model.tags), extra_data: None, } }) .collect::<Vec<_>>();
let thing = ThingInput { cover: self.cover.clone(), derived_from: self.derived_from.clone(), instructions: clean_lines(&self.instructions), intended_to_work_with: self.intended_to_work_with.clone(), license: into_s( self.license .as_deref() .map(str::trim) .filter(|v| !v.is_empty()) .unwrap_or(super::DEFAULT_LICENSE), ), models, name: into_s(self.name.as_deref().unwrap_or_default()), previews: self.previews.clone(), summary: opt_s(&self.summary), tags: opt_vec_s(&self.tags), extra_data: None, };
// Defer length/grapheme limits to the generated lexicon validators so // this code never duplicates (and cannot drift from) the constraints. if let Err(err) = thing.validate() { errors.push("name", lexicon_message(&err)); } for (mi, model) in thing.models.iter().enumerate() { if let Err(err) = model.validate() { errors.push(format!("models[{mi}]"), lexicon_message(&err)); } for (pi, part) in model.parts.iter().enumerate() { if let Err(err) = part.validate() { errors.push(format!("models[{mi}].parts[{pi}]"), lexicon_message(&err)); } } }
if errors.is_empty() { Ok(thing) } else { Err(errors) } }}
fn lexicon_message(err: &jacquard_lexicon::validation::ConstraintError) -> String { format!("Exceeds a lexicon limit: {err}")}
#[cfg(test)]mod tests { use super::*;
fn part_with_upload(name: &str) -> DraftPartInput { DraftPartInput { name: Some(name.to_string()), upload_id: Some("abcdef0123456789-42".to_string()), ..Default::default() } }
fn complete_draft() -> DraftThingInput { DraftThingInput { name: Some("Desk Organizer".to_string()), license: Some("CC-BY-4.0".to_string()), summary: Some("A tidy desk caddy.".to_string()), models: vec![DraftModelInput { name: Some("Tray".to_string()), parts: vec![part_with_upload("tray.stl")], ..Default::default() }], ..Default::default() } }
#[test] fn partial_draft_round_trips_through_json() { let draft = DraftThingInput { name: Some("WIP".to_string()), ..Default::default() }; let json = serde_json::to_string(&draft).unwrap(); let restored: DraftThingInput = serde_json::from_str(&json).unwrap(); assert_eq!(draft, restored); // A bare partial draft must not serialize required-but-absent fields. assert!(!json.contains("license")); assert!(json.contains("\"name\":\"WIP\"")); }
#[test] fn empty_draft_round_trips_and_is_not_publishable() { let draft = DraftThingInput::default(); let json = serde_json::to_string(&draft).unwrap(); let restored: DraftThingInput = serde_json::from_str(&json).unwrap(); assert_eq!(draft, restored); assert!(!draft.basics_complete()); assert!(!draft.files_complete()); assert!(draft.assemble().is_err()); }
#[test] fn complete_draft_assembles_to_valid_thing_input() { let draft = complete_draft(); assert!(draft.basics_complete()); assert!(draft.files_complete()); let thing = draft.assemble().expect("complete draft assembles"); assert_eq!(thing.name.as_str(), "Desk Organizer"); assert_eq!(thing.license.as_str(), "CC-BY-4.0"); assert_eq!(thing.models.len(), 1); assert_eq!(thing.models[0].name.as_str(), "Tray"); assert_eq!(thing.models[0].parts.len(), 1); assert_eq!(thing.models[0].parts[0].name.as_str(), "tray.stl"); // The assembled record passes the generated lexicon validators. assert!(thing.validate().is_ok()); }
#[test] fn missing_thing_fields_are_reported() { let draft = DraftThingInput::default(); let errors = draft.assemble().expect_err("empty draft fails"); assert!(errors.has_field("name")); assert!(errors.has_field("models")); // License is not required: it defaults at assembly, never an error. assert!(!errors.has_field("license")); }
#[test] fn unset_license_defaults_at_assembly() { let mut draft = complete_draft(); draft.license = None; let thing = draft.assemble().expect("unset license defaults"); assert_eq!(thing.license.as_str(), "CC-BY-4.0"); }
#[test] fn missing_model_name_and_parts_are_reported() { let mut draft = complete_draft(); draft.models = vec![DraftModelInput::default()]; let errors = draft .assemble() .expect_err("model with no name/parts fails"); assert!(errors.has_field("models[0].name")); assert!(errors.has_field("models[0].parts")); }
#[test] fn part_without_file_is_reported() { let mut draft = complete_draft(); draft.models[0].parts = vec![DraftPartInput { name: Some("loose.stl".to_string()), ..Default::default() }]; let errors = draft.assemble().expect_err("part with no file fails"); assert!(errors.has_field("models[0].parts[0].file")); assert!(!errors.has_field("models[0].parts[0].name")); }
#[test] fn over_long_name_trips_lexicon_validator() { let mut draft = complete_draft(); // 65 graphemes exceeds the 64-grapheme name limit. draft.name = Some("x".repeat(65)); let errors = draft .assemble() .expect_err("over-long name fails lexicon validate"); assert!(errors.has_field("name")); }
#[test] fn whitespace_only_name_is_not_complete() { let mut draft = complete_draft(); draft.name = Some(" ".to_string()); assert!(!draft.basics_complete()); assert!(draft.assemble().is_err()); }}