///|
/// Minimal FHIR-style bundle support built on the structured resource layer.
/// It intentionally models only the resource metadata needed by a sanitizer.
pub(all) enum BundleType {
Batch
Transaction
Collection
History
} derive(Debug, Eq)
///|
pub(all) struct BundleEntry {
full_url : String
resource_type : String
resource_id : String
document : ResourceDocument
position : Int
} derive(Debug)
///|
pub(all) struct ResourceBundle {
bundle_type : BundleType
bundle_id : String
entries : Array[BundleEntry]
metadata : Map[String, String]
} derive(Debug)
///|
pub(all) struct BundleReport {
bundle_id : String
input_entries : Int
output_entries : Int
changed_entries : Int
removed_entries : Int
issues : Array[ResourceIssue]
checksum : String
} derive(Debug)
///|
pub fn bundle_type_name(value : BundleType) -> String {
match value {
Batch => "batch"
Transaction => "transaction"
Collection => "collection"
History => "history"
}
}
///|
pub fn ResourceBundle::new(
bundle_type : BundleType,
bundle_id : String,
) -> ResourceBundle {
{ bundle_type, bundle_id, entries: [], metadata: Map([]) }
}
///|
pub fn ResourceBundle::add(
self : ResourceBundle,
document : ResourceDocument,
full_url : String,
) -> Unit {
self.entries.push({
full_url,
resource_type: document.resource_type,
resource_id: document.resource_id,
document,
position: self.entries.length(),
})
}
///|
pub fn ResourceBundle::size(self : ResourceBundle) -> Int {
self.entries.length()
}
///|
pub fn ResourceBundle::types(self : ResourceBundle) -> Array[String] {
self.entries.map(fn(entry) { entry.resource_type })
}
///|
pub fn ResourceBundle::ids(self : ResourceBundle) -> Array[String] {
self.entries.map(fn(entry) { entry.resource_id })
}
///|
pub fn ResourceBundle::find_type(
self : ResourceBundle,
resource_type : String,
) -> Array[BundleEntry] {
self.entries.filter(fn(entry) { entry.resource_type == resource_type })
}
///|
pub fn ResourceBundle::find_id(
self : ResourceBundle,
resource_id : String,
) -> BundleEntry? {
let mut result : BundleEntry? = None
for entry in self.entries {
if entry.resource_id == resource_id {
result = Some(entry)
}
}
result
}
///|
pub fn ResourceBundle::set_metadata(
self : ResourceBundle,
key : String,
value : String,
) -> Unit {
self.metadata[key] = value
}
///|
pub fn ResourceBundle::metadata_value(
self : ResourceBundle,
key : String,
) -> String? {
self.metadata.get(key)
}
///|
pub fn ResourceBundle::copy(self : ResourceBundle) -> ResourceBundle {
let result = ResourceBundle::new(self.bundle_type, self.bundle_id)
for entry in self.entries {
result.add(entry.document.copy(), entry.full_url)
}
for key, value in self.metadata {
result.metadata[key] = value
}
result
}
///|
pub fn bundle_redact(
bundle : ResourceBundle,
policy : ResourcePolicy,
) -> ResourceBundle raise DeidError {
let output = ResourceBundle::new(bundle.bundle_type, bundle.bundle_id)
for entry in bundle.entries {
output.add(resource_redact(entry.document, policy), entry.full_url)
}
for key, value in bundle.metadata {
output.metadata[key] = value
}
output
}
///|
pub fn bundle_changed_positions(
before : ResourceBundle,
after : ResourceBundle,
) -> Array[Int] {
let changed = []
let limit = if before.size() < after.size() {
before.size()
} else {
after.size()
}
for i in 0.. Array[Int] {
if after.size() >= before.size() {
[]
} else {
let result = []
for i in after.size().. Array[ResourceIssue] {
let issues = []
if bundle.bundle_id.trim().is_empty() {
issues.push({
path: "id",
code: "BUNDLE_ID",
message: "bundle id is empty",
severity: Error,
})
}
let ids : Map[String, Unit] = Map([])
for entry in bundle.entries {
if entry.resource_type.trim().is_empty() {
issues.push({
path: "entry[\{entry.position}]",
code: "RESOURCE_TYPE",
message: "resource type is empty",
severity: Error,
})
}
if entry.resource_id.trim().is_empty() {
issues.push({
path: "entry[\{entry.position}]",
code: "RESOURCE_ID",
message: "resource id is empty",
severity: Warning,
})
}
if ids.contains(entry.resource_id) && !entry.resource_id.is_empty() {
issues.push({
path: "entry[\{entry.position}]",
code: "DUPLICATE_ID",
message: "resource id is duplicated",
severity: Warning,
})
}
ids[entry.resource_id] = ()
issues.append(resource_validate(entry.document, ResourcePolicy::default()))
}
issues
}
///|
pub fn bundle_report(
before : ResourceBundle,
after : ResourceBundle,
policy : ResourcePolicy,
) -> BundleReport {
let issues = bundle_validate(after)
for entry in after.entries {
issues.append(resource_validate(entry.document, policy))
}
{
bundle_id: after.bundle_id,
input_entries: before.size(),
output_entries: after.size(),
changed_entries: bundle_changed_positions(before, after).length(),
removed_entries: bundle_removed_positions(before, after).length(),
issues,
checksum: stable_hash(bundle_render(after)),
}
}
///|
pub fn bundle_report_text(report : BundleReport) -> String {
[
"bundle_id=\{report.bundle_id}",
"input_entries=\{report.input_entries}",
"output_entries=\{report.output_entries}",
"changed_entries=\{report.changed_entries}",
"removed_entries=\{report.removed_entries}",
"issues=\{report.issues.length()}",
"checksum=\{report.checksum}",
].join("\n")
}
///|
pub fn bundle_render(bundle : ResourceBundle) -> String {
let lines = [
"bundle_type=\{bundle_type_name(bundle.bundle_type)}",
"bundle_id=\{bundle.bundle_id}",
]
for key, value in bundle.metadata {
lines.push("meta.\{key}=\{value}")
}
for entry in bundle.entries {
lines.push("entry.\{entry.position}.full_url=\{entry.full_url}")
lines.push("entry.\{entry.position}.resource_type=\{entry.resource_type}")
lines.push("entry.\{entry.position}.resource_id=\{entry.resource_id}")
for field in entry.document.fields {
lines.push("entry.\{entry.position}.\{field.path}=\{field.value}")
}
}
lines.join("\n")
}
///|
pub fn bundle_type_counts(bundle : ResourceBundle) -> Map[String, Int] {
let counts : Map[String, Int] = Map([])
for entry in bundle.entries {
counts[entry.resource_type] = counts.get_or_default(entry.resource_type, 0) +
1
}
counts
}
///|
pub fn bundle_kind_counts(bundle : ResourceBundle) -> Map[String, Int] {
let counts : Map[String, Int] = Map([])
for entry in bundle.entries {
for field in entry.document.fields {
let key = phi_kind_name(field.kind)
counts[key] = counts.get_or_default(key, 0) + 1
}
}
counts
}
///|
pub fn bundle_sensitive_field_count(
bundle : ResourceBundle,
policy : ResourcePolicy,
) -> Int {
bundle.entries.fold(init=0, (sum, entry) => {
sum + resource_sensitive_path_count(entry.document, policy)
})
}
///|
pub fn bundle_safe(
before : ResourceBundle,
after : ResourceBundle,
policy : ResourcePolicy,
) -> Bool {
let report = bundle_report(before, after, policy)
!report.issues.any(fn(issue) { issue.severity == Error }) &&
after.entries.all(fn(entry) {
let before_entry = before.find_id(entry.resource_id)
match before_entry {
Some(previous) =>
resource_is_safe(previous.document, entry.document, policy)
None => true
}
})
}
///|
pub fn bundle_json(bundle : ResourceBundle) -> String {
let entries = bundle.entries
.map(fn(entry) {
"{" +
"\"full_url\":\{json_escape(entry.full_url)}," +
"\"resource_type\":\{json_escape(entry.resource_type)}," +
"\"resource_id\":\{json_escape(entry.resource_id)}," +
"\"field_count\":\{entry.document.field_count()}," +
"\"checksum\":\{json_escape(stable_hash(resource_render(entry.document)))}" +
"}"
})
.join(",")
"{" +
"\"bundle_type\":\{json_escape(bundle_type_name(bundle.bundle_type))}," +
"\"bundle_id\":\{json_escape(bundle.bundle_id)}," +
"\"entries\":[" +
entries +
"]}"
}
///|
pub fn bundle_round_trip(
bundle : ResourceBundle,
policy : ResourcePolicy,
) -> Bool raise DeidError {
let rendered = bundle_render(bundle)
let copy = bundle.copy()
let output = bundle_redact(copy, policy)
rendered.length() >= output.bundle_id.length()
}
///|
pub fn bundle_resource_ids_unique(bundle : ResourceBundle) -> Bool {
let seen : Map[String, Unit] = Map([])
let mut unique = true
for entry in bundle.entries {
if !entry.resource_id.is_empty() && seen.contains(entry.resource_id) {
unique = false
}
seen[entry.resource_id] = ()
}
unique
}
///|
pub fn bundle_order_is_stable(bundle : ResourceBundle) -> Bool {
for i in 0.. Bool {
bundle.entries.any(fn(entry) { entry.resource_type == resource_type })
}
///|
pub fn bundle_filter_type(
bundle : ResourceBundle,
resource_type : String,
) -> ResourceBundle {
let result = ResourceBundle::new(bundle.bundle_type, bundle.bundle_id)
for entry in bundle.find_type(resource_type) {
result.add(entry.document.copy(), entry.full_url)
}
result
}
///|
pub fn bundle_without_type(
bundle : ResourceBundle,
resource_type : String,
) -> ResourceBundle {
let result = ResourceBundle::new(bundle.bundle_type, bundle.bundle_id)
for entry in bundle.entries {
if entry.resource_type != resource_type {
result.add(entry.document.copy(), entry.full_url)
}
}
result
}
///|
pub fn bundle_checksum(bundle : ResourceBundle) -> String {
stable_hash(bundle_render(bundle))
}
///|
pub fn bundle_checksum_stable(bundle : ResourceBundle) -> Bool {
bundle_checksum(bundle) == bundle_checksum(bundle.copy())
}