diff --git a/fhirpy/base/lib.py b/fhirpy/base/lib.py index 00cc755..f255d53 100644 --- a/fhirpy/base/lib.py +++ b/fhirpy/base/lib.py @@ -70,7 +70,7 @@ def execute(self, path, method=None, **kwargs): pass @abstractmethod # pragma: no cover - def _do_request(self, method, path, data=None, params=None): + def _do_request(self, method, path, data=None, params=None, returning_status=False): pass @abstractmethod # pragma: no cover @@ -118,7 +118,7 @@ def __init__( async def execute(self, path, method="post", **kwargs): return await self._do_request(method, path, **kwargs) - async def _do_request(self, method, path, data=None, params=None): + async def _do_request(self, method, path, data=None, params=None, returning_status=False): headers = self._build_request_headers() url = self._build_request_url(path, params) async with aiohttp.ClientSession(headers=headers) as session: @@ -127,11 +127,15 @@ async def _do_request(self, method, path, data=None, params=None): ) as r: if 200 <= r.status < 300: data = await r.text() - return json.loads(data, object_hook=AttrDict) if data else None + r_data = json.loads(data, object_hook=AttrDict) if data else None + return (r_data, r.status) if returning_status else r_data if r.status == 404 or r.status == 410: raise ResourceNotFound(await r.text()) + if r.status == 412: + raise MultipleResourcesFound(await r.text()) + data = await r.text() try: parsed_data = json.loads(data) @@ -158,7 +162,7 @@ def __init__( def execute(self, path, method="post", **kwargs): return self._do_request(method, path, **kwargs) - def _do_request(self, method, path, data=None, params=None): + def _do_request(self, method, path, data=None, params=None, returning_status=False): headers = self._build_request_headers() url = self._build_request_url(path, params) r = requests.request( @@ -166,15 +170,15 @@ def _do_request(self, method, path, data=None, params=None): ) if 200 <= r.status_code < 300: - return ( - json.loads(r.content.decode(), object_hook=AttrDict) - if r.content - else None - ) + r_data = json.loads(r.content.decode(), object_hook=AttrDict) if r.content else None + return (r_data, r.status_code) if returning_status else r_data if r.status_code == 404 or r.status_code == 410: raise ResourceNotFound(r.content.decode()) + if r.status_code == 412: + raise MultipleResourcesFound(r.content.decode()) + data = r.content.decode() try: parsed_data = json.loads(data) @@ -240,6 +244,24 @@ def first(self): return result[0] if result else None + def get_or_create(self, resource): + assert resource.resource_type == self.resource_type + data, status_code = self.client._do_request("POST", self.resource_type, resource.serialize(), self.params, True) + return data, (True if status_code == 201 else False) + + def update(self, resource): + # TODO: Support cases where resource with id is provided + # accordingly to the https://build.fhir.org/http.html#cond-update + assert resource.resource_type == self.resource_type + data, status_code = self.client._do_request("PUT", self.resource_type, resource.serialize(), self.params, True) + return data, (True if status_code == 201 else False) + + def patch(self, resource): + # TODO: Handle cases where resource with id is provided + assert resource.resource_type == self.resource_type + # TODO: Should we omit resourceType after serialization? (not to pollute history) + return self.client._do_request("PATCH", self.resource_type, resource.serialize(), self.params) + def __iter__(self): next_link = None while True: @@ -260,7 +282,6 @@ def __iter__(self): if not next_link: break - class AsyncSearchSet(AbstractSearchSet, ABC): async def fetch(self): bundle_data = await self.client._fetch_resource(self.resource_type, self.params) @@ -313,6 +334,24 @@ async def first(self): return result[0] if result else None + async def get_or_create(self, resource): + assert resource.resource_type == self.resource_type + data, status_code = await self.client._do_request("POST", self.resource_type, resource.serialize(), self.params, True) + return data, (True if status_code == 201 else False) + + async def update(self, resource): + # TODO: Support cases where resource with id is provided + # accordingly to the https://build.fhir.org/http.html#cond-update + assert resource.resource_type == self.resource_type + data, status_code = await self.client._do_request("PUT", self.resource_type, resource.serialize(), self.params, True) + return data, (True if status_code == 201 else False) + + async def patch(self, resource): + # TODO: Handle cases where resource with id is provided + assert resource.resource_type == self.resource_type + # TODO: Should we omit resourceType after serialization? (not to pollute history) + return await self.client._do_request("PATCH", self.resource_type, resource.serialize(), self.params) + async def __aiter__(self): next_link = None while True: @@ -335,7 +374,7 @@ async def __aiter__(self): class SyncResource(BaseResource, ABC): - def save(self, fields=None): + def save(self, fields=None, search_params=None): data = self.serialize() if fields: if not self.id: @@ -344,14 +383,22 @@ def save(self, fields=None): method = "patch" else: method = "put" if self.id else "post" - response_data = self.client._do_request(method, self._get_path(), data=data) + response_data = self.client._do_request(method, self._get_path(), data=data, params=search_params) if response_data: super(BaseResource, self).clear() super(BaseResource, self).update( **self.client.resource(self.resource_type, **response_data) ) - def update(self, **kwargs): + def create(self, **kwargs): + self.save(search_params=kwargs) + return self + + def update(self): + if not self.id: + raise TypeError("Resource `id` is required for update operation") + self.save() + def patch(self, **kwargs): super(BaseResource, self).update(**kwargs) self.save(fields=kwargs.keys()) @@ -383,7 +430,7 @@ def execute(self, operation, method="post", data=None, params=None): class AsyncResource(BaseResource, ABC): - async def save(self, fields=None): + async def save(self, fields=None, search_params=None): data = self.serialize() if fields: if not self.id: @@ -394,7 +441,7 @@ async def save(self, fields=None): method = "put" if self.id else "post" response_data = await self.client._do_request( - method, self._get_path(), data=data + method, self._get_path(), data=data, params=search_params ) if response_data: super(BaseResource, self).clear() @@ -402,7 +449,15 @@ async def save(self, fields=None): **self.client.resource(self.resource_type, **response_data) ) - async def update(self, **kwargs): + async def create(self, **kwargs): + await self.save(search_params=kwargs) + return self + + async def update(self): + if not self.id: + raise TypeError("Resource `id` is required for update operation") + await self.save() + async def patch(self, **kwargs): super(BaseResource, self).update(**kwargs) await self.save(fields=kwargs.keys()) diff --git a/fhirpy/base/resource.py b/fhirpy/base/resource.py index d0b0064..b04f350 100644 --- a/fhirpy/base/resource.py +++ b/fhirpy/base/resource.py @@ -104,14 +104,17 @@ def __repr__(self): # pragma: no cover return self.__str__() @abstractmethod # pragma: no cover - def save(self, fields=None): + def save(self, fields=None, search_params=None): pass @abstractmethod # pragma: no cover - def update(self, **kwargs): + def patch(self, **kwargs): pass @abstractmethod # pragma: no cover + def update(self): + pass + @abstractmethod # pragma: no cover def delete(self): pass diff --git a/fhirpy/base/searchset.py b/fhirpy/base/searchset.py index d696137..ca277df 100644 --- a/fhirpy/base/searchset.py +++ b/fhirpy/base/searchset.py @@ -206,6 +206,18 @@ def count(self): def first(self): pass + @abstractmethod + async def get_or_create(self, resource): + pass + + @abstractmethod + def update(self, resource): + pass + + @abstractmethod + def patch(self, resource): + pass + def clone(self, override=False, **kwargs): new_params = copy.deepcopy(self.params) for key, value in kwargs.items(): diff --git a/tests/test_lib_async.py b/tests/test_lib_async.py index ee53283..9cdda11 100644 --- a/tests/test_lib_async.py +++ b/tests/test_lib_async.py @@ -37,10 +37,9 @@ def setup_class(cls): cls.client = AsyncFHIRClient(cls.URL, authorization=FHIR_SERVER_AUTHORIZATION) async def create_resource(self, resource_type, **kwargs): - p = self.client.resource(resource_type, identifier=self.identifier, **kwargs) - await p.save() - - return p + return await self.client.resource( + resource_type, identifier=self.identifier, **kwargs + ).create() @pytest.mark.asyncio async def test_create_patient(self): @@ -49,6 +48,154 @@ async def test_create_patient(self): patient = await self.client.resources("Patient").search(_id="patient").get() assert patient["name"] == [{"text": "My patient"}] + @pytest.mark.asyncio + async def test_conditional_create__create_on_no_match(self): + await self.create_resource("Patient", id="patient") + + patient = self.client.resource( + "Patient", + identifier=[{"system": "http://example.com/env", "value": "other"}, self.identifier[0]], + name=[{"text": "Indiana Jones"}], + ) + await patient.create(identifier="other") + + assert patient.id != "patient" + assert patient.get_by_path(["name", 0, "text"]) == "Indiana Jones" + + @pytest.mark.asyncio + async def test_conditional_create__skip_on_one_match(self): + existing_patient = await self.create_resource("Patient", id="patient") + + patient = self.client.resource("Patient", identifier=self.identifier, name=[{"text": "Indiana Jones"}]) + await patient.create(identifier="fhirpy") + + assert patient.id == "patient" + assert patient.get("name") is None + assert patient.get_by_path(["meta", "versionId"]) == existing_patient.get_by_path(["meta", "versionId"]) + + @pytest.mark.asyncio + async def test_conditional_create__fail_on_multiple_matches(self): + await self.create_resource("Patient", id="patient-one") + await self.create_resource("Patient", id="patient-two") + + with pytest.raises(MultipleResourcesFound): + await self.client.resource("Patient", identifier=self.identifier).create(identifier="fhirpy") + + @pytest.mark.asyncio + async def test_get_or_create__create_on_no_match(self): + await self.create_resource("Patient", id="patient") + + patient_to_save = self.client.resource( + "Patient", + identifier=[{"system": "http://example.com/env", "value": "other"}, self.identifier[0]], + name=[{"text": "Indiana Jones"}], + ) + patient, created = ( + await self.client.resources("Patient") + .search(identifier="other") + .get_or_create(patient_to_save) + ) + assert patient.id != "patient" + assert patient.get_by_path(["name", 0, "text"]) == "Indiana Jones" + assert created is True + + @pytest.mark.asyncio + async def test_get_or_create__skip_on_one_match(self): + existing_patient = await self.create_resource("Patient", id="patient") + + patient_to_save = self.client.resource("Patient", identifier=self.identifier) + patient, created = ( + await self.client.resources("Patient") + .search(identifier="fhirpy") + .get_or_create(patient_to_save) + ) + assert patient.id == "patient" + assert created is False + assert patient.get_by_path(["meta", "versionId"]) == existing_patient.get_by_path(["meta", "versionId"]) + + @pytest.mark.asyncio + async def test_conditional_operations__fail_on_multiple_matches(self): + await self.create_resource("Patient", id="patient-one") + await self.create_resource("Patient", id="patient-two") + + patient_to_save = self.client.resource("Patient", identifier=self.identifier) + with pytest.raises(MultipleResourcesFound): + await self.client.resources("Patient").search(identifier="fhirpy").get_or_create( + patient_to_save + ) + with pytest.raises(MultipleResourcesFound): + await self.client.resources("Patient").search(identifier="fhirpy").update(patient_to_save) + with pytest.raises(MultipleResourcesFound): + await self.client.resources("Patient").search(identifier="fhirpy").patch(patient_to_save) + + @pytest.mark.asyncio + async def test_update_with_params__no_match(self): + patient = await self.create_resource("Patient", id="patient", active=True) + + patient_to_update = self.client.resource( + "Patient", + identifier=[{"system": "http://example.com/env", "value": "other"}, self.identifier[0]], + active=False + ) + new_patient, created = await ( + self.client.resources("Patient") + .search(identifier="other") + .update(patient_to_update) + ) + + await patient.refresh() + assert patient.active is True + assert new_patient.id != "patient" + assert new_patient.active is False + assert created is True + + @pytest.mark.asyncio + async def test_update_with_params__one_match(self): + patient = await self.create_resource("Patient", id="patient", active=True) + + patient_to_update = self.client.resource("Patient", identifier=self.identifier, name=[{"text": "Indiana Jones"}]) + updated_patient, created = await ( + self.client.resources("Patient") + .search(identifier="fhirpy") + .update(patient_to_update) + ) + assert updated_patient.id == patient.id + assert created is False + assert updated_patient.get_by_path(["meta", "versionId"]) != patient.get_by_path(["meta", "versionId"]) + assert updated_patient.get_by_path(["name", 0, "text"]) == "Indiana Jones" + + await patient.refresh() + assert updated_patient.get_by_path(["meta", "versionId"]) == patient.get_by_path(["meta", "versionId"]) + assert patient.get("active") is None + + @pytest.mark.asyncio + async def test_patch_with_params__no_match(self): + patient_to_patch = self.client.resource( + "Patient", + identifier=[{"system": "http://example.com/env", "value": "other"}, self.identifier[0]], + active=False + ) + with pytest.raises(ResourceNotFound): + await self.client.resources("Patient").search(identifier="other").patch(patient_to_patch) + + @pytest.mark.asyncio + async def test_patch_with_params__one_match(self): + patient = await self.create_resource("Patient", id="patient", active=True) + + patient_to_patch = self.client.resource("Patient", identifier=self.identifier, name=[{"text": "Indiana Jones"}]) + patched_patient = await ( + self.client.resources("Patient") + .search(identifier="fhirpy") + .patch(patient_to_patch) + ) + assert patched_patient.id == patient.id + assert patched_patient.get_by_path(["meta", "versionId"]) != patient.get_by_path(["meta", "versionId"]) + assert patched_patient.get_by_path(["name", 0, "text"]) == "Indiana Jones" + + await patient.refresh() + assert patched_patient.get_by_path(["meta", "versionId"]) == patient.get_by_path(["meta", "versionId"]) + assert patient.active is True + @pytest.mark.asyncio async def test_update_patient(self): patient = await self.create_resource("Patient", id="patient", name=[{"text": "My patient"}]) @@ -363,8 +510,25 @@ async def test_save_fields(self): @pytest.mark.asyncio async def test_update(self): - patient = await self.create_resource( - "Patient", id="patient_to_update", name=[{"text": "J London"}], active=False + patient_id = "patient_to_update" + patient_initial = await self.create_resource( + "Patient", id=patient_id, name=[{"text": "J London"}], active=False + ) + patient_updated = self.client.resource("Patient", id=patient_id, identifier=self.identifier, active=True) + await patient_updated.update() + + await patient_initial.refresh() + + assert patient_initial.id == patient_updated.id + assert patient_updated.get("name") is None + assert patient_initial.get("name") is None + assert patient_initial["active"] is True + + @pytest.mark.asyncio + async def test_patch(self): + patient_id = "patient_to_patch" + patient_instance_1 = await self.create_resource( + "Patient", id=patient_id, name=[{"text": "J London"}], active=False, birthDate="1998-01-01" ) new_name = [ { @@ -373,11 +537,14 @@ async def test_update(self): "given": ["Jack"], } ] - await patient.update(active=True, name=new_name) - patient_refreshed = await patient.to_reference().to_resource() - assert patient_refreshed.serialize() == patient.serialize() - assert patient["name"] == new_name - assert patient["active"] is True + patient_instance_2 = self.client.resource("Patient", id=patient_id, birthDate="2001-01-01") + await patient_instance_2.patch(active=True, name=new_name) + patient_instance_1_refreshed = await patient_instance_1.to_reference().to_resource() + + assert patient_instance_1_refreshed.serialize() == patient_instance_2.serialize() + assert patient_instance_1_refreshed.active is True + assert patient_instance_1_refreshed.birthDate == "1998-01-01" + assert patient_instance_1_refreshed["name"] == new_name @pytest.mark.asyncio async def test_update_without_id(self): @@ -392,7 +559,9 @@ async def test_update_without_id(self): } ] with pytest.raises(TypeError): - await patient.update(active=True, name=new_name) + await patient.update() + with pytest.raises(TypeError): + await patient.patch(active=True, name=new_name) with pytest.raises(TypeError): patient["name"] = new_name await patient.save(fields=["name"]) @@ -404,7 +573,7 @@ async def test_refresh(self): patient = await self.create_resource("Patient", id=patient_id, active=True) test_patient = await self.client.reference("Patient", patient_id).to_resource() - await test_patient.update(gender="male", name=[{"text": "Jack London"}]) + await test_patient.patch(gender="male", name=[{"text": "Jack London"}]) assert patient.serialize() != test_patient.serialize() await patient.refresh() diff --git a/tests/test_lib_sync.py b/tests/test_lib_sync.py index 86f3825..5db6dfe 100644 --- a/tests/test_lib_sync.py +++ b/tests/test_lib_sync.py @@ -43,10 +43,7 @@ def setup_class(cls): ) def create_resource(self, resource_type, **kwargs): - p = self.client.resource(resource_type, identifier=self.identifier, **kwargs) - p.save() - - return p + return self.client.resource(resource_type, identifier=self.identifier, **kwargs).create() def test_create_patient(self): self.create_resource("Patient", id="patient", name=[{"text": "My patient"}]) @@ -54,6 +51,143 @@ def test_create_patient(self): patient = self.client.resources("Patient").search(_id="patient").get() assert patient["name"] == [{"text": "My patient"}] + def test_conditional_create__create_on_no_match(self): + self.create_resource("Patient", id="patient") + + patient = self.client.resource( + "Patient", + identifier=[{"system": "http://example.com/env", "value": "other"}, self.identifier[0]], + name=[{"text": "Indiana Jones"}], + ) + patient.create(identifier="other") + + assert patient.id != "patient" + assert patient.get_by_path(["name", 0, "text"]) == "Indiana Jones" + + def test_conditional_create__skip_on_one_match(self): + existing_patient = self.create_resource("Patient", id="patient") + + patient = self.client.resource("Patient", identifier=self.identifier, name=[{"text": "Indiana Jones"}]) + patient.create(identifier="fhirpy") + + assert patient.id == "patient" + assert patient.get("name") is None + assert patient.get_by_path(["meta", "versionId"]) == existing_patient.get_by_path(["meta", "versionId"]) + + def test_conditional_create__fail_on_multiple_matches(self): + self.create_resource("Patient", id="patient-one") + self.create_resource("Patient", id="patient-two") + + with pytest.raises(MultipleResourcesFound): + self.client.resource("Patient", identifier=self.identifier).create(identifier="fhirpy") + + def test_get_or_create__create_on_no_match(self): + self.create_resource("Patient", id="patient") + + patient_to_save = self.client.resource( + "Patient", + identifier=[{"system": "http://example.com/env", "value": "other"}, self.identifier[0]], + name=[{"text": "Indiana Jones"}], + ) + patient, created = ( + self.client.resources("Patient") + .search(identifier="other") + .get_or_create(patient_to_save) + ) + assert patient.id != "patient" + assert patient.get_by_path(["name", 0, "text"]) == "Indiana Jones" + assert created is True + + def test_get_or_create__skip_on_one_match(self): + existing_patient = self.create_resource("Patient", id="patient") + + patient_to_save = self.client.resource("Patient", identifier=self.identifier) + patient, created = ( + self.client.resources("Patient") + .search(identifier="fhirpy") + .get_or_create(patient_to_save) + ) + assert patient.id == "patient" + assert created is False + assert patient.get_by_path(["meta", "versionId"]) == existing_patient.get_by_path(["meta", "versionId"]) + + def test_conditional_operations__fail_on_multiple_matches(self): + self.create_resource("Patient", id="patient-one") + self.create_resource("Patient", id="patient-two") + + patient_to_save = self.client.resource("Patient", identifier=self.identifier) + with pytest.raises(MultipleResourcesFound): + self.client.resources("Patient").search(identifier="fhirpy").get_or_create(patient_to_save) + with pytest.raises(MultipleResourcesFound): + self.client.resources("Patient").search(identifier="fhirpy").update(patient_to_save) + with pytest.raises(MultipleResourcesFound): + self.client.resources("Patient").search(identifier="fhirpy").patch(patient_to_save) + + def test_update_with_params__no_match(self): + patient = self.create_resource("Patient", id="patient", active=True) + + patient_to_update = self.client.resource( + "Patient", + identifier=[{"system": "http://example.com/env", "value": "other"}, self.identifier[0]], + active=False + ) + new_patient, created = ( + self.client.resources("Patient") + .search(identifier="other") + .update(patient_to_update) + ) + + patient.refresh() + assert patient.active is True + assert new_patient.id != "patient" + assert new_patient.active is False + assert created is True + + + def test_update_with_params__one_match(self): + patient = self.create_resource("Patient", id="patient", active=True) + + patient_to_update = self.client.resource("Patient", identifier=self.identifier, name=[{"text": "Indiana Jones"}]) + updated_patient, created = ( + self.client.resources("Patient") + .search(identifier="fhirpy") + .update(patient_to_update) + ) + assert updated_patient.id == patient.id + assert created is False + assert updated_patient.get_by_path(["meta", "versionId"]) != patient.get_by_path(["meta", "versionId"]) + assert updated_patient.get_by_path(["name", 0, "text"]) == "Indiana Jones" + + patient.refresh() + assert updated_patient.get_by_path(["meta", "versionId"]) == patient.get_by_path(["meta", "versionId"]) + assert patient.get("active") is None + + def test_patch_with_params__no_match(self): + patient_to_patch = self.client.resource( + "Patient", + identifier=[{"system": "http://example.com/env", "value": "other"}, self.identifier[0]], + active=False + ) + with pytest.raises(ResourceNotFound): + self.client.resources("Patient").search(identifier="other").patch(patient_to_patch) + + def test_patch_with_params__one_match(self): + patient = self.create_resource("Patient", id="patient", active=True) + + patient_to_patch = self.client.resource("Patient", identifier=self.identifier, name=[{"text": "Indiana Jones"}]) + patched_patient = ( + self.client.resources("Patient") + .search(identifier="fhirpy") + .patch(patient_to_patch) + ) + assert patched_patient.id == patient.id + assert patched_patient.get_by_path(["meta", "versionId"]) != patient.get_by_path(["meta", "versionId"]) + assert patched_patient.get_by_path(["name", 0, "text"]) == "Indiana Jones" + + patient.refresh() + assert patched_patient.get_by_path(["meta", "versionId"]) == patient.get_by_path(["meta", "versionId"]) + assert patient.active is True + def test_update_patient(self): patient = self.create_resource( "Patient", id="patient", name=[{"text": "My patient"}] @@ -356,8 +490,24 @@ def test_save_fields(self): assert patient_refreshed["name"] == [{"text": "Abc"}] def test_update(self): - patient = self.create_resource( - "Patient", id="patient_to_update", name=[{"text": "J London"}], active=False + patient_id = "patient_to_update" + patient_initial = self.create_resource( + "Patient", id=patient_id, name=[{"text": "J London"}], active=False + ) + patient_updated = self.client.resource("Patient", id=patient_id, identifier=self.identifier, active=True) + patient_updated.update() + + patient_initial.refresh() + + assert patient_initial.id == patient_updated.id + assert patient_updated.get("name") is None + assert patient_initial.get("name") is None + assert patient_initial["active"] is True + + def test_patch(self): + patient_id = "patient_to_patch" + patient_instance_1 = self.create_resource( + "Patient", id=patient_id, name=[{"text": "J London"}], active=False, birthDate="1998-01-01" ) new_name = [ { @@ -366,11 +516,14 @@ def test_update(self): "given": ["Jack"], } ] - patient.update(active=True, name=new_name) - patient_refreshed = patient.to_reference().to_resource() - assert patient_refreshed.serialize() == patient.serialize() - assert patient["name"] == new_name - assert patient["active"] is True + patient_instance_2 = self.client.resource("Patient", id=patient_id, birthDate="2001-01-01") + patient_instance_2.patch(active=True, name=new_name) + patient_instance_1_refreshed = patient_instance_1.to_reference().to_resource() + + assert patient_instance_1_refreshed.serialize() == patient_instance_2.serialize() + assert patient_instance_1_refreshed.active is True + assert patient_instance_1_refreshed.birthDate == "1998-01-01" + assert patient_instance_1_refreshed["name"] == new_name def test_update_without_id(self): patient = self.client.resource( @@ -384,7 +537,9 @@ def test_update_without_id(self): } ] with pytest.raises(TypeError): - patient.update(active=True, name=new_name) + patient.update() + with pytest.raises(TypeError): + patient.patch(active=True, name=new_name) with pytest.raises(TypeError): patient["name"] = new_name patient.save(fields=["name"]) @@ -395,7 +550,7 @@ def test_refresh(self): patient = self.create_resource("Patient", id=patient_id, active=True) test_patient = self.client.reference("Patient", patient_id).to_resource() - test_patient.update(gender="male", name=[{"text": "Jack London"}]) + test_patient.patch(gender="male", name=[{"text": "Jack London"}]) assert patient.serialize() != test_patient.serialize() patient.refresh()