Introduction
In DICOM medical imaging systems, data accuracy and consistency are critical. This article explores how to use Rust’s type system to create custom type-safe wrappers that prevent runtime errors and improve code maintainability.
BoundedString: Length-Limited Strings
BoundedString<N> is a generic struct that ensures strings do not exceed N characters in length.
FixedLengthString: Fixed-Length Strings
FixedLengthString<N> ensures strings are exactly N characters long, which is useful when handling certain DICOM fields.
DicomDateString: DICOM Date Format
DICOM standards require date fields to use the YYYYMMDD format. The DicomDateString type is specifically designed to handle this format.
Advantages of the Type System
1. Compile-Time Guarantees
With these custom types, many data validation errors can be caught at compile time rather than being exposed at runtime.
2. Clear Intent Expression
Type names themselves express data constraints, making code more readable and self-documenting.
3. Preventing Error Propagation
Once data passes type validation, subsequent code can assume the data format is correct without repeating validation.
Implementation Details:
1. dicom_dbtype.rs
use serde::{Deserialize, Serialize};
use snafu::Snafu;
use std::fmt;
use std::hash::Hash;
#[derive(Debug, Snafu)]
#[non_exhaustive]
pub enum BoundedStringError {
#[snafu(display("String too long: {} > {}", len, max))]
TooLong { max: usize, len: usize },
#[snafu(display("String length is: {} and expected: {}", len, fixlen))]
LengthError { fixlen: usize, len: usize },
}
type BoundedResult<T, E = BoundedStringError> = Result<T, E>;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(transparent)]
#[derive(Default)]
pub struct BoundedString<const N: usize> {
value: String,
}
impl<const N: usize> fmt::Display for BoundedString<N> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.value)
}
}
impl<const N: usize> BoundedString<N> {
pub fn new(s: String) -> BoundedResult<BoundedString<N>> {
if s.len() > N {
Err(BoundedStringError::TooLong {
max: N,
len: s.len(),
})
} else {
Ok(Self { value: s })
}
}
pub fn from_str(s: &str) -> BoundedResult<BoundedString<N>> {
if s.len() > N {
Err(BoundedStringError::TooLong {
max: N,
len: s.len(),
})
} else {
Ok(Self {
value: s.to_string(),
})
}
}
pub fn from_string(s: &String) -> BoundedResult<BoundedString<N>> {
if s.len() > N {
Err(BoundedStringError::TooLong {
max: N,
len: s.len(),
})
} else {
Ok(Self { value: s.clone() })
}
}
pub fn as_str(&self) -> &str {
&self.value
}
// Using deref to access
pub fn as_ref(&self) -> &String {
&self.value
}
}
impl<const N: usize> Hash for BoundedString<N> {
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
self.value.hash(state);
}
}
impl<const N: usize> PartialEq for BoundedString<N> {
fn eq(&self, other: &Self) -> bool {
self.value == other.value
}
}
impl<const N: usize> Eq for BoundedString<N> {}
impl<const N: usize> TryFrom<&str> for BoundedString<N> {
type Error = BoundedStringError;
fn try_from(s: &str) -> BoundedResult<Self> {
BoundedString::from_str(s)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(transparent)]
pub struct FixedLengthString<const N: usize> {
pub(crate) value: String,
}
impl<const N: usize> Hash for FixedLengthString<N> {
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
self.value.hash(state);
}
}
impl<const N: usize> PartialEq for FixedLengthString<N> {
fn eq(&self, other: &Self) -> bool {
self.value == other.value
}
}
impl<const N: usize> Eq for FixedLengthString<N> {}
/// DICOM date string in format YYYYMMDD with length 8, e.g. "20231005"
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
#[serde(transparent)]
pub struct DicomDateString {
pub(crate) value: String,
}
// Implement Display trait for DicomDateString
impl fmt::Display for DicomDateString {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.value)
}
}
impl TryFrom<&str> for DicomDateString {
type Error = BoundedStringError;
fn try_from(s: &str) -> BoundedResult<Self> {
// Use NaiveDate to validate date format and validity
chrono::NaiveDate::parse_from_str(&s, "%Y%m%d").map_err(|_| {
BoundedStringError::LengthError {
fixlen: 8,
len: s.len(),
}
})?;
Ok(Self {
value: s.to_string(),
})
}
}
impl TryFrom<&String> for DicomDateString {
type Error = BoundedStringError;
fn try_from(s: &String) -> BoundedResult<Self> {
// Use NaiveDate to validate date format and validity
chrono::NaiveDate::parse_from_str(&s, "%Y%m%d").map_err(|_| {
BoundedStringError::LengthError {
fixlen: 8,
len: s.len(),
}
})?;
Ok(Self { value: s.clone() })
}
}
impl<const N: usize> FixedLengthString<N> {
pub fn new(s: String) -> BoundedResult<FixedLengthString<N>> {
if s.len() != N {
Err(BoundedStringError::LengthError {
fixlen: N,
len: s.len(),
})
} else {
Ok(Self { value: s })
}
}
pub fn from_str(s: &str) -> BoundedResult<FixedLengthString<N>> {
if s.len() != N {
Err(BoundedStringError::LengthError {
fixlen: N,
len: s.len(),
})
} else {
Ok(Self {
value: s.to_string(),
})
}
}
pub fn from_string(s: &String) -> BoundedResult<FixedLengthString<N>> {
if s.len() != N {
Err(BoundedStringError::LengthError {
fixlen: N,
len: s.len(),
})
} else {
Ok(Self { value: s.clone() })
}
}
pub fn as_str(&self) -> &str {
&self.value
}
}
impl DicomDateString {
pub fn as_str(&self) -> &str {
self.value.as_str()
}
pub(crate) fn from_db(s: &str) -> Self {
// Use NaiveDate to validate date format and validity
chrono::NaiveDate::parse_from_str(&s, "%Y%m%d")
.map_err(|_| BoundedStringError::LengthError {
fixlen: 8,
len: s.len(),
})
.expect(
format!(
"DicomDateString::make_from_db only supports YYYYMMDD format, but got {}",
s
)
.as_str(),
);
Self {
value: s.to_string(),
}
}
}
2. dicom_meta.rs
For those unfamiliar with model definitions, refer to: DICOM Basic Concepts Introduction
use crate::dicom_dbtype::{BoundedString, DicomDateString, FixedLengthString};
use chrono::{NaiveDate, NaiveDateTime, NaiveTime};
use serde::{Deserialize, Serialize};
use std::hash::{Hash, Hasher};
#[derive(Clone, Debug, Serialize, Deserialize)]
#[non_exhaustive]
pub enum TransferStatus {
NoNeedTransfer,
Success,
Failed,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DicomStoreMeta {
#[serde(rename = "trace_id")]
pub trace_id: FixedLengthString<36>,
#[serde(rename = "worker_node_id")]
pub worker_node_id: BoundedString<64>,
#[serde(rename = "tenant_id")]
pub tenant_id: BoundedString<64>,
#[serde(rename = "patient_id")]
pub patient_id: BoundedString<64>,
#[serde(rename = "study_uid")]
pub study_uid: BoundedString<64>,
#[serde(rename = "series_uid")]
pub series_uid: BoundedString<64>,
#[serde(rename = "sop_uid")]
pub sop_uid: BoundedString<64>,
#[serde(rename = "file_size")]
pub file_size: u32,
#[serde(rename = "file_path")]
pub file_path: BoundedString<512>,
#[serde(rename = "transfer_syntax_uid")]
pub transfer_syntax_uid: BoundedString<64>,
#[serde(rename = "number_of_frames")]
pub number_of_frames: i32,
#[serde(rename = "created_time")]
pub created_time: NaiveDateTime,
#[serde(rename = "series_uid_hash")]
pub series_uid_hash: BoundedString<20>,
#[serde(rename = "study_uid_hash")]
pub study_uid_hash: BoundedString<20>,
#[serde(rename = "accession_number")]
pub accession_number: BoundedString<16>,
#[serde(rename = "target_ts")]
pub target_ts: BoundedString<64>,
#[serde(rename = "study_date")]
pub study_date: NaiveDate,
#[serde(rename = "transfer_status")]
pub transfer_status: TransferStatus,
#[serde(rename = "source_ip")]
pub source_ip: BoundedString<24>,
#[serde(rename = "source_ae")]
pub source_ae: BoundedString<64>,
}
impl Hash for DicomStoreMeta {
fn hash<H: Hasher>(&self, state: &mut H) {
self.tenant_id.hash(state);
self.patient_id.hash(state);
self.study_uid.hash(state);
self.series_uid.hash(state);
self.sop_uid.hash(state);
}
}
impl PartialEq for DicomStoreMeta {
fn eq(&self, other: &Self) -> bool {
self.tenant_id == other.tenant_id
&& self.patient_id == other.patient_id
&& self.study_uid == other.study_uid
&& self.series_uid == other.series_uid
&& self.sop_uid == other.sop_uid
}
}
impl Eq for DicomStoreMeta {};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DicomJsonMeta {
#[serde(rename = "tenant_id")]
pub tenant_id: BoundedString<64>,
#[serde(rename = "study_uid")]
pub study_uid: BoundedString<64>,
#[serde(rename = "series_uid")]
pub series_uid: BoundedString<64>,
#[serde(rename = "study_uid_hash")]
pub study_uid_hash: BoundedString<20>,
#[serde(rename = "series_uid_hash")]
pub series_uid_hash: BoundedString<20>,
#[serde(rename = "study_date_origin")]
pub study_date_origin: DicomDateString,
#[serde(rename = "flag_time")]
pub flag_time: NaiveDateTime,
#[serde(rename = "created_time")]
pub created_time: NaiveDateTime,
#[serde(rename = "json_status")]
pub json_status: i32,
#[serde(rename = "retry_times")]
pub retry_times: i32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DicomStateMeta {
#[serde(rename = "tenant_id")]
pub tenant_id: BoundedString<64>,
#[serde(rename = "patient_id")]
pub patient_id: BoundedString<64>,
#[serde(rename = "study_uid")]
pub study_uid: BoundedString<64>,
#[serde(rename = "series_uid")]
pub series_uid: BoundedString<64>,
#[serde(rename = "study_uid_hash")]
pub study_uid_hash: BoundedString<20>,
#[serde(rename = "series_uid_hash")]
pub series_uid_hash: BoundedString<20>,
#[serde(rename = "study_date_origin")]
pub study_date_origin: DicomDateString,
#[serde(rename = "patient_name")]
pub patient_name: Option<BoundedString<64>>,
#[serde(rename = "patient_sex")]
pub patient_sex: Option<BoundedString<1>>,
#[serde(rename = "patient_birth_date")]
pub patient_birth_date: Option<NaiveDate>,
#[serde(rename = "patient_birth_time")]
pub patient_birth_time: Option<NaiveTime>,
#[serde(rename = "patient_age")]
pub patient_age: Option<BoundedString<16>>,
#[serde(rename = "patient_size")]
pub patient_size: Option<f64>,
#[serde(rename = "patient_weight")]
pub patient_weight: Option<f64>,
#[serde(rename = "study_date")]
pub study_date: NaiveDate,
#[serde(rename = "study_time")]
pub study_time: Option<NaiveTime>,
#[serde(rename = "accession_number")]
pub accession_number: BoundedString<16>,
#[serde(rename = "study_id")]
pub study_id: Option<BoundedString<16>>,
#[serde(rename = "study_description")]
pub study_description: Option<BoundedString<64>>,
#[serde(rename = "modality")]
pub modality: Option<BoundedString<16>>,
#[serde(rename = "series_number")]
pub series_number: Option<i32>,
#[serde(rename = "series_date")]
pub series_date: Option<NaiveDate>,
#[serde(rename = "series_time")]
pub series_time: Option<NaiveTime>,
#[serde(rename = "series_description")]
pub series_description: Option<BoundedString<256>>,
#[serde(rename = "body_part_examined")]
pub body_part_examined: Option<BoundedString<64>>,
#[serde(rename = "protocol_name")]
pub protocol_name: Option<BoundedString<64>>,
#[serde(rename = "series_related_instances")]
pub series_related_instances: Option<i32>,
#[serde(rename = "created_time")]
pub created_time: NaiveDateTime,
#[serde(rename = "updated_time")]
pub updated_time: NaiveDateTime,
}
impl DicomStateMeta {
pub fn unique_key(&self) -> (String, String, String, String) {
(
self.tenant_id.as_str().to_string(),
self.patient_id.as_str().to_string(),
self.study_uid.as_str().to_string(),
self.series_uid.as_str().to_string(),
)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DicomImageMeta {
#[serde(rename = "tenant_id")]
pub tenant_id: BoundedString<64>,
#[serde(rename = "patient_id")]
pub patient_id: BoundedString<64>,
#[serde(rename = "study_uid")]
pub study_uid: BoundedString<64>,
#[serde(rename = "series_uid")]
pub series_uid: BoundedString<64>,
#[serde(rename = "sop_uid")]
pub sop_uid: BoundedString<64>,
#[serde(rename = "study_uid_hash")]
pub study_uid_hash: BoundedString<20>,
#[serde(rename = "series_uid_hash")]
pub series_uid_hash: BoundedString<20>,
#[serde(rename = "instance_number")]
pub instance_number: Option<i32>,
#[serde(rename = "content_date")]
pub content_date: Option<DicomDateString>,
#[serde(rename = "content_time")]
pub content_time: Option<NaiveTime>,
#[serde(rename = "image_type")]
pub image_type: Option<BoundedString<128>>,
#[serde(rename = "image_orientation_patient")]
pub image_orientation_patient: Option<BoundedString<128>>,
#[serde(rename = "image_position_patient")]
pub image_position_patient: Option<BoundedString<64>>,
#[serde(rename = "slice_thickness")]
pub slice_thickness: Option<f64>,
#[serde(rename = "spacing_between_slices")]
pub spacing_between_slices: Option<f64>,
#[serde(rename = "slice_location")]
pub slice_location: Option<f64>,
#[serde(rename = "samples_per_pixel")]
pub samples_per_pixel: Option<i32>,
#[serde(rename = "photometric_interpretation")]
pub photometric_interpretation: Option<BoundedString<32>>,
#[serde(rename = "width")]
pub width: Option<i32>,
#[serde(rename = "columns")]
pub columns: Option<i32>,
#[serde(rename = "bits_allocated")]
pub bits_allocated: Option<i32>,
#[serde(rename = "bits_stored")]
pub bits_stored: Option<i32>,
#[serde(rename = "high_bit")]
pub high_bit: Option<i32>,
#[serde(rename = "pixel_representation")]
pub pixel_representation: Option<i32>,
#[serde(rename = "rescale_intercept")]
pub rescale_intercept: Option<f64>,
#[serde(rename = "rescale_slope")]
pub rescale_slope: Option<f64>,
#[serde(rename = "rescale_type")]
pub rescale_type: Option<BoundedString<64>>,
#[serde(rename = "window_center")]
pub window_center: Option<BoundedString<64>>,
#[serde(rename = "window_width")]
pub window_width: Option<BoundedString<64>>,
#[serde(rename = "transfer_syntax_uid")]
pub transfer_syntax_uid: BoundedString<64>,
#[serde(rename = "pixel_data_location")]
pub pixel_data_location: Option<BoundedString<512>>,
#[serde(rename = "thumbnail_location")]
pub thumbnail_location: Option<BoundedString<512>>,
#[serde(rename = "sop_class_uid")]
pub sop_class_uid: BoundedString<64>,
#[serde(rename = "image_status")]
pub image_status: Option<BoundedString<32>>,
#[serde(rename = "space_size")]
pub space_size: Option<u32>,
#[serde(rename = "created_time")]
pub created_time: Option<NaiveDateTime>,
#[serde(rename = "updated_time")]
pub updated_time: Option<NaiveDateTime>,
}
3. dicom_mysql_types.rs
use crate::dicom_dbtype::{BoundedString, DicomDateString};
use mysql::prelude::*;
use mysql::Value;
impl<const N: usize> From<BoundedString<N>> for Value {
fn from(bounded_string: BoundedString<N>) -> Self {
Value::from(bounded_string.as_str())
}
}
impl<const N: usize> From<String> for BoundedString<N> {
fn from(value: String) -> Self {
BoundedString::<N>::new(value.as_str().parse().unwrap()).unwrap()
}
}
impl<const N: usize> FromValue for BoundedString<N> {
type Intermediate = String; // Revert to String
fn from_value(v: Value) -> BoundedString<N> {
let s = String::from_value(v);
// Directly use try_from and handle errors
BoundedString::<N>::try_from(s).unwrap_or_else(|_| BoundedString::<N>::default())
}
}
impl From<DicomDateString> for Value {
fn from(dicom_date_string: DicomDateString) -> Self {
Value::from(dicom_date_string.as_str())
}
}
impl From<String> for DicomDateString {
fn from(value: String) -> Self {
DicomDateString::from_db(value.as_str())
}
}
impl FromValue for DicomDateString {
type Intermediate = String; // Revert to String
fn from_value(v: Value) -> DicomDateString {
let s = String::from_value(v);
// Directly use try_from and handle errors
DicomDateString::try_from(s).unwrap_or_else(|_| DicomDateString::default())
}
}
impl Default for DicomDateString {
fn default() -> Self {
DicomDateString {
value: "00000000".to_string(),
}
}
}
4. dicom_postgres_types.rs
use crate::dicom_dbtype::{BoundedString, DicomDateString};
use postgres_types::private::BytesMut;
use postgres_types::{FromSql, IsNull, ToSql, Type};
use std::error::Error;
impl<const N: usize> ToSql for BoundedString<N> {
fn to_sql(&self, ty: &Type, out: &mut BytesMut) -> Result<IsNull, Box<dyn Error + Sync + Send>>
where
Self: Sized,
{
<&str as ToSql>::to_sql(&self.as_str(), ty, out)
}
fn accepts(ty: &Type) -> bool {
<&str as ToSql>::accepts(ty)
}
fn to_sql_checked(
&self,
ty: &Type,
out: &mut BytesMut,
) -> Result<IsNull, Box<dyn Error + Sync + Send>> {
<&str as ToSql>::to_sql_checked(&self.as_str(), ty, out)
}
}
impl<const N: usize> FromSql<'_> for BoundedString<N> {
fn from_sql(ty: &Type, raw: &[u8]) -> Result<Self, Box<dyn Error + Sync + Send>> {
let str_val = <&str as FromSql>::from_sql(ty, raw)?;
Ok(BoundedString::try_from(str_val.to_string())
.map_err(|e| format!("Failed to create BoundedString FromSql: {}", e))?)
}
fn accepts(ty: &Type) -> bool {
<&str as FromSql>::accepts(ty)
}
}
impl ToSql for DicomDateString {
fn to_sql(&self, ty: &Type, out: &mut BytesMut) -> Result<IsNull, Box<dyn Error + Sync + Send>>
where
Self: Sized,
{
<&str as ToSql>::to_sql(&self.as_str(), ty, out)
}
fn accepts(ty: &Type) -> bool {
<&str as ToSql>::accepts(ty)
}
fn to_sql_checked(
&self,
ty: &Type,
out: &mut BytesMut,
) -> Result<IsNull, Box<dyn Error + Sync + Send>> {
<&str as ToSql>::to_sql_checked(&self.as_str(), ty, out)
}
}
impl FromSql<'_> for DicomDateString {
fn from_sql(ty: &Type, raw: &[u8]) -> Result<Self, Box<dyn Error + Sync + Send>> {
let str_val = <&str as FromSql>::from_sql(ty, raw)?;
Ok(DicomDateString::try_from(str_val.to_string())
.map_err(|e| format!("Failed to create DicomDateString FromSql: {}", e))?)
}
fn accepts(ty: &Type) -> bool {
<&str as FromSql>::accepts(ty)
}
}
Conclusion
By creating these custom types, we can encode business rules within Rust’s type system to build safer and more reliable DICOM medical imaging systems. This approach not only improves code quality but also enhances system maintainability and scalability.
Keywords and Descriptions
- Primary Keywords: DICOM-WEB, medical imaging, healthcare cloud, DICOM storage, Rust type safety
- Secondary Keywords: healthcare software development, DICOM database, medical data validation, Rust programming, type-safe wrappers
- Meta Description: Learn how to implement type-safe Rust wrappers for DICOM medical imaging systems. Explore BoundedString, FixedLengthString, and DicomDateString implementations for building scalable cloud DICOM-WEB services.
- Target Audience: Healthcare software developers, medical imaging system architects, Rust developers working in healthcare IT
- Content Value: Technical implementation guide for creating type-safe DICOM data structures in Rust to improve data integrity and system reliability
GoTo Summary: how-to-build-cloud-dicom
GoTo Database Section: how-to-build-cloud-dicom: Part 1 Database Design