fix(rest): parse bundle entry URLs without their query string - #512
Open
aacruzgon wants to merge 5 commits into
Open
fix(rest): parse bundle entry URLs without their query string#512aacruzgon wants to merge 5 commits into
aacruzgon wants to merge 5 commits into
Conversation
aacruzgon
force-pushed
the
fix/503-batch-request-url-query
branch
from
August 5, 2026 21:29
a20d81d to
caab19f
Compare
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
aacruzgon
pushed a commit
that referenced
this pull request
Aug 6, 2026
A GET bundle entry may carry a read URL (`Patient/123`) or a search URL (`Patient?name=x`, or bare `Patient` for an unfiltered type search) — the spec's "read or search" wording for bundle GETs. Only the read form was implemented; a search-style entry was dispatched as an instance read against an empty id. Adds `parse_search_entry_url` and `searchset_result`, dispatches type-level GET entries through `execute_search_bundle` in both arms, and lets a transaction's GET searches see the bundle's own committed writes. Rebased from `main` onto the #501/#489/#503/#502/#504 stack (originally opened against `main` as #481; base is now `fix/504-batch-entry-issue-codes`). Five conflict hunks in `batch.rs` and one keep-both append in `batch_conformance.rs`. The resolution, recorded because it is more than textual: - **The two new failure sites now render through `entry_failure`.** Both were written as `let (status, _, details) = e.client_response(); create_error_result( status.as_u16(), &details)` — the same code-discard #504 deleted from every other call site, which would have left search entries as the one path still answering `processing`. The transaction-arm site is the more consequential of the two: its loop bypasses the backend executor, making it the first *reachable* per-entry outcome on that arm, where #504 could accurately say none existed. - **`parse_request_url`'s query strip is dropped as redundant.** This commit added `url.split('?').next()`; #503 had already landed the same fix with the empty-id write guards that make it safe. #512 predicted this exact outcome: "if this lands first, #481's strip becomes a no-op on rebase." - **The GET arm keys off `BundleMethod::Get`**, not the raw `"GET"` string, since #502 replaced the string matcher with an exhaustive enum match. - **The rollback fan-out** keeps #504's status/code threading and gains this commit's `.chain(&search_entries)`. - **The batch unit tests' `DelayStorage` gains `SearchProvider`, `IncludeProvider` and `RevincludeProvider`**, and `run_batch`'s bound widens to match `process_batch`'s. Every method is `unimplemented!()`, the same lever the mock's write methods already use: no unit test drives a search entry, and one that started to would panic rather than silently exercise a stub. That pulls `parking_lot` in as a dev-dependency, because `search_param_registry` returns a `parking_lot::RwLock` and the crate's own code never names the lock type. Tests: 1114 pass (1109 on the base + this commit's 5). Its own five tests are happy-path only; the failure paths on both new call sites are covered by the follow-up commit. Refs #478
aacruzgon
force-pushed
the
fix/503-batch-request-url-query
branch
from
August 6, 2026 17:25
12766fe to
5212072
Compare
aacruzgon
added a commit
that referenced
this pull request
Aug 6, 2026
…ror mapping Every error a batch entry can produce was built by one helper that hardcoded its issue code, so a scope denial, a missing resource, a malformed entry and an unsupported method all reached the client as `"code": "processing"`, distinguishable only by `response.status` and free-text English. `OperationOutcome.issue.code` is bound `required` to `http://hl7.org/fhir/ValueSet/issue-type` in all four bundled versions, and the ElementDefinition adds an unqualified SHALL: "The system that creates an OperationOutcome SHALL choose the most applicable code from the IssueType value set". Nineteen call sites emitting one code is the absence of a choice. The fix is not an issue-code argument on `create_error_result`. The defect is that `create_error_result` existed at all: a second renderer of failure-to-OperationOutcome, written beside the one `IntoResponse` already uses. Giving it an argument keeps two renderers agreeing by hand — the arrangement #502 died of. So the second renderer is deleted. `RestError::client_outcome` becomes the only place a `RestError` becomes an OperationOutcome; `IntoResponse` renders the pair as an HTTP body and `handlers::batch` as a `Bundle.entry.response.outcome`. Each call site now constructs the same `RestError` its transaction twin already constructs, so the two arms cannot report different codes for one failure because there is no second table to disagree with. `entry_error` goes with it — it called `client_response()`, bound the correct code to `_code` and discarded it, after which the wrapper stamped `processing` over the result. Deleting that one underscore corrects five sites by construction and makes `client_response`'s own doc comment ("shared by IntoResponse and the batch/transaction handler so both sanitize identically") true for the first time. `EntryMethodRefusal::status()` is deleted too. #515 made the arms agree on the refusal status by writing the number twice — once there, once implicitly in `into_rest_error`'s choice of variant — and pinning the copies with a test. There is now one function, so the status and the code are decided once. The same move gives HEAD one message instead of two: `into_rest_error` computed a message and discarded it on that arm, so the batch arm printed guidance the transaction arm never showed. It now rides in `resource_type`, which `MethodNotAllowed` renders, and both arms print it. `EntryParseError::Malformed(String)` becomes `MissingRequest`/`MissingUrl`, moving its two strings into helpers both arms call — an absent `request.url` was caught by `parse_bundle_entry` on one arm and by `unwrap_or("")` on the other, with different text for the same input. Two new `RestError` variants, both children of `BadRequest`'s `invalid` in the `issue-type` hierarchy rather than alternatives to it: `MissingElement` (400 + `required`) for an absent mandatory element, and `InvalidElementValue` (400 + `value`) for one present and unusable. The crate could say neither before. `MissingElement` is used only where the SD gives `min=1` — `request` (mandatory by bdl-3 in R4/R4B and transitively by bdl-3c in R5/R6), `request.method` (1..1), `request.url` (1..1) — and deliberately NOT for an absent `Bundle.entry.resource`, which is 0..1 with only R5/R6's bdl-3c requiring it: a call site serving four versions must not assert a rule two of them lack. The invariant keys stay in doc comments and off the wire, because `bdl-3` does not exist in R5 or R6. On the element this writes into. `Bundle.entry.response.outcome` carries a comment, byte-identical in all four bundled versions and generated into the model at `crates/fhir/src/r4.rs:10011`: "This outcome is not used for error responses in batch/transaction, only for hints and warnings. In a batch operation, the error will be in Bundle.entry.response". Four things about it. It is a `comment`, not an invariant — no `bdl-*` constrains that element. HFS has placed error outcomes there since before this stack, pinned by `test_batch_error_outcome_in_response_not_resource`, so this commit changes the code and not the placement. "The error will be in Bundle.entry.response" holds: `response.status` still carries it and `outcome` is a sibling under the same `response`, not a substitute. And the SHALL applies to any OperationOutcome the system creates — if HFS creates one here it must code it correctly regardless of whether it was obliged to create one. Tests: a twelve-row table over the mapping, a cross-arm test asserting both arms agree on status, code AND message for every method refusal, four assertion-only extensions to the existing #515/#512 refusal tests, and a new integration file asserting the code on the wire for every reachable class plus byte-identical parity with `GET [base]/Patient/ghost`. Verified non-vacuous. With `entry_failure` reverted to a hardcoded `processing` outcome while every call site keeps its new argument, 8 unit tests and 3 integration tests fail — `left: String("processing"), right: "forbidden"`, `right: "not-found"`, `right: "required"`, `right: "exception"`. The refusal tests keep their teeth for free: `DelayStorage`'s write methods are `unimplemented!()` and `peak() == 0` is still asserted, so a refusal moved after dispatch panics rather than merely reporting a different code. Closes #504
aacruzgon
pushed a commit
that referenced
this pull request
Aug 6, 2026
A GET bundle entry may carry a read URL (`Patient/123`) or a search URL (`Patient?name=x`, or bare `Patient` for an unfiltered type search) — the spec's "read or search" wording for bundle GETs. Only the read form was implemented; a search-style entry was dispatched as an instance read against an empty id. Adds `parse_search_entry_url` and `searchset_result`, dispatches type-level GET entries through `execute_search_bundle` in both arms, and lets a transaction's GET searches see the bundle's own committed writes. Rebased from `main` onto the #501/#489/#503/#502/#504 stack (originally opened against `main` as #481; base is now `fix/504-batch-entry-issue-codes`). Five conflict hunks in `batch.rs` and one keep-both append in `batch_conformance.rs`. The resolution, recorded because it is more than textual: - **The two new failure sites now render through `entry_failure`.** Both were written as `let (status, _, details) = e.client_response(); create_error_result( status.as_u16(), &details)` — the same code-discard #504 deleted from every other call site, which would have left search entries as the one path still answering `processing`. The transaction-arm site is the more consequential of the two: its loop bypasses the backend executor, making it the first *reachable* per-entry outcome on that arm, where #504 could accurately say none existed. - **`parse_request_url`'s query strip is dropped as redundant.** This commit added `url.split('?').next()`; #503 had already landed the same fix with the empty-id write guards that make it safe. #512 predicted this exact outcome: "if this lands first, #481's strip becomes a no-op on rebase." - **The GET arm keys off `BundleMethod::Get`**, not the raw `"GET"` string, since #502 replaced the string matcher with an exhaustive enum match. - **The rollback fan-out** keeps #504's status/code threading and gains this commit's `.chain(&search_entries)`. - **The batch unit tests' `DelayStorage` gains `SearchProvider`, `IncludeProvider` and `RevincludeProvider`**, and `run_batch`'s bound widens to match `process_batch`'s. Every method is `unimplemented!()`, the same lever the mock's write methods already use: no unit test drives a search entry, and one that started to would panic rather than silently exercise a stub. That pulls `parking_lot` in as a dev-dependency, because `search_param_registry` returns a `parking_lot::RwLock` and the crate's own code never names the lock type. Tests: 1114 pass (1109 on the base + this commit's 5). Its own five tests are happy-path only; the failure paths on both new call sites are covered by the follow-up commit. Refs #478
aacruzgon
force-pushed
the
fix/503-batch-request-url-query
branch
from
August 7, 2026 14:49
5212072 to
a373206
Compare
`parse_request_url` split a batch entry's `request.url` on `/` and nothing else, so every FHIR conditional interaction was dispatched against a resource type containing its own criteria. `Patient?identifier=http://example.org|12345` parsed as the type `Patient?identifier=http:` with an empty id, the `//` inside the value supplying the extra segment. Storage was then addressed with that type: `create` wrote a row under it, and `create_or_update` was called with an empty id. Split the query off before the path, and drop empty segments so a leading `/` and the `[type]/?[criteria]` form both reduce to the type alone. The signature is unchanged, so four of the five call sites — the concurrency scan, both transaction sites and the audit path — are repaired without edits. The `0 => Err("Empty URL")` arm was unreachable, since `str::split` always yields at least one element. An absent `request.url` therefore parsed as the resource type "" and POST created a row under it. That arm is now reachable. Stripping the query alone would be a regression, not a fix. With the criteria gone, `PUT Patient?identifier=x` reaches `create_or_update` with an empty id and SQLite writes the row rather than rejecting it: the backend inserts `"id": ""` before delegating to `create`, whose id fallback fires on an absent id, not an empty one, so every later such entry reads that row back and overwrites it. Conditional entries are therefore refused per-entry with a 400, as are `PUT`/`DELETE` entries that name no instance. GET is untouched, leaving search-style entries to #478. Resolving conditional criteria is #511. The concurrency scan's `StructureDefinition` carve-out now also matches `StructureDefinition?url=...`, where it silently did not before. Such entries are refused before they write, so that clamp is conservative rather than load-bearing, but the scan and the write still agree. Tests: 6 unit tests over the parser and the criteria predicate, and 4 integration tests against SQLite asserting nothing is written. Verified non-vacuous — with the empty-id guard disabled, `PUT Patient` returns 201 Created and the test fails. Refs #503
Bundle entry URLs reach the backends unparsed, and every backend's `parse_url` splits on `/` alone and takes the last two segments — sqlite, postgres and mongodb carry byte-equivalent copies, none of which strips a query. So a query lands in storage as part of the resource type or the id, and the transaction PUT arm commits it without validating either: - `PUT Patient?identifier=http://example.org|12345` commits a row typed `Patient?identifier=http:` with the id `example.org|12345`. - `PUT Patient/123?_format=json` commits one whose id is `123?_format=json`. - `PUT Patient?name=peter` yields a single segment, so `parse_url` errors and the whole bundle rolls back citing the URL format rather than the criteria. This corrects issue #503's claim that the transaction path is unaffected because the backends resolve conditionals themselves. They do not; only MongoDB's `ifNoneExist` path does. Decline any non-GET entry whose URL carries a query, raised before `process_transaction` runs so the bundle is declined intact and nothing is applied. `RestError::NotSupported` (400 + `not-supported`) is the documented variant for a spec-defined feature the server refuses by design, as opposed to `NotImplemented`, and it matches R4's repeated SHOULD for unsupported conditional interactions. Two exemptions, both deliberate. GET entries stay on their existing path so search-style entries remain #478's deliverable. `ifNoneExist` is untouched because MongoDB already resolves it inside the bundle's session — refusing it here would remove a working, atomic, covered feature. SQLite and Postgres silently ignore it instead, which wants a backend capability predicate rather than a REST-layer constant; that and resolving URL criteria within a transaction's atomic scope are #511. Tests: an integration test asserting a declined bundle applies none of its entries, including a sibling create that would otherwise have landed, and one pinning that GET entries are not caught by this guard. Refs #503
The "Conditional Operations in Bundles" section advertised support the server does not have. `ifNoneMatch` is populated by `parse_bundle_entry` and read by no handler or backend. `ifNoneExist` is honoured only by MongoDB's transaction path — the batch path never reads it, and SQLite and Postgres ignore it in a transaction and create a duplicate. State what is true per header, document that URL-borne conditional interactions are refused (per-entry in a batch, whole-bundle in a transaction), and note that `/metadata` advertises `conditionalCreate`, `conditionalUpdate` and `conditionalDelete` for every resource type — accurate for the resource endpoints, not for bundle entries. Reconciling those is #511, which is also added to Current Limitations. The PATCH bullet is knowingly left alone: it claims 501 while the batch path returns 405 and only the transaction path returns 501. That belongs to #502, which owns the behaviour. Tests: none — documentation only. Refs #503
The comment justified exempting GET entries from the query guard by saying those URLs are "resolved by the REST layer rather than by a backend `parse_url`". That describes a state this branch does not have: nothing in `process_transaction` resolves a GET entry, and `parse_search_entry_url` does not exist here — it arrives with #478/PR #481, which is still open. A query-bearing GET entry still reaches the backend's `parse_url` and still fails there. The exemption itself is correct, for a different reason: it keeps this guard off the dispatch arm #478 is rewriting, so that work lands on an untouched path rather than merging against a refusal it is about to replace. Say that instead. Comment only; no behaviour change. `a_transaction_get_with_a_query_is_not_declined_by_the_query_guard` already pins the exemption, and it asserts only that the guard does not catch the entry — not that the entry succeeds. Tests: unchanged and re-run — 20 unit tests in handlers::batch pass. Refs #503
…tually writes
`a_transaction_get_with_a_query_is_not_declined_by_the_query_guard`, added two
commits ago, cannot fail. Its predicate is a three-way conjunction whose third
clause reads `body["issue"][0]["diagnostics"]`, but the guard it tests raises
`RestError::NotSupported`, which renders through `create_operation_outcome`
(error.rs:560-571) into `details.text`. `grep -n diagnostics
crates/rest/src/error.rs` returns nothing: `RestError` never emits that field.
So `declined_by_the_guard` is permanently `false`, and
`assert!(!declined_by_the_guard)` would hold even if a GET entry *were*
declined by the guard — which is the one thing the test exists to catch. The
sibling assertion three tests earlier reads `body["issue"][0]["code"]` and
passes, confirming the shape.
One field path: `["diagnostics"]` becomes `["details"]["text"]`.
Verified non-vacuous in the only way available for a negation. With the GET
exemption at batch.rs:418 removed so the guard does fire on a GET entry, the
corrected test fails:
GET entries must stay on #478's path, not this guard: {"resourceType":
"OperationOutcome","issue":[{"severity":"error","code":"not-supported",
"details":{"text":"Transaction entry 0 (GET Patient?name=Nguyen) carries a
query string. ..."}}]}
while the original, against that same broken guard, still reports
`test result: ok. 1 passed`.
Found while grounding #504.
Refs #503
aacruzgon
force-pushed
the
fix/503-batch-request-url-query
branch
from
August 9, 2026 20:32
a373206 to
581a599
Compare
aacruzgon
added a commit
that referenced
this pull request
Aug 9, 2026
…ror mapping Every error a batch entry can produce was built by one helper that hardcoded its issue code, so a scope denial, a missing resource, a malformed entry and an unsupported method all reached the client as `"code": "processing"`, distinguishable only by `response.status` and free-text English. `OperationOutcome.issue.code` is bound `required` to `http://hl7.org/fhir/ValueSet/issue-type` in all four bundled versions, and the ElementDefinition adds an unqualified SHALL: "The system that creates an OperationOutcome SHALL choose the most applicable code from the IssueType value set". Nineteen call sites emitting one code is the absence of a choice. The fix is not an issue-code argument on `create_error_result`. The defect is that `create_error_result` existed at all: a second renderer of failure-to-OperationOutcome, written beside the one `IntoResponse` already uses. Giving it an argument keeps two renderers agreeing by hand — the arrangement #502 died of. So the second renderer is deleted. `RestError::client_outcome` becomes the only place a `RestError` becomes an OperationOutcome; `IntoResponse` renders the pair as an HTTP body and `handlers::batch` as a `Bundle.entry.response.outcome`. Each call site now constructs the same `RestError` its transaction twin already constructs, so the two arms cannot report different codes for one failure because there is no second table to disagree with. `entry_error` goes with it — it called `client_response()`, bound the correct code to `_code` and discarded it, after which the wrapper stamped `processing` over the result. Deleting that one underscore corrects five sites by construction and makes `client_response`'s own doc comment ("shared by IntoResponse and the batch/transaction handler so both sanitize identically") true for the first time. `EntryMethodRefusal::status()` is deleted too. #515 made the arms agree on the refusal status by writing the number twice — once there, once implicitly in `into_rest_error`'s choice of variant — and pinning the copies with a test. There is now one function, so the status and the code are decided once. The same move gives HEAD one message instead of two: `into_rest_error` computed a message and discarded it on that arm, so the batch arm printed guidance the transaction arm never showed. It now rides in `resource_type`, which `MethodNotAllowed` renders, and both arms print it. `EntryParseError::Malformed(String)` becomes `MissingRequest`/`MissingUrl`, moving its two strings into helpers both arms call — an absent `request.url` was caught by `parse_bundle_entry` on one arm and by `unwrap_or("")` on the other, with different text for the same input. Two new `RestError` variants, both children of `BadRequest`'s `invalid` in the `issue-type` hierarchy rather than alternatives to it: `MissingElement` (400 + `required`) for an absent mandatory element, and `InvalidElementValue` (400 + `value`) for one present and unusable. The crate could say neither before. `MissingElement` is used only where the SD gives `min=1` — `request` (mandatory by bdl-3 in R4/R4B and transitively by bdl-3c in R5/R6), `request.method` (1..1), `request.url` (1..1) — and deliberately NOT for an absent `Bundle.entry.resource`, which is 0..1 with only R5/R6's bdl-3c requiring it: a call site serving four versions must not assert a rule two of them lack. The invariant keys stay in doc comments and off the wire, because `bdl-3` does not exist in R5 or R6. On the element this writes into. `Bundle.entry.response.outcome` carries a comment, byte-identical in all four bundled versions and generated into the model at `crates/fhir/src/r4.rs:10011`: "This outcome is not used for error responses in batch/transaction, only for hints and warnings. In a batch operation, the error will be in Bundle.entry.response". Four things about it. It is a `comment`, not an invariant — no `bdl-*` constrains that element. HFS has placed error outcomes there since before this stack, pinned by `test_batch_error_outcome_in_response_not_resource`, so this commit changes the code and not the placement. "The error will be in Bundle.entry.response" holds: `response.status` still carries it and `outcome` is a sibling under the same `response`, not a substitute. And the SHALL applies to any OperationOutcome the system creates — if HFS creates one here it must code it correctly regardless of whether it was obliged to create one. Tests: a twelve-row table over the mapping, a cross-arm test asserting both arms agree on status, code AND message for every method refusal, four assertion-only extensions to the existing #515/#512 refusal tests, and a new integration file asserting the code on the wire for every reachable class plus byte-identical parity with `GET [base]/Patient/ghost`. Verified non-vacuous. With `entry_failure` reverted to a hardcoded `processing` outcome while every call site keeps its new argument, 8 unit tests and 3 integration tests fail — `left: String("processing"), right: "forbidden"`, `right: "not-found"`, `right: "required"`, `right: "exception"`. The refusal tests keep their teeth for free: `DelayStorage`'s write methods are `unimplemented!()` and `peak() == 0` is still asserted, so a refusal moved after dispatch panics rather than merely reporting a different code. Closes #504
aacruzgon
pushed a commit
that referenced
this pull request
Aug 9, 2026
A GET bundle entry may carry a read URL (`Patient/123`) or a search URL (`Patient?name=x`, or bare `Patient` for an unfiltered type search) — the spec's "read or search" wording for bundle GETs. Only the read form was implemented; a search-style entry was dispatched as an instance read against an empty id. Adds `parse_search_entry_url` and `searchset_result`, dispatches type-level GET entries through `execute_search_bundle` in both arms, and lets a transaction's GET searches see the bundle's own committed writes. Rebased from `main` onto the #501/#489/#503/#502/#504 stack (originally opened against `main` as #481; base is now `fix/504-batch-entry-issue-codes`). Five conflict hunks in `batch.rs` and one keep-both append in `batch_conformance.rs`. The resolution, recorded because it is more than textual: - **The two new failure sites now render through `entry_failure`.** Both were written as `let (status, _, details) = e.client_response(); create_error_result( status.as_u16(), &details)` — the same code-discard #504 deleted from every other call site, which would have left search entries as the one path still answering `processing`. The transaction-arm site is the more consequential of the two: its loop bypasses the backend executor, making it the first *reachable* per-entry outcome on that arm, where #504 could accurately say none existed. - **`parse_request_url`'s query strip is dropped as redundant.** This commit added `url.split('?').next()`; #503 had already landed the same fix with the empty-id write guards that make it safe. #512 predicted this exact outcome: "if this lands first, #481's strip becomes a no-op on rebase." - **The GET arm keys off `BundleMethod::Get`**, not the raw `"GET"` string, since #502 replaced the string matcher with an exhaustive enum match. - **The rollback fan-out** keeps #504's status/code threading and gains this commit's `.chain(&search_entries)`. - **The batch unit tests' `DelayStorage` gains `SearchProvider`, `IncludeProvider` and `RevincludeProvider`**, and `run_batch`'s bound widens to match `process_batch`'s. Every method is `unimplemented!()`, the same lever the mock's write methods already use: no unit test drives a search entry, and one that started to would panic rather than silently exercise a stub. That pulls `parking_lot` in as a dev-dependency, because `search_param_registry` returns a `parking_lot::RwLock` and the crate's own code never names the lock type. Tests: 1114 pass (1109 on the base + this commit's 5). Its own five tests are happy-path only; the failure paths on both new call sites are covered by the follow-up commit. Refs #478
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
parse_request_urlsplit a bundle entry'srequest.urlon/and nothing else, so every FHIRconditional interaction was dispatched against a resource type containing its own criteria.
Patient?identifier=http://example.org|12345parsed as the typePatient?identifier=http:with anempty id — the
//inside the value supplying the extra segment — and storage was then addressedwith that type.
This PR parses the URL correctly and refuses conditional entries rather than resolving them.
Resolving them is #511, filed alongside this work with the full implementation map.
The critical detail is that stripping the query alone is a regression, not a fix. With the
criteria gone,
PUT Patient?identifier=xreachescreate_or_updatewith an empty id, and SQLitewrites the row rather than rejecting it:
create_or_updateinserts"id": ""into the resourcebefore delegating to
create, whose id fallback fires on an absent id, not an empty one(
sqlite/storage.rs:186-214and:95-99). Every later such entry then reads that row back andoverwrites it. So today's corruption is quarantined under a bogus resource type no search can
address; a bare strip moves it into the real
Patienttable. The empty-id guards here are what makethe parse fix safe.
Changes
fix(rest): parse bundle entry URLs without their query string/and the[type]/?[criteria]form both reduce to the type alone. Signature unchanged, so four of the fivecall sites — the concurrency scan, both transaction sites, the audit path — are repaired with no
edits.
0 => Err("Empty URL")arm was unreachable (str::splitalways yields ≥1 element), so anabsent
request.urlparsed as the resource type""and POST created a row under it. Nowreachable.
400, and refusePUT/DELETEentries that name noinstance. GET is untouched, leaving search-style entries to batch/transaction: search-style GET entries are mis-parsed as instance reads #478.
StructureDefinitioncarve-out now also matchesStructureDefinition?url=…, where it silently did not before.fix(rest): decline transaction entries whose URL carries a queryparse_urlsplits on/alone and takes the last two segments (sqlite, postgresand mongodb carry byte-equivalent copies), and the transaction PUT arm commits without validating
either field.
PUT Patient?identifier=…commits a row typedPatient?identifier=http:;PUT Patient/123?_format=jsoncommits one whose id is123?_format=json;PUT Patient?name=peterfails the whole bundle citing the URL format rather than the criteria.
process_transactionso thebundle is declined intact.
RestError::NotSupported(400 +not-supported) per the conventiondocumented at
error.rs:38-41and R4's repeated SHOULD.conditionals themselves. They do not — only MongoDB's
ifNoneExistpath does.designs
Transactionas CRUD-only —tenant/create/read/update/delete/commit/rollback— and the real trait at
core/transaction.rs:147-189is that shape plusis_active(). Building a Modern FHIR Persistence Layer - Architectural Considerations for Healthcare's AI Era #28 alsostates the isolation contract the resolution would have to satisfy: "Operations within a
transaction see their own uncommitted changes but are isolated from concurrent transactions."
A resolver that searches committed state would miss the bundle's own uncommitted entries. MongoDB
is the one backend with a session-scoped resolver that meets this
(
find_matching_resources_in_bundle_transaction,.session(&mut *session)), which is exactly whyits
ifNoneExistpath is carved out below rather than refused.test(rest): assert the query-guard refusal on the field the server actually writesa_transaction_get_with_a_query_is_not_declined_by_the_query_guardcannot fail: its predicate isa three-way conjunction whose third clause reads
body["issue"][0]["diagnostics"], but the guard ittests raises
RestError::NotSupported, which renders throughcreate_operation_outcome(
error.rs:560-571) intodetails.text.grep -n diagnostics crates/rest/src/error.rsreturnsnothing —
RestErrornever emits that field. Sodeclined_by_the_guardis permanentlyfalseandthe negated assert holds even when a GET entry is declined by the guard, which is the one thing
the test exists to catch.
["diagnostics"]→["details"]["text"]. No production code changes and the testcount is unchanged.
batch.rs:418removed so the guard does fire on a GET entry, the corrected test fails(
GET entries must stay on #478's path, not this guard: …) while the original, against that samebroken guard, still reports
test result: ok. 1 passed.processing— a 403, 404 and 405 are indistinguishable without parsing English #504. It landed here rather than there so the blame stays with the PR thatowns the test; fix(rest): parse bundle entry methods through one shared matcher #515 and fix(rest): render batch entry failures through the single-resource error mapping #516 were restacked on top.
docs(rest): correct the bundle conditional-header claimsifNoneMatch(populated byparse_bundle_entry, read by nothing) andifNoneExist(MongoDB transactions only) as supported bundle-entry headers.Testing
12 new tests: 6 unit (parser table, criteria predicate, concurrency carve-out, refusals against a
mock whose write methods are
unimplemented!()) and 6 integration against SQLite asserting nothingis written or deleted.
Verified non-vacuous. With the empty-id guard disabled,
a_type_level_put_never_writes_an_empty_id_rowfails with
left: "201 Created", right: "400 Bad Request"— confirmingPUT Patientwrites theempty-id row on the unguarded parse.
Corrected. This section previously said the CI-style gate "exits 101 on 12 pre-existing lints,
filed as #513", implying a caveat a reviewer had to accept on trust. That describes the bare
cargo clippy --all-targets --all-features -- -D warnings, which is not what CI runs. CI'sinvocation is
.github/workflows/ci.yml:497and carries eight allow-flags —-A collapsible_if,-A doc-lazy-continuation,-A field_reassign_with_defaultand five siblings. Every lint in thosefive files falls inside them, so CI's actual command passes clean, exit 0. Verified on the
current tip of this stack.
cargo clippy -p helios-rest --all-targets --all-featuresreports nolints in either file this PR touches. #513 remains open but is lower priority than its framing
suggests, since CI allows these today.
Notes
let path = url.split('?').next()inside
parse_request_urlbut does not guard the write arms, so on its own it moves the empty-idwrite into the real
Patienttable. If fix(rest): execute search-style GET bundle entries as searches #481 lands first, this should follow immediately; if thislands first, fix(rest): execute search-style GET bundle entries as searches #481's strip becomes a no-op on rebase. Flagged on that PR.
ifNoneExistis deliberately untouched on the transaction path. MongoDB resolves it inside thebundle's session (
mongodb/storage.rs:2675→find_matching_resources_in_bundle_transaction,.session(&mut *session)), covered bymongodb_integration_transaction_bundle_conditional_headers.Refusing it would have removed a working atomic feature, and CI would not have caught it — that
test drives the backend directly and bypasses the REST layer. SQLite and Postgres silently ignore
it and create a duplicate; that wants a backend capability predicate, tracked in batch/transaction: conditional interactions are refused rather than resolved — wire bundle entries to ConditionalStorage #511.
PUT Patient/123?_format=jsonis now acceptedin a batch (the query is stripped, the instance is addressed) but declines the whole
transaction. The batch path keys on
conditional_criteria, which correctly ignores a query on aninstance URL; the transaction guard keys on
url.contains('?'). The broader transaction predicateis intentional — the backends re-parse the raw URL query-blind, so letting it through commits an id
of
123?_format=json. Refusing is strictly better than committing garbage, but narrowing the guardproperly means normalising the URL at the
process_transactionhandoff, which is a behaviour changewith no transaction-side coverage. Left as-is; noted so it is a decision rather than a surprise.
/metadatastill advertisesconditionalCreate/conditionalUpdate/conditionalDeleteforevery resource type (
capabilities.rs:370-373). Accurate for the resource endpoints, not forbundle entries. The literal claims ~16 capabilities unconditionally and the trait meant to replace
it is itself a stub — filed as capabilities: /metadata is a hardcoded literal claiming ~16 capabilities unconditionally, and CapabilityProvider is a stub that cannot yet replace it #514. Making the conditional flags true on the bundle path is batch/transaction: conditional interactions are refused rather than resolved — wire bundle entries to ConditionalStorage #511.
the type, a separate defect);
[type]/$opentry URLs; batch PATCH (batch: PATCH entries return 405 and methods are case-sensitive — the batch and transaction method matchers disagree #502); per-entryOperationOutcome issue codes — the new refusals carry
processinglike the other 15 call sites,which batch: every per-entry error carries OperationOutcome code
processing— a 403, 404 and 405 are indistinguishable without parsing English #504 fixes uniformly.batch_conformance.rsgains an appendedmod conditional_entriesat EOF, wherefix(rest): execute search-style GET bundle entries as searches #481 and fix(transaction): resolve conditional references before execution #467 also append. Trivial three-way keep-both.
Closes #503