Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion crates/rest/src/handlers/batch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1668,7 +1668,16 @@ fn parse_bundle_entry(entry: &Value) -> Result<(BundleEntry, Option<String>), En
.ok_or_else(|| EntryParseError::Malformed("Entry request missing 'url'".to_string()))?
.to_string();

let resource = entry.get("resource").cloned();
let mut resource = entry.get("resource").cloned();
// Per http.html#create the server ignores an id supplied on a POST — the
// same strip create_handler applies. Bundles that repeat shared resources
// under fixed ids (Synthea's Organizations/Practitioners) used to fail
// whole with "already exists" on the second transaction (#647).
if matches!(method, BundleMethod::Post)
&& let Some(Value::Object(obj)) = resource.as_mut()
{
obj.remove("id");
}
let full_url = entry
.get("fullUrl")
.and_then(|v| v.as_str())
Expand Down
12 changes: 12 additions & 0 deletions crates/rest/src/handlers/create.rs
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,18 @@ where
});
}

// Per http.html#create "the server ignores the id provided in the
// resource" — a create always assigns a fresh server id. Honoring a
// client id made a second POST of the same document answer 409, which
// broke standard transaction ingestion of bundles that repeat shared
// resources (#647). Internal callers that rely on caller-supplied ids
// for idempotency (spec seeding) go straight to storage, not through
// this handler.
let mut resource = resource;
if let Some(obj) = resource.as_object_mut() {
obj.remove("id");
}

// Write-path validation (HFS_VALIDATION_MODE: off | log | enforce).
state
.validation()
Expand Down
118 changes: 118 additions & 0 deletions crates/rest/tests/create_client_id.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
//! #647: per http.html#create "the server ignores the id provided in the
//! resource" — a POST always gets a fresh server id. Honoring the client id
//! made a second POST of the same document 409, which failed whole Synthea
//! transaction bundles repeating shared Organizations/Practitioners.

use std::path::PathBuf;
use std::sync::Arc;

use axum::http::{HeaderName, StatusCode};
use axum_test::TestServer;
use helios_persistence::backends::sqlite::{SqliteBackend, SqliteBackendConfig};
use helios_rest::ServerConfig;
use helios_rest::config::{MultitenancyConfig, TenantRoutingMode};
use serde_json::{Value, json};

const X_TENANT_ID: HeaderName = HeaderName::from_static("x-tenant-id");

async fn create_test_server() -> TestServer {
let data_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.parent()
.and_then(|p| p.parent())
.map(|p| p.join("data"))
.unwrap_or_else(|| PathBuf::from("data"));
let backend_config = SqliteBackendConfig {
data_dir: Some(data_dir),
..Default::default()
};
let backend = SqliteBackend::with_config(":memory:", backend_config)
.expect("Failed to create SQLite backend");
backend.init_schema().expect("Failed to init schema");
let backend = Arc::new(backend);

let config = ServerConfig {
multitenancy: MultitenancyConfig {
routing_mode: TenantRoutingMode::HeaderOnly,
..Default::default()
},
base_url: "http://localhost:8080".to_string(),
default_tenant: "test-tenant".to_string(),
..ServerConfig::for_testing()
};
let state = helios_rest::AppState::new(backend, config);
let app = helios_rest::routing::fhir_routes::create_routes(state);
TestServer::new(app).expect("Failed to create test server")
}

fn org(id: &str) -> Value {
json!({
"resourceType": "Organization",
"id": id,
"name": "Shared Org",
})
}

#[tokio::test]
async fn post_assigns_a_server_id_and_repeats_never_conflict() {
let server = create_test_server().await;

let first = server
.post("/Organization")
.add_header(X_TENANT_ID, "test-tenant")
.json(&org("dup-test"))
.await;
first.assert_status(StatusCode::CREATED);
let first_id = first.json::<Value>()["id"].as_str().unwrap().to_string();
assert_ne!(
first_id, "dup-test",
"the client id is ignored, not honored"
);

// The exact POST that used to answer 409.
let second = server
.post("/Organization")
.add_header(X_TENANT_ID, "test-tenant")
.json(&org("dup-test"))
.await;
second.assert_status(StatusCode::CREATED);
let second_id = second.json::<Value>()["id"].as_str().unwrap().to_string();
assert_ne!(second_id, first_id, "every create gets its own id");

// Nothing was ever stored under the client-supplied id.
server
.get("/Organization/dup-test")
.add_header(X_TENANT_ID, "test-tenant")
.await
.assert_status(StatusCode::NOT_FOUND);
}

#[tokio::test]
async fn transaction_bundles_can_repeat_shared_post_entries() {
let server = create_test_server().await;
let bundle = json!({
"resourceType": "Bundle",
"type": "transaction",
"entry": [{
"fullUrl": "urn:uuid:00000000-0000-4000-8000-000000000001",
"resource": org("shared-org"),
"request": { "method": "POST", "url": "Organization" },
}],
});

// The Synthea shape: consecutive patient bundles each POST the same
// shared Organization under its fixed id. Both transactions must commit.
for _ in 0..2 {
let response = server
.post("/")
.add_header(X_TENANT_ID, "test-tenant")
.json(&bundle)
.await;
response.assert_status(StatusCode::OK);
let body = response.json::<Value>();
let status = body["entry"][0]["response"]["status"]
.as_str()
.unwrap_or_default()
.to_string();
assert!(status.starts_with("201"), "entry status: {status}");
}
}
2 changes: 2 additions & 0 deletions crates/ui/tests/router_http.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1342,6 +1342,7 @@ async fn view_definitions_workspace_lists_edits_and_previews() {
std::sync::Arc::new(source),
helios_fhir::FhirVersion::R4,
None,
"http://localhost:8080".to_string(),
);

let response = app
Expand Down Expand Up @@ -1414,6 +1415,7 @@ async fn view_definitions_save_roundtrips_and_rejects_bad_json() {
std::sync::Arc::new(source),
helios_fhir::FhirVersion::R4,
None,
"http://localhost:8080".to_string(),
);

let body = "id=&action=save&json=%7B%22resourceType%22%3A%22ViewDefinition%22%2C%22name%22%3A%22x%22%7D";
Expand Down
Loading