Skip to content

fix(rest): parse bundle entry URLs without their query string - #512

Open
aacruzgon wants to merge 5 commits into
fix/489-s3-decline-unsupported-transactionsfrom
fix/503-batch-request-url-query
Open

fix(rest): parse bundle entry URLs without their query string#512
aacruzgon wants to merge 5 commits into
fix/489-s3-decline-unsupported-transactionsfrom
fix/503-batch-request-url-query

Conversation

@aacruzgon

@aacruzgon aacruzgon commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Summary

parse_request_url split a bundle 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 — and storage was then addressed
with 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=x reaches create_or_update with an empty id, and SQLite
writes the row rather than rejecting it: create_or_update inserts "id": "" into the resource
before delegating to create, whose id fallback fires on an absent id, not an empty one
(sqlite/storage.rs:186-214 and :95-99). Every later such entry then reads that row back and
overwrites it. So today's corruption is quarantined under a bogus resource type no search can
address; a bare strip moves it into the real Patient table. The empty-id guards here are what make
the parse fix safe.

Changes

fix(rest): parse bundle entry URLs without their query string

  • Split the query off before the path; drop empty segments so a leading / and the
    [type]/?[criteria] form both reduce to the type alone. Signature unchanged, so four of the five
    call sites — the concurrency scan, both transaction sites, the audit path — are repaired with no
    edits.
  • The 0 => Err("Empty URL") arm was unreachable (str::split always yields ≥1 element), so an
    absent request.url parsed as the resource type "" and POST created a row under it. Now
    reachable.
  • Refuse conditional entries per-entry with 400, and refuse PUT/DELETE entries that name no
    instance. GET is untouched, leaving search-style entries to batch/transaction: search-style GET entries are mis-parsed as instance reads #478.
  • The concurrency scan's StructureDefinition carve-out now also matches
    StructureDefinition?url=…, where it silently did not before.

fix(rest): decline transaction entries whose URL carries a query

  • Every backend's parse_url splits on / alone and takes the last two segments (sqlite, postgres
    and mongodb carry byte-equivalent copies), and the transaction PUT arm commits without validating
    either field. PUT Patient?identifier=… commits a row typed Patient?identifier=http:;
    PUT Patient/123?_format=json commits one whose id is 123?_format=json; PUT Patient?name=peter
    fails the whole bundle citing the URL format rather than the criteria.
  • Decline any non-GET entry whose URL carries a query, raised before process_transaction so the
    bundle is declined intact. RestError::NotSupported (400 + not-supported) per the convention
    documented at error.rs:38-41 and R4's repeated SHOULD.
  • This corrects batch: parse_request_url never strips the query string, so every conditional entry is dispatched against a resource type containing the query #503's own claim that the transaction path is unaffected because the backends resolve
    conditionals themselves. They do not — only MongoDB's ifNoneExist path does.
  • This is a designed constraint, not a wiring gap. Discussion #28
    designs Transaction as CRUD-only — tenant/create/read/update/delete/commit/rollback
    — and the real trait at core/transaction.rs:147-189 is that shape plus is_active(). Building a Modern FHIR Persistence Layer - Architectural Considerations for Healthcare's AI Era #28 also
    states 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 why
    its ifNoneExist path is carved out below rather than refused.

test(rest): assert the query-guard refusal on the field the server actually writes

  • Added after review began, and it corrects this PR's own second commit.
    a_transaction_get_with_a_query_is_not_declined_by_the_query_guard 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
    the negated assert holds even when a GET entry is declined by the guard, which is the one thing
    the test exists to catch.
  • One field path: ["diagnostics"]["details"]["text"]. No production code changes and the test
    count is unchanged.
  • 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: …) while the original, against that same
    broken guard, still reports test result: ok. 1 passed.
  • Found while grounding batch: every per-entry error carries OperationOutcome code 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 that
    owns 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 claims

  • The README advertised ifNoneMatch (populated by parse_bundle_entry, read by nothing) and
    ifNoneExist (MongoDB transactions only) as supported bundle-entry headers.

Testing

cargo test -p helios-rest        →  1093 passed, 0 failed
cargo fmt --all                  →  clean

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 nothing
is written or deleted.

Verified non-vacuous. With the empty-id guard disabled, a_type_level_put_never_writes_an_empty_id_row
fails with left: "201 Created", right: "400 Bad Request" — confirming PUT Patient writes the
empty-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's
invocation is .github/workflows/ci.yml:497 and carries eight allow-flags — -A collapsible_if,
-A doc-lazy-continuation, -A field_reassign_with_default and five siblings. Every lint in those
five 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-features reports no
lints in either file this PR touches
. #513 remains open but is lower priority than its framing
suggests, since CI allows these today.

Notes

Closes #503

@aacruzgon aacruzgon changed the title fix/503 batch request url query fix(rest): parse bundle entry URLs without their query string Aug 5, 2026
@aacruzgon
aacruzgon force-pushed the fix/503-batch-request-url-query branch from a20d81d to caab19f Compare August 5, 2026 21:29
@codecov

codecov Bot commented Aug 5, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@aacruzgon
aacruzgon requested a review from smunini August 6, 2026 01:30
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
aacruzgon force-pushed the fix/503-batch-request-url-query branch from 12766fe to 5212072 Compare August 6, 2026 17:25
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
aacruzgon force-pushed the fix/503-batch-request-url-query branch from 5212072 to a373206 Compare August 7, 2026 14:49
`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
aacruzgon force-pushed the fix/503-batch-request-url-query branch from a373206 to 581a599 Compare August 9, 2026 20:32
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

batch: parse_request_url never strips the query string, so every conditional entry is dispatched against a resource type containing the query

1 participant