diff --git a/packages/Cargo.lock b/packages/Cargo.lock index 8130d4d..d4f3e2b 100644 --- a/packages/Cargo.lock +++ b/packages/Cargo.lock @@ -384,7 +384,10 @@ dependencies = [ name = "ccdi-cde" version = "0.1.0" dependencies = [ + "indexmap 2.0.2", + "introspect", "rand", + "regex", "serde", "serde_json", "utoipa", @@ -435,6 +438,8 @@ name = "ccdi-spec" version = "0.1.0" dependencies = [ "actix-web", + "ccdi-cde", + "ccdi-models", "ccdi-openapi", "ccdi-server", "clap", @@ -863,6 +868,36 @@ dependencies = [ "serde", ] +[[package]] +name = "introspect" +version = "0.1.1" +source = "git+https://github.com/claymcleod/introspect.git#31fe496dc1ec26b4bfd4aaf8ea039c703af41ae1" +dependencies = [ + "introspect-core", + "introspect-proc-macros", +] + +[[package]] +name = "introspect-core" +version = "0.1.1" +source = "git+https://github.com/claymcleod/introspect.git#31fe496dc1ec26b4bfd4aaf8ea039c703af41ae1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.38", +] + +[[package]] +name = "introspect-proc-macros" +version = "0.1.0" +source = "git+https://github.com/claymcleod/introspect.git#31fe496dc1ec26b4bfd4aaf8ea039c703af41ae1" +dependencies = [ + "introspect-core", + "proc-macro2", + "quote", + "syn 2.0.38", +] + [[package]] name = "is-terminal" version = "0.4.9" @@ -1172,9 +1207,9 @@ dependencies = [ [[package]] name = "regex" -version = "1.10.0" +version = "1.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d119d7c7ca818f8a53c300863d4f87566aac09943aef5b355bb83969dae75d87" +checksum = "380b951a9c5e80ddfd6136919eef32310721aa4aacd4889a8d39124b026ab343" dependencies = [ "aho-corasick", "memchr", @@ -1184,9 +1219,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.1" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "465c6fc0621e4abc4187a2bda0937bfd4f722c2730b29562e19689ea796c9a4b" +checksum = "5f804c7828047e88b2d32e2d7fe5a105da8ee3264f01902f796c8e067dc2483f" dependencies = [ "aho-corasick", "memchr", @@ -1195,9 +1230,9 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56d84fdd47036b038fc80dd333d10b6aab10d5d31f4a366e20014def75328d33" +checksum = "c08c74e62047bb2de4ff487b251e4a92e24f48745648451635cec7d591162d9f" [[package]] name = "rust-embed" diff --git a/packages/Cargo.toml b/packages/Cargo.toml index 8171adc..c14d17e 100644 --- a/packages/Cargo.toml +++ b/packages/Cargo.toml @@ -15,9 +15,11 @@ edition = "2021" [workspace.dependencies] actix-web = "4.4.0" indexmap = "2.0.2" +introspect = { git = "https://github.com/claymcleod/introspect.git" } log = "0.4.20" mime = "0.3.17" rand = "0.8.5" +regex = "1.10.2" serde = { version = "1.0.189", features = ["serde_derive"] } serde_json = { version = "1.0.107", features = ["preserve_order"] } serde_test = "1.0.176" diff --git a/packages/ccdi-cde/Cargo.toml b/packages/ccdi-cde/Cargo.toml index b77f8c5..ffefe09 100644 --- a/packages/ccdi-cde/Cargo.toml +++ b/packages/ccdi-cde/Cargo.toml @@ -5,7 +5,10 @@ license.workspace = true edition.workspace = true [dependencies] +indexmap.workspace = true +introspect.workspace = true rand.workspace = true +regex.workspace = true serde.workspace = true serde_json.workspace = true utoipa.workspace = true diff --git a/packages/ccdi-cde/src/lib.rs b/packages/ccdi-cde/src/lib.rs index 3437d91..b39fe17 100644 --- a/packages/ccdi-cde/src/lib.rs +++ b/packages/ccdi-cde/src/lib.rs @@ -7,22 +7,133 @@ #![warn(rust_2021_compatibility)] #![warn(missing_debug_implementations)] #![deny(rustdoc::broken_intra_doc_links)] +#![feature(trivial_bounds)] +use introspect::Entity; +use introspect::Introspected; +use introspect::Member; + +use crate::parse::cde::member; + +pub mod parse; pub mod v1; pub mod v2; -/// A trait to define the harmonization standard used for a common data element. -pub trait Standard { - /// Gets the harmonization standard name for a common data element. - fn standard() -> &'static str; +/// An error related to a [`CDE`]. +#[derive(Debug)] +pub enum Error { + /// The common data element is missing documentation. + MissingDocumentation, + + /// An error occurred while parsing an entity. + EntityError(parse::cde::entity::ParseError), + + /// An error occurred while parsing a member of an entity. + MemberError(parse::cde::member::ParseError), } -/// A trait to define the URL where users can learn more about a common data -/// element. -pub trait Url { - /// Gets the URL to learn more about a common data element. - fn url() -> &'static str; +impl std::fmt::Display for Error { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Error::MissingDocumentation => write!(f, "missing documentation"), + Error::EntityError(err) => write!(f, "parse entity error: {err}"), + Error::MemberError(err) => write!(f, "parse member error: {err}"), + } + } } +impl std::error::Error for Error {} + +/// A [`Result`](std::result::Result) with an [`Error`]. +pub type Result = std::result::Result; + /// A marker trait for common data elements (CDEs). -pub trait CDE: std::fmt::Display + Eq + PartialEq + Standard + Url {} +pub trait CDE: std::fmt::Display + Eq + PartialEq + Introspected { + /// Gets the parsed entity information from the corresponding entity's + /// documentation. + fn entity() -> Result { + let entity = Self::introspected_entity(); + + let documentation = match &entity { + Entity::Enum(entity) => entity.documentation().to_owned(), + Entity::Struct(entity) => entity.documentation().to_owned(), + } + .map(Ok) + .unwrap_or(Err(Error::MissingDocumentation))?; + + documentation + .parse::() + .map_err(Error::EntityError) + } + + /// Gets the parsed members of an entity from the corresponding member's + /// documentation. + fn members() -> Result> { + Self::introspected_members() + .into_iter() + .map(|member| match member { + Member::Field(member) => { + member + .documentation() + .map(|doc| match doc.parse::() { + Ok(field) => Ok(( + member.identifier().unwrap_or("").to_string(), + crate::parse::cde::Member::Field(field), + )), + Err(err) => { + Err(Error::MemberError(member::ParseError::FieldError(err))) + } + }) + } + Member::Variant(member) => member.documentation().map(|doc| match doc + .parse::() + { + Ok(variant) => Ok(( + member.identifier().to_string(), + crate::parse::cde::Member::Variant(variant), + )), + Err(err) => Err(Error::MemberError(member::ParseError::VariantError(err))), + }), + }) + .map(|member| member.unwrap_or(Err(Error::MissingDocumentation))) + .collect::>>() + } +} + +#[cfg(test)] +mod tests { + use crate::v1::subject::Sex; + + use super::*; + + #[test] + fn entity_parsing_works_correctly() { + let entity = Sex::entity().unwrap(); + + assert_eq!(entity.standard(), "caDSR CDE 6343385 v1.00"); + } + + #[test] + fn member_parsing_works_correctly() { + let mut entity = Sex::members().unwrap().into_iter(); + + let (identifer, variant) = entity.next().unwrap(); + assert_eq!(identifer, "Unknown"); + assert_eq!(variant.get_variant().unwrap().permissible_value(), "U"); + + let (identifer, variant) = entity.next().unwrap(); + assert_eq!(identifer, "Female"); + assert_eq!(variant.get_variant().unwrap().permissible_value(), "F"); + + let (identifer, variant) = entity.next().unwrap(); + assert_eq!(identifer, "Male"); + assert_eq!(variant.get_variant().unwrap().permissible_value(), "M"); + + let (identifer, variant) = entity.next().unwrap(); + assert_eq!(identifer, "Undifferentiated"); + assert_eq!( + variant.get_variant().unwrap().permissible_value(), + "UNDIFFERENTIATED" + ); + } +} diff --git a/packages/ccdi-cde/src/parse.rs b/packages/ccdi-cde/src/parse.rs new file mode 100644 index 0000000..fd48d39 --- /dev/null +++ b/packages/ccdi-cde/src/parse.rs @@ -0,0 +1,71 @@ +//! Parsing common data elements and their members from documentation. + +use std::iter::Peekable; +use std::str::Lines; + +/// Conventional line endings in text files (platform-specific). +#[cfg(windows)] +pub const LINE_ENDING: &str = "\r\n"; + +/// Conventional line endings in text files (platform-specific). +#[cfg(not(windows))] +pub const LINE_ENDING: &str = "\n"; + +pub mod cde; + +/// Reads from an iterator over lines and trims the concatenates lines together. +/// This is useful when you want to treat a multiline comment as a single +/// [`String`]. +/// +/// Note that, if the contiguous lines are postceded by an empty line, then the +/// postceding empty line is also consumed. This is convenient for calling this +/// function multiple times in a row on the same iterator. +/// +/// # Examples +/// +/// ``` +/// use ccdi_cde as cde; +/// +/// use cde::parse::trim_and_concat_contiguous_lines; +/// +/// let mut lines = "hello\nthere,\nworld\n\nfoo\nbar\n\n\"baz\ntest\"".lines().peekable(); +/// +/// assert_eq!( +/// trim_and_concat_contiguous_lines(&mut lines), +/// Some(String::from("hello there, world")) +/// ); +/// +/// assert_eq!( +/// trim_and_concat_contiguous_lines(&mut lines), +/// Some(String::from("foo bar")) +/// ); +/// +/// assert_eq!( +/// trim_and_concat_contiguous_lines(&mut lines), +/// Some(String::from(r#""baz test""#)) +/// ); +/// +/// assert_eq!( +/// trim_and_concat_contiguous_lines(&mut lines), +/// None +/// ); +/// ``` +pub fn trim_and_concat_contiguous_lines(lines: &mut Peekable>) -> Option { + // If the first line is `None`, return `None`; + lines.peek()?; + + let mut results = vec![]; + + for line in lines.by_ref() { + let line = line.trim(); + + // Consume the postceding empty line. + if line.is_empty() { + break; + } + + results.push(line); + } + + Some(results.join(" ").trim().to_string()) +} diff --git a/packages/ccdi-cde/src/parse/cde.rs b/packages/ccdi-cde/src/parse/cde.rs new file mode 100644 index 0000000..a68cef9 --- /dev/null +++ b/packages/ccdi-cde/src/parse/cde.rs @@ -0,0 +1,7 @@ +//! Parsing information for common data elements. + +pub mod entity; +pub mod member; + +pub use entity::Entity; +pub use member::Member; diff --git a/packages/ccdi-cde/src/parse/cde/entity.rs b/packages/ccdi-cde/src/parse/cde/entity.rs new file mode 100644 index 0000000..ce5bbc5 --- /dev/null +++ b/packages/ccdi-cde/src/parse/cde/entity.rs @@ -0,0 +1,391 @@ +//! Parsing an entity for common data elements. + +use std::iter::Peekable; +use std::str::Lines; + +use regex::Regex; +use serde::Deserialize; +use serde::Serialize; + +use crate::parse::trim_and_concat_contiguous_lines; + +const STANDARD_PATTERN: &str = r"^\*\*`(?P.*?)`\*\*$"; +const URL_PATTERN: &str = r"^Link: <(?P.*)>$"; + +/// A error related to parsing an [`Entity`]. +#[derive(Debug, Eq, PartialEq)] +pub enum ParseError { + /// Attempted to parse a field with no documentation. + Empty, + + /// The iterator ended early. The argument is a plain-text description of + /// the part of the docstring that was being parsed when the iterator + /// stopped. + IteratorEndedEarly(String), + + /// The standard line was does not match the format we expect. The + /// argument is the line that we are attempting to parse. + InvalidStandardFormat(String), + + /// A field URL line was does not match the format we expect. The + /// argument is the line that we are attempting to parse. + InvalidURLFormat(String), +} + +impl std::fmt::Display for ParseError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ParseError::Empty => write!(f, "cannot parse entity with no documentation"), + ParseError::IteratorEndedEarly(entity) => { + write!(f, "iterator ended early when parsing {entity}") + } + ParseError::InvalidStandardFormat(value) => { + write!( + f, + "entity's standard line does not match expected format: \"{value}\". \ + The following format is expected: \"**`STANDARD`**\"" + ) + } + ParseError::InvalidURLFormat(value) => { + write!( + f, + "entity's URL line does not match expected format: \"{value}\". \ + The following format is expected: \"Link: \"" + ) + } + } + } +} + +impl std::error::Error for ParseError {} + +/// A [`Result`](std::result::Result) with a [`ParseError`]. +pub type Result = std::result::Result; + +/// A parsed entity that describes a common data element. An entity is either a +/// `struct` or an `enum` (both can be used to describe common data elements). +#[derive(Debug, Deserialize, Serialize)] +pub struct Entity { + standard: String, + description: String, + url: String, +} + +impl Entity { + /// Gets the standard for the [`Entity`] by reference. + /// + /// # Examples + /// + /// ``` + /// use ccdi_cde as cde; + /// + /// use cde::parse::cde::Entity; + /// + /// let entity = r#"**`A Standard`** + /// + /// A description that spans + /// multiple lines. + /// + /// Link: "#.parse::()?; + /// + /// assert_eq!(entity.standard(), "A Standard"); + /// + /// # Ok::<(), Box>(()) + /// ``` + pub fn standard(&self) -> &str { + self.standard.as_str() + } + + /// Gets the description for the [`Entity`] by reference. + /// + /// # Examples + /// + /// ``` + /// use ccdi_cde as cde; + /// + /// use cde::parse::cde::Entity; + /// + /// let entity = r#"**`A Standard`** + /// + /// A description that spans + /// multiple lines. + /// + /// Link: "#.parse::()?; + /// + /// assert_eq!(entity.description(), "A description that spans multiple lines."); + /// + /// # Ok::<(), Box>(()) + /// ``` + pub fn description(&self) -> &str { + self.description.as_str() + } + + /// Gets the URL for the [`Entity`] by reference. + /// + /// # Examples + /// + /// ``` + /// use ccdi_cde as cde; + /// + /// use cde::parse::cde::Entity; + /// + /// let entity = r#"**`A Standard`** + /// + /// A description that spans + /// multiple lines. + /// + /// Link: "#.parse::()?; + /// + /// assert_eq!(entity.url(), "https://example.com"); + /// + /// # Ok::<(), Box>(()) + /// ``` + pub fn url(&self) -> &str { + self.url.as_str() + } +} + +impl std::str::FromStr for Entity { + type Err = ParseError; + + fn from_str(s: &str) -> Result { + let mut lines = s.lines().peekable(); + + if lines.peek().is_none() { + return Err(ParseError::Empty); + } + + let standard = parse_standard(&mut lines)?; + let description = parse_description(&mut lines)?; + let url = parse_url(&mut lines)?; + + Ok(Self { + standard, + description, + url, + }) + } +} + +fn parse_standard(lines: &mut Peekable>) -> Result { + let line = trim_and_concat_contiguous_lines(lines) + .map(Ok) + .unwrap_or(Err(ParseError::IteratorEndedEarly(String::from( + "standard", + ))))?; + + // SAFETY: we test that this pattern unwraps statically below. + let regex = Regex::new(STANDARD_PATTERN).unwrap(); + + match regex.captures(line.as_str()) { + Some(captures) => { + // SAFETY: this key is tested for existence in the regex below, so + // it will always be present. + Ok(captures.name("standard").unwrap().as_str().to_string()) + } + None => Err(ParseError::InvalidStandardFormat(line.to_owned())), + } +} + +fn parse_description(lines: &mut Peekable>) -> Result { + trim_and_concat_contiguous_lines(lines) + .map(Ok) + .unwrap_or(Err(ParseError::IteratorEndedEarly(String::from( + "description", + )))) +} + +fn parse_url(lines: &mut Peekable>) -> Result { + let line = match trim_and_concat_contiguous_lines(lines) { + Some(line) => line, + None => return Err(ParseError::IteratorEndedEarly(String::from("url"))), + }; + + // SAFETY: we test that this pattern unwraps statically below. + let regex = Regex::new(URL_PATTERN).unwrap(); + + match regex.captures(line.as_str()) { + // SAFETY: we test that the 'url' named group exists in the tests below, + // so this will always be present. + Some(captures) => Ok(captures.name("url").unwrap().as_str().to_string()), + None => Err(ParseError::InvalidURLFormat(line.to_owned())), + } +} + +#[cfg(test)] +mod tests { + use regex::Regex; + + use super::*; + + #[test] + fn the_standard_pattern_compiles_and_captures() { + let regex = Regex::new(STANDARD_PATTERN).unwrap(); + + let captures = regex.captures("**`caDSR CDE 12217251 v1.00`**").unwrap(); + assert_eq!( + captures.name("standard").unwrap().as_str(), + "caDSR CDE 12217251 v1.00" + ); + } + + #[test] + fn the_url_pattern_compiles_and_captures() { + let regex = Regex::new(URL_PATTERN).unwrap(); + let captures = regex.captures("Link: ").unwrap(); + + assert_eq!(captures.name("url").unwrap().as_str(), "https://test.com"); + } + + #[test] + fn it_parses_a_multiline_standard() -> std::result::Result<(), Box> { + let entity = r#"**`A Standard + That Spans Multiple Lines`** + + A description that spans + multiple lines. + + Link: "# + .parse::()?; + + assert_eq!(entity.standard(), "A Standard That Spans Multiple Lines"); + + Ok(()) + } + + #[test] + fn it_parses_a_multiline_url() -> std::result::Result<(), Box> { + let entity = r#"**`A Standard`** + + A description that spans + multiple lines. + + Link: + "# + .parse::()?; + + assert_eq!(entity.url(), "https://example.com"); + + Ok(()) + } + + #[test] + fn it_fails_to_parse_a_field_with_no_documentation( + ) -> std::result::Result<(), Box> { + let err = "".parse::().unwrap_err(); + assert_eq!(err, ParseError::Empty); + + Ok(()) + } + + #[test] + fn it_fails_to_parse_a_field_with_a_missing_description( + ) -> std::result::Result<(), Box> { + let err = r#"**`A Standard`** + "# + .parse::() + .unwrap_err(); + + assert_eq!( + err, + ParseError::IteratorEndedEarly(String::from("description")) + ); + + Ok(()) + } + + #[test] + fn it_fails_to_parse_a_field_with_a_missing_url( + ) -> std::result::Result<(), Box> { + let err = r#"**`A Standard`** + + A description. + "# + .parse::() + .unwrap_err(); + + assert_eq!(err, ParseError::IteratorEndedEarly(String::from("url"))); + + Ok(()) + } + + #[test] + fn it_fails_to_parse_an_incorrectly_formatted_standard( + ) -> std::result::Result<(), Box> { + let err = r#"A Standard + + A description that spans + multiple lines. + + Link: "# + .parse::() + .unwrap_err(); + + assert_eq!( + err, + ParseError::InvalidStandardFormat(String::from("A Standard")) + ); + + assert_eq!(err.to_string(), "entity's standard line does not match expected format: \"A Standard\". The following format is expected: \"**`STANDARD`**\""); + + // Ensure that we must have code backticks in the standard name. + + let err = r#"**A Standard** + + A description that spans + multiple lines. + + Link: "# + .parse::() + .unwrap_err(); + + assert_eq!( + err, + ParseError::InvalidStandardFormat(String::from("**A Standard**")) + ); + + assert_eq!(err.to_string(), "entity's standard line does not match expected format: \"**A Standard**\". The following format is expected: \"**`STANDARD`**\""); + + Ok(()) + } + + #[test] + fn it_fails_to_parse_an_incorrectly_formatted_url( + ) -> std::result::Result<(), Box> { + let err = r#"**`A Standard`** + + A description that spans + multiple lines. + + Link:"# + .parse::() + .unwrap_err(); + + assert_eq!( + err, + ParseError::InvalidURLFormat(String::from("Link:")) + ); + + assert_eq!(err.to_string(), "entity's URL line does not match expected format: \"Link:\". The following format is expected: \"Link: \""); + + // Ensure that we must have code backticks in the standard name. + + let err = r#"**`A Standard`** + + A description that spans + multiple lines. + + Link: https://example.com"# + .parse::() + .unwrap_err(); + + assert_eq!( + err, + ParseError::InvalidURLFormat(String::from("Link: https://example.com")) + ); + + assert_eq!(err.to_string(), "entity's URL line does not match expected format: \"Link: https://example.com\". The following format is expected: \"Link: \""); + + Ok(()) + } +} diff --git a/packages/ccdi-cde/src/parse/cde/member.rs b/packages/ccdi-cde/src/parse/cde/member.rs new file mode 100644 index 0000000..145f645 --- /dev/null +++ b/packages/ccdi-cde/src/parse/cde/member.rs @@ -0,0 +1,160 @@ +//! Parsing the members of an entity common data elements. + +mod field; +pub mod variant; + +pub use field::Field; +use serde::Deserialize; +use serde::Serialize; +pub use variant::Variant; + +/// An error related to parsing a [`Member`]. +#[derive(Debug)] +pub enum ParseError { + /// An error related to parsing a [`Field`]. + FieldError(field::ParseError), + + /// An error related to parsing a [`Variant`]. + VariantError(variant::ParseError), +} + +impl std::fmt::Display for ParseError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ParseError::FieldError(err) => write!(f, "field parsing error: {err}"), + ParseError::VariantError(err) => write!(f, "variant parsing error: {err}"), + } + } +} + +impl std::error::Error for ParseError {} + +/// A parsed member of an entity that describes a common data element. A member +/// is either a member of a `struct` or a variant of an `enum`. (both can be used +/// to describe common data elements). +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub enum Member { + /// A documentation block parsed for information pertaining to a field. + Field(field::Field), + + /// A documentation block parsed for information pertaining to a variant. + Variant(variant::Variant), +} + +impl Member { + /// Returns whether or not this member is a [`Member::Field`]. + /// + /// # Examples + /// + /// ``` + /// use ccdi_cde as cde; + /// + /// use cde::parse::cde::member::Field; + /// use cde::parse::cde::member::Variant; + /// use cde::parse::cde::Member; + /// + /// let field = "A description.".parse::()?; + /// let member = Member::Field(field); + /// assert_eq!(member.is_field(), true); + /// + /// let variant = "`A Permissible Value` + /// + /// A description.".parse::()?; + /// let member = Member::Variant(variant); + /// assert_eq!(member.is_field(), false); + /// + /// # Ok::<(), Box>(()) + /// ``` + pub fn is_field(&self) -> bool { + matches!(self, Member::Field(_)) + } + + /// Gets a reference to the inner [`Field`] if this [`Member`] is a + /// [`Member::Field`]. Else, this returns [`None`]. + /// + /// # Examples + /// + /// ``` + /// use ccdi_cde as cde; + /// + /// use cde::parse::cde::member::Field; + /// use cde::parse::cde::member::Variant; + /// use cde::parse::cde::Member; + /// + /// let field = "A description.".parse::()?; + /// let member = Member::Field(field.clone()); + /// assert_eq!(member.get_field(), Some(&field)); + /// + /// let variant = "`A Permissible Value` + /// + /// A description.".parse::()?; + /// let member = Member::Variant(variant.clone()); + /// assert_eq!(member.get_field(), None); + /// + /// # Ok::<(), Box>(()) + /// ``` + pub fn get_field(&self) -> Option<&field::Field> { + match self { + Member::Field(field) => Some(field), + _ => None, + } + } + + /// Returns whether or not this member is a [`Member::Variant`]. + /// + /// # Examples + /// + /// ``` + /// use ccdi_cde as cde; + /// + /// use cde::parse::cde::member::Field; + /// use cde::parse::cde::member::Variant; + /// use cde::parse::cde::Member; + /// + /// let field = "A description.".parse::()?; + /// let member = Member::Field(field); + /// assert_eq!(member.is_field(), true); + /// + /// let variant = "`A Permissible Value` + /// + /// A description.".parse::()?; + /// let member = Member::Variant(variant); + /// assert_eq!(member.is_field(), false); + /// + /// # Ok::<(), Box>(()) + /// ``` + pub fn is_variant(&self) -> bool { + matches!(self, Member::Variant(_)) + } + + /// Gets a reference to the inner [`Variant`] if this [`Member`] is a + /// [`Member::Variant`]. Else, this returns [`None`]. + /// + /// # Examples + /// + /// ``` + /// use ccdi_cde as cde; + /// + /// use cde::parse::cde::member::Field; + /// use cde::parse::cde::member::Variant; + /// use cde::parse::cde::Member; + /// + /// let field = "A description.".parse::()?; + /// let member = Member::Field(field.clone()); + /// assert_eq!(member.get_variant(), None); + /// + /// let variant = "`A Permissible Value` + /// + /// A description.".parse::()?; + /// let member = Member::Variant(variant.clone()); + /// assert_eq!(member.get_variant(), Some(&variant)); + /// + /// # Ok::<(), Box>(()) + /// ``` + pub fn get_variant(&self) -> Option<&variant::Variant> { + match self { + Member::Variant(variant) => Some(variant), + _ => None, + } + } +} diff --git a/packages/ccdi-cde/src/parse/cde/member/field.rs b/packages/ccdi-cde/src/parse/cde/member/field.rs new file mode 100644 index 0000000..07fa748 --- /dev/null +++ b/packages/ccdi-cde/src/parse/cde/member/field.rs @@ -0,0 +1,79 @@ +//! Parsing the members of `struct`s ("fields") as common data elements. + +use serde::Deserialize; +use serde::Serialize; + +#[derive(Debug, Eq, PartialEq)] +pub enum ParseError { + /// Attempted to parse a field with no documentation. + Empty, +} + +impl std::fmt::Display for ParseError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ParseError::Empty => write!(f, "cannot parse field with no documentation"), + } + } +} + +impl std::error::Error for ParseError {} + +type Result = std::result::Result; + +/// A parsed field of a `struct` that describes a common data element. +#[derive(Clone, Debug, Deserialize, Eq, Serialize, PartialEq)] +pub struct Field(String); + +impl Field { + /// Gets the description for the [`Field`] by reference. + /// + /// # Examples + /// + /// ``` + /// use ccdi_cde as cde; + /// + /// use cde::parse::cde::member::Field; + /// + /// let member = "The namespace of the identifier.".parse::()?; + /// + /// assert_eq!(member.description(), "The namespace of the identifier."); + /// + /// # Ok::<(), Box>(()) + /// ``` + pub fn description(&self) -> &str { + self.0.as_str() + } +} + +impl From for Field { + fn from(value: String) -> Self { + Self(value) + } +} + +impl std::str::FromStr for Field { + type Err = ParseError; + + fn from_str(s: &str) -> Result { + if s.is_empty() { + return Err(ParseError::Empty); + } + + Ok(Field::from(s.to_owned())) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn it_fails_to_parse_a_variant_with_no_documentation( + ) -> std::result::Result<(), Box> { + let err = "".parse::().unwrap_err(); + assert_eq!(err, ParseError::Empty); + + Ok(()) + } +} diff --git a/packages/ccdi-cde/src/parse/cde/member/variant.rs b/packages/ccdi-cde/src/parse/cde/member/variant.rs new file mode 100644 index 0000000..7216315 --- /dev/null +++ b/packages/ccdi-cde/src/parse/cde/member/variant.rs @@ -0,0 +1,465 @@ +//! Parsing the members of `enum`s ("variants") as common data elements. + +use regex::Regex; +use serde::Deserialize; +use serde::Serialize; +use std::iter::Peekable; +use std::str::FromStr; +use std::str::Lines; + +use indexmap::IndexMap; + +use crate::parse::trim_and_concat_contiguous_lines; + +const PERMISSIBLE_VALUE_PATTERN: &str = r"^`(?P.*)`$"; +const METADATA_PATTERN: &str = r"^\*\s*\*\*(?P.*)\*\*:\s*(?P.*)$"; + +/// An error related to parsing a [`Variant`]. +#[derive(Debug, Eq, PartialEq)] +pub enum ParseError { + /// Attempted to parse a variant with no documentation. + Empty, + + /// The iterator ended early. The argument is a plain-text description of + /// the part of the docstring that was being parsed when the iterator + /// stopped. + IteratorEndedEarly(String), + + /// A permissible value line was does not match the format we expect. The + /// argument is the line that we are attempting to parse. + InvalidPermissibleValueFormat(String), + + /// A variant metadata line was does not match the format we expect. The + /// argument is the line that we are attempting to parse. + InvalidMemberMetadataFormat(String), +} + +impl std::fmt::Display for ParseError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ParseError::Empty => write!(f, "cannot parse variant with no documentation"), + ParseError::IteratorEndedEarly(entity) => { + write!(f, "iterator ended early when parsing {entity}") + } + ParseError::InvalidPermissibleValueFormat(value) => { + write!( + f, + "permissible value does not match expected format: \"{value}\". + The following format is expected: \"`PERMISSIBLE VALUE`\"" + ) + } + ParseError::InvalidMemberMetadataFormat(value) => { + write!( + f, + "variant metadata does not match expected format: \"{value}\". + The following format is expected: \"* **NAME**: DESCRIPTION\"" + ) + } + } + } +} + +impl std::error::Error for ParseError {} + +type Result = std::result::Result; + +/// A parsed variant of an `enum` that describes a common data element. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct Variant { + permissible_value: String, + metadata: Option>, + description: String, +} + +impl Variant { + /// Gets the permissible value for the [`Variant`] by reference. + /// + /// # Examples + /// + /// ``` + /// use ccdi_cde as cde; + /// + /// use cde::parse::cde::member::Variant; + /// + /// let variant = r#"`Unknown` + /// + /// * **VM Long Name**: Unknown + /// * **VM Public ID**: 4266671 + /// * **Concept Code**: C17998 + /// * **Begin Date**: 03/09/2023 + /// + /// Not known, not observed, not recorded, or refused."#.parse::()?; + /// + /// assert_eq!(variant.permissible_value(), "Unknown"); + /// + /// # Ok::<(), Box>(()) + /// ``` + pub fn permissible_value(&self) -> &str { + self.permissible_value.as_str() + } + + /// Gets the metadata map for the [`Variant`] by reference. + /// + /// # Examples + /// + /// ``` + /// use ccdi_cde as cde; + /// + /// use cde::parse::cde::member::Variant; + /// + /// let variant = r#"`Unknown` + /// + /// * **VM Long Name**: Unknown + /// * **VM Public ID**: 4266671 + /// * **Concept Code**: C17998 + /// * **Begin Date**: 03/09/2023 + /// + /// Not known, not observed, not recorded, or refused."#.parse::()?; + /// + /// let metadata = variant.metadata().unwrap(); + /// + /// assert_eq!(metadata.get("VM Long Name").unwrap(), "Unknown"); + /// assert_eq!(metadata.get("VM Public ID").unwrap(), "4266671"); + /// assert_eq!(metadata.get("Concept Code").unwrap(), "C17998"); + /// assert_eq!(metadata.get("Begin Date").unwrap(), "03/09/2023"); + /// + /// # Ok::<(), Box>(()) + /// ``` + pub fn metadata(&self) -> Option<&IndexMap> { + self.metadata.as_ref() + } + + /// Gets the description for the [`Variant`] by reference. + /// + /// # Examples + /// + /// ``` + /// use ccdi_cde as cde; + /// + /// use cde::parse::cde::member::Variant; + /// + /// let variant = r#"`Unknown` + /// + /// * **VM Long Name**: Unknown + /// * **VM Public ID**: 4266671 + /// * **Concept Code**: C17998 + /// * **Begin Date**: 03/09/2023 + /// + /// Not known, not observed, not recorded, or refused."#.parse::()?; + /// + /// assert_eq!(variant.description(), "Not known, not observed, not recorded, or refused."); + /// + /// # Ok::<(), Box>(()) + /// ``` + pub fn description(&self) -> &str { + self.description.as_str() + } +} + +impl FromStr for Variant { + type Err = ParseError; + + fn from_str(s: &str) -> Result { + let mut lines = s.lines().peekable(); + + if lines.peek().is_none() { + return Err(ParseError::Empty); + } + + let permissible_value = parse_permissible_value(&mut lines)?; + let metadata = parse_metadata(&mut lines)?; + let description = parse_description(&mut lines)?; + + Ok(Self { + permissible_value, + metadata, + description, + }) + } +} + +fn parse_permissible_value(lines: &mut Peekable>) -> Result { + let permissible_value = trim_and_concat_contiguous_lines(lines) + .map(Ok) + .unwrap_or(Err(ParseError::IteratorEndedEarly(String::from( + "permissible value", + ))))?; + + // SAFETY: we test that this pattern unwraps statically below. + let regex = Regex::new(PERMISSIBLE_VALUE_PATTERN).unwrap(); + + regex + .captures(&permissible_value) + .and_then(|value| value.name("permissible_value")) + .map(|match_| Ok(match_.as_str().to_string())) + .unwrap_or(Err(ParseError::InvalidPermissibleValueFormat( + permissible_value.to_string(), + ))) +} + +fn parse_metadata(lines: &mut Peekable>) -> Result>> { + match lines.peek() { + Some(line) => { + let line = line.trim(); + + if !line.starts_with('*') { + return Ok(None); + } + } + None => return Err(ParseError::IteratorEndedEarly(String::from("metadata"))), + } + + // SAFETY: we test that this pattern unwraps statically below. + let regex = Regex::new(METADATA_PATTERN).unwrap(); + let mut results = IndexMap::::new(); + + while let Some(line) = lines.next().map(|line| line.trim()) { + if !line.starts_with('*') { + break; + } + + match regex.captures(line) { + Some(captures) => results.insert( + // SAFETY: these two keys are tested for existence in the regex + // below, so they will always be present. + captures.name("key").unwrap().as_str().to_string(), + captures.name("value").unwrap().as_str().to_string(), + ), + None => return Err(ParseError::InvalidMemberMetadataFormat(line.to_owned())), + }; + } + + Ok(Some(results)) +} + +fn parse_description(lines: &mut Peekable>) -> Result { + match trim_and_concat_contiguous_lines(lines) { + Some(line) => Ok(line.to_owned()), + None => Err(ParseError::IteratorEndedEarly(String::from("description"))), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_permissible_value_pattern_compiles_and_captures() { + let regex = Regex::new(PERMISSIBLE_VALUE_PATTERN).unwrap(); + let captures = regex.captures("`A Permissible Value`").unwrap(); + + assert_eq!( + captures.name("permissible_value").unwrap().as_str(), + "A Permissible Value" + ); + } + + #[test] + fn the_metadata_pattern_compiles_and_captures() { + let regex = Regex::new(METADATA_PATTERN).unwrap(); + let captures = regex.captures("* **Hello**: World").unwrap(); + + assert_eq!(captures.name("key").unwrap().as_str(), "Hello"); + assert_eq!(captures.name("value").unwrap().as_str(), "World"); + } + + #[test] + fn it_parses_a_variant_with_metadata_correctly( + ) -> std::result::Result<(), Box> { + let value = "`Not Reported` + + * **VM Long Name**: Not Reported + * **VM Public ID**: 5612322 + * **Concept Code**: C43234 + * **Begin Date**: 10/03/2023 + + Not provided or available." + .parse::()?; + + assert_eq!(value.permissible_value(), "Not Reported"); + + let metadata = value.metadata().unwrap(); + assert_eq!( + metadata.get("VM Long Name").unwrap().as_str(), + "Not Reported" + ); + assert_eq!(metadata.get("VM Public ID").unwrap().as_str(), "5612322"); + assert_eq!(metadata.get("Concept Code").unwrap().as_str(), "C43234"); + assert_eq!(metadata.get("Begin Date").unwrap().as_str(), "10/03/2023"); + + assert_eq!(value.description(), "Not provided or available."); + + Ok(()) + } + + #[test] + fn it_parses_a_variant_with_no_metadata_correctly( + ) -> std::result::Result<(), Box> { + let value = "`Not Reported` + + Not provided or available." + .parse::()?; + + assert_eq!(value.permissible_value(), "Not Reported"); + assert_eq!(value.metadata(), None); + assert_eq!(value.description(), "Not provided or available."); + + Ok(()) + } + + #[test] + fn it_parses_a_multiline_permissible_value_correctly( + ) -> std::result::Result<(), Box> { + let value = "`Not Reported, alongside + another + line.` + + * **VM Long Name**: Not Reported + * **VM Public ID**: 5612322 + * **Concept Code**: C43234 + * **Begin Date**: 10/03/2023 + + Not provided or available." + .parse::()?; + + assert_eq!( + value.permissible_value(), + "Not Reported, alongside another line." + ); + + let value = "`Not Reported, alongside + another + line.` + + Not provided or available." + .parse::()?; + + assert_eq!( + value.permissible_value(), + "Not Reported, alongside another line." + ); + + Ok(()) + } + + #[test] + fn it_parses_a_multiline_description_correctly( + ) -> std::result::Result<(), Box> { + let value = "`Not Reported` + + * **VM Long Name**: Not Reported + * **VM Public ID**: 5612322 + * **Concept Code**: C43234 + * **Begin Date**: 10/03/2023 + + Not provided or available, + alongside another + line." + .parse::()?; + + assert_eq!( + value.description(), + "Not provided or available, alongside another line." + ); + + let value = "`Not Reported` + + Not provided or available, + alongside another + line." + .parse::()?; + + assert_eq!( + value.description(), + "Not provided or available, alongside another line." + ); + + Ok(()) + } + + #[test] + fn it_fails_to_parse_a_variant_with_no_documentation( + ) -> std::result::Result<(), Box> { + let err = "".parse::().unwrap_err(); + assert_eq!(err, ParseError::Empty); + + Ok(()) + } + + #[test] + fn it_fails_to_parse_a_variant_with_missing_metadata( + ) -> std::result::Result<(), Box> { + let err = "`Not Reported`".parse::().unwrap_err(); + assert_eq!( + err, + ParseError::IteratorEndedEarly(String::from("metadata")) + ); + + let err = "`Not Reported` + " + .parse::() + .unwrap_err(); + assert_eq!( + err, + ParseError::IteratorEndedEarly(String::from("metadata")) + ); + + Ok(()) + } + + #[test] + fn it_fails_to_parse_a_variant_with_a_missing_description( + ) -> std::result::Result<(), Box> { + let err = "`Not Reported` + + * **Hello**: World" + .parse::() + .unwrap_err(); + assert_eq!( + err, + ParseError::IteratorEndedEarly(String::from("description")) + ); + + let err = "`Not Reported` + + * **Hello**: World + " + .parse::() + .unwrap_err(); + assert_eq!( + err, + ParseError::IteratorEndedEarly(String::from("description")) + ); + + Ok(()) + } + + #[test] + fn it_fails_to_parse_an_invalid_permissible_value( + ) -> std::result::Result<(), Box> { + let err = "Not Reported + + * **Hello**: World + + A description." + .parse::() + .unwrap_err(); + assert_eq!( + err, + ParseError::InvalidPermissibleValueFormat(String::from("Not Reported")) + ); + + let err = "`Not Reported` + + * **Hello**: World + " + .parse::() + .unwrap_err(); + assert_eq!( + err, + ParseError::IteratorEndedEarly(String::from("description")) + ); + + Ok(()) + } +} diff --git a/packages/ccdi-cde/src/v1.rs b/packages/ccdi-cde/src/v1.rs index d7ee22a..1f94f57 100644 --- a/packages/ccdi-cde/src/v1.rs +++ b/packages/ccdi-cde/src/v1.rs @@ -1,9 +1,4 @@ //! Common data elements that have a major version of one. -mod identifier; -mod race; -mod sex; - -pub use identifier::Identifier; -pub use race::Race; -pub use sex::Sex; +pub mod sample; +pub mod subject; diff --git a/packages/ccdi-cde/src/v1/sample.rs b/packages/ccdi-cde/src/v1/sample.rs new file mode 100644 index 0000000..c35e99d --- /dev/null +++ b/packages/ccdi-cde/src/v1/sample.rs @@ -0,0 +1,8 @@ +//! Common data elements that have a major version of one and are related to a +//! sample. + +mod disease_phase; +mod tumor_classification; + +pub use disease_phase::DiseasePhase; +pub use tumor_classification::TumorClassification; diff --git a/packages/ccdi-cde/src/v1/sample/disease_phase.rs b/packages/ccdi-cde/src/v1/sample/disease_phase.rs new file mode 100644 index 0000000..2050c41 --- /dev/null +++ b/packages/ccdi-cde/src/v1/sample/disease_phase.rs @@ -0,0 +1,199 @@ +use introspect::Introspect; +use rand::distributions::Standard; +use rand::prelude::Distribution; +use serde::Deserialize; +use serde::Serialize; +use utoipa::ToSchema; + +use crate::CDE; + +/// **`caDSR CDE 12217251 v1.00`** +/// +/// This metadata element is defined by the caDSR as "The stage or period of an +/// individual's treatment process during which relevant observations were +/// recorded.". +/// +/// Link: +/// +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize, ToSchema, Introspect)] +#[schema(as = cde::v1::sample::DiseasePhase)] +pub enum DiseasePhase { + /// `Post-Mortem` + /// + /// * **VM Long Name**: Postmortem + /// * **VM Public ID**: 5236215 + /// * **Concept Code**: C94193 + /// * **Begin Date**: 03/10/2023 + /// + /// After death. Often used to describe an autopsy. + #[serde(rename = "Post-Mortem")] + PostMortem, + + /// `Not Reported` + /// + /// * **VM Long Name**: Not Reported + /// * **VM Public ID**: 5612322 + /// * **Concept Code**: C43234 + /// * **Begin Date**: 03/09/2023 + /// + /// Not provided or available. + #[serde(rename = "Not Reported")] + NotReported, + + /// `Unknown` + /// + /// * **VM Long Name**: Unknown + /// * **VM Public ID**: 4266671 + /// * **Concept Code**: C17998 + /// * **Begin Date**: 03/09/2023 + /// + /// Not known, not observed, not recorded, or refused. + #[serde(rename = "Unknown")] + Unknown, + + /// `Initial Diagnosis` + /// + /// * **VM Long Name**: Initial Diagnosis + /// * **VM Public ID**: 8002761 + /// * **Concept Code**: C156813 + /// * **Begin Date**: 12/27/2022 + /// + /// The first diagnosis of the individual's condition. + #[serde(rename = "Initial Diagnosis")] + InitialDiagnosis, + + /// `Progression` + /// + /// * **VM Long Name**: Disease Progression + /// * **VM Public ID**: 2816916 + /// * **Concept Code**: C17747 + /// * **Begin Date**: 2/27/2022 + /// + /// The worsening of a disease over time + #[serde(rename = "Progression")] + Progression, + + /// `Refactory` + /// + /// * **VM Long Name**: Refractory + /// * **VM Public ID**: 2566882 + /// * **Concept Code**: C38014 + /// * **Begin Date**: 12/27/2022 + /// + /// Not responding to treatment. + #[serde(rename = "Refactory")] + Refactory, + + /// `Relapse` + /// + /// * **VM Long Name**: Recurrent Disease + /// * **VM Public ID**: 3828963 + /// * **Concept Code**: C38155 + /// * **Begin Date**: 12/27/2022 + /// + /// The return of a disease after a period of remission. + #[serde(rename = "Relapse")] + Relapse, + + /// `Relapse/Progression` + /// + /// * **VM Long Name**: Disease Relapse/Progression + /// * **VM Public ID**: 12217248 + /// * **Concept Code**: C174991 + /// * **Begin Date**: 12/27/2022 + /// + /// Either the return of the disease or the progression of the disease. + #[serde(rename = "Relapse/Progression")] + RelapseOrProgression, +} + +impl CDE for DiseasePhase {} + +impl std::fmt::Display for DiseasePhase { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + DiseasePhase::PostMortem => write!(f, "Post-Mortem"), + DiseasePhase::NotReported => write!(f, "Not Reported"), + DiseasePhase::Unknown => write!(f, "Unknown"), + DiseasePhase::InitialDiagnosis => write!(f, "Initial Diagnosis"), + DiseasePhase::Progression => write!(f, "Progression"), + DiseasePhase::Refactory => write!(f, "Refactory"), + DiseasePhase::Relapse => write!(f, "Relapse"), + DiseasePhase::RelapseOrProgression => write!(f, "Relapse/Progression"), + } + } +} + +impl Distribution for Standard { + fn sample(&self, rng: &mut R) -> DiseasePhase { + match rng.gen_range(0..=7) { + 0 => DiseasePhase::PostMortem, + 1 => DiseasePhase::NotReported, + 2 => DiseasePhase::Unknown, + 3 => DiseasePhase::InitialDiagnosis, + 4 => DiseasePhase::Progression, + 5 => DiseasePhase::Refactory, + 6 => DiseasePhase::Relapse, + _ => DiseasePhase::RelapseOrProgression, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn it_converts_to_string_correctly() { + assert_eq!(DiseasePhase::PostMortem.to_string(), "Post-Mortem"); + assert_eq!(DiseasePhase::NotReported.to_string(), "Not Reported"); + assert_eq!(DiseasePhase::Unknown.to_string(), "Unknown"); + assert_eq!( + DiseasePhase::InitialDiagnosis.to_string(), + "Initial Diagnosis" + ); + assert_eq!(DiseasePhase::Progression.to_string(), "Progression"); + assert_eq!(DiseasePhase::Refactory.to_string(), "Refactory"); + assert_eq!(DiseasePhase::Relapse.to_string(), "Relapse"); + assert_eq!( + DiseasePhase::RelapseOrProgression.to_string(), + "Relapse/Progression" + ); + } + + #[test] + fn it_serializes_to_json_correctly() { + assert_eq!( + serde_json::to_string(&DiseasePhase::PostMortem).unwrap(), + "\"Post-Mortem\"" + ); + assert_eq!( + serde_json::to_string(&DiseasePhase::NotReported).unwrap(), + "\"Not Reported\"" + ); + assert_eq!( + serde_json::to_string(&DiseasePhase::Unknown).unwrap(), + "\"Unknown\"" + ); + assert_eq!( + serde_json::to_string(&DiseasePhase::InitialDiagnosis).unwrap(), + "\"Initial Diagnosis\"" + ); + assert_eq!( + serde_json::to_string(&DiseasePhase::Progression).unwrap(), + "\"Progression\"" + ); + assert_eq!( + serde_json::to_string(&DiseasePhase::Refactory).unwrap(), + "\"Refactory\"" + ); + assert_eq!( + serde_json::to_string(&DiseasePhase::Relapse).unwrap(), + "\"Relapse\"" + ); + assert_eq!( + serde_json::to_string(&DiseasePhase::RelapseOrProgression).unwrap(), + "\"Relapse/Progression\"" + ); + } +} diff --git a/packages/ccdi-cde/src/v1/sample/tumor_classification.rs b/packages/ccdi-cde/src/v1/sample/tumor_classification.rs new file mode 100644 index 0000000..0c0e1b1 --- /dev/null +++ b/packages/ccdi-cde/src/v1/sample/tumor_classification.rs @@ -0,0 +1,140 @@ +use introspect::Introspect; +use rand::distributions::Standard; +use rand::prelude::Distribution; +use serde::Deserialize; +use serde::Serialize; +use utoipa::ToSchema; + +use crate::CDE; + +/// **`caDSR CDE 12922545 v1.00`** +/// +/// This metadata element is defined by the caDSR as "The classification of a +/// tumor based primarily on histopathological characteristics.". +/// +/// Link: +/// +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize, ToSchema, Introspect)] +#[schema(as = cde::v1::sample::TumorClassification)] +pub enum TumorClassification { + /// `Metastatic` + /// + /// * **VM Long Name**: Metastatic + /// * **VM Public ID**: 5189148 + /// * **Concept Code**: C14174 + /// * **Begin Date**: 02/23/2023 + /// + /// A term referring to the clinical or pathologic observation of a tumor + /// extension from its original site of growth to another anatomic site. + #[serde(rename = "Metastatic")] + Metastatic, + + /// `Not Reported` + /// + /// * **VM Long Name**: Not Reported + /// * **VM Public ID**: 5612322 + /// * **Concept Code**: C43234 + /// * **Begin Date**: 02/23/2023 + /// + /// Not provided or available. + #[serde(rename = "Not Reported")] + NotReported, + + /// `Primary` + /// + /// * **VM Long Name**: Primary tumor + /// * **VM Public ID**: 5189150 + /// * **Concept Code**: C8509 + /// * **Begin Date**: 02/23/2023 + /// + /// A tumor at the original site of origin. + #[serde(rename = "Primary")] + Primary, + + /// `Regional` + /// + /// * **VM Long Name**: Regional Disease + /// * **VM Public ID**: 2971661 + /// * **Concept Code**: C41844 + /// * **Begin Date**: 02/23/2023 + /// + /// A disease or condition that extends beyond the site and spreads into + /// adjacent tissues and regional lymph nodes. + #[serde(rename = "Regional")] + Regional, + + /// `Unknown` + /// + /// * **VM Long Name**: Unknown + /// * **VM Public ID**: 4266671 + /// * **Concept Code**: C17998 + /// * **Begin Date**: 02/23/2023 + /// + /// Not known, not observed, not recorded, or refused. + #[serde(rename = "Unknown")] + Unknown, +} + +impl CDE for TumorClassification {} + +impl std::fmt::Display for TumorClassification { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + TumorClassification::Metastatic => write!(f, "Metastatic"), + TumorClassification::NotReported => write!(f, "Not Reported"), + TumorClassification::Primary => write!(f, "Primary"), + TumorClassification::Regional => write!(f, "Regional"), + TumorClassification::Unknown => write!(f, "Unknown"), + } + } +} + +impl Distribution for Standard { + fn sample(&self, rng: &mut R) -> TumorClassification { + match rng.gen_range(0..=4) { + 0 => TumorClassification::Metastatic, + 1 => TumorClassification::NotReported, + 2 => TumorClassification::Primary, + 3 => TumorClassification::Regional, + _ => TumorClassification::Unknown, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn it_converts_to_string_correctly() { + assert_eq!(TumorClassification::Metastatic.to_string(), "Metastatic"); + assert_eq!(TumorClassification::NotReported.to_string(), "Not Reported"); + assert_eq!(TumorClassification::Primary.to_string(), "Primary"); + assert_eq!(TumorClassification::Regional.to_string(), "Regional"); + assert_eq!(TumorClassification::Unknown.to_string(), "Unknown"); + } + + #[test] + fn it_serializes_to_json_correctly() { + assert_eq!( + serde_json::to_string(&TumorClassification::Metastatic).unwrap(), + "\"Metastatic\"" + ); + assert_eq!( + serde_json::to_string(&TumorClassification::NotReported).unwrap(), + "\"Not Reported\"" + ); + assert_eq!( + serde_json::to_string(&TumorClassification::Primary).unwrap(), + "\"Primary\"" + ); + assert_eq!( + serde_json::to_string(&TumorClassification::Regional).unwrap(), + "\"Regional\"" + ); + assert_eq!( + serde_json::to_string(&TumorClassification::Unknown).unwrap(), + "\"Unknown\"" + ); + } +} diff --git a/packages/ccdi-cde/src/v1/subject.rs b/packages/ccdi-cde/src/v1/subject.rs new file mode 100644 index 0000000..6d75ee9 --- /dev/null +++ b/packages/ccdi-cde/src/v1/subject.rs @@ -0,0 +1,10 @@ +//! Common data elements that have a major version of one and are related to a +//! subject. + +mod identifier; +mod race; +mod sex; + +pub use identifier::Identifier; +pub use race::Race; +pub use sex::Sex; diff --git a/packages/ccdi-cde/src/v1/identifier.rs b/packages/ccdi-cde/src/v1/subject/identifier.rs similarity index 88% rename from packages/ccdi-cde/src/v1/identifier.rs rename to packages/ccdi-cde/src/v1/subject/identifier.rs index 252d3df..392a88e 100644 --- a/packages/ccdi-cde/src/v1/identifier.rs +++ b/packages/ccdi-cde/src/v1/subject/identifier.rs @@ -1,3 +1,4 @@ +use introspect::Introspect; use serde::Deserialize; use serde::Serialize; use utoipa::ToSchema; @@ -36,7 +37,7 @@ impl std::fmt::Display for Error { impl std::error::Error for Error {} -/// **caDSR CDE 6380049 v1.00** +/// **`caDSR CDE 6380049 v1.00`** /// /// This metadata element is defined by the caDSR as "A unique subject /// identifier within a site and a study.". No permissible values are defined @@ -44,12 +45,9 @@ impl std::error::Error for Error {} /// /// Link: /// -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize, ToSchema)] -#[schema(as = cde::v1::Identifier)] -pub struct Identifier -where - Self: CDE, -{ +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize, ToSchema, Introspect)] +#[schema(as = cde::v1::subject::Identifier)] +pub struct Identifier { /// The namespace of the identifier. #[schema(example = "organization")] namespace: String, @@ -66,7 +64,7 @@ impl Identifier { /// /// ``` /// use ccdi_cde as cde; - /// use cde::v1::Identifier; + /// use cde::v1::subject::Identifier; /// /// let identifier = Identifier::new("organization", "Name"); /// assert_eq!(identifier.namespace(), &String::from("organization")); @@ -85,7 +83,7 @@ impl Identifier { /// /// ``` /// use ccdi_cde as cde; - /// use cde::v1::Identifier; + /// use cde::v1::subject::Identifier; /// /// let identifier = Identifier::parse("organization:Name", ":")?; /// assert_eq!(identifier.namespace(), &String::from("organization")); @@ -102,7 +100,7 @@ impl Identifier { /// /// ``` /// use ccdi_cde as cde; - /// use cde::v1::Identifier; + /// use cde::v1::subject::Identifier; /// /// let identifier = Identifier::parse("organization:Name", ":")?; /// assert_eq!(identifier.name(), &String::from("Name")); @@ -120,7 +118,7 @@ impl Identifier { /// /// ``` /// use ccdi_cde as cde; - /// use cde::v1::Identifier; + /// use cde::v1::subject::Identifier; /// /// let identifier = Identifier::parse("organization:Name", ":")?; /// assert_eq!(identifier.namespace(), &String::from("organization")); @@ -149,18 +147,6 @@ impl Identifier { impl CDE for Identifier {} -impl crate::Standard for Identifier { - fn standard() -> &'static str { - "caDSR CDE 6380049 v1.00" - } -} - -impl crate::Url for Identifier { - fn url() -> &'static str { - "https://cadsr.cancer.gov/onedata/dmdirect/NIH/NCI/CO/CDEDD?filter=CDEDD.ITEM_ID=6380049%20and%20ver_nr=1" - } -} - impl std::fmt::Display for Identifier { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!( @@ -173,7 +159,7 @@ impl std::fmt::Display for Identifier { #[cfg(test)] mod tests { - use crate::v1::Identifier; + use crate::v1::subject::Identifier; #[test] fn it_displays_correctly() { diff --git a/packages/ccdi-cde/src/v1/race.rs b/packages/ccdi-cde/src/v1/subject/race.rs similarity index 66% rename from packages/ccdi-cde/src/v1/race.rs rename to packages/ccdi-cde/src/v1/subject/race.rs index 8d4e226..664c242 100644 --- a/packages/ccdi-cde/src/v1/race.rs +++ b/packages/ccdi-cde/src/v1/subject/race.rs @@ -1,3 +1,4 @@ +use introspect::Introspect; use rand::distributions::Standard; use rand::prelude::Distribution; use serde::Deserialize; @@ -6,7 +7,7 @@ use utoipa::ToSchema; use crate::CDE; -/// **caDSR CDE 2192199 v1.00** +/// **`caDSR CDE 2192199 v1.00`** /// /// This metadata element is defined by the caDSR as "The text for reporting /// information about race based on the Office of Management and Budget (OMB) @@ -15,19 +16,26 @@ use crate::CDE; /// /// Link: /// -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize, ToSchema)] -#[schema(as = cde::v1::Race)] -pub enum Race -where - Self: CDE, -{ - /// Not Allowed To Collect +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize, ToSchema, Introspect)] +#[schema(as = cde::v1::subject::Race)] +pub enum Race { + /// `Not allowed to collect` + /// + /// * **VM Long Name**: Not Allowed To Collect + /// * **VM Public ID**: 6662191 + /// * **Concept Code**: C141478 + /// * **Begin Date**: 03/06/2019 /// /// An indicator that specifies that a collection event was not permitted. #[serde(rename = "Not allowed to collect")] NotAllowedToCollect, - /// Native Hawaiian or Other Pacific Islander + /// `Native Hawaiian or other Pacific Islander` + /// + /// * **VM Long Name**: Native Hawaiian or Other Pacific Islander + /// * **VM Public ID**: 2572235 + /// * **Concept Code**: C41219 + /// * **Begin Date**: 05/31/2002 /// /// Denotes a person having origins in any of the original peoples of /// Hawaii, Guam, Samoa, or other Pacific Islands. The term covers @@ -39,19 +47,34 @@ where #[serde(rename = "Native Hawaiian or other Pacific Islander")] NativeHawaiianOrOtherPacificIslander, - /// Not Reported + /// `Not Reported` + /// + /// * **VM Long Name**: Not Reported + /// * **VM Public ID**: 2572578 + /// * **Concept Code**: C43234 + /// * **Begin Date**: 10/16/2003 /// /// Not provided or available. #[serde(rename = "Not Reported")] NotReported, - /// Unknown + /// `Unknown` + /// + /// * **VM Long Name**: Unknown + /// * **VM Public ID**: 2572577 + /// * **Concept Code**: C17998 + /// * **Begin Date**: 02/11/2002 /// /// Not known, not observed, not recorded, or refused. #[serde(rename = "Unknown")] Unknown, - /// American Indian or Alaska Native + /// `American Indian or Alaska Native` + /// + /// * **VM Long Name**: American Indian or Alaska Native + /// * **VM Public ID**: 2572232 + /// * **Concept Code**: C41259 + /// * **Begin Date**: 05/31/2002 /// /// A person having origins in any of the original peoples of North and /// South America (including Central America) and who maintains tribal @@ -59,7 +82,12 @@ where #[serde(rename = "American Indian or Alaska Native")] AmericanIndianOrAlaskaNative, - /// Asian + /// `Asian` + /// + /// * **VM Long Name**: Asian + /// * **VM Public ID**: 2572233 + /// * **Concept Code**: C41260 + /// * **Begin Date**: 05/31/2002 /// /// A person having origins in any of the original peoples of the Far East, /// Southeast Asia, or the Indian subcontinent, including for example, @@ -68,15 +96,26 @@ where #[serde(rename = "Asian")] Asian, - /// Black or African American + /// `Asian` + /// + /// * **VM Long Name**: Asian + /// * **VM Public ID**: 2572233 + /// * **Concept Code**: C41260 + /// * **Begin Date**: 05/31/2002 /// - /// A person having origins in any of the Black racial groups of Africa. - /// Terms such as "Haitian" or "Negro" can be used in addition to "Black or - /// African American". (OMB) + /// A person having origins in any of the original peoples of the Far East, + /// Southeast Asia, or the Indian subcontinent, including for example, + /// Cambodia, China, India, Japan, Korea, Malaysia, Pakistan, the Philippine + /// Islands, Thailand, and Vietnam. (OMB) #[serde(rename = "Black or African American")] BlackOrAfricanAmerican, - /// White + /// `White` + /// + /// * **VM Long Name**: White + /// * **VM Public ID**: 2572236 + /// * **Concept Code**: C41261 + /// * **Begin Date**: 05/31/2002 /// /// Denotes person with European, Middle Eastern, or North African ancestral /// origin who identifies, or is identified, as White. @@ -86,18 +125,6 @@ where impl CDE for Race {} -impl crate::Standard for Race { - fn standard() -> &'static str { - "caDSR CDE 2192199 v1.00" - } -} - -impl crate::Url for Race { - fn url() -> &'static str { - "https://cadsr.cancer.gov/onedata/dmdirect/NIH/NCI/CO/CDEDD?filter=CDEDD.ITEM_ID=2192199%20and%20ver_nr=1" - } -} - impl std::fmt::Display for Race { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { diff --git a/packages/ccdi-cde/src/v1/sex.rs b/packages/ccdi-cde/src/v1/subject/sex.rs similarity index 71% rename from packages/ccdi-cde/src/v1/sex.rs rename to packages/ccdi-cde/src/v1/subject/sex.rs index c39734c..44de14a 100644 --- a/packages/ccdi-cde/src/v1/sex.rs +++ b/packages/ccdi-cde/src/v1/subject/sex.rs @@ -1,3 +1,4 @@ +use introspect::Introspect; use rand::distributions::Standard; use rand::prelude::Distribution; use serde::Deserialize; @@ -6,31 +7,38 @@ use utoipa::ToSchema; use crate::CDE; -/// **caDSR CDE 6343385 v1.00** +/// **`caDSR CDE 6343385 v1.00`** /// /// This metadata element is defined by the caDSR as "Sex of the subject as /// determined by the investigator." In particular, this field does not dictate /// the time period: whether it represents sex at birth, sex at sample /// collection, or any other determined time point. Further, the descriptions /// for F and M suggest that this term can represent either biological sex, -/// culture gender roles, or both. Thus, this field cannot be assumed to +/// cultural gender roles, or both. Thus, this field cannot be assumed to /// strictly represent biological sex. /// /// Link: /// -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize, ToSchema)] -#[schema(as = cde::v1::Sex)] -pub enum Sex -where - Self: CDE, -{ - /// Unknown +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize, ToSchema, Introspect)] +#[schema(as = cde::v1::subject::Sex)] +pub enum Sex { + /// `U` + /// + /// * **VM Long Name**: Unknown + /// * **VM Public ID**: 5682944 + /// * **Concept Code**: C17998 + /// * **Begin Date**: 06/27/2018 /// /// Not known, not observed, not recorded, or refused. #[serde(rename = "U")] Unknown, - /// Female + /// `F` + /// + /// * **VM Long Name**: Female + /// * **VM Public ID**: 2567172 + /// * **Concept Code**: C16576 + /// * **Begin Date**: 06/27/2018 /// /// A person who belongs to the sex that normally produces ova. The term is /// used to indicate biological sex distinctions, or cultural gender role @@ -38,7 +46,12 @@ where #[serde(rename = "F")] Female, - /// Male + /// `M` + /// + /// * **VM Long Name**: Male + /// * **VM Public ID**: 2567171 + /// * **Concept Code**: C20197 + /// * **Begin Date**: 06/27/2018 /// /// A person who belongs to the sex that normally produces sperm. The term /// is used to indicate biological sex distinctions, cultural gender role @@ -46,7 +59,12 @@ where #[serde(rename = "M")] Male, - /// Intersex + /// `UNDIFFERENTIATED` + /// + /// * **VM Long Name**: Intersex + /// * **VM Public ID**: 2575558 + /// * **Concept Code**: C45908 + /// * **Begin Date**: 06/27/2018 /// /// A person (one of unisexual specimens) who is born with genitalia and/or /// secondary sexual characteristics of indeterminate sex, or which combine @@ -57,18 +75,6 @@ where impl CDE for Sex {} -impl crate::Standard for Sex { - fn standard() -> &'static str { - "caDSR CDE 6343385 v1.00" - } -} - -impl crate::Url for Sex { - fn url() -> &'static str { - "https://cadsr.cancer.gov/onedata/dmdirect/NIH/NCI/CO/CDEDD?filter=CDEDD.ITEM_ID=6343385%20and%20ver_nr=1" - } -} - impl std::fmt::Display for Sex { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { diff --git a/packages/ccdi-cde/src/v2.rs b/packages/ccdi-cde/src/v2.rs index d0a39a3..1f94f57 100644 --- a/packages/ccdi-cde/src/v2.rs +++ b/packages/ccdi-cde/src/v2.rs @@ -1,5 +1,4 @@ //! Common data elements that have a major version of one. -mod ethnicity; - -pub use ethnicity::Ethnicity; +pub mod sample; +pub mod subject; diff --git a/packages/ccdi-cde/src/v2/sample.rs b/packages/ccdi-cde/src/v2/sample.rs new file mode 100644 index 0000000..793e10a --- /dev/null +++ b/packages/ccdi-cde/src/v2/sample.rs @@ -0,0 +1,6 @@ +//! Common data elements that have a major version of two and are related to a +//! sample. + +mod tissue_type; + +pub use tissue_type::TissueType; diff --git a/packages/ccdi-cde/src/v2/sample/tissue_type.rs b/packages/ccdi-cde/src/v2/sample/tissue_type.rs new file mode 100644 index 0000000..696510c --- /dev/null +++ b/packages/ccdi-cde/src/v2/sample/tissue_type.rs @@ -0,0 +1,221 @@ +use introspect::Introspect; +use rand::distributions::Standard; +use rand::prelude::Distribution; +use serde::Deserialize; +use serde::Serialize; +use utoipa::ToSchema; + +use crate::CDE; + +/// **`caDSR CDE 5432687 v2.00`** +/// +/// This metadata element is defined by the caDSR as "Text term that represents +/// a description of the kind of tissue collected with respect to disease status +/// or proximity to tumor tissue." +/// +/// Link: +/// +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize, ToSchema, Introspect)] +#[schema(as = cde::v2::sample::TissueType)] +pub enum TissueType { + /// `Not Reported` + /// + /// * **VM Long Name**: Not Reported + /// * **VM Public ID**: 5612322 + /// * **Concept Code**: C43234 + /// * **Begin Date**: 10/03/2023 + /// + /// Not provided or available. + #[serde(rename = "Not Reported")] + NotReported, + + /// `Abnormal` + /// + /// * **VM Long Name**: Abnormal + /// * **VM Public ID**: 4265117 + /// * **Concept Code**: C25401 + /// * **Begin Date**: 08/25/2016 + /// + /// Deviating in any way from the state, position, structure, condition, + /// behavior, or rule which is considered a norm. + #[serde(rename = "Abnormal")] + Abnormal, + + /// `Normal` + /// + /// * **VM Long Name**: Normal + /// * **VM Public ID**: 4494160 + /// * **Concept Code**: C14165 + /// * **Begin Date**: 08/25/2016 + /// + /// Being approximately average or within certain limits; conforming with or + /// constituting a norm or standard or level or type or social norm. + #[serde(rename = "Normal")] + Normal, + + /// `Peritumoral` + /// + /// * **VM Long Name**: Peritumoral + /// * **VM Public ID**: 4633527 + /// * **Concept Code**: C119010 + /// * **Begin Date**: 08/25/2016 + /// + /// Located in tissues surrounding a tumor. + #[serde(rename = "Peritumoral")] + Peritumoral, + + /// `Tumor` + /// + /// * **VM Long Name**: Malignant Neoplasm + /// * **VM Public ID**: 2749852 + /// * **Concept Code**: C9305 + /// * **Begin Date**: /25/2016 + /// + /// A tumor composed of atypical neoplastic, often pleomorphic cells that + /// invade other tissues. Malignant neoplasms usually metastasize to + /// distant anatomic sites and may recur after excision. The most common + /// malignant neoplasms are carcinomas (adenocarcinomas or squamous cell + /// carcinomas), Hodgkin's and non-Hodgkin's lymphomas, leukemias, + /// melanomas, and sarcomas. -- 2004 + #[serde(rename = "Tumor")] + Tumor, + + /// `Non-neoplastic` + /// + /// * **VM Long Name**: Non-Neoplastic + /// * **VM Public ID**: 5828001 + /// * **Concept Code**: C25594:C45315 + /// * **Begin Date**: 05/16/2017 + /// + /// An operation in which a term denies or inverts the meaning of another + /// term or construction.: Exhibiting characteristics of independently + /// proliferating cells, notably altered morphology, growth characteristics, + /// and/or biochemical and molecular properties. + #[serde(rename = "Non-neoplastic")] + NonNeoplastic, + + /// `Unavailable` + /// + /// * **VM Long Name**: Unavailable + /// * **VM Public ID**: 5828000 + /// * **Concept Code**: C126101 + /// * **Begin Date**: 05/16/2017 + /// + /// The desired information is not available. + #[serde(rename = "Unavailable")] + Unavailable, + + /// `Unknown` + /// + /// * **VM Long Name**: Unknown + /// * **VM Public ID**: 2572577 + /// * **Concept Code**: C17998 + /// * **Begin Date**: 05/16/2017 + /// + /// Not known, not observed, not recorded, or refused. + #[serde(rename = "Unknown")] + Unknown, + + /// `Unspecified` + /// + /// * **VM Long Name**: Unspecified + /// * **VM Public ID**: 2573360 + /// * **Concept Code**: C38046 + /// * **Begin Date**: 05/16/2017 + /// + /// Not stated explicitly or in detail. + #[serde(rename = "Unspecified")] + Unspecified, +} + +impl CDE for TissueType {} + +impl std::fmt::Display for TissueType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + TissueType::NotReported => write!(f, "Not Reported"), + TissueType::Abnormal => write!(f, "Abnormal"), + TissueType::Normal => write!(f, "Normal"), + TissueType::Peritumoral => write!(f, "Peritumoral"), + TissueType::Tumor => write!(f, "Tumor"), + TissueType::NonNeoplastic => write!(f, "Non-neoplastic"), + TissueType::Unavailable => write!(f, "Unavailable"), + TissueType::Unknown => write!(f, "Unknown"), + TissueType::Unspecified => write!(f, "Unspecified"), + } + } +} + +impl Distribution for Standard { + fn sample(&self, rng: &mut R) -> TissueType { + match rng.gen_range(0..=8) { + 0 => TissueType::NotReported, + 1 => TissueType::Abnormal, + 2 => TissueType::Normal, + 3 => TissueType::Peritumoral, + 4 => TissueType::Tumor, + 5 => TissueType::NonNeoplastic, + 6 => TissueType::Unavailable, + 7 => TissueType::Unknown, + _ => TissueType::Unspecified, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn it_converts_to_string_correctly() { + assert_eq!(TissueType::NotReported.to_string(), "Not Reported"); + assert_eq!(TissueType::Abnormal.to_string(), "Abnormal"); + assert_eq!(TissueType::Normal.to_string(), "Normal"); + assert_eq!(TissueType::Peritumoral.to_string(), "Peritumoral"); + assert_eq!(TissueType::Tumor.to_string(), "Tumor"); + assert_eq!(TissueType::NonNeoplastic.to_string(), "Non-neoplastic"); + assert_eq!(TissueType::Unavailable.to_string(), "Unavailable"); + assert_eq!(TissueType::Unknown.to_string(), "Unknown"); + assert_eq!(TissueType::Unspecified.to_string(), "Unspecified"); + } + + #[test] + fn it_serializes_to_json_correctly() { + assert_eq!( + serde_json::to_string(&TissueType::NotReported).unwrap(), + "\"Not Reported\"" + ); + assert_eq!( + serde_json::to_string(&TissueType::Abnormal).unwrap(), + "\"Abnormal\"" + ); + assert_eq!( + serde_json::to_string(&TissueType::Normal).unwrap(), + "\"Normal\"" + ); + assert_eq!( + serde_json::to_string(&TissueType::Peritumoral).unwrap(), + "\"Peritumoral\"" + ); + assert_eq!( + serde_json::to_string(&TissueType::Tumor).unwrap(), + "\"Tumor\"" + ); + assert_eq!( + serde_json::to_string(&TissueType::NonNeoplastic).unwrap(), + "\"Non-neoplastic\"" + ); + assert_eq!( + serde_json::to_string(&TissueType::Unavailable).unwrap(), + "\"Unavailable\"" + ); + assert_eq!( + serde_json::to_string(&TissueType::Unknown).unwrap(), + "\"Unknown\"" + ); + assert_eq!( + serde_json::to_string(&TissueType::Unspecified).unwrap(), + "\"Unspecified\"" + ); + } +} diff --git a/packages/ccdi-cde/src/v2/subject.rs b/packages/ccdi-cde/src/v2/subject.rs new file mode 100644 index 0000000..091f999 --- /dev/null +++ b/packages/ccdi-cde/src/v2/subject.rs @@ -0,0 +1,6 @@ +//! Common data elements that have a major version of two and are related to a +//! subject. + +mod ethnicity; + +pub use ethnicity::Ethnicity; diff --git a/packages/ccdi-cde/src/v2/ethnicity.rs b/packages/ccdi-cde/src/v2/subject/ethnicity.rs similarity index 68% rename from packages/ccdi-cde/src/v2/ethnicity.rs rename to packages/ccdi-cde/src/v2/subject/ethnicity.rs index c0e25f7..9c83307 100644 --- a/packages/ccdi-cde/src/v2/ethnicity.rs +++ b/packages/ccdi-cde/src/v2/subject/ethnicity.rs @@ -1,3 +1,4 @@ +use introspect::Introspect; use rand::distributions::Standard; use rand::prelude::Distribution; use serde::Deserialize; @@ -6,7 +7,7 @@ use utoipa::ToSchema; use crate::CDE; -/// **caDSR CDE 2192217 v2.00** +/// **`caDSR CDE 2192217 v2.00`** /// /// This metadata element is defined by the caDSR as "The text for reporting /// information about ethnicity based on the Office of Management and Budget @@ -15,20 +16,27 @@ use crate::CDE; /// ethnicity. /// /// Link: -/// -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize, ToSchema)] -#[schema(as = cde::v2::Ethnicity)] -pub enum Ethnicity -where - Self: CDE, -{ - /// Not Allowed To Collect +/// +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize, ToSchema, Introspect)] +#[schema(as = cde::v2::subject::Ethnicity)] +pub enum Ethnicity { + /// `Not allowed to collect` + /// + /// * **VM Long Name**: Not Allowed To Collect + /// * **VM Public ID**: 6662191 + /// * **Concept Code**: C141478 + /// * **Begin Date**: 03/06/2019 /// /// An indicator that specifies that a collection event was not permitted. #[serde(rename = "Not allowed to collect")] NotAllowedToCollect, - /// Hispanic or Latino + /// `Hispanic or Latino` + /// + /// * **VM Long Name**: Hispanic or Latino + /// * **VM Public ID**: 2581176 + /// * **Concept Code**: C17459 + /// * **Begin Date**: 05/20/2002 /// /// A person of Cuban, Mexican, Puerto Rican, South or Central American, or /// other Spanish culture or origin, regardless of race. The term, "Spanish @@ -36,20 +44,35 @@ where #[serde(rename = "Hispanic or Latino")] HispanicOrLatino, - /// Not Hispanic or Latino + /// `Not Hispanic or Latino` + /// + /// * **VM Long Name**: Not Hispanic or Latino + /// * **VM Public ID**: 2567110 + /// * **Concept Code**: C41222 + /// * **Begin Date**: 05/20/2002 /// /// A person not of Cuban, Mexican, Puerto Rican, South or Central American, /// or other Spanish culture or origin, regardless of race. #[serde(rename = "Not Hispanic or Latino")] NotHispanicOrLatino, - /// Unknown + /// `Unknown` + /// + /// * **VM Long Name**: Unknown + /// * **VM Public ID**: 2572577 + /// * **Concept Code**: C17998 + /// * **Begin Date**: 07/09/2002 /// /// Not known, not observed, not recorded, or refused. #[serde(rename = "Unknown")] Unknown, - /// Not Reported + /// `Not reported` + /// + /// * **VM Long Name**: Not Reported + /// * **VM Public ID**: 2572578 + /// * **Concept Code**: C43234 + /// * **Begin Date**: 10/16/2003 /// /// Not provided or available. #[serde(rename = "Not reported")] @@ -58,18 +81,6 @@ where impl CDE for Ethnicity {} -impl crate::Standard for Ethnicity { - fn standard() -> &'static str { - "caDSR CDE 2192217 v2.00" - } -} - -impl crate::Url for Ethnicity { - fn url() -> &'static str { - "https://cadsr.cancer.gov/onedata/dmdirect/NIH/NCI/CO/CDEDD?filter=CDEDD.ITEM_ID=2192217%20and%20ver_nr=2.0" - } -} - impl std::fmt::Display for Ethnicity { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { diff --git a/packages/ccdi-models/src/count/metadata.rs b/packages/ccdi-models/src/count/metadata.rs new file mode 100644 index 0000000..e69de29 diff --git a/packages/ccdi-models/src/lib.rs b/packages/ccdi-models/src/lib.rs index ac0d01e..9f3c3a7 100644 --- a/packages/ccdi-models/src/lib.rs +++ b/packages/ccdi-models/src/lib.rs @@ -6,11 +6,14 @@ #![warn(rust_2021_compatibility)] #![warn(missing_debug_implementations)] #![deny(rustdoc::broken_intra_doc_links)] +#![feature(const_trait_impl)] #![feature(decl_macro)] #![feature(trivial_bounds)] pub mod count; pub mod metadata; +pub mod sample; pub mod subject; +pub use sample::Sample; pub use subject::Subject; diff --git a/packages/ccdi-models/src/metadata/field.rs b/packages/ccdi-models/src/metadata/field.rs index 5a99f38..b6eece0 100644 --- a/packages/ccdi-models/src/metadata/field.rs +++ b/packages/ccdi-models/src/metadata/field.rs @@ -5,16 +5,12 @@ use serde::Serialize; use utoipa::ToSchema; pub mod description; + pub mod owned; pub mod unowned; pub use description::Description; -pub use owned::Identifier; -pub use unowned::Ethnicity; -pub use unowned::Race; -pub use unowned::Sex; - use crate::metadata::field; /// A metadata field. diff --git a/packages/ccdi-models/src/metadata/field/description.rs b/packages/ccdi-models/src/metadata/field/description.rs index b3880a0..3139854 100644 --- a/packages/ccdi-models/src/metadata/field/description.rs +++ b/packages/ccdi-models/src/metadata/field/description.rs @@ -4,8 +4,8 @@ use serde::Deserialize; use serde::Serialize; use utoipa::ToSchema; -mod harmonized; -mod unharmonized; +pub mod harmonized; +pub mod unharmonized; pub use harmonized::Harmonized; pub use unharmonized::Unharmonized; @@ -28,50 +28,14 @@ pub enum Description { pub mod r#trait { use ccdi_cde as cde; - use cde::Standard; - use cde::Url; use cde::CDE; - use crate::metadata::field::description::Harmonized; - /// A trait to get a [`Description`] for a [`CDE`]. pub trait Description where - Self: CDE, + Self: CDE + Sized, { /// Gets the [`Description`]. fn description() -> super::Description; } - - impl Description for cde::v1::Sex { - fn description() -> super::Description { - super::Description::Harmonized(Harmonized::new("sex", Self::standard(), Self::url())) - } - } - - impl Description for cde::v1::Race { - fn description() -> super::Description { - super::Description::Harmonized(Harmonized::new("race", Self::standard(), Self::url())) - } - } - - impl Description for cde::v2::Ethnicity { - fn description() -> super::Description { - super::Description::Harmonized(Harmonized::new( - "ethnicity", - Self::standard(), - Self::url(), - )) - } - } - - impl Description for cde::v1::Identifier { - fn description() -> super::Description { - super::Description::Harmonized(Harmonized::new( - "identifiers", - Self::standard(), - Self::url(), - )) - } - } } diff --git a/packages/ccdi-models/src/metadata/field/description/harmonized.rs b/packages/ccdi-models/src/metadata/field/description/harmonized.rs index 132a730..34f832b 100644 --- a/packages/ccdi-models/src/metadata/field/description/harmonized.rs +++ b/packages/ccdi-models/src/metadata/field/description/harmonized.rs @@ -4,6 +4,14 @@ use serde::Deserialize; use serde::Serialize; use utoipa::ToSchema; +use ccdi_cde as cde; + +use cde::parse::cde::Entity; +use cde::parse::cde::Member; + +pub mod sample; +pub mod subject; + /// A harmonized metadata field description. #[derive(Debug, Deserialize, Serialize, ToSchema)] #[schema(as = models::metadata::field::description::Harmonized)] @@ -25,6 +33,14 @@ pub struct Harmonized { /// A URL to the CCDI documentation where the definition of this harmonized /// field resides. url: String, + + /// The parsed [`Entity`]. + #[serde(skip_serializing)] + entity: Entity, + + /// The parsed [`Member`]s and their respective identifiers of the entity. + #[serde(skip_serializing)] + members: Vec<(String, Member)>, } impl Harmonized { @@ -33,21 +49,261 @@ impl Harmonized { /// # Examples /// /// ``` + /// use ccdi_cde as cde; /// use ccdi_models as models; + /// + /// use cde::parse::cde::Entity; + /// use cde::parse::cde::Member; + /// use cde::parse::cde::member::Variant; /// use models::metadata::field::description::Harmonized; /// /// let description = Harmonized::new( /// "test", /// "caDSR ------ v1.00", - /// "https://cancer.gov" + /// "https://cancer.gov", + /// "**`A Standard`** + /// + /// A description for the entity. + /// + /// Link: ".parse::()?, + /// vec![ + /// ( + /// String::from("Unknown"), + /// Member::Variant("`Unknown` + /// + /// A description for the variant.".parse::().unwrap()) + /// ) + /// ] /// ); + /// + /// # Ok::<(), Box>(()) /// ``` - pub fn new>(path: S, standard: S, url: S) -> Self { + pub fn new( + path: impl Into, + standard: impl Into, + url: impl Into, + entity: Entity, + members: Vec<(String, Member)>, + ) -> Self { Harmonized { harmonized: true, path: path.into(), standard: standard.into(), url: url.into(), + entity, + members, } } + + /// Gets the path of the [`Harmonized`] by reference. + /// + /// # Examples + /// + /// ``` + /// use ccdi_cde as cde; + /// use ccdi_models as models; + /// + /// use cde::parse::cde::Entity; + /// use cde::parse::cde::Member; + /// use cde::parse::cde::member::Variant; + /// use models::metadata::field::description::Harmonized; + /// + /// let description = Harmonized::new( + /// "test", + /// "caDSR ------ v1.00", + /// "https://cancer.gov", + /// "**`A Standard`** + /// + /// A description for the entity. + /// + /// Link: ".parse::()?, + /// vec![ + /// ( + /// String::from("Unknown"), + /// Member::Variant("`Unknown` + /// + /// A description for the variant.".parse::().unwrap()) + /// ) + /// ] + /// ); + /// + /// assert_eq!(description.path(), &String::from("test")); + /// + /// # Ok::<(), Box>(()) + /// ``` + pub fn path(&self) -> &String { + &self.path + } + + /// Gets the harmonization standard name of the [`Harmonized`] by + /// reference. + /// + /// # Examples + /// + /// ``` + /// use ccdi_cde as cde; + /// use ccdi_models as models; + /// + /// use cde::parse::cde::Entity; + /// use cde::parse::cde::Member; + /// use cde::parse::cde::member::Variant; + /// use models::metadata::field::description::Harmonized; + /// + /// let description = Harmonized::new( + /// "test", + /// "caDSR ------ v1.00", + /// "https://cancer.gov", + /// "**`A Standard`** + /// + /// A description for the entity. + /// + /// Link: ".parse::()?, + /// vec![ + /// ( + /// String::from("Unknown"), + /// Member::Variant("`Unknown` + /// + /// A description for the variant.".parse::().unwrap()) + /// ) + /// ] + /// ); + /// + /// assert_eq!(description.standard(), &String::from("caDSR ------ v1.00")); + /// + /// # Ok::<(), Box>(()) + /// ``` + pub fn standard(&self) -> &String { + &self.standard + } + + /// Gets the URL for which one can learn more about the [`Harmonized`] by + /// reference. + /// + /// # Examples + /// + /// ``` + /// use ccdi_cde as cde; + /// use ccdi_models as models; + /// + /// use cde::parse::cde::Entity; + /// use cde::parse::cde::Member; + /// use cde::parse::cde::member::Variant; + /// use models::metadata::field::description::Harmonized; + /// + /// let description = Harmonized::new( + /// "test", + /// "caDSR ------ v1.00", + /// "https://cancer.gov", + /// "**`A Standard`** + /// + /// A description for the entity. + /// + /// Link: ".parse::()?, + /// vec![ + /// ( + /// String::from("Unknown"), + /// Member::Variant("`Unknown` + /// + /// A description for the variant.".parse::().unwrap()) + /// ) + /// ] + /// ); + /// + /// assert_eq!(description.url(), &String::from("https://cancer.gov")); + /// + /// # Ok::<(), Box>(()) + /// ``` + pub fn url(&self) -> &String { + &self.url + } + + /// Gets the entity for the [`Harmonized`] by reference. + /// + /// # Examples + /// + /// ``` + /// use ccdi_cde as cde; + /// use ccdi_models as models; + /// + /// use cde::parse::cde::Entity; + /// use cde::parse::cde::Member; + /// use cde::parse::cde::member::Variant; + /// use models::metadata::field::description::Harmonized; + /// + /// let description = Harmonized::new( + /// "test", + /// "caDSR ------ v1.00", + /// "https://cancer.gov", + /// "**`A Standard`** + /// + /// A description for the entity. + /// + /// Link: ".parse::()?, + /// vec![ + /// ( + /// String::from("Unknown"), + /// Member::Variant("`Unknown` + /// + /// A description for the variant.".parse::().unwrap()) + /// ) + /// ] + /// ); + /// + /// assert_eq!(description.entity().standard(), "A Standard"); + /// assert_eq!(description.entity().description(), "A description for the entity."); + /// assert_eq!(description.entity().url(), "https://example.com"); + /// + /// # Ok::<(), Box>(()) + /// ``` + pub fn entity(&self) -> &Entity { + &self.entity + } + + /// Gets the members for the [`Harmonized`] by reference. + /// + /// # Examples + /// + /// ``` + /// use ccdi_cde as cde; + /// use ccdi_models as models; + /// + /// use cde::parse::cde::Entity; + /// use cde::parse::cde::Member; + /// use cde::parse::cde::member::Variant; + /// use models::metadata::field::description::Harmonized; + /// + /// let description = Harmonized::new( + /// "test", + /// "caDSR ------ v1.00", + /// "https://cancer.gov", + /// "**`A Standard`** + /// + /// A description for the entity. + /// + /// Link: ".parse::()?, + /// vec![ + /// ( + /// String::from("Unknown"), + /// Member::Variant("`Unknown` + /// + /// A description for the variant.".parse::().unwrap()) + /// ) + /// ] + /// ); + /// + /// assert_eq!(description.members().len(), 1); + /// + /// let (identifier, variant) = description.members() + /// .first() + /// .unwrap(); + /// + /// assert_eq!(identifier.as_str(), "Unknown"); + /// assert_eq!(variant.get_variant().unwrap().permissible_value(), "Unknown"); + /// assert_eq!(variant.get_variant().unwrap().description(), "A description for the variant."); + /// + /// # Ok::<(), Box>(()) + /// ``` + pub fn members(&self) -> &Vec<(String, Member)> { + &self.members + } } diff --git a/packages/ccdi-models/src/metadata/field/description/harmonized/sample.rs b/packages/ccdi-models/src/metadata/field/description/harmonized/sample.rs new file mode 100644 index 0000000..81a4da1 --- /dev/null +++ b/packages/ccdi-models/src/metadata/field/description/harmonized/sample.rs @@ -0,0 +1,82 @@ +//! Harmonized metadata field descriptions for samples. + +use ccdi_cde as cde; + +use cde::CDE; + +use crate::metadata::field::description; +use crate::metadata::field::description::r#trait::Description as _; +use crate::metadata::field::description::Harmonized; + +/// Gets the harmonized fields for samples. +pub fn get_field_descriptions() -> Vec { + vec![ + cde::v1::sample::DiseasePhase::description(), + cde::v2::sample::TissueType::description(), + cde::v1::sample::TumorClassification::description(), + ] +} + +impl description::r#trait::Description for cde::v1::sample::DiseasePhase { + fn description() -> description::Description { + // SAFETY: these two unwraps are tested statically below in the test + // that constructs the description using `get_fields()`. + let entity = Self::entity().unwrap(); + let members = Self::members().unwrap(); + + description::Description::Harmonized(Harmonized::new( + "disease_phase", + entity.standard().to_string(), + entity.url().to_string(), + entity, + members, + )) + } +} + +impl description::r#trait::Description for cde::v2::sample::TissueType { + fn description() -> description::Description { + // SAFETY: these two unwraps are tested statically below in the test + // that constructs the description using `get_fields()`. + let entity = Self::entity().unwrap(); + let members = Self::members().unwrap(); + + description::Description::Harmonized(Harmonized::new( + "tissue_type", + entity.standard().to_string(), + entity.url().to_string(), + entity, + members, + )) + } +} + +impl description::r#trait::Description for cde::v1::sample::TumorClassification { + fn description() -> description::Description { + // SAFETY: these two unwraps are tested statically below in the test + // that constructs the description using `get_fields()`. + let entity = Self::entity().unwrap(); + let members = Self::members().unwrap(); + + description::Description::Harmonized(Harmonized::new( + "tumor_classification", + entity.standard().to_string(), + entity.url().to_string(), + entity, + members, + )) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn all_of_the_descriptions_unwrap() { + // Because each of the field descriptions are instantiated when this + // array is constructed, we can simply use this function to test that + // all of them unwrap successfully. + get_field_descriptions(); + } +} diff --git a/packages/ccdi-models/src/metadata/field/description/harmonized/subject.rs b/packages/ccdi-models/src/metadata/field/description/harmonized/subject.rs new file mode 100644 index 0000000..c7ab296 --- /dev/null +++ b/packages/ccdi-models/src/metadata/field/description/harmonized/subject.rs @@ -0,0 +1,101 @@ +//! Harmonized metadata field descriptions for subjects. + +use ccdi_cde as cde; + +use cde::CDE; + +use crate::metadata::field::description; + +use crate::metadata::field::description::r#trait::Description; +use crate::metadata::field::description::Harmonized; + +/// Gets the harmonized fields for subjects. +pub fn get_field_descriptions() -> Vec { + vec![ + cde::v1::subject::Sex::description(), + cde::v1::subject::Race::description(), + cde::v2::subject::Ethnicity::description(), + cde::v1::subject::Identifier::description(), + ] +} + +impl Description for cde::v1::subject::Sex { + fn description() -> description::Description { + // SAFETY: these two unwraps are tested statically below in the test + // that constructs the description using `get_fields()`. + let entity = Self::entity().unwrap(); + let members = Self::members().unwrap(); + + description::Description::Harmonized(Harmonized::new( + "sex", + entity.standard().to_string(), + entity.url().to_string(), + entity, + members, + )) + } +} + +impl Description for cde::v1::subject::Race { + fn description() -> description::Description { + // SAFETY: these two unwraps are tested statically below in the test + // that constructs the description using `get_fields()`. + let entity = Self::entity().unwrap(); + let members = Self::members().unwrap(); + + description::Description::Harmonized(Harmonized::new( + "race", + entity.standard().to_string(), + entity.url().to_string(), + entity, + members, + )) + } +} + +impl Description for cde::v2::subject::Ethnicity { + fn description() -> description::Description { + // SAFETY: these two unwraps are tested statically below in the test + // that constructs the description using `get_fields()`. + let entity = Self::entity().unwrap(); + let members = Self::members().unwrap(); + + description::Description::Harmonized(Harmonized::new( + "ethnicity", + entity.standard().to_string(), + entity.url().to_string(), + entity, + members, + )) + } +} + +impl Description for cde::v1::subject::Identifier { + fn description() -> description::Description { + // SAFETY: these two unwraps are tested statically below in the test + // that constructs the description using `get_fields()`. + let entity = Self::entity().unwrap(); + let members = Self::members().unwrap(); + + description::Description::Harmonized(Harmonized::new( + "identifiers", + entity.standard().to_string(), + entity.url().to_string(), + entity, + members, + )) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn all_of_the_descriptions_unwrap() { + // Because each of the field descriptions are instantiated when this + // array is constructed, we can simply use this function to test that + // all of them unwrap successfully. + get_field_descriptions(); + } +} diff --git a/packages/ccdi-models/src/metadata/field/description/unharmonized.rs b/packages/ccdi-models/src/metadata/field/description/unharmonized.rs index 4244b3b..4848be7 100644 --- a/packages/ccdi-models/src/metadata/field/description/unharmonized.rs +++ b/packages/ccdi-models/src/metadata/field/description/unharmonized.rs @@ -45,6 +45,7 @@ impl Unharmonized { /// /// ``` /// use ccdi_models as models; + /// /// use models::metadata::field::description::Unharmonized; /// /// let field = Unharmonized::new( @@ -78,6 +79,7 @@ impl Unharmonized { /// /// ``` /// use ccdi_models as models; + /// /// use models::metadata::field::description::Unharmonized; /// /// let field = Unharmonized::new( @@ -100,6 +102,7 @@ impl Unharmonized { /// /// ``` /// use ccdi_models as models; + /// /// use models::metadata::field::description::Unharmonized; /// /// let field = Unharmonized::new( @@ -122,6 +125,7 @@ impl Unharmonized { /// /// ``` /// use ccdi_models as models; + /// /// use models::metadata::field::description::Unharmonized; /// /// let field = Unharmonized::new( @@ -145,6 +149,7 @@ impl Unharmonized { /// /// ``` /// use ccdi_models as models; + /// /// use models::metadata::field::description::Unharmonized; /// /// let field = Unharmonized::new( @@ -168,6 +173,7 @@ impl Unharmonized { /// /// ``` /// use ccdi_models as models; + /// /// use models::metadata::field::description::Unharmonized; /// /// let field = Unharmonized::new( diff --git a/packages/ccdi-models/src/metadata/field/owned.rs b/packages/ccdi-models/src/metadata/field/owned.rs index 06036b2..1056146 100644 --- a/packages/ccdi-models/src/metadata/field/owned.rs +++ b/packages/ccdi-models/src/metadata/field/owned.rs @@ -13,8 +13,6 @@ use serde::Serialize; use serde_json::Value; use utoipa::ToSchema; -use ccdi_cde as cde; - #[macropol::macropol] macro_rules! owned_field { ($name: ident, $as: ty, $inner: ty, $value: expr, $import: expr) => { @@ -53,7 +51,7 @@ macro_rules! owned_field { /// use ${stringify!($import)}; /// use ccdi_models as models; /// - /// use models::metadata::field::owned::${stringify!($name)}; + /// use models::metadata::${stringify!($as)}; /// /// let field = ${stringify!($name)}::new( /// ${stringify!($value)}, @@ -84,7 +82,7 @@ macro_rules! owned_field { /// use ${stringify!($import)}; /// use ccdi_models as models; /// - /// use models::metadata::field::owned::${stringify!($name)}; + /// use models::metadata::${stringify!($as)}; /// /// let field = ${stringify!($name)}::new( /// ${stringify!($value)}, @@ -107,7 +105,7 @@ macro_rules! owned_field { /// use ${stringify!($import)}; /// use ccdi_models as models; /// - /// use models::metadata::field::owned::${stringify!($name)}; + /// use models::metadata::${stringify!($as)}; /// /// let field = ${stringify!($name)}::new( /// ${stringify!($value)}, @@ -130,7 +128,7 @@ macro_rules! owned_field { /// use ${stringify!($import)}; /// use ccdi_models as models; /// - /// use models::metadata::field::owned::${stringify!($name)}; + /// use models::metadata::${stringify!($as)}; /// /// let field = ${stringify!($name)}::new( /// ${stringify!($value)}, @@ -153,7 +151,7 @@ macro_rules! owned_field { /// use ${stringify!($import)}; /// use ccdi_models as models; /// - /// use models::metadata::field::owned::${stringify!($name)}; + /// use models::metadata::${stringify!($as)}; /// /// let field = ${stringify!($name)}::new( /// ${stringify!($value)}, @@ -189,10 +187,15 @@ owned_field!( serde_json::Value ); -owned_field!( - Identifier, - field::Identifier, - cde::v1::Identifier, - cde::v1::Identifier::new("organization", "Name"), - ccdi_cde as cde -); +pub mod subject { + use super::*; + use ccdi_cde as cde; + + owned_field!( + Identifier, + field::owned::subject::Identifier, + cde::v1::subject::Identifier, + cde::v1::subject::Identifier::new("organization", "Name"), + ccdi_cde as cde + ); +} diff --git a/packages/ccdi-models/src/metadata/field/unowned.rs b/packages/ccdi-models/src/metadata/field/unowned.rs index 7f87708..f0d6dd8 100644 --- a/packages/ccdi-models/src/metadata/field/unowned.rs +++ b/packages/ccdi-models/src/metadata/field/unowned.rs @@ -13,8 +13,6 @@ use serde::Serialize; use serde_json::Value; use utoipa::ToSchema; -use ccdi_cde as cde; - #[macropol::macropol] macro_rules! unowned_field { ($name: ident, $as: ty, $inner: ty, $value: expr, $import: expr) => { @@ -48,7 +46,7 @@ macro_rules! unowned_field { /// use ${stringify!($import)}; /// use ccdi_models as models; /// - /// use models::metadata::field::unowned::${stringify!($name)}; + /// use models::metadata::${stringify!($as)}; /// /// let field = ${stringify!($name)}::new( /// ${stringify!($value)}, @@ -76,7 +74,7 @@ macro_rules! unowned_field { /// use ${stringify!($import)}; /// use ccdi_models as models; /// - /// use models::metadata::field::unowned::${stringify!($name)}; + /// use models::metadata::${stringify!($as)}; /// /// let field = ${stringify!($name)}::new( /// ${stringify!($value)}, @@ -98,7 +96,7 @@ macro_rules! unowned_field { /// use ${stringify!($import)}; /// use ccdi_models as models; /// - /// use models::metadata::field::unowned::${stringify!($name)}; + /// use models::metadata::${stringify!($as)}; /// /// let field = ${stringify!($name)}::new( /// ${stringify!($value)}, @@ -120,7 +118,7 @@ macro_rules! unowned_field { /// use ${stringify!($import)}; /// use ccdi_models as models; /// - /// use models::metadata::field::unowned::${stringify!($name)}; + /// use models::metadata::${stringify!($as)}; /// /// let field = ${stringify!($name)}::new( /// ${stringify!($value)}, @@ -155,26 +153,62 @@ unowned_field!( serde_json::Value ); -unowned_field!( - Sex, - field::Sex, - cde::v1::Sex, - cde::v1::Sex::Unknown, - ccdi_cde as cde -); +pub mod sample { + use super::*; -unowned_field!( - Race, - field::Race, - cde::v1::Race, - cde::v1::Race::Unknown, - ccdi_cde as cde -); + use ccdi_cde as cde; -unowned_field!( - Ethnicity, - field::Ethnicity, - cde::v2::Ethnicity, - cde::v2::Ethnicity::Unknown, - ccdi_cde as cde -); + unowned_field!( + DiseasePhase, + field::unowned::sample::DiseasePhase, + cde::v1::sample::DiseasePhase, + cde::v1::sample::DiseasePhase::InitialDiagnosis, + ccdi_cde as cde + ); + + unowned_field!( + TissueType, + field::unowned::sample::TissueType, + cde::v2::sample::TissueType, + cde::v2::sample::TissueType::Tumor, + ccdi_cde as cde + ); + + unowned_field!( + TumorClassification, + field::unowned::sample::TumorClassification, + cde::v1::sample::TumorClassification, + cde::v1::sample::TumorClassification::Primary, + ccdi_cde as cde + ); +} + +pub mod subject { + use super::*; + + use ccdi_cde as cde; + + unowned_field!( + Sex, + field::unowned::subject::Sex, + cde::v1::subject::Sex, + cde::v1::subject::Sex::Unknown, + ccdi_cde as cde + ); + + unowned_field!( + Race, + field::unowned::subject::Race, + cde::v1::subject::Race, + cde::v1::subject::Race::Unknown, + ccdi_cde as cde + ); + + unowned_field!( + Ethnicity, + field::unowned::subject::Ethnicity, + cde::v2::subject::Ethnicity, + cde::v2::subject::Ethnicity::Unknown, + ccdi_cde as cde + ); +} diff --git a/packages/ccdi-models/src/sample.rs b/packages/ccdi-models/src/sample.rs new file mode 100644 index 0000000..9c5f687 --- /dev/null +++ b/packages/ccdi-models/src/sample.rs @@ -0,0 +1,126 @@ +//! Representations of samples. + +use rand::thread_rng; +use rand::Rng; +use serde::Deserialize; +use serde::Serialize; +use utoipa::ToSchema; + +pub mod metadata; + +pub use metadata::Metadata; + +/// A sample. +#[derive(Clone, Debug, Deserialize, Serialize, ToSchema)] +#[schema(as = models::Sample)] +pub struct Sample { + /// The primary name for a sample used within the source server. + /// + /// Note that this field is not namespaced like an `identifier` is, and, + /// instead, is intended to represent a colloquial or display name for the + /// sample (without indicating the scope of the name). + #[schema(example = "SampleName001")] + name: String, + + /// Metadata associated with this [`Sample`]. + #[serde(skip_serializing_if = "Option::is_none")] + #[schema(value_type = Option)] + metadata: Option, +} + +impl Sample { + /// Creates a new [`Sample`]. + /// + /// # Examples + /// + /// ``` + /// use ccdi_cde as cde; + /// use ccdi_models as models; + /// + /// use models::Sample; + /// use models::sample::metadata::Builder; + /// + /// let sample = Sample::new( + /// String::from("Name"), + /// Some(Builder::default().build()) + /// ); + /// + /// # Ok::<(), Box>(()) + /// ``` + pub fn new(name: String, metadata: Option) -> Self { + Self { name, metadata } + } + + /// Gets the name for this [`Sample`] by reference. + /// + /// # Examples + /// + /// ``` + /// use ccdi_cde as cde; + /// use ccdi_models as models; + /// + /// use models::Sample; + /// use models::sample::metadata::Builder; + /// + /// let sample = Sample::new( + /// String::from("Name"), + /// Some(Builder::default().build()) + /// ); + /// + /// assert_eq!(sample.name(), &String::from("Name")); + /// + /// # Ok::<(), Box>(()) + /// ``` + pub fn name(&self) -> &String { + &self.name + } + + /// Gets the metadata for this [`Sample`] by reference. + /// + /// # Examples + /// + /// ``` + /// use ccdi_cde as cde; + /// use ccdi_models as models; + /// + /// use models::Sample; + /// use models::sample::metadata::Builder; + /// + /// let metadata = Builder::default().build(); + /// + /// let sample = Sample::new( + /// String::from("Name"), + /// Some(metadata.clone()) + /// ); + /// + /// assert_eq!(sample.metadata(), Some(&metadata)); + /// + /// # Ok::<(), Box>(()) + /// ``` + pub fn metadata(&self) -> Option<&Metadata> { + self.metadata.as_ref() + } + + /// Generates a random [`Sample`]. + /// + /// # Examples + /// + /// ``` + /// use ccdi_models as models; + /// + /// use models::Sample; + /// + /// let sample = Sample::random(1usize); + /// ``` + pub fn random(sample_number: usize) -> Self { + let mut rng = thread_rng(); + + Self { + name: format!("SampleName{:0>6}", sample_number), + metadata: match rng.gen_bool(0.7) { + true => Some(Metadata::random()), + false => None, + }, + } + } +} diff --git a/packages/ccdi-models/src/sample/metadata.rs b/packages/ccdi-models/src/sample/metadata.rs new file mode 100644 index 0000000..cfac6be --- /dev/null +++ b/packages/ccdi-models/src/sample/metadata.rs @@ -0,0 +1,184 @@ +//! Metadata for a [`Sample`](super::Sample). + +use serde::Deserialize; +use serde::Serialize; +use utoipa::ToSchema; + +use crate::metadata::field; +use crate::metadata::fields; + +pub mod builder; + +pub use builder::Builder; + +/// Metadata associated with a sample. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize, ToSchema)] +#[schema(as = models::sample::Metadata)] +pub struct Metadata { + /// The phase of the disease when this sample was acquired. + #[schema(value_type = field::unowned::sample::DiseasePhase, nullable = true)] + disease_phase: Option, + + /// The type of tissue for this sample. + #[schema(value_type = field::unowned::sample::TissueType, nullable = true)] + tissue_type: Option, + + /// The classification for this tumor based mainly on histological + /// characteristics. + #[schema(value_type = field::unowned::sample::TumorClassification, nullable = true)] + tumor_classification: Option, + + /// An unharmonized map of metadata fields. + #[schema(value_type = fields::Unharmonized)] + #[serde(skip_serializing_if = "fields::Unharmonized::is_empty")] + unharmonized: fields::Unharmonized, +} + +impl Metadata { + /// Gets the harmonized disease phase for the [`Metadata`]. + /// + /// # Examples + /// + /// ``` + /// use ccdi_cde as cde; + /// use ccdi_models as models; + /// + /// use models::metadata::field::unowned::sample::DiseasePhase; + /// use models::sample::metadata::Builder; + /// + /// let metadata = Builder::default() + /// .disease_phase( + /// DiseasePhase::new(cde::v1::sample::DiseasePhase::InitialDiagnosis, None, None) + /// ) + /// .build(); + /// + /// assert_eq!( + /// metadata.disease_phase(), + /// &Some(DiseasePhase::new(cde::v1::sample::DiseasePhase::InitialDiagnosis, None, None)) + /// ); + /// ``` + pub fn disease_phase(&self) -> &Option { + &self.disease_phase + } + + /// Gets the harmonized tissue type for the [`Metadata`]. + /// + /// # Examples + /// + /// ``` + /// use ccdi_cde as cde; + /// use ccdi_models as models; + /// + /// use models::metadata::field::unowned::sample::TissueType; + /// use models::sample::metadata::Builder; + /// + /// let metadata = Builder::default() + /// .tissue_type( + /// TissueType::new(cde::v2::sample::TissueType::Tumor, None, None) + /// ) + /// .build(); + /// + /// assert_eq!( + /// metadata.tissue_type(), + /// &Some(TissueType::new(cde::v2::sample::TissueType::Tumor, None, None)) + /// ); + /// ``` + pub fn tissue_type(&self) -> &Option { + &self.tissue_type + } + + /// Gets the harmonized tumor classification for the [`Metadata`]. + /// + /// # Examples + /// + /// ``` + /// use ccdi_cde as cde; + /// use ccdi_models as models; + /// + /// use models::metadata::field::unowned::sample::TumorClassification; + /// use models::sample::metadata::Builder; + /// + /// let metadata = Builder::default() + /// .tumor_classification( + /// TumorClassification::new(cde::v1::sample::TumorClassification::Primary, None, None) + /// ) + /// .build(); + /// + /// assert_eq!( + /// metadata.tumor_classification(), + /// &Some(TumorClassification::new(cde::v1::sample::TumorClassification::Primary, None, None)) + /// ); + /// ``` + pub fn tumor_classification(&self) -> &Option { + &self.tumor_classification + } + + /// Gets the unharmonized fields for the [`Metadata`]. + /// + /// # Examples + /// + /// ``` + /// use serde_json::Value; + /// + /// use ccdi_cde as cde; + /// use ccdi_models as models; + /// + /// use models::metadata::field::UnharmonizedField; + /// use models::metadata::field::owned; + /// use models::metadata::field::unowned; + /// use models::sample::metadata::Builder; + /// + /// let metadata = Builder::default() + /// .insert_unharmonized( + /// "unowned", + /// UnharmonizedField::Unowned(unowned::Field::new(Value::String("test".into()), None, None)) + /// ) + /// .insert_unharmonized( + /// "owned", + /// UnharmonizedField::Owned(owned::Field::new(Value::String("test".into()), None, None, None)) + /// ) + /// .build(); + /// + /// assert!(matches!(metadata.unharmonized().inner().get("unowned".into()), Some(&UnharmonizedField::Unowned(_)))); + /// assert!(matches!(metadata.unharmonized().inner().get("owned".into()), Some(&UnharmonizedField::Owned(_)))); + /// + /// # Ok::<(), Box>(()) + /// ``` + pub fn unharmonized(&self) -> &fields::Unharmonized { + &self.unharmonized + } + + /// Generates a random [`Metadata`]. + /// + /// # Examples + /// + /// ``` + /// use ccdi_models as models; + /// + /// use models::sample::Metadata; + /// + /// let metadata = Metadata::random(); + /// ``` + pub fn random() -> Metadata { + Metadata { + disease_phase: rand::random(), + tissue_type: rand::random(), + tumor_classification: rand::random(), + unharmonized: Default::default(), + } + } +} + +#[cfg(test)] +mod tests { + use crate::sample::metadata::builder; + + #[test] + fn it_skips_serializing_the_unharmonized_key_when_it_is_empty() { + let metadata = builder::Builder::default().build(); + assert_eq!( + &serde_json::to_string(&metadata).unwrap(), + "{\"disease_phase\":null,\"tissue_type\":null,\"tumor_classification\":null}" + ); + } +} diff --git a/packages/ccdi-models/src/sample/metadata/builder.rs b/packages/ccdi-models/src/sample/metadata/builder.rs new file mode 100644 index 0000000..d67b6c0 --- /dev/null +++ b/packages/ccdi-models/src/sample/metadata/builder.rs @@ -0,0 +1,147 @@ +//! A builder for [`Metadata`]. + +use crate::metadata::field; +use crate::metadata::fields; +use crate::sample::Metadata; + +/// A builder for [`Metadata`]. +#[derive(Clone, Debug, Default)] +pub struct Builder { + /// The phase of the disease when this sample was acquired. + disease_phase: Option, + + /// The type of tissue for this sample. + tissue_type: Option, + + /// The classification for this tumor based mainly on histological + /// characteristics. + tumor_classification: Option, + + /// An unharmonized map of metadata fields. + unharmonized: fields::Unharmonized, +} + +impl Builder { + /// Sets the `disease_phase` field of the [`Builder`]. + /// + /// # Examples + /// + /// ``` + /// use ccdi_cde as cde; + /// use ccdi_models as models; + /// + /// use models::metadata::field::unowned::sample::DiseasePhase; + /// use models::sample::metadata::Builder; + /// + /// let field = DiseasePhase::new(cde::v1::sample::DiseasePhase::InitialDiagnosis, None, None); + /// let builder = Builder::default().disease_phase(field); + /// ``` + pub fn disease_phase(mut self, field: field::unowned::sample::DiseasePhase) -> Self { + self.disease_phase = Some(field); + self + } + + /// Sets the `tissue_type` field of the [`Builder`]. + /// + /// # Examples + /// + /// ``` + /// use ccdi_cde as cde; + /// use ccdi_models as models; + /// + /// use models::metadata::field::unowned::sample::TissueType; + /// use models::sample::metadata::Builder; + /// + /// let field = TissueType::new(cde::v2::sample::TissueType::Tumor, None, None); + /// let builder = Builder::default().tissue_type(field); + /// ``` + pub fn tissue_type(mut self, field: field::unowned::sample::TissueType) -> Self { + self.tissue_type = Some(field); + self + } + + /// Sets the `tumor_classification` field of the [`Builder`]. + /// + /// # Examples + /// + /// ``` + /// use ccdi_cde as cde; + /// use ccdi_models as models; + /// + /// use models::metadata::field::unowned::sample::TumorClassification; + /// use models::sample::metadata::Builder; + /// + /// let field = TumorClassification::new(cde::v1::sample::TumorClassification::Primary, None, None); + /// let builder = Builder::default().tumor_classification(field); + /// ``` + pub fn tumor_classification( + mut self, + field: field::unowned::sample::TumorClassification, + ) -> Self { + self.tumor_classification = Some(field); + self + } + + /// Inserts an [`UnharmonizedField`](field::UnharmonizedField) into the + /// `unharmonized` map. + /// + /// # Examples + /// + /// ``` + /// use serde_json::Value; + /// + /// use ccdi_cde as cde; + /// use ccdi_models as models; + /// + /// use models::metadata::field::UnharmonizedField; + /// use models::metadata::field::owned; + /// use models::metadata::field::unowned; + /// use models::sample::metadata::Builder; + /// + /// let builder = Builder::default() + /// .insert_unharmonized( + /// "unowned", + /// UnharmonizedField::Unowned(unowned::Field::new(Value::String("test".into()), None, None)) + /// ) + /// .insert_unharmonized( + /// "owned", + /// UnharmonizedField::Owned(owned::Field::new(Value::String("test".into()), None, None, None)) + /// ); + /// + /// # Ok::<(), Box>(()) + /// ``` + pub fn insert_unharmonized>( + mut self, + key: S, + field: field::UnharmonizedField, + ) -> Self { + let key = key.into(); + + let mut unharmonized = self.unharmonized; + unharmonized.inner_mut().insert(key, field); + + self.unharmonized = unharmonized; + + self + } + + /// Consumes `self` to build a [`Metadata`]. + /// + /// # Examples + /// + /// ``` + /// use ccdi_models as models; + /// + /// use models::sample::metadata::Builder; + /// + /// let builder = Builder::default(); + /// ``` + pub fn build(self) -> Metadata { + Metadata { + disease_phase: self.disease_phase, + tissue_type: self.tissue_type, + tumor_classification: self.tumor_classification, + unharmonized: self.unharmonized, + } + } +} diff --git a/packages/ccdi-models/src/subject.rs b/packages/ccdi-models/src/subject.rs index e14441b..642d1f6 100644 --- a/packages/ccdi-models/src/subject.rs +++ b/packages/ccdi-models/src/subject.rs @@ -8,7 +8,7 @@ use utoipa::ToSchema; use ccdi_cde as cde; -use cde::v1::Identifier; +use cde::v1::subject::Identifier; mod kind; pub mod metadata; @@ -24,11 +24,14 @@ pub struct Subject { /// /// This identifier should *ALWAYS* be included in the `identifiers` key /// under `metadata`, should that key exist. - #[schema(value_type = cde::v1::Identifier)] + #[schema(value_type = cde::v1::subject::Identifier)] id: Identifier, - /// The primary name or identifier for a subject used within the source - /// server. + /// The primary name for a subject used within the source server. + /// + /// Note that this field is not namespaced like an `identifier` is, and, + /// instead, is intended to represent a colloquial or display name for the + /// sample (without indicating the scope of the name). #[schema(example = "SubjectName001")] name: String, @@ -51,7 +54,7 @@ impl Subject { /// use ccdi_cde as cde; /// use ccdi_models as models; /// - /// use cde::v1::Identifier; + /// use cde::v1::subject::Identifier; /// use models::Subject; /// use models::subject::Kind; /// use models::subject::metadata::Builder; @@ -82,7 +85,7 @@ impl Subject { /// use ccdi_cde as cde; /// use ccdi_models as models; /// - /// use cde::v1::Identifier; + /// use cde::v1::subject::Identifier; /// use models::Subject; /// use models::subject::Kind; /// use models::subject::metadata::Builder; @@ -113,7 +116,7 @@ impl Subject { /// use ccdi_cde as cde; /// use ccdi_models as models; /// - /// use cde::v1::Identifier; + /// use cde::v1::subject::Identifier; /// use models::Subject; /// use models::subject::Kind; /// use models::subject::metadata::Builder; @@ -141,7 +144,7 @@ impl Subject { /// use ccdi_cde as cde; /// use ccdi_models as models; /// - /// use cde::v1::Identifier; + /// use cde::v1::subject::Identifier; /// use models::Subject; /// use models::subject::Kind; /// use models::subject::metadata::Builder; @@ -169,7 +172,7 @@ impl Subject { /// use ccdi_cde as cde; /// use ccdi_models as models; /// - /// use cde::v1::Identifier; + /// use cde::v1::subject::Identifier; /// use models::Subject; /// use models::subject::Kind; /// use models::subject::metadata::Builder; @@ -201,7 +204,7 @@ impl Subject { /// /// use models::Subject; /// - /// let identifier = cde::v1::Identifier::parse("organization:Name", ":").unwrap(); + /// let identifier = cde::v1::subject::Identifier::parse("organization:Name", ":").unwrap(); /// let subject = Subject::random(identifier); /// ``` pub fn random(identifier: Identifier) -> Self { diff --git a/packages/ccdi-models/src/subject/metadata.rs b/packages/ccdi-models/src/subject/metadata.rs index a41c4c0..fd588a8 100644 --- a/packages/ccdi-models/src/subject/metadata.rs +++ b/packages/ccdi-models/src/subject/metadata.rs @@ -18,23 +18,23 @@ pub use builder::Builder; #[schema(as = models::subject::Metadata)] pub struct Metadata { /// The sex of the subject. - #[schema(value_type = field::Sex, nullable = true)] - sex: Option, + #[schema(value_type = field::unowned::subject::Sex, nullable = true)] + sex: Option, /// The race(s) of the subject. - #[schema(value_type = Vec, nullable = true)] - race: Option>, + #[schema(value_type = Vec, nullable = true)] + race: Option>, /// The ethnicity of the subject. - #[schema(value_type = field::Ethnicity, nullable = true)] - ethnicity: Option, + #[schema(value_type = field::unowned::subject::Ethnicity, nullable = true)] + ethnicity: Option, /// The identifiers for the subject. /// /// Note that this list of identifiers *must* include the main identifier /// for the [`Subject`]. - #[schema(value_type = Vec, nullable = true)] - identifiers: Option>, + #[schema(value_type = Vec, nullable = true)] + identifiers: Option>, /// An unharmonized map of metadata fields. #[schema(value_type = fields::Unharmonized)] @@ -51,19 +51,19 @@ impl Metadata { /// use ccdi_cde as cde; /// use ccdi_models as models; /// - /// use models::metadata::field::Sex; + /// use models::metadata::field::unowned::subject::Sex; /// use models::subject::metadata::Builder; /// /// let metadata = Builder::default() - /// .sex(Sex::new(cde::v1::Sex::Female, None, None)) + /// .sex(Sex::new(cde::v1::subject::Sex::Female, None, None)) /// .build(); /// /// assert_eq!( /// metadata.sex(), - /// &Some(Sex::new(cde::v1::Sex::Female, None, None)) + /// &Some(Sex::new(cde::v1::subject::Sex::Female, None, None)) /// ); /// ``` - pub fn sex(&self) -> &Option { + pub fn sex(&self) -> &Option { &self.sex } @@ -75,19 +75,19 @@ impl Metadata { /// use ccdi_cde as cde; /// use ccdi_models as models; /// - /// use models::metadata::field::Race; + /// use models::metadata::field::unowned::subject::Race; /// use models::subject::metadata::Builder; /// /// let metadata = Builder::default() - /// .append_race(Race::new(cde::v1::Race::Asian, None, None)) + /// .append_race(Race::new(cde::v1::subject::Race::Asian, None, None)) /// .build(); /// /// assert_eq!( /// metadata.race(), - /// &Some(vec![Race::new(cde::v1::Race::Asian, None, None)]) + /// &Some(vec![Race::new(cde::v1::subject::Race::Asian, None, None)]) /// ); /// ``` - pub fn race(&self) -> &Option> { + pub fn race(&self) -> &Option> { &self.race } @@ -99,19 +99,19 @@ impl Metadata { /// use ccdi_cde as cde; /// use ccdi_models as models; /// - /// use models::metadata::field::Ethnicity; + /// use models::metadata::field::unowned::subject::Ethnicity; /// use models::subject::metadata::Builder; /// /// let metadata = Builder::default() - /// .ethnicity(Ethnicity::new(cde::v2::Ethnicity::NotHispanicOrLatino, None, None)) + /// .ethnicity(Ethnicity::new(cde::v2::subject::Ethnicity::NotHispanicOrLatino, None, None)) /// .build(); /// /// assert_eq!( /// metadata.ethnicity(), - /// &Some(Ethnicity::new(cde::v2::Ethnicity::NotHispanicOrLatino, None, None)) + /// &Some(Ethnicity::new(cde::v2::subject::Ethnicity::NotHispanicOrLatino, None, None)) /// ); /// ``` - pub fn ethnicity(&self) -> &Option { + pub fn ethnicity(&self) -> &Option { &self.ethnicity } @@ -123,13 +123,13 @@ impl Metadata { /// use ccdi_cde as cde; /// use ccdi_models as models; /// - /// use models::metadata::field::Identifier; + /// use models::metadata::field::owned::subject::Identifier; /// use models::subject::metadata::Builder; /// /// let metadata = Builder::default() /// .append_identifier( /// Identifier::new( - /// cde::v1::Identifier::parse("organization:Name", ":").unwrap(), + /// cde::v1::subject::Identifier::parse("organization:Name", ":").unwrap(), /// None, None, Some(true) /// ) /// ) @@ -140,14 +140,14 @@ impl Metadata { /// &Some( /// vec![ /// Identifier::new( - /// cde::v1::Identifier::parse("organization:Name", ":").unwrap(), + /// cde::v1::subject::Identifier::parse("organization:Name", ":").unwrap(), /// None, None, Some(true) /// ) /// ] /// ) /// ); /// ``` - pub fn identifiers(&self) -> &Option> { + pub fn identifiers(&self) -> &Option> { &self.identifiers } @@ -161,19 +161,11 @@ impl Metadata { /// use ccdi_cde as cde; /// use ccdi_models as models; /// - /// use models::metadata::field::Identifier; /// use models::metadata::field::UnharmonizedField; /// use models::metadata::field::owned; /// use models::metadata::field::unowned; /// use models::subject::metadata::Builder; /// - /// let field = Identifier::new( - /// cde::v1::Identifier::parse("organization:Name", ":")?, - /// None, - /// None, - /// None - /// ); - /// /// let metadata = Builder::default() /// .insert_unharmonized( /// "unowned", @@ -194,7 +186,7 @@ impl Metadata { &self.unharmonized } - /// Generates a random [`Metadata`] based on a particular [`Identifier`](cde::v1::Identifier). + /// Generates a random [`Metadata`] based on a particular [`Identifier`](cde::v1::subject::Identifier). /// /// # Examples /// @@ -204,15 +196,15 @@ impl Metadata { /// /// use models::subject::Metadata; /// - /// let identifier = cde::v1::Identifier::parse("organization:Name", ":").unwrap(); + /// let identifier = cde::v1::subject::Identifier::parse("organization:Name", ":").unwrap(); /// let metadata = Metadata::random(identifier); /// ``` - pub fn random(identifier: cde::v1::Identifier) -> Metadata { + pub fn random(identifier: cde::v1::subject::Identifier) -> Metadata { Metadata { sex: Some(rand::random()), race: Some(vec![rand::random()]), ethnicity: Some(rand::random()), - identifiers: Some(vec![field::owned::Identifier::new( + identifiers: Some(vec![field::owned::subject::Identifier::new( identifier, None, None, diff --git a/packages/ccdi-models/src/subject/metadata/builder.rs b/packages/ccdi-models/src/subject/metadata/builder.rs index 2307afe..0c5c77b 100644 --- a/packages/ccdi-models/src/subject/metadata/builder.rs +++ b/packages/ccdi-models/src/subject/metadata/builder.rs @@ -8,16 +8,16 @@ use crate::subject::Metadata; #[derive(Clone, Debug, Default)] pub struct Builder { /// The sex of the subject. - sex: Option, + sex: Option, /// The race of the subject. - race: Option>, + race: Option>, /// The ethnicity of the subject. - ethnicity: Option, + ethnicity: Option, /// The identifiers for the subject. - identifiers: Option>, + identifiers: Option>, /// An unharmonized map of metadata fields. unharmonized: fields::Unharmonized, @@ -32,13 +32,13 @@ impl Builder { /// use ccdi_cde as cde; /// use ccdi_models as models; /// - /// use models::metadata::field::Sex; + /// use models::metadata::field::unowned::subject::Sex; /// use models::subject::metadata::Builder; /// - /// let field = Sex::new(cde::v1::Sex::Unknown, None, None); + /// let field = Sex::new(cde::v1::subject::Sex::Unknown, None, None); /// let builder = Builder::default().sex(field); /// ``` - pub fn sex(mut self, sex: field::Sex) -> Self { + pub fn sex(mut self, sex: field::unowned::subject::Sex) -> Self { self.sex = Some(sex); self } @@ -51,13 +51,13 @@ impl Builder { /// use ccdi_cde as cde; /// use ccdi_models as models; /// - /// use models::metadata::field::Race; + /// use models::metadata::field::unowned::subject::Race; /// use models::subject::metadata::Builder; /// - /// let field = Race::new(cde::v1::Race::Unknown, None, None); + /// let field = Race::new(cde::v1::subject::Race::Unknown, None, None); /// let builder = Builder::default().append_race(field); /// ``` - pub fn append_race(mut self, race: field::Race) -> Self { + pub fn append_race(mut self, race: field::unowned::subject::Race) -> Self { let mut inner = self.race.unwrap_or_default(); inner.push(race); @@ -74,13 +74,13 @@ impl Builder { /// use ccdi_cde as cde; /// use ccdi_models as models; /// - /// use models::metadata::field::Ethnicity; + /// use models::metadata::field::unowned::subject::Ethnicity; /// use models::subject::metadata::Builder; /// - /// let field = Ethnicity::new(cde::v2::Ethnicity::Unknown, None, None); + /// let field = Ethnicity::new(cde::v2::subject::Ethnicity::Unknown, None, None); /// let builder = Builder::default().ethnicity(field); /// ``` - pub fn ethnicity(mut self, ethnicity: field::Ethnicity) -> Self { + pub fn ethnicity(mut self, ethnicity: field::unowned::subject::Ethnicity) -> Self { self.ethnicity = Some(ethnicity); self } @@ -93,11 +93,11 @@ impl Builder { /// use ccdi_cde as cde; /// use ccdi_models as models; /// - /// use models::metadata::field::Identifier; + /// use models::metadata::field::owned::subject::Identifier; /// use models::subject::metadata::Builder; /// /// let field = Identifier::new( - /// cde::v1::Identifier::parse("organization:Name", ":")?, + /// cde::v1::subject::Identifier::parse("organization:Name", ":")?, /// None, /// None, /// None @@ -107,7 +107,7 @@ impl Builder { /// /// # Ok::<(), Box>(()) /// ``` - pub fn append_identifier(mut self, identifier: field::Identifier) -> Self { + pub fn append_identifier(mut self, identifier: field::owned::subject::Identifier) -> Self { let mut inner = self.identifiers.unwrap_or_default(); inner.push(identifier); @@ -127,19 +127,11 @@ impl Builder { /// use ccdi_cde as cde; /// use ccdi_models as models; /// - /// use models::metadata::field::Identifier; /// use models::metadata::field::UnharmonizedField; /// use models::metadata::field::owned; /// use models::metadata::field::unowned; /// use models::subject::metadata::Builder; /// - /// let field = Identifier::new( - /// cde::v1::Identifier::parse("organization:Name", ":")?, - /// None, - /// None, - /// None - /// ); - /// /// let builder = Builder::default() /// .insert_unharmonized( /// "unowned", diff --git a/packages/ccdi-openapi/src/api.rs b/packages/ccdi-openapi/src/api.rs index 9da9175..e7c54ca 100644 --- a/packages/ccdi-openapi/src/api.rs +++ b/packages/ccdi-openapi/src/api.rs @@ -23,7 +23,7 @@ a variety of query parameters.", name = "Childhood Cancer Data Initiative support email", email = "NCIChildhoodCancerDataInitiative@mail.nih.gov", ), - version = "0.1", + version = "0.2", ), external_docs( description = "Learn more about the Childhood Cancer Data Initiative", @@ -44,63 +44,98 @@ a variety of query parameters.", ), ), tags( - ( - name = "Info", - description = "Information about the API implementation itself." - ), ( name = "Subject", description = "Subjects within the CCDI federated ecosystem." ), + ( + name = "Sample", + description = "Samples within the CCDI federated ecosystem." + ), ( name = "Metadata", description = "List and describe provided metadata fields." - ) + ), + ( + name = "Info", + description = "Information about the API implementation itself." + ), ), paths( + // Subject routes. + server::routes::subject::subject_index, + server::routes::subject::subject_show, + server::routes::subject::subjects_by_count, + + // Sample routes. + server::routes::sample::sample_index, + server::routes::sample::sample_show, + server::routes::sample::samples_by_count, + // Metadata. server::routes::metadata::metadata_fields_subject, - - // Subject. - server::routes::subject::index, - server::routes::subject::show, - server::routes::subject::subjects_by_count, + server::routes::metadata::metadata_fields_sample, ), components(schemas( - // Common data elements. - cde::v1::Race, - cde::v1::Sex, - cde::v2::Ethnicity, - cde::v1::Identifier, - - // Harmonized Fields. - field::Sex, - field::Race, - field::Ethnicity, - field::Identifier, - - // Unharmonized Fields. + // Subject common data elements (CDEs). + cde::v1::subject::Race, + cde::v1::subject::Sex, + cde::v2::subject::Ethnicity, + cde::v1::subject::Identifier, + + // Sample common data elements (CDEs). + cde::v1::sample::DiseasePhase, + cde::v2::sample::TissueType, + cde::v1::sample::TumorClassification, + + // Harmonized subject fields. + field::unowned::subject::Sex, + field::unowned::subject::Race, + field::unowned::subject::Ethnicity, + field::owned::subject::Identifier, + + // Harmonized sample fields. + field::unowned::sample::DiseasePhase, + field::unowned::sample::TissueType, + field::unowned::sample::TumorClassification, + + // Unharmonized fields. field::owned::Field, field::unowned::Field, field::UnharmonizedField, fields::Unharmonized, - // Models. + // Subject models. models::Subject, + models::subject::Kind, + models::subject::Metadata, + + // Sample models. + models::Sample, + models::sample::Metadata, + + // Metadata models. models::metadata::field::Description, models::metadata::field::description::Harmonized, models::metadata::field::description::Unharmonized, - models::subject::Kind, - models::subject::Metadata, // Counts. models::count::Total, - // Responses. + // General responses. responses::Error, + + // Subject responses. responses::Subject, responses::Subjects, responses::by::count::Subjects, + + // Sample responses. + responses::Sample, + responses::Samples, + responses::by::count::Samples, + + // Metadata responses. responses::metadata::FieldDescriptions )), modifiers(&RemoveLicense) diff --git a/packages/ccdi-server/src/responses.rs b/packages/ccdi-server/src/responses.rs index 705d4db..e1ec475 100644 --- a/packages/ccdi-server/src/responses.rs +++ b/packages/ccdi-server/src/responses.rs @@ -3,8 +3,11 @@ pub mod by; mod error; pub mod metadata; +mod sample; mod subject; pub use error::Error; +pub use sample::Sample; +pub use sample::Samples; pub use subject::Subject; pub use subject::Subjects; diff --git a/packages/ccdi-server/src/responses/by/count.rs b/packages/ccdi-server/src/responses/by/count.rs index 2a277e7..de55790 100644 --- a/packages/ccdi-server/src/responses/by/count.rs +++ b/packages/ccdi-server/src/responses/by/count.rs @@ -1,50 +1,7 @@ //! Responses for grouping by fields and counting them. -use indexmap::IndexMap; -use serde::Deserialize; -use serde::Serialize; -use utoipa::ToSchema; +mod sample; +mod subject; -use ccdi_models as models; - -/// A response for grouping [`Subject`](models::Subject)s by a metadata field -/// and then summing the counts. -#[derive(Debug, Deserialize, Serialize, ToSchema)] -#[schema(as = responses::by::count::Subjects)] -pub struct Subjects { - #[serde(flatten)] - total: models::count::Total, - - values: IndexMap, -} - -impl From> for Subjects { - /// Creates a new [`Subjects`] from an [`IndexMap`]. - /// - /// # Examples - /// - /// ``` - /// use indexmap::IndexMap; - /// - /// use ccdi_models as models; - /// use ccdi_server as server; - /// - /// use models::count::Total; - /// use server::responses::by::count::Subjects; - /// - /// let mut map = IndexMap::::new(); - /// map.insert("U".into(), 18); - /// map.insert("F".into(), 37); - /// map.insert("M".into(), 26); - /// map.insert("UNDIFFERENTIATED".into(), 31); - /// - /// let subjects = Subjects::from(map); - /// ``` - fn from(values: IndexMap) -> Self { - let total = values.values().sum::(); - Self { - total: models::count::Total::from(total), - values, - } - } -} +pub use sample::Samples; +pub use subject::Subjects; diff --git a/packages/ccdi-server/src/responses/by/count/sample.rs b/packages/ccdi-server/src/responses/by/count/sample.rs new file mode 100644 index 0000000..d78e43d --- /dev/null +++ b/packages/ccdi-server/src/responses/by/count/sample.rs @@ -0,0 +1,49 @@ +//! Responses for grouping by fields for samples and counting them. + +use indexmap::IndexMap; +use serde::Deserialize; +use serde::Serialize; +use utoipa::ToSchema; + +use ccdi_models as models; + +/// A response for grouping [`Sample`](models::Sample)s by a metadata field +/// and then summing the counts. +#[derive(Debug, Deserialize, Serialize, ToSchema)] +#[schema(as = responses::by::count::Samples)] +pub struct Samples { + #[serde(flatten)] + total: models::count::Total, + + values: IndexMap, +} + +impl From> for Samples { + /// Creates a new [`Samples`] from an [`IndexMap`]. + /// + /// # Examples + /// + /// ``` + /// use indexmap::IndexMap; + /// + /// use ccdi_models as models; + /// use ccdi_server as server; + /// + /// use models::count::Total; + /// use server::responses::by::count::Samples; + /// + /// let mut map = IndexMap::::new(); + /// map.insert("Diagnosis".into(), 10); + /// map.insert("Relapse".into(), 10); + /// map.insert("Metastasis".into(), 10); + /// + /// let samples = Samples::from(map); + /// ``` + fn from(values: IndexMap) -> Self { + let total = values.values().sum::(); + Self { + total: models::count::Total::from(total), + values, + } + } +} diff --git a/packages/ccdi-server/src/responses/by/count/subject.rs b/packages/ccdi-server/src/responses/by/count/subject.rs new file mode 100644 index 0000000..a320c57 --- /dev/null +++ b/packages/ccdi-server/src/responses/by/count/subject.rs @@ -0,0 +1,50 @@ +//! Responses for grouping by fields for subjects and counting them. + +use indexmap::IndexMap; +use serde::Deserialize; +use serde::Serialize; +use utoipa::ToSchema; + +use ccdi_models as models; + +/// A response for grouping [`Subject`](models::Subject)s by a metadata field +/// and then summing the counts. +#[derive(Debug, Deserialize, Serialize, ToSchema)] +#[schema(as = responses::by::count::Subjects)] +pub struct Subjects { + #[serde(flatten)] + total: models::count::Total, + + values: IndexMap, +} + +impl From> for Subjects { + /// Creates a new [`Subjects`] from an [`IndexMap`]. + /// + /// # Examples + /// + /// ``` + /// use indexmap::IndexMap; + /// + /// use ccdi_models as models; + /// use ccdi_server as server; + /// + /// use models::count::Total; + /// use server::responses::by::count::Subjects; + /// + /// let mut map = IndexMap::::new(); + /// map.insert("U".into(), 18); + /// map.insert("F".into(), 37); + /// map.insert("M".into(), 26); + /// map.insert("UNDIFFERENTIATED".into(), 31); + /// + /// let subjects = Subjects::from(map); + /// ``` + fn from(values: IndexMap) -> Self { + let total = values.values().sum::(); + Self { + total: models::count::Total::from(total), + values, + } + } +} diff --git a/packages/ccdi-server/src/responses/sample.rs b/packages/ccdi-server/src/responses/sample.rs new file mode 100644 index 0000000..5b79f32 --- /dev/null +++ b/packages/ccdi-server/src/responses/sample.rs @@ -0,0 +1,40 @@ +use serde::Deserialize; +use serde::Serialize; +use utoipa::ToSchema; + +use ccdi_models as models; + +use models::count::Total; + +/// A response representing a single [`Sample`](models::Sample). +#[derive(Debug, Deserialize, Serialize, ToSchema)] +#[schema(as = responses::Sample)] +pub struct Sample { + /// Sample. + #[serde(flatten)] + inner: models::Sample, +} + +/// A response representing multiple samples known about by the server with a +/// summarized total count. +#[derive(Debug, Deserialize, Serialize, ToSchema)] +#[schema(as = responses::Samples)] +pub struct Samples { + /// The total number of samples in this response. + #[schema(value_type = models::count::Total)] + count: Total, + + /// The samples, if available. + #[serde(skip_serializing_if = "Option::is_none")] + #[schema(nullable = false)] + samples: Option>, +} + +impl From> for Samples { + fn from(samples: Vec) -> Self { + Self { + count: samples.len().into(), + samples: Some(samples), + } + } +} diff --git a/packages/ccdi-server/src/routes.rs b/packages/ccdi-server/src/routes.rs index b9ced73..482c44a 100644 --- a/packages/ccdi-server/src/routes.rs +++ b/packages/ccdi-server/src/routes.rs @@ -1,4 +1,5 @@ //! Routing. pub mod metadata; +pub mod sample; pub mod subject; diff --git a/packages/ccdi-server/src/routes/metadata.rs b/packages/ccdi-server/src/routes/metadata.rs index 58da080..255600c 100644 --- a/packages/ccdi-server/src/routes/metadata.rs +++ b/packages/ccdi-server/src/routes/metadata.rs @@ -5,17 +5,15 @@ use actix_web::web::ServiceConfig; use actix_web::HttpResponse; use actix_web::Responder; -use ccdi_cde as cde; use ccdi_models as models; -use models::metadata::field::description::r#trait::Description as _; - use crate::responses::metadata::FieldDescriptions; /// Configures the [`ServiceConfig`] with the metadata paths. pub fn configure() -> impl FnOnce(&mut ServiceConfig) { |config: &mut ServiceConfig| { config.service(metadata_fields_subject); + config.service(metadata_fields_sample); } } @@ -30,10 +28,23 @@ pub fn configure() -> impl FnOnce(&mut ServiceConfig) { )] #[get("/metadata/fields/subject")] pub async fn metadata_fields_subject() -> impl Responder { - HttpResponse::Ok().json(FieldDescriptions::from(vec![ - cde::v1::Sex::description(), - cde::v1::Race::description(), - cde::v2::Ethnicity::description(), - cde::v1::Identifier::description(), - ])) + HttpResponse::Ok().json(FieldDescriptions::from( + models::metadata::field::description::harmonized::subject::get_field_descriptions(), + )) +} + +/// Gets the metadata fields for samples that are supported by this server. +#[utoipa::path( + get, + path = "/metadata/fields/sample", + tag = "Metadata", + responses( + (status = 200, description = "Successful operation.", body = responses::metadata::FieldDescriptions) + ) +)] +#[get("/metadata/fields/sample")] +pub async fn metadata_fields_sample() -> impl Responder { + HttpResponse::Ok().json(FieldDescriptions::from( + models::metadata::field::description::harmonized::sample::get_field_descriptions(), + )) } diff --git a/packages/ccdi-server/src/routes/sample.rs b/packages/ccdi-server/src/routes/sample.rs new file mode 100644 index 0000000..e77da6b --- /dev/null +++ b/packages/ccdi-server/src/routes/sample.rs @@ -0,0 +1,189 @@ +//! Routes related to samples. + +use std::sync::Mutex; + +use actix_web::get; +use actix_web::web::Data; +use actix_web::web::Path; +use actix_web::web::ServiceConfig; +use actix_web::HttpResponse; +use actix_web::Responder; +use indexmap::IndexMap; +use serde_json::Value; + +use ccdi_models as models; + +use models::Sample; + +use crate::responses::by; +use crate::responses::Error; +use crate::responses::Samples; + +/// A store for [`Sample`]s. +#[derive(Debug)] +pub struct Store { + samples: Mutex>, +} + +impl Store { + /// Creates a new [`Store`] with randomized [`Sample`]s. + /// + /// # Examples + /// + /// ``` + /// use ccdi_server as server; + /// + /// use server::routes::sample::Store; + /// + /// let store = Store::random(100); + /// ``` + pub fn random(count: usize) -> Self { + Self { + samples: Mutex::new( + (0..count) + .map(|i| Sample::random(i + 1)) + .collect::>(), + ), + } + } +} + +/// Configures the [`ServiceConfig`] with the sample paths. +pub fn configure(store: Data) -> impl FnOnce(&mut ServiceConfig) { + |config: &mut ServiceConfig| { + config + .app_data(store) + .service(sample_index) + .service(sample_show) + .service(samples_by_count); + } +} + +/// Gets the samples known by this server. +#[utoipa::path( + get, + path = "/sample", + tag = "Sample", + responses( + (status = 200, description = "Successful operation.", body = responses::Samples) + ) +)] +#[get("/sample")] +pub async fn sample_index(samples: Data) -> impl Responder { + let samples = samples.samples.lock().unwrap().clone(); + HttpResponse::Ok().json(Samples::from(samples)) +} + +/// Gets the sample matching the provided name (if the sample exists). +#[utoipa::path( + get, + path = "/sample/{name}", + params( + ( + "name" = String, + description = "The name for the sample." + ) + ), + tag = "Sample", + responses( + (status = 200, description = "Successful operation.", body = responses::Sample), + ( + status = 404, + description = "Not found.", + body = responses::Error, + example = json!(Error::new("Sample with name 'foo' not found.")) + ) + ) +)] +#[get("/sample/{name}")] +pub async fn sample_show(path: Path, samples: Data) -> impl Responder { + let samples = samples.samples.lock().unwrap(); + let name = path.into_inner(); + + samples + .iter() + .find(|sample| sample.name() == &name) + .map(|sample| HttpResponse::Ok().json(sample)) + .unwrap_or_else(|| { + HttpResponse::NotFound().json(Error::new(format!( + "Sample with name '{}' not found.", + name + ))) + }) +} + +/// Groups the samples by the specified metadata field and returns counts. +#[utoipa::path( + get, + path = "/sample/by/{field}/count", + params( + ("field" = String, description = "The field to group by and count."), + ), + tag = "Sample", + responses( + (status = 200, description = "Successful operation.", body = responses::by::count::Samples), + ( + status = 422, + description = "Unsupported field.", + body = responses::Error, + example = json!(Error::new("Field 'handedness' is unsupported.")) + ), + ) +)] +#[get("/sample/by/{field}/count")] +pub async fn samples_by_count(path: Path, samples: Data) -> impl Responder { + let samples = samples.samples.lock().unwrap().clone(); + let field = path.into_inner(); + + let values = samples + .iter() + .map(|sample| parse_field(&field, sample)) + .collect::>>(); + + let result = match values { + Some(values) => values + .into_iter() + .map(|value| match value { + Value::Null => String::from("null"), + Value::String(s) => s, + _ => todo!(), + }) + .fold(IndexMap::new(), |mut map, value| { + *map.entry(value).or_insert_with(|| 0usize) += 1; + map + }), + None => { + return HttpResponse::UnprocessableEntity() + .json(Error::new(format!("Field '{}' is not supported.", field))) + } + }; + + HttpResponse::Ok().json(by::count::Samples::from(result)) +} + +fn parse_field(field: &str, sample: &Sample) -> Option { + match field { + "disease_phase" => match sample.metadata() { + Some(metadata) => metadata + .disease_phase() + .as_ref() + .map(|value| Value::String(value.value().to_string())), + None => None, + }, + "tissue_type" => match sample.metadata() { + Some(metadata) => metadata + .tissue_type() + .as_ref() + .map(|value| Value::String(value.value().to_string())), + None => None, + }, + "tumor_classification" => match sample.metadata() { + Some(metadata) => metadata + .tumor_classification() + .as_ref() + .map(|value| Value::String(value.value().to_string())), + None => None, + }, + _ => None, + } +} diff --git a/packages/ccdi-server/src/routes/subject.rs b/packages/ccdi-server/src/routes/subject.rs index 44adb92..371fce6 100644 --- a/packages/ccdi-server/src/routes/subject.rs +++ b/packages/ccdi-server/src/routes/subject.rs @@ -14,7 +14,7 @@ use serde_json::Value; use ccdi_cde as cde; use ccdi_models as models; -use cde::v1::Identifier; +use cde::v1::subject::Identifier; use models::Subject; use crate::responses::by; @@ -68,8 +68,8 @@ pub fn configure(store: Data) -> impl FnOnce(&mut ServiceConfig) { |config: &mut ServiceConfig| { config .app_data(store) - .service(index) - .service(show) + .service(subject_index) + .service(subject_show) .service(subjects_by_count); } } @@ -84,12 +84,12 @@ pub fn configure(store: Data) -> impl FnOnce(&mut ServiceConfig) { ) )] #[get("/subject")] -pub async fn index(subjects: Data) -> impl Responder { +pub async fn subject_index(subjects: Data) -> impl Responder { let subjects = subjects.subjects.lock().unwrap().clone(); HttpResponse::Ok().json(Subjects::from(subjects)) } -/// Gets the subject matching the provided id (if it exists). +/// Gets the subject matching the provided id (if the subject exists). #[utoipa::path( get, path = "/subject/{namespace}/{name}", @@ -115,7 +115,7 @@ pub async fn index(subjects: Data) -> impl Responder { ) )] #[get("/subject/{namespace}/{name}")] -pub async fn show(path: Path<(String, String)>, subjects: Data) -> impl Responder { +pub async fn subject_show(path: Path<(String, String)>, subjects: Data) -> impl Responder { let subjects = subjects.subjects.lock().unwrap(); let (namespace, name) = path.into_inner(); diff --git a/packages/ccdi-spec/Cargo.toml b/packages/ccdi-spec/Cargo.toml index b80b360..b4264fd 100644 --- a/packages/ccdi-spec/Cargo.toml +++ b/packages/ccdi-spec/Cargo.toml @@ -7,6 +7,8 @@ edition.workspace = true [dependencies] actix-web.workspace = true env_logger = "0.10.0" +ccdi-cde = { path = "../ccdi-cde", version = "0.1.0" } +ccdi-models = { path = "../ccdi-models", version = "0.1.0" } ccdi-openapi = { path = "../ccdi-openapi", version = "0.1.0" } ccdi-server = { path = "../ccdi-server", version = "0.1.0" } clap = { version = "4.4.6", features = ["derive"] } diff --git a/packages/ccdi-spec/src/main.rs b/packages/ccdi-spec/src/main.rs index 8d4bc41..f183f28 100644 --- a/packages/ccdi-spec/src/main.rs +++ b/packages/ccdi-spec/src/main.rs @@ -14,13 +14,19 @@ use log::LevelFilter; use utoipa::OpenApi; use utoipa_swagger_ui::SwaggerUi; +use ccdi_models as models; use ccdi_openapi as api; use ccdi_server as server; use api::Api; + use server::routes::metadata; +use server::routes::sample; use server::routes::subject; -use server::routes::subject::Store; + +mod utils; + +use utils::markdown; const ERROR_EXIT_CODE: i32 = 1; @@ -67,6 +73,21 @@ pub struct ServeArgs { port: u16, } +#[derive(Clone, Debug, clap::ValueEnum)] +pub enum WikiEntity { + /// A subject. + Subject, + + /// A sample. + Sample, +} + +#[derive(Debug, Parser)] +pub struct WikiArgs { + /// The API entity for which to generate a wiki page. + entity: WikiEntity, +} + #[derive(Debug, Subcommand)] pub enum Command { /// Generate the OpenAPI specification as YAML. @@ -74,6 +95,9 @@ pub enum Command { /// Runs the test server. Serve(ServeArgs), + + /// Generates the documentation for a wiki page covering an API entity. + Wiki(WikiArgs), } // A program to prepare the Childhood Cancer Data Initiative OpenAPI @@ -118,10 +142,13 @@ fn inner() -> Result<(), Box> { Command::Serve(args) => { rt::System::new().block_on( HttpServer::new(move || { - let data = Data::new(Store::random(args.number_of_subjects)); + let subjects = Data::new(subject::Store::random(args.number_of_subjects)); + let samples = Data::new(sample::Store::random(args.number_of_subjects)); + App::new() .wrap(Logger::default()) - .configure(subject::configure(data)) + .configure(subject::configure(subjects)) + .configure(sample::configure(samples)) .configure(metadata::configure()) .service( SwaggerUi::new("/swagger-ui/{_:.*}") @@ -132,6 +159,22 @@ fn inner() -> Result<(), Box> { .run(), )?; } + Command::Wiki(args) => { + let fields = match args.entity { + WikiEntity::Subject => { + models::metadata::field::description::harmonized::subject::get_field_descriptions() + } + WikiEntity::Sample => { + models::metadata::field::description::harmonized::sample::get_field_descriptions( + ) + } + }; + + for field in fields { + let section = markdown::Section::from(field); + println!("{}\n", section); + } + } } Ok(()) diff --git a/packages/ccdi-spec/src/utils.rs b/packages/ccdi-spec/src/utils.rs new file mode 100644 index 0000000..163a4fb --- /dev/null +++ b/packages/ccdi-spec/src/utils.rs @@ -0,0 +1 @@ +pub mod markdown; diff --git a/packages/ccdi-spec/src/utils/markdown.rs b/packages/ccdi-spec/src/utils/markdown.rs new file mode 100644 index 0000000..f4b2ecf --- /dev/null +++ b/packages/ccdi-spec/src/utils/markdown.rs @@ -0,0 +1,143 @@ +use std::iter::Peekable; +use std::slice::Iter; + +use ccdi_cde as cde; +use ccdi_models as models; + +use cde::parse::cde::Member; +use models::metadata::field::description; +use models::metadata::field::description::Description; + +const METADATA_TABLE_FIELDS: &[&str] = + &["VM Long Name", "VM Public ID", "Concept Code", "Begin Date"]; + +pub struct Section(Description); + +impl From for Section { + fn from(value: Description) -> Self { + Self(value) + } +} + +impl std::fmt::Display for Section { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match &self.0 { + Description::Harmonized(description) => display_harmonized(f, description), + // SAFETY: this command is concerned with printing out the wiki + // entries for the _harmonized_ data elements. As such, we should + // never be printing out information for _unharmonized_ data + // elements (the documentation for those are handled by each + // individual site). + Description::Unharmonized(_) => unreachable!(), + } + } +} + +fn display_harmonized( + f: &mut std::fmt::Formatter<'_>, + harmonized: &description::Harmonized, +) -> std::fmt::Result { + // Write the path to the metadata element in the response. + writeln!(f, "### **`{}`**\n", harmonized.path())?; + + // Write the header line for the metadata element. + writeln!( + f, + "**Formal Name: `{}`** ([Link]({}))\n", + harmonized.standard(), + harmonized.url() + )?; + + // Write the documentation for the metadata element. + writeln!(f, "{}\n", harmonized.entity().description())?; + + let mut members = harmonized.members().iter().peekable(); + + match members.peek() { + Some((_, member)) => match member { + Member::Field(_) => write_field_members(f, members), + Member::Variant(_) => write_variant_members(f, members), + }, + None => Ok(()), + } +} + +fn write_field_members( + f: &mut std::fmt::Formatter<'_>, + members: Peekable>, +) -> std::fmt::Result { + for (identifier, member) in members { + let field = match member { + Member::Field(field) => field, + // SAFETY: this function is only called when we check that the first + // member in the `members` list is a [`Member::Field`]. If the first + // element is a [`Member::Field`], then all of them should be. + _ => unreachable!(), + }; + + writeln!(f, "* **{}.** {}", identifier, field.description())?; + } + + Ok(()) +} + +fn write_variant_members( + f: &mut std::fmt::Formatter<'_>, + members: Peekable>, +) -> std::fmt::Result { + // Write table header. + write!(f, "| Permissible Value | Description |")?; + + for field in METADATA_TABLE_FIELDS { + write!(f, " {} |", field)?; + } + + writeln!(f)?; + + // Write the alignment line. + write!(f, "|:-- | -- |")?; + + for _ in METADATA_TABLE_FIELDS { + write!(f, " -- |")?; + } + + writeln!(f)?; + + for (_, member) in members { + let variant = match member { + Member::Variant(variant) => variant, + // SAFETY: this function is only called when we check that the first + // member in the `members` list is a [`Member::Variant`]. If the + // first element is a [`Member::Variant`], then all of them should + // be. + _ => unreachable!(), + }; + + // Write the row. + write!( + f, + "| `{}` | {} |", + variant.permissible_value(), + variant.description() + )?; + + for field in METADATA_TABLE_FIELDS { + let key = field.to_string(); + let value = variant + .metadata() + .map(|metadata| { + metadata + .get(&key) + .map(|value| value.to_string()) + .unwrap_or(String::new()) + }) + .unwrap_or(String::new()); + + write!(f, " {} |", value)?; + } + + writeln!(f)?; + } + + Ok(()) +} diff --git a/swagger.yml b/swagger.yml index 3b42a02..5804b38 100644 --- a/swagger.yml +++ b/swagger.yml @@ -9,7 +9,7 @@ info: contact: name: Childhood Cancer Data Initiative support email email: NCIChildhoodCancerDataInitiative@mail.nih.gov - version: '0.1' + version: '0.2' servers: - url: https://ccdi.stjude.cloud/api/v0 description: St. Jude Children's Research Hospital CCDI API server @@ -18,27 +18,13 @@ servers: - url: https://ccdi.treehouse.gi.ucsc.edu/api/v0 description: UCSC Treehouse CCDI API server paths: - /metadata/fields/subject: - get: - tags: - - Metadata - summary: Gets the metadata fields for subjects that are supported by this server. - description: Gets the metadata fields for subjects that are supported by this server. - operationId: metadata_fields_subject - responses: - '200': - description: Successful operation. - content: - application/json: - schema: - $ref: '#/components/schemas/responses.metadata.FieldDescriptions' /subject: get: tags: - Subject summary: Gets the subjects known by this server. description: Gets the subjects known by this server. - operationId: index + operationId: subject_index responses: '200': description: Successful operation. @@ -50,9 +36,9 @@ paths: get: tags: - Subject - summary: Gets the subject matching the provided id (if it exists). - description: Gets the subject matching the provided id (if it exists). - operationId: show + summary: Gets the subject matching the provided id (if the subject exists). + description: Gets the subject matching the provided id (if the subject exists). + operationId: subject_show parameters: - name: namespace in: path @@ -110,12 +96,148 @@ paths: $ref: '#/components/schemas/responses.Error' example: error: Field 'handedness' is unsupported. + /sample: + get: + tags: + - Sample + summary: Gets the samples known by this server. + description: Gets the samples known by this server. + operationId: sample_index + responses: + '200': + description: Successful operation. + content: + application/json: + schema: + $ref: '#/components/schemas/responses.Samples' + /sample/{name}: + get: + tags: + - Sample + summary: Gets the sample matching the provided name (if the sample exists). + description: Gets the sample matching the provided name (if the sample exists). + operationId: sample_show + parameters: + - name: name + in: path + description: The name for the sample. + required: true + schema: + type: string + responses: + '200': + description: Successful operation. + content: + application/json: + schema: + $ref: '#/components/schemas/responses.Sample' + '404': + description: Not found. + content: + application/json: + schema: + $ref: '#/components/schemas/responses.Error' + example: + error: Sample with name 'foo' not found. + /sample/by/{field}/count: + get: + tags: + - Sample + summary: Groups the samples by the specified metadata field and returns counts. + description: Groups the samples by the specified metadata field and returns counts. + operationId: samples_by_count + parameters: + - name: field + in: path + description: The field to group by and count. + required: true + schema: + type: string + responses: + '200': + description: Successful operation. + content: + application/json: + schema: + $ref: '#/components/schemas/responses.by.count.Samples' + '422': + description: Unsupported field. + content: + application/json: + schema: + $ref: '#/components/schemas/responses.Error' + example: + error: Field 'handedness' is unsupported. + /metadata/fields/subject: + get: + tags: + - Metadata + summary: Gets the metadata fields for subjects that are supported by this server. + description: Gets the metadata fields for subjects that are supported by this server. + operationId: metadata_fields_subject + responses: + '200': + description: Successful operation. + content: + application/json: + schema: + $ref: '#/components/schemas/responses.metadata.FieldDescriptions' + /metadata/fields/sample: + get: + tags: + - Metadata + summary: Gets the metadata fields for samples that are supported by this server. + description: Gets the metadata fields for samples that are supported by this server. + operationId: metadata_fields_sample + responses: + '200': + description: Successful operation. + content: + application/json: + schema: + $ref: '#/components/schemas/responses.metadata.FieldDescriptions' components: schemas: - cde.v1.Identifier: + cde.v1.sample.DiseasePhase: + type: string + description: |- + **`caDSR CDE 12217251 v1.00`** + + This metadata element is defined by the caDSR as "The stage or period of an + individual's treatment process during which relevant observations were + recorded.". + + Link: + + enum: + - Post-Mortem + - Not Reported + - Unknown + - Initial Diagnosis + - Progression + - Refactory + - Relapse + - Relapse/Progression + cde.v1.sample.TumorClassification: + type: string + description: |- + **`caDSR CDE 12922545 v1.00`** + + This metadata element is defined by the caDSR as "The classification of a + tumor based primarily on histopathological characteristics.". + + Link: + + enum: + - Metastatic + - Not Reported + - Primary + - Regional + - Unknown + cde.v1.subject.Identifier: type: object description: |- - **caDSR CDE 6380049 v1.00** + **`caDSR CDE 6380049 v1.00`** This metadata element is defined by the caDSR as "A unique subject identifier within a site and a study.". No permissible values are defined @@ -135,10 +257,10 @@ components: type: string description: The name of the identifier. example: SubjectName001 - cde.v1.Race: + cde.v1.subject.Race: type: string description: |- - **caDSR CDE 2192199 v1.00** + **`caDSR CDE 2192199 v1.00`** This metadata element is defined by the caDSR as "The text for reporting information about race based on the Office of Management and Budget (OMB) @@ -156,17 +278,17 @@ components: - Asian - Black or African American - White - cde.v1.Sex: + cde.v1.subject.Sex: type: string description: |- - **caDSR CDE 6343385 v1.00** + **`caDSR CDE 6343385 v1.00`** This metadata element is defined by the caDSR as "Sex of the subject as determined by the investigator." In particular, this field does not dictate the time period: whether it represents sex at birth, sex at sample collection, or any other determined time point. Further, the descriptions for F and M suggest that this term can represent either biological sex, - culture gender roles, or both. Thus, this field cannot be assumed to + cultural gender roles, or both. Thus, this field cannot be assumed to strictly represent biological sex. Link: @@ -176,10 +298,31 @@ components: - F - M - UNDIFFERENTIATED - cde.v2.Ethnicity: + cde.v2.sample.TissueType: type: string description: |- - **caDSR CDE 2192217 v2.00** + **`caDSR CDE 5432687 v2.00`** + + This metadata element is defined by the caDSR as "Text term that represents + a description of the kind of tissue collected with respect to disease status + or proximity to tumor tissue." + + Link: + + enum: + - Not Reported + - Abnormal + - Normal + - Peritumoral + - Tumor + - Non-neoplastic + - Unavailable + - Unknown + - Unspecified + cde.v2.subject.Ethnicity: + type: string + description: |- + **`caDSR CDE 2192217 v2.00`** This metadata element is defined by the caDSR as "The text for reporting information about ethnicity based on the Office of Management and Budget @@ -188,20 +331,25 @@ components: ethnicity. Link: - + enum: - Not allowed to collect - Hispanic or Latino - Not Hispanic or Latino - Unknown - Not reported - field.Ethnicity: + field.UnharmonizedField: + oneOf: + - $ref: '#/components/schemas/field.owned.Field' + - $ref: '#/components/schemas/field.unowned.Field' + description: A metadata field. + field.owned.Field: type: object required: - value properties: value: - $ref: '#/components/schemas/cde.v2.Ethnicity' + description: The value of the metadata field. ancestors: type: array items: @@ -214,13 +362,16 @@ components: comment: type: string description: A free-text comment field. - field.Identifier: + owned: + type: boolean + description: Whether or not the field is owned by the source server. + field.owned.subject.Identifier: type: object required: - value properties: value: - $ref: '#/components/schemas/cde.v1.Identifier' + $ref: '#/components/schemas/cde.v1.subject.Identifier' ancestors: type: array items: @@ -236,13 +387,13 @@ components: owned: type: boolean description: Whether or not the field is owned by the source server. - field.Race: + field.unowned.Field: type: object required: - value properties: value: - $ref: '#/components/schemas/cde.v1.Race' + description: The value of the metadata field. ancestors: type: array items: @@ -255,13 +406,13 @@ components: comment: type: string description: A free-text comment field. - field.Sex: + field.unowned.sample.DiseasePhase: type: object required: - value properties: value: - $ref: '#/components/schemas/cde.v1.Sex' + $ref: '#/components/schemas/cde.v1.sample.DiseasePhase' ancestors: type: array items: @@ -274,18 +425,13 @@ components: comment: type: string description: A free-text comment field. - field.UnharmonizedField: - oneOf: - - $ref: '#/components/schemas/field.owned.Field' - - $ref: '#/components/schemas/field.unowned.Field' - description: A metadata field. - field.owned.Field: + field.unowned.sample.TissueType: type: object required: - value properties: value: - description: The value of the metadata field. + $ref: '#/components/schemas/cde.v2.sample.TissueType' ancestors: type: array items: @@ -298,16 +444,70 @@ components: comment: type: string description: A free-text comment field. - owned: - type: boolean - description: Whether or not the field is owned by the source server. - field.unowned.Field: + field.unowned.sample.TumorClassification: type: object required: - value properties: value: - description: The value of the metadata field. + $ref: '#/components/schemas/cde.v1.sample.TumorClassification' + ancestors: + type: array + items: + type: string + description: |- + The ancestors from which this field was derived. + + Ancestors should be provided as period (`.`) delimited paths + from the `metadata` key in the subject response object. + comment: + type: string + description: A free-text comment field. + field.unowned.subject.Ethnicity: + type: object + required: + - value + properties: + value: + $ref: '#/components/schemas/cde.v2.subject.Ethnicity' + ancestors: + type: array + items: + type: string + description: |- + The ancestors from which this field was derived. + + Ancestors should be provided as period (`.`) delimited paths + from the `metadata` key in the subject response object. + comment: + type: string + description: A free-text comment field. + field.unowned.subject.Race: + type: object + required: + - value + properties: + value: + $ref: '#/components/schemas/cde.v1.subject.Race' + ancestors: + type: array + items: + type: string + description: |- + The ancestors from which this field was derived. + + Ancestors should be provided as period (`.`) delimited paths + from the `metadata` key in the subject response object. + comment: + type: string + description: A free-text comment field. + field.unowned.subject.Sex: + type: object + required: + - value + properties: + value: + $ref: '#/components/schemas/cde.v1.subject.Sex' ancestors: type: array items: @@ -328,6 +528,25 @@ components: $ref: '#/components/schemas/field.UnharmonizedField' - type: object description: A map of unharmonized metadata fields. + models.Sample: + type: object + description: A sample. + required: + - name + properties: + name: + type: string + description: |- + The primary name for a sample used within the source server. + + Note that this field is not namespaced like an `identifier` is, and, + instead, is intended to represent a colloquial or display name for the + sample (without indicating the scope of the name). + example: SampleName001 + metadata: + allOf: + - $ref: '#/components/schemas/models.sample.Metadata' + nullable: true models.Subject: type: object description: A subject. @@ -337,12 +556,15 @@ components: - kind properties: id: - $ref: '#/components/schemas/cde.v1.Identifier' + $ref: '#/components/schemas/cde.v1.subject.Identifier' name: type: string description: |- - The primary name or identifier for a subject used within the source - server. + The primary name for a subject used within the source server. + + Note that this field is not namespaced like an `identifier` is, and, + instead, is intended to represent a colloquial or display name for the + sample (without indicating the scope of the name). example: SubjectName001 kind: $ref: '#/components/schemas/models.subject.Kind' @@ -440,6 +662,28 @@ components: type: string description: A url that describes more about the metadata field, if available. nullable: true + models.sample.Metadata: + type: object + description: Metadata associated with a sample. + required: + - disease_phase + - tissue_type + - tumor_classification + properties: + disease_phase: + allOf: + - $ref: '#/components/schemas/field.unowned.sample.DiseasePhase' + nullable: true + tissue_type: + allOf: + - $ref: '#/components/schemas/field.unowned.sample.TissueType' + nullable: true + tumor_classification: + allOf: + - $ref: '#/components/schemas/field.unowned.sample.TumorClassification' + nullable: true + unharmonized: + $ref: '#/components/schemas/fields.Unharmonized' models.subject.Kind: type: string description: A kind of [`Subject`](super::Subject). @@ -459,22 +703,22 @@ components: properties: sex: allOf: - - $ref: '#/components/schemas/field.Sex' + - $ref: '#/components/schemas/field.unowned.subject.Sex' nullable: true race: type: array items: - $ref: '#/components/schemas/field.Race' + $ref: '#/components/schemas/field.unowned.subject.Race' description: The race(s) of the subject. nullable: true ethnicity: allOf: - - $ref: '#/components/schemas/field.Ethnicity' + - $ref: '#/components/schemas/field.unowned.subject.Ethnicity' nullable: true identifiers: type: array items: - $ref: '#/components/schemas/field.Identifier' + $ref: '#/components/schemas/field.owned.subject.Identifier' description: |- The identifiers for the subject. @@ -492,6 +736,26 @@ components: error: type: string example: An error occurred. + responses.Sample: + allOf: + - $ref: '#/components/schemas/models.Sample' + - type: object + description: A response representing a single [`Sample`](models::Sample). + responses.Samples: + type: object + description: |- + A response representing multiple samples known about by the server with a + summarized total count. + required: + - count + properties: + count: + $ref: '#/components/schemas/models.count.Total' + samples: + type: array + items: + $ref: '#/components/schemas/models.Sample' + description: The samples, if available. responses.Subject: allOf: - $ref: '#/components/schemas/models.Subject' @@ -512,6 +776,21 @@ components: items: $ref: '#/components/schemas/models.Subject' description: The subjects, if available. + responses.by.count.Samples: + allOf: + - $ref: '#/components/schemas/models.count.Total' + - type: object + required: + - values + properties: + values: + type: object + additionalProperties: + type: integer + minimum: 0 + description: |- + A response for grouping [`Sample`](models::Sample)s by a metadata field + and then summing the counts. responses.by.count.Subjects: allOf: - $ref: '#/components/schemas/models.count.Total' @@ -539,12 +818,14 @@ components: $ref: '#/components/schemas/models.metadata.field.Description' description: Field descriptions. tags: -- name: Info - description: Information about the API implementation itself. - name: Subject description: Subjects within the CCDI federated ecosystem. +- name: Sample + description: Samples within the CCDI federated ecosystem. - name: Metadata description: List and describe provided metadata fields. +- name: Info + description: Information about the API implementation itself. externalDocs: url: https://www.cancer.gov/research/areas/childhood/childhood-cancer-data-initiative description: Learn more about the Childhood Cancer Data Initiative