diff --git a/CHANGELOG.md b/CHANGELOG.md index d1264d4..62dae3e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,26 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.8.0] - 2026-05-25 + +### Added + +- `FhirResolution` now types the `value_as_concept` and `value_target_field` + fields the resolver returns when a composite concept is decomposed via the + `Maps to value` relationship (HL7 FHIR-to-OMOP IG Value-as-Concept pattern — + e.g. "Allergy to penicillin" → standard "Allergy to drug" + value + "Penicillin G"), plus `concept_map_id` / `mapping_note` for FHIR + administrative-code resolutions. These were already passed through; they are + now part of the typed response shape. +- `resolve()`, `resolve_batch()`, and `resolve_codeable_concept()` accept an + `on_unmapped` argument (`"error"` default / `"sentinel"`). With `"sentinel"` + the resolver returns a `concept_id` 0 record instead of a 404 when nothing + resolves, so ETL callers always get a writable row. +- Coding inputs now carry `user_selected` through to the resolver, and FHIR's + camelCase `userSelected` is mapped to it. A user-selected coding wins over + vocabulary preference in `resolve_codeable_concept()` (FHIR-to-OMOP IG + CodeableConcept pattern). + ## [1.7.1] - 2026-05-20 Maintenance release. Dependency and lock-file updates only — there are no diff --git a/README.md b/README.md index 9e9de57..cafae79 100644 --- a/README.md +++ b/README.md @@ -102,6 +102,24 @@ result = client.fhir.resolve_codeable_concept( print(result["best_match"]["resolution"]["source_concept"]["vocabulary_id"]) # "SNOMED" ``` +The resolver also follows the [HL7 FHIR-to-OMOP IG](https://hl7.org/fhir/uv/omop/INFORMATIVE1/en/): it resolves FHIR administrative codes via the IG ConceptMaps, decomposes composite concepts (`Maps to value`), honors `Coding.userSelected`, and can return a `concept_id` 0 sentinel instead of a 404. + +```python +# Administrative gender → person.gender_concept_id (via IG ConceptMap) +client.fhir.resolve(system="http://hl7.org/fhir/administrative-gender", code="male") + +# A user-selected coding wins over vocabulary preference +client.fhir.resolve_codeable_concept(coding=[ + {"system": "http://snomed.info/sct", "code": "44054006"}, + {"system": "http://hl7.org/fhir/sid/icd-10-cm", "code": "E11.9", "user_selected": True}, +]) + +# on_unmapped="sentinel" → a concept_id 0 record instead of a 404 (one row per input for ETL) +client.fhir.resolve(system="http://snomed.info/sct", code="00000000", on_unmapped="sentinel") +``` + +Composite concepts (e.g. "Allergy to penicillin") additionally surface `resolution["value_as_concept"]` (the IG Value-as-Concept pattern). `on_unmapped` is accepted by `resolve()`, `resolve_batch()`, and `resolve_codeable_concept()` on both the sync and async clients. + ### Type Interoperability The resolver accepts any Coding-like input via duck typing - a plain dict, omophub's lightweight `Coding` TypedDict, or any object with `.system` / `.code` attributes (e.g. `fhir.resources.Coding`, `fhirpy` codings). diff --git a/examples/fhir_resolver.py b/examples/fhir_resolver.py index 742ba58..19760b0 100644 --- a/examples/fhir_resolver.py +++ b/examples/fhir_resolver.py @@ -489,6 +489,103 @@ def error_handling_examples() -> None: client.close() +# --------------------------------------------------------------------------- +# 13. Administrative code via the HL7 FHIR-to-OMOP IG ConceptMap layer +# --------------------------------------------------------------------------- + + +def resolve_administrative_gender() -> None: + """Resolve a FHIR administrative code (gender) via the IG ConceptMap layer. + + Administrative/structural systems (administrative-gender, v3-ActCode + encounter class, condition-clinical, ...) have no OMOP vocabulary-table + representation and resolve through the IG's ConceptMaps. + """ + print("\n=== 13. Administrative gender (IG ConceptMap) ===") + + client = omophub.OMOPHub() + try: + result = client.fhir.resolve( + system="http://hl7.org/fhir/administrative-gender", + code="male", + resource_type="Patient", + ) + res = result["resolution"] + std = res["standard_concept"] + print(f" male → {std['concept_name']} ({std['concept_id']})") + print(f" target_table: {res['target_table']}") + print(f" via IG ConceptMap: {res.get('concept_map_id')}") + # Composite source concepts also surface a `value_as_concept` + # (the IG 'Maps to value' / Value-as-Concept pattern) when present. + if res.get("value_as_concept"): + val = res["value_as_concept"] + print( + f" value_as_concept: {val['concept_name']}" + f" → {res.get('value_target_field')}" + ) + finally: + client.close() + + +# --------------------------------------------------------------------------- +# 14. CodeableConcept with user_selected (overrides vocabulary preference) +# --------------------------------------------------------------------------- + + +def resolve_user_selected() -> None: + """A coding marked user_selected wins best_match over vocabulary preference.""" + print("\n=== 14. CodeableConcept with user_selected ===") + + client = omophub.OMOPHub() + try: + result = client.fhir.resolve_codeable_concept( + coding=[ + # SNOMED would normally win on OHDSI preference, but the + # user-selected ICD-10-CM coding takes precedence. + {"system": "http://snomed.info/sct", "code": "44054006"}, + { + "system": "http://hl7.org/fhir/sid/icd-10-cm", + "code": "E11.9", + "user_selected": True, + }, + ], + resource_type="Condition", + ) + best = result["best_match"] + if best: + src = best["resolution"]["source_concept"] + print(f" best_match source vocabulary: {src['vocabulary_id']}") + finally: + client.close() + + +# --------------------------------------------------------------------------- +# 15. on_unmapped="sentinel" — a concept_id 0 record instead of a 404 +# --------------------------------------------------------------------------- + + +def resolve_unmapped_sentinel() -> None: + """With on_unmapped='sentinel', an unresolvable code yields a row, not a 404. + + Handy for ETL pipelines that need one output row per input. Works on + resolve(), resolve_batch(), and resolve_codeable_concept(). + """ + print("\n=== 15. on_unmapped='sentinel' ===") + + client = omophub.OMOPHub() + try: + result = client.fhir.resolve( + system="http://snomed.info/sct", + code="00000000", + on_unmapped="sentinel", + ) + res = result["resolution"] + print(f" mapping_type: {res['mapping_type']}") + print(f" standard concept_id: {res['standard_concept']['concept_id']}") + finally: + client.close() + + # --------------------------------------------------------------------------- # Main # --------------------------------------------------------------------------- @@ -505,5 +602,8 @@ def error_handling_examples() -> None: resolve_batch() resolve_codeable_concept() resolve_codeable_concept_text_fallback() + resolve_administrative_gender() + resolve_user_selected() + resolve_unmapped_sentinel() asyncio.run(async_resolve()) error_handling_examples() diff --git a/src/omophub/resources/fhir.py b/src/omophub/resources/fhir.py index cc67e8d..68dab7e 100644 --- a/src/omophub/resources/fhir.py +++ b/src/omophub/resources/fhir.py @@ -43,14 +43,17 @@ def _extract_coding( # Keys the FHIR Concept Resolver accepts on a single coding item. Any -# other keys in a dict input (e.g. ``userSelected``, ``extension``, -# ``version`` from ``fhir.resources.Coding.model_dump()``) are dropped -# so the server never sees FHIR metadata it does not understand. +# other keys in a dict input (e.g. ``extension``, ``version`` from +# ``fhir.resources.Coding.model_dump()``) are dropped so the server never +# sees FHIR metadata it does not understand. FHIR's camelCase +# ``userSelected`` is mapped to the API's snake_case ``user_selected`` in +# :func:`_coding_to_dict`. _ALLOWED_CODING_KEYS: tuple[str, ...] = ( "system", "code", "display", "vocabulary_id", + "user_selected", ) @@ -58,15 +61,26 @@ def _coding_to_dict(coding_input: object) -> dict[str, Any]: """Convert any Coding-like input to a wire-format dict. Preserves only the keys the resolver endpoint understands - (:data:`_ALLOWED_CODING_KEYS`). Skips keys whose values are ``None`` - so the request payload stays tight. + (:data:`_ALLOWED_CODING_KEYS`). FHIR's camelCase ``userSelected`` is + accepted and mapped to ``user_selected``. Skips keys whose values are + ``None`` so the request payload stays tight. """ if isinstance(coding_input, dict): src: dict[str, Any] = { key: coding_input.get(key) for key in _ALLOWED_CODING_KEYS } + if ( + src.get("user_selected") is None + and coding_input.get("userSelected") is not None + ): + src["user_selected"] = coding_input.get("userSelected") else: src = {key: getattr(coding_input, key, None) for key in _ALLOWED_CODING_KEYS} + if ( + src.get("user_selected") is None + and getattr(coding_input, "userSelected", None) is not None + ): + src["user_selected"] = getattr(coding_input, "userSelected", None) return {k: v for k, v in src.items() if v is not None} @@ -130,6 +144,7 @@ def _build_resolve_body( include_recommendations: bool = False, recommendations_limit: int = 5, include_quality: bool = False, + on_unmapped: str | None = None, ) -> dict[str, Any]: body: dict[str, Any] = {} if system is not None: @@ -147,6 +162,8 @@ def _build_resolve_body( body["recommendations_limit"] = recommendations_limit if include_quality: body["include_quality"] = True + if on_unmapped is not None: + body["on_unmapped"] = on_unmapped return body @@ -182,6 +199,7 @@ def resolve( include_recommendations: bool = False, recommendations_limit: int = 5, include_quality: bool = False, + on_unmapped: str | None = None, ) -> FhirResolveResult: """Resolve a single FHIR Coding to an OMOP standard concept. @@ -204,6 +222,8 @@ def resolve( include_recommendations: Include Phoebe recommendations recommendations_limit: Max recommendations to return (1-20) include_quality: Include mapping quality signal + on_unmapped: ``"error"`` (default) raises 404 when nothing + resolves; ``"sentinel"`` returns a ``concept_id`` 0 record Returns: Resolution result with source concept, standard concept, @@ -221,6 +241,7 @@ def resolve( include_recommendations=include_recommendations, recommendations_limit=recommendations_limit, include_quality=include_quality, + on_unmapped=on_unmapped, ) return self._request.post("/fhir/resolve", json_data=body) @@ -232,6 +253,7 @@ def resolve_batch( include_recommendations: bool = False, recommendations_limit: int = 5, include_quality: bool = False, + on_unmapped: str | None = None, ) -> FhirBatchResult: """Batch-resolve up to 100 FHIR Codings. @@ -260,6 +282,8 @@ def resolve_batch( body["recommendations_limit"] = recommendations_limit if include_quality: body["include_quality"] = True + if on_unmapped is not None: + body["on_unmapped"] = on_unmapped return self._request.post("/fhir/resolve/batch", json_data=body) def resolve_codeable_concept( @@ -271,6 +295,7 @@ def resolve_codeable_concept( include_recommendations: bool = False, recommendations_limit: int = 5, include_quality: bool = False, + on_unmapped: str | None = None, ) -> FhirCodeableConceptResult: """Resolve a FHIR CodeableConcept with vocabulary preference. @@ -309,6 +334,8 @@ def resolve_codeable_concept( body["recommendations_limit"] = recommendations_limit if include_quality: body["include_quality"] = True + if on_unmapped is not None: + body["on_unmapped"] = on_unmapped return self._request.post("/fhir/resolve/codeable-concept", json_data=body) @@ -333,6 +360,7 @@ async def resolve( include_recommendations: bool = False, recommendations_limit: int = 5, include_quality: bool = False, + on_unmapped: str | None = None, ) -> FhirResolveResult: """Resolve a single FHIR Coding to an OMOP standard concept. @@ -350,6 +378,7 @@ async def resolve( include_recommendations=include_recommendations, recommendations_limit=recommendations_limit, include_quality=include_quality, + on_unmapped=on_unmapped, ) return await self._request.post("/fhir/resolve", json_data=body) @@ -361,6 +390,7 @@ async def resolve_batch( include_recommendations: bool = False, recommendations_limit: int = 5, include_quality: bool = False, + on_unmapped: str | None = None, ) -> FhirBatchResult: """Batch-resolve up to 100 FHIR Codings. @@ -374,6 +404,8 @@ async def resolve_batch( body["recommendations_limit"] = recommendations_limit if include_quality: body["include_quality"] = True + if on_unmapped is not None: + body["on_unmapped"] = on_unmapped return await self._request.post("/fhir/resolve/batch", json_data=body) async def resolve_codeable_concept( @@ -385,6 +417,7 @@ async def resolve_codeable_concept( include_recommendations: bool = False, recommendations_limit: int = 5, include_quality: bool = False, + on_unmapped: str | None = None, ) -> FhirCodeableConceptResult: """Resolve a FHIR CodeableConcept with vocabulary preference. @@ -402,6 +435,8 @@ async def resolve_codeable_concept( body["recommendations_limit"] = recommendations_limit if include_quality: body["include_quality"] = True + if on_unmapped is not None: + body["on_unmapped"] = on_unmapped return await self._request.post( "/fhir/resolve/codeable-concept", json_data=body ) diff --git a/src/omophub/types/fhir.py b/src/omophub/types/fhir.py index e1f976c..1d5c2f9 100644 --- a/src/omophub/types/fhir.py +++ b/src/omophub/types/fhir.py @@ -37,6 +37,11 @@ class FhirResolution(TypedDict): vocabulary_id: str | None source_concept: ResolvedConcept standard_concept: ResolvedConcept + # Value concept for composite source concepts decomposed via the + # ``Maps to value`` relationship (HL7 FHIR-to-OMOP IG Value-as-Concept + # pattern). Present only when the source has a ``Maps to value`` target. + value_as_concept: NotRequired[ResolvedConcept] + value_target_field: NotRequired[str] mapping_type: str target_table: str | None domain_resource_alignment: str @@ -47,6 +52,10 @@ class FhirResolution(TypedDict): quality_note: NotRequired[str] alternative_standard_concepts: NotRequired[list[ResolvedConcept]] recommendations: NotRequired[list[RecommendedConceptOutput]] + # Set when a FHIR administrative code resolved via an IG ConceptMap + # (e.g. ``GenderClass``); ``mapping_note`` carries any advisory. + concept_map_id: NotRequired[str] + mapping_note: NotRequired[str] class FhirResolveResult(TypedDict): @@ -91,6 +100,7 @@ class Coding(TypedDict, total=False): code: str display: str version: str + user_selected: bool class CodeableConcept(TypedDict, total=False): diff --git a/tests/unit/resources/test_fhir.py b/tests/unit/resources/test_fhir.py index f82e9dc..275dc6f 100644 --- a/tests/unit/resources/test_fhir.py +++ b/tests/unit/resources/test_fhir.py @@ -47,7 +47,11 @@ "domain_resource_alignment": "aligned", }, }, - "meta": {"request_id": "test", "timestamp": "2026-04-10T00:00:00Z", "vocab_release": "2025.2"}, + "meta": { + "request_id": "test", + "timestamp": "2026-04-10T00:00:00Z", + "vocab_release": "2025.2", + }, } ICD10_MAPPED_RESPONSE = { @@ -186,13 +190,17 @@ def test_resolve_text_only(self, sync_client: OMOPHub, base_url: str) -> None: return_value=Response(200, json=semantic_response) ) - result = sync_client.fhir.resolve(display="Blood Sugar", resource_type="Observation") + result = sync_client.fhir.resolve( + display="Blood Sugar", resource_type="Observation" + ) assert result["resolution"]["mapping_type"] == "semantic_match" assert result["resolution"]["similarity_score"] == 0.91 @respx.mock - def test_resolve_with_recommendations(self, sync_client: OMOPHub, base_url: str) -> None: + def test_resolve_with_recommendations( + self, sync_client: OMOPHub, base_url: str + ) -> None: """Recommendations are included when requested.""" recs_response = {**SNOMED_RESOLVE_RESPONSE} recs_response["data"] = { @@ -232,7 +240,45 @@ def test_resolve_with_recommendations(self, sync_client: OMOPHub, base_url: str) assert body["recommendations_limit"] == 3 @respx.mock - def test_resolve_unknown_system_400(self, sync_client: OMOPHub, base_url: str) -> None: + def test_resolve_on_unmapped_passthrough( + self, sync_client: OMOPHub, base_url: str + ) -> None: + """on_unmapped is forwarded to the request body when set.""" + route = respx.post(f"{base_url}/fhir/resolve").mock( + return_value=Response(200, json=SNOMED_RESOLVE_RESPONSE) + ) + + sync_client.fhir.resolve( + system="http://snomed.info/sct", + code="44054006", + on_unmapped="sentinel", + ) + + import json + + body = json.loads(route.calls[0].request.content) + assert body["on_unmapped"] == "sentinel" + + @respx.mock + def test_resolve_omits_on_unmapped_by_default( + self, sync_client: OMOPHub, base_url: str + ) -> None: + """on_unmapped is omitted from the body when not provided.""" + route = respx.post(f"{base_url}/fhir/resolve").mock( + return_value=Response(200, json=SNOMED_RESOLVE_RESPONSE) + ) + + sync_client.fhir.resolve(system="http://snomed.info/sct", code="44054006") + + import json + + body = json.loads(route.calls[0].request.content) + assert "on_unmapped" not in body + + @respx.mock + def test_resolve_unknown_system_400( + self, sync_client: OMOPHub, base_url: str + ) -> None: """Unknown URI raises an API error.""" respx.post(f"{base_url}/fhir/resolve").mock( return_value=Response( @@ -268,7 +314,9 @@ def test_resolve_cpt4_403(self, sync_client: OMOPHub, base_url: str) -> None: ) with pytest.raises(Exception): - sync_client.fhir.resolve(system="http://www.ama-assn.org/go/cpt", code="99213") + sync_client.fhir.resolve( + system="http://www.ama-assn.org/go/cpt", code="99213" + ) @respx.mock def test_resolve_batch(self, sync_client: OMOPHub, base_url: str) -> None: @@ -286,7 +334,9 @@ def test_resolve_batch(self, sync_client: OMOPHub, base_url: str) -> None: assert len(result["results"]) == 1 @respx.mock - def test_resolve_codeable_concept(self, sync_client: OMOPHub, base_url: str) -> None: + def test_resolve_codeable_concept( + self, sync_client: OMOPHub, base_url: str + ) -> None: """CodeableConcept resolution returns best_match and alternatives.""" respx.post(f"{base_url}/fhir/resolve/codeable-concept").mock( return_value=Response(200, json=CODEABLE_CONCEPT_RESPONSE) @@ -301,11 +351,54 @@ def test_resolve_codeable_concept(self, sync_client: OMOPHub, base_url: str) -> ) assert result["best_match"] is not None - assert result["best_match"]["resolution"]["source_concept"]["vocabulary_id"] == "SNOMED" + assert ( + result["best_match"]["resolution"]["source_concept"]["vocabulary_id"] + == "SNOMED" + ) assert len(result["alternatives"]) == 1 @respx.mock - def test_resolve_batch_with_all_options(self, sync_client: OMOPHub, base_url: str) -> None: + def test_resolve_batch_forwards_on_unmapped( + self, sync_client: OMOPHub, base_url: str + ) -> None: + """on_unmapped is forwarded in the batch request body.""" + route = respx.post(f"{base_url}/fhir/resolve/batch").mock( + return_value=Response(200, json=BATCH_RESPONSE) + ) + + sync_client.fhir.resolve_batch( + [{"system": "http://snomed.info/sct", "code": "44054006"}], + on_unmapped="sentinel", + ) + + import json + + body = json.loads(route.calls[0].request.content) + assert body["on_unmapped"] == "sentinel" + + @respx.mock + def test_resolve_codeable_concept_forwards_on_unmapped( + self, sync_client: OMOPHub, base_url: str + ) -> None: + """on_unmapped is forwarded in the codeable-concept request body.""" + route = respx.post(f"{base_url}/fhir/resolve/codeable-concept").mock( + return_value=Response(200, json=CODEABLE_CONCEPT_RESPONSE) + ) + + sync_client.fhir.resolve_codeable_concept( + coding=[{"system": "http://snomed.info/sct", "code": "44054006"}], + on_unmapped="sentinel", + ) + + import json + + body = json.loads(route.calls[0].request.content) + assert body["on_unmapped"] == "sentinel" + + @respx.mock + def test_resolve_batch_with_all_options( + self, sync_client: OMOPHub, base_url: str + ) -> None: """Batch passes resource_type, include_recommendations, and include_quality.""" route = respx.post(f"{base_url}/fhir/resolve/batch").mock( return_value=Response(200, json=BATCH_RESPONSE) @@ -354,7 +447,9 @@ def test_resolve_codeable_concept_with_all_options( assert body["include_quality"] is True @respx.mock - def test_resolve_codeable_concept_minimal(self, sync_client: OMOPHub, base_url: str) -> None: + def test_resolve_codeable_concept_minimal( + self, sync_client: OMOPHub, base_url: str + ) -> None: """CodeableConcept with no optional flags (covers False branches).""" route = respx.post(f"{base_url}/fhir/resolve/codeable-concept").mock( return_value=Response(200, json=CODEABLE_CONCEPT_RESPONSE) @@ -373,7 +468,9 @@ def test_resolve_codeable_concept_minimal(self, sync_client: OMOPHub, base_url: assert "include_quality" not in body @respx.mock - def test_resolve_sends_correct_body(self, sync_client: OMOPHub, base_url: str) -> None: + def test_resolve_sends_correct_body( + self, sync_client: OMOPHub, base_url: str + ) -> None: """Verify the POST body includes only non-None parameters.""" route = respx.post(f"{base_url}/fhir/resolve").mock( return_value=Response(200, json=SNOMED_RESOLVE_RESPONSE) @@ -406,7 +503,9 @@ class TestFhirAsync: @respx.mock @pytest.mark.anyio - async def test_async_resolve(self, async_client: AsyncOMOPHub, base_url: str) -> None: + async def test_async_resolve( + self, async_client: AsyncOMOPHub, base_url: str + ) -> None: """Async resolve returns the same shape as sync.""" respx.post(f"{base_url}/fhir/resolve").mock( return_value=Response(200, json=SNOMED_RESOLVE_RESPONSE) @@ -422,7 +521,9 @@ async def test_async_resolve(self, async_client: AsyncOMOPHub, base_url: str) -> @respx.mock @pytest.mark.anyio - async def test_async_resolve_batch(self, async_client: AsyncOMOPHub, base_url: str) -> None: + async def test_async_resolve_batch( + self, async_client: AsyncOMOPHub, base_url: str + ) -> None: """Async batch resolve returns results and summary.""" respx.post(f"{base_url}/fhir/resolve/batch").mock( return_value=Response(200, json=BATCH_RESPONSE) @@ -459,6 +560,46 @@ async def test_async_resolve_codeable_concept( assert result["best_match"] is not None + @respx.mock + @pytest.mark.anyio + async def test_async_resolve_batch_forwards_on_unmapped( + self, async_client: AsyncOMOPHub, base_url: str + ) -> None: + """Async batch resolve forwards on_unmapped in the request body.""" + route = respx.post(f"{base_url}/fhir/resolve/batch").mock( + return_value=Response(200, json=BATCH_RESPONSE) + ) + + await async_client.fhir.resolve_batch( + [{"system": "http://snomed.info/sct", "code": "44054006"}], + on_unmapped="sentinel", + ) + + import json + + body = json.loads(route.calls[0].request.content) + assert body["on_unmapped"] == "sentinel" + + @respx.mock + @pytest.mark.anyio + async def test_async_resolve_codeable_concept_forwards_on_unmapped( + self, async_client: AsyncOMOPHub, base_url: str + ) -> None: + """Async codeable concept resolve forwards on_unmapped.""" + route = respx.post(f"{base_url}/fhir/resolve/codeable-concept").mock( + return_value=Response(200, json=CODEABLE_CONCEPT_RESPONSE) + ) + + await async_client.fhir.resolve_codeable_concept( + coding=[{"system": "http://snomed.info/sct", "code": "44054006"}], + on_unmapped="sentinel", + ) + + import json + + body = json.loads(route.calls[0].request.content) + assert body["on_unmapped"] == "sentinel" + @respx.mock @pytest.mark.anyio async def test_async_resolve_vocabulary_id_bypass( @@ -513,13 +654,14 @@ async def test_async_resolve_batch_all_flags( @respx.mock @pytest.mark.anyio - async def test_async_fhir_property_cached(self, async_client: AsyncOMOPHub, base_url: str) -> None: + async def test_async_fhir_property_cached( + self, async_client: AsyncOMOPHub, base_url: str + ) -> None: """Accessing client.fhir twice returns the same cached instance.""" fhir1 = async_client.fhir fhir2 = async_client.fhir assert fhir1 is fhir2 - @respx.mock @pytest.mark.anyio async def test_async_resolve_codeable_minimal( @@ -652,8 +794,9 @@ def test_coding_to_dict_drops_none(self) -> None: def test_coding_to_dict_filters_unknown_keys_from_dict(self) -> None: """Dict inputs with FHIR metadata keys must be filtered to the resolver's allowed key set - the server should never see - ``userSelected``, ``extension``, or version markers from - ``fhir.resources.Coding.model_dump()``. + ``extension`` or version markers from + ``fhir.resources.Coding.model_dump()``. FHIR's camelCase + ``userSelected`` is mapped to the API's ``user_selected``. """ from omophub.resources.fhir import _ALLOWED_CODING_KEYS, _coding_to_dict @@ -662,7 +805,7 @@ def test_coding_to_dict_filters_unknown_keys_from_dict(self) -> None: "code": "44054006", "display": "Type 2 diabetes mellitus", "version": "20240301", # Not in allowed keys - "userSelected": True, # FHIR extension + "userSelected": True, # FHIR camelCase -> user_selected "extension": [{"url": "http://example.com"}], "id": "coding-1", } @@ -672,6 +815,7 @@ def test_coding_to_dict_filters_unknown_keys_from_dict(self) -> None: "system": "http://snomed.info/sct", "code": "44054006", "display": "Type 2 diabetes mellitus", + "user_selected": True, } def test_coding_to_dict_filters_unknown_attrs_from_object(self) -> None: @@ -686,14 +830,15 @@ def test_coding_to_dict_filters_unknown_attrs_from_object(self) -> None: system="http://snomed.info/sct", code="44054006", display=None, - userSelected=True, # Should not leak into payload - extension="anything", + userSelected=True, # FHIR camelCase -> user_selected + extension="anything", # Should not leak into payload ) payload = _coding_to_dict(obj) assert set(payload.keys()) <= set(_ALLOWED_CODING_KEYS) assert payload == { "system": "http://snomed.info/sct", "code": "44054006", + "user_selected": True, }