dic-helper-toolkit 1.0.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- dic_helper_toolkit-1.0.2.dist-info/METADATA +152 -0
- dic_helper_toolkit-1.0.2.dist-info/RECORD +9 -0
- dic_helper_toolkit-1.0.2.dist-info/WHEEL +5 -0
- dic_helper_toolkit-1.0.2.dist-info/top_level.txt +1 -0
- dic_helpers/__init__.py +0 -0
- dic_helpers/directory_reader.py +80 -0
- dic_helpers/fhir_client.py +314 -0
- dic_helpers/fhir_file_reader.py +307 -0
- dic_helpers/http_client.py +300 -0
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: dic-helper-toolkit
|
|
3
|
+
Version: 1.0.2
|
|
4
|
+
Summary: Helper libraries for data integration centers' daily tasks
|
|
5
|
+
Project-URL: Repository, https://github.com/haemka/dic-helper-toolkit
|
|
6
|
+
Project-URL: Issues, https://github.com/haemka/dic-helper-toolkit/issues
|
|
7
|
+
Requires-Python: >=3.13
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
Requires-Dist: requests~=2.34.2
|
|
10
|
+
Requires-Dist: requests-oauthlib~=2.0.0
|
|
11
|
+
Requires-Dist: oauthlib~=3.3.1
|
|
12
|
+
Requires-Dist: fhir.resources~=8.2.0
|
|
13
|
+
Requires-Dist: pytest~=9.0.3
|
|
14
|
+
Requires-Dist: certifi~=2026.5.20
|
|
15
|
+
Requires-Dist: charset-normalizer~=3.4.7
|
|
16
|
+
Requires-Dist: pydantic~=2.13.4
|
|
17
|
+
Requires-Dist: typing-extensions~=4.15.0
|
|
18
|
+
Requires-Dist: typing-inspection~=0.4.2
|
|
19
|
+
Requires-Dist: responses~=0.26.1
|
|
20
|
+
Provides-Extra: xml
|
|
21
|
+
Requires-Dist: lxml~=6.1; extra == "xml"
|
|
22
|
+
Provides-Extra: tests
|
|
23
|
+
Requires-Dist: pytest; extra == "tests"
|
|
24
|
+
Requires-Dist: responses; extra == "tests"
|
|
25
|
+
Requires-Dist: lxml~=6.1; extra == "tests"
|
|
26
|
+
|
|
27
|
+
# Python DIC Helper Toolkit
|
|
28
|
+
|
|
29
|
+
This Toolkit contains helper libraries for the data integration centers daily tasks.
|
|
30
|
+
|
|
31
|
+
## Installation
|
|
32
|
+
|
|
33
|
+
```shell
|
|
34
|
+
pip install dic-helper-toolkit
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
The package is published on [PyPI](https://pypi.org/project/dic-helper-toolkit/). To use the FHIR XML reader, install
|
|
38
|
+
the `xml` extra: `pip install dic-helper-toolkit[xml]`.
|
|
39
|
+
|
|
40
|
+
## Contents
|
|
41
|
+
|
|
42
|
+
### HTTP client
|
|
43
|
+
|
|
44
|
+
A wrapper around the `requests` library which allows to create an object for an endpoint holding all needed
|
|
45
|
+
configuration including authentication and certificate verification. It supports Basic Auth and OAuth 2.0.
|
|
46
|
+
|
|
47
|
+
#### Usage:
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
from dic_helpers.http_client import HTTPClient
|
|
51
|
+
|
|
52
|
+
## No authentication or CA certificate at all
|
|
53
|
+
client = HTTPClient(base_url="https://example.com")
|
|
54
|
+
|
|
55
|
+
## Basic Auth with CA certificate
|
|
56
|
+
#client = HTTPClient(base_url="https://example.com", username="user", password="P4SSW0RD", ca_file="MyCA.crt")
|
|
57
|
+
|
|
58
|
+
## OAuth 2.0 with username and password
|
|
59
|
+
#client = HTTPClient(base_url="https://example.com", username="user", password="P4SSW0RD",
|
|
60
|
+
# auth_url="https://keycloak.example.com", realm="MyRealm", client_id="MyClient",
|
|
61
|
+
# client_secret="00000000-00000000-00000000-00000000", ca_file="MyCA.crt")
|
|
62
|
+
|
|
63
|
+
with client.get("index.html") as result:
|
|
64
|
+
print(result)
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
### FHIR client
|
|
69
|
+
|
|
70
|
+
A simple FHIR client library which uses the HTTP client from above and handles resources directly as `fhir.resources`
|
|
71
|
+
(https://github.com/nazrulworld/fhir.resources) object.
|
|
72
|
+
|
|
73
|
+
#### Usage:
|
|
74
|
+
|
|
75
|
+
```python
|
|
76
|
+
from dic_helpers.fhir_client import FHIRClient
|
|
77
|
+
|
|
78
|
+
client = FHIRClient(base_url="https://example.com", username="user", password="P4SSW0RD",
|
|
79
|
+
auth_url="https://keycloak.example.com", realm="MyRealm", client_id="MyClient",
|
|
80
|
+
client_secret="00000000-00000000-00000000-00000000", ca_file="MyCA.crt")
|
|
81
|
+
|
|
82
|
+
# Fetch a patient
|
|
83
|
+
patients = client.search("Patient", "identifier=0123456789&_lastUpdated=ge2025-01-01"):
|
|
84
|
+
for patient in patients:
|
|
85
|
+
print(patient)
|
|
86
|
+
```
|
|
87
|
+
*Keep in mind, that this returns an object.*
|
|
88
|
+
|
|
89
|
+
### Directory reader
|
|
90
|
+
|
|
91
|
+
Lists the files of a directory, optionally including all subdirectories, whose names match shell-style patterns.
|
|
92
|
+
|
|
93
|
+
#### Usage:
|
|
94
|
+
|
|
95
|
+
```python
|
|
96
|
+
from dic_helpers.directory_reader import DirectoryReader
|
|
97
|
+
|
|
98
|
+
reader = DirectoryReader("/data/export", patterns=["*.json", "*.ndjson"], recursive=True)
|
|
99
|
+
for path in reader.list_files():
|
|
100
|
+
print(path)
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
- **Only regular files** are listed, sorted by path, as `pathlib.Path` objects that start with the directory as given.
|
|
104
|
+
- **`patterns`** (default `"*"`) are matched case-sensitively against the file names (`*`, `?`, `[seq]`). A single
|
|
105
|
+
pattern can be a string; a file is listed if it matches any pattern. Special characters in the directory's own name
|
|
106
|
+
don't matter.
|
|
107
|
+
- **`recursive`** (default `False`) also lists the files of all subdirectories.
|
|
108
|
+
- **`include_hidden`** (default `False`): hidden files (names starting with `.`) are skipped and hidden directories
|
|
109
|
+
are not read, unless this is `True`.
|
|
110
|
+
- **`follow_symlinks`** (default `False`): symbolic links to directories are only read if this is `True`. Every
|
|
111
|
+
directory is read once at most, so symlink loops end. Symbolic links to files are always listed, broken links never.
|
|
112
|
+
- **Errors:** a missing directory raises `FileNotFoundError`, a file instead of a directory `NotADirectoryError`, and
|
|
113
|
+
an unreadable directory `PermissionError`. An empty list therefore always means that no file matched.
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
### FHIR file reader
|
|
117
|
+
|
|
118
|
+
Reads FHIR R4B resources from files as `fhir.resources` objects, together with the file and line they were read from.
|
|
119
|
+
Combined with the directory reader:
|
|
120
|
+
|
|
121
|
+
```python
|
|
122
|
+
from dic_helpers.directory_reader import DirectoryReader
|
|
123
|
+
from dic_helpers.fhir_file_reader import FHIRFileReader
|
|
124
|
+
|
|
125
|
+
paths = DirectoryReader("/data/export", patterns=["*.json", "*.ndjson", "*.xml", "*.gz"], recursive=True).list_files()
|
|
126
|
+
for item in FHIRFileReader(paths).resources():
|
|
127
|
+
print(item.path, item.line, item.resource.get_resource_type(), item.resource.id)
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
**Formats**, by file extension (case-insensitive):
|
|
131
|
+
|
|
132
|
+
| Extension | Content | `item.line` |
|
|
133
|
+
|---|---|---|
|
|
134
|
+
| `.json` | one resource | `None` |
|
|
135
|
+
| `.ndjson` | one resource per line (FHIR bulk data), empty lines are skipped | line number, from 1 |
|
|
136
|
+
| `.xml` | one resource in FHIR XML, the resource type comes from the root element | `None` |
|
|
137
|
+
| `.json.gz`, `.ndjson.gz`, `.xml.gz` | the same, gzip compressed | as above |
|
|
138
|
+
|
|
139
|
+
Other extensions are an error, the format is not guessed. Text is UTF-8, a byte order mark is allowed. Reading XML
|
|
140
|
+
needs `lxml`: install `dic-helper-toolkit[xml]`. External entities are not resolved, an XML file that references one is
|
|
141
|
+
invalid.
|
|
142
|
+
|
|
143
|
+
**Parameters:**
|
|
144
|
+
- **`paths`**: the files, read in this order; a single path can be given on its own. Resources come in the order they
|
|
145
|
+
appear in each file. NDJSON files are read line by line, also compressed ones; JSON and XML files as a whole.
|
|
146
|
+
- **`unpack_bundles`** (default `True`): a Bundle gives the resources of its entries, of any Bundle type. Only the
|
|
147
|
+
outermost Bundle is unpacked, entries without a resource are left out. With `False` the Bundle is returned.
|
|
148
|
+
- **`on_error`** (default `"raise"`): an unreadable or invalid file, NDJSON line or resource raises `FHIRFileError`,
|
|
149
|
+
whose message starts with the file and line, e.g. `export/Patient.ndjson:17: invalid JSON: …` or
|
|
150
|
+
`patient.json: invalid Patient: birthDate: Date value string does not match spec regex.`. With `"skip"` the message
|
|
151
|
+
is logged as a warning, and reading continues with the next line (NDJSON) or the next file. A missing `lxml` is
|
|
152
|
+
always an error.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
dic_helpers/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
dic_helpers/directory_reader.py,sha256=JQQMaBfoORzOwXAWCNJvcJiHJERe8F_p69yKXP_5uKg,3997
|
|
3
|
+
dic_helpers/fhir_client.py,sha256=yYuo2hPQGa2F11eiBaOMuKfjrXb0DYTOHCOcqom6LOM,14050
|
|
4
|
+
dic_helpers/fhir_file_reader.py,sha256=8Y43J-2cc3X79iuQpupRV08JsgxhprkwnihfvpEj11A,13315
|
|
5
|
+
dic_helpers/http_client.py,sha256=tej3q1BsDTRQ3t8Iw8ISuElVuV1tUYEtX1YAA-KrIxU,11759
|
|
6
|
+
dic_helper_toolkit-1.0.2.dist-info/METADATA,sha256=gyZKqm22Wq-WWgBAn2cMfAiO1pysGjVgE3xcYTStx0U,6569
|
|
7
|
+
dic_helper_toolkit-1.0.2.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
|
|
8
|
+
dic_helper_toolkit-1.0.2.dist-info/top_level.txt,sha256=BNkYErUWeTgH4-0z0EnR2h1rzj5-JCq_tw14_q53f_w,12
|
|
9
|
+
dic_helper_toolkit-1.0.2.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
dic_helpers
|
dic_helpers/__init__.py
ADDED
|
File without changes
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Listing the files of a directory, optionally including its subdirectories."""
|
|
2
|
+
import fnmatch
|
|
3
|
+
import os
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Iterable, List, Set, Tuple, Union
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class DirectoryReader:
|
|
9
|
+
"""
|
|
10
|
+
Lists the files of a directory whose names match one of the given patterns.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
def __init__(self, directory: Union[str, os.PathLike], patterns: Union[str, Iterable[str]] = ("*",),
|
|
14
|
+
recursive: bool = False, include_hidden: bool = False, follow_symlinks: bool = False) -> None:
|
|
15
|
+
"""
|
|
16
|
+
Initialize the DirectoryReader. The directory is only read by list_files().
|
|
17
|
+
|
|
18
|
+
:param directory: The directory to read.
|
|
19
|
+
:type directory: Union[str, os.PathLike]
|
|
20
|
+
:param patterns: Shell-style patterns (fnmatch: *, ?, [seq]) matched case-sensitively against the file names,
|
|
21
|
+
e.g. ("*.json", "*.ndjson"). A single pattern can be given as a string. A file is listed if it matches any
|
|
22
|
+
pattern.
|
|
23
|
+
:type patterns: Union[str, Iterable[str]]
|
|
24
|
+
:param recursive: Also list the files of all subdirectories.
|
|
25
|
+
:type recursive: bool
|
|
26
|
+
:param include_hidden: Also list hidden files (names starting with '.') and read hidden subdirectories.
|
|
27
|
+
:type include_hidden: bool
|
|
28
|
+
:param follow_symlinks: Also read subdirectories that are symbolic links. Every directory is read once at most,
|
|
29
|
+
so symlink loops end. Symbolic links to files are always listed.
|
|
30
|
+
:type follow_symlinks: bool
|
|
31
|
+
"""
|
|
32
|
+
self.directory = Path(directory)
|
|
33
|
+
self.patterns: Tuple[str, ...] = (patterns,) if isinstance(patterns, str) else tuple(patterns)
|
|
34
|
+
self.recursive = recursive
|
|
35
|
+
self.include_hidden = include_hidden
|
|
36
|
+
self.follow_symlinks = follow_symlinks
|
|
37
|
+
|
|
38
|
+
def list_files(self) -> List[Path]:
|
|
39
|
+
"""
|
|
40
|
+
List the matching files.
|
|
41
|
+
|
|
42
|
+
:return: The paths of the matching regular files, sorted. Each path starts with the directory as given.
|
|
43
|
+
:rtype: List[Path]
|
|
44
|
+
:raises FileNotFoundError: If the directory doesn't exist.
|
|
45
|
+
:raises NotADirectoryError: If the path is not a directory.
|
|
46
|
+
:raises PermissionError: If the directory or one of the subdirectories to read can't be read.
|
|
47
|
+
"""
|
|
48
|
+
if not self.directory.exists():
|
|
49
|
+
raise FileNotFoundError(f"Directory not found: {self.directory}")
|
|
50
|
+
if not self.directory.is_dir():
|
|
51
|
+
raise NotADirectoryError(f"Not a directory: {self.directory}")
|
|
52
|
+
files: List[Path] = []
|
|
53
|
+
self._read(self.directory, files, visited=set())
|
|
54
|
+
return sorted(files)
|
|
55
|
+
|
|
56
|
+
def _read(self, directory: Path, files: List[Path], visited: Set[Tuple[int, int]]) -> None:
|
|
57
|
+
"""
|
|
58
|
+
Add the matching files of a directory to files, and read its subdirectories if recursive.
|
|
59
|
+
|
|
60
|
+
:param directory: The directory to read.
|
|
61
|
+
:type directory: Path
|
|
62
|
+
:param files: The list the matching files are added to.
|
|
63
|
+
:type files: List[Path]
|
|
64
|
+
:param visited: Device and inode of the directories read so far; directories in it are not read again.
|
|
65
|
+
:type visited: Set[Tuple[int, int]]
|
|
66
|
+
"""
|
|
67
|
+
status = directory.stat()
|
|
68
|
+
visited.add((status.st_dev, status.st_ino))
|
|
69
|
+
with os.scandir(directory) as entries:
|
|
70
|
+
for entry in entries:
|
|
71
|
+
if not self.include_hidden and entry.name.startswith("."):
|
|
72
|
+
continue
|
|
73
|
+
# is_file() follows symbolic links: links to files are listed, broken links are not
|
|
74
|
+
if entry.is_file():
|
|
75
|
+
if any(fnmatch.fnmatchcase(entry.name, pattern) for pattern in self.patterns):
|
|
76
|
+
files.append(Path(entry.path))
|
|
77
|
+
elif self.recursive and entry.is_dir(follow_symlinks=self.follow_symlinks):
|
|
78
|
+
status = entry.stat()
|
|
79
|
+
if (status.st_dev, status.st_ino) not in visited:
|
|
80
|
+
self._read(Path(entry.path), files, visited)
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import json
|
|
3
|
+
from typing import Dict, Any, Generator, Optional
|
|
4
|
+
from urllib.parse import urlparse, parse_qs, urlencode, urljoin
|
|
5
|
+
from fhir.resources.R4B import get_fhir_model_class
|
|
6
|
+
from fhir.resources.R4B.bundle import Bundle
|
|
7
|
+
from dic_helpers.http_client import HTTPClient
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class FHIRClient(HTTPClient):
|
|
14
|
+
"""
|
|
15
|
+
A FHIR API client that abstracts an advanced HTTP client.
|
|
16
|
+
|
|
17
|
+
This client uses the custom implementation for a HTTP client from the same package (dic-helper-toolkit). It offers
|
|
18
|
+
easy to use function for FHIR requests and uses fhir.resources as object representations of resources.
|
|
19
|
+
"""
|
|
20
|
+
def __init__(self, page_size=50, next_param: Optional[str] = None, *args, **kwargs) -> None:
|
|
21
|
+
"""Initialize the FHIRClient
|
|
22
|
+
|
|
23
|
+
:param page_size: Number of entries to request per search page.
|
|
24
|
+
:param next_param: Query parameter for offset paging, for servers that don't provide standard FHIR paging
|
|
25
|
+
links (e.g. '_offset'). Defaults to None, which uses standard FHIR paging only.
|
|
26
|
+
:param args: Positional arguments to pass to the parent HTTPClient.
|
|
27
|
+
:param kwargs: Keyword arguments to pass to the parent HTTPClient.
|
|
28
|
+
"""
|
|
29
|
+
super().__init__(*args, **kwargs)
|
|
30
|
+
self.page_size = page_size
|
|
31
|
+
# An empty string counts as not configured
|
|
32
|
+
self.next_param: Optional[str] = next_param or None
|
|
33
|
+
|
|
34
|
+
def _is_standard_paging(self, bundle: Dict[str, Any]) -> bool:
|
|
35
|
+
"""Check if the bundle follows standard FHIR paging
|
|
36
|
+
|
|
37
|
+
:param bundle: A dictionary representing a FHIR Bundle.
|
|
38
|
+
:type bundle: Dict[str, Any]
|
|
39
|
+
:return: True if the bundle uses standard paging, False otherwise.
|
|
40
|
+
:rtype: bool
|
|
41
|
+
"""
|
|
42
|
+
if 'link' in bundle:
|
|
43
|
+
return any(link['relation'] in ['next', 'previous'] for link in bundle['link'])
|
|
44
|
+
return False
|
|
45
|
+
|
|
46
|
+
def _get_next_url(self, bundle: Dict[str, Any]) -> Optional[str]:
|
|
47
|
+
"""Get the 'next' URL from the bundle
|
|
48
|
+
|
|
49
|
+
:param bundle: A dictionary representing a FHIR Bundle.
|
|
50
|
+
:type bundle: Dict[str, Any]
|
|
51
|
+
:return: The next URL from the bundle, None if it has no 'next' link.
|
|
52
|
+
:rtype: Optional[str]
|
|
53
|
+
"""
|
|
54
|
+
if 'link' in bundle:
|
|
55
|
+
next_link = next((link['url'] for link in bundle['link'] if link['relation'] == 'next'), None)
|
|
56
|
+
return next_link
|
|
57
|
+
return None
|
|
58
|
+
|
|
59
|
+
def _construct_next_params(self, original_params: Dict[str, Any], offset: int) -> Dict[str, Any]:
|
|
60
|
+
"""Construct the search parameters of the next page for non-standard (offset) paging
|
|
61
|
+
|
|
62
|
+
:param original_params: The search parameters of the current page; they are changed and returned.
|
|
63
|
+
:type original_params: Dict[str, Any]
|
|
64
|
+
:param offset: The offset of the next page, set as the next_param parameter.
|
|
65
|
+
:type offset: int
|
|
66
|
+
:return: The search parameters of the next page, _count unchanged.
|
|
67
|
+
:rtype: Dict[str, Any]
|
|
68
|
+
"""
|
|
69
|
+
# _count stays as requested, the page size of the whole search
|
|
70
|
+
query_params = original_params
|
|
71
|
+
query_params[self.next_param] = str(offset)
|
|
72
|
+
return query_params
|
|
73
|
+
|
|
74
|
+
@staticmethod
|
|
75
|
+
def _parse_params(params: str) -> Dict[str, Any]:
|
|
76
|
+
"""Parse search parameters given as a query string into a dictionary
|
|
77
|
+
|
|
78
|
+
:param params: The parameters, e.g. 'identifier=0123456789&_lastUpdated=ge2025-01-01', not URL encoded.
|
|
79
|
+
:type params: str
|
|
80
|
+
:return: The parameters by name; a repeated parameter keeps its last value.
|
|
81
|
+
:rtype: Dict[str, Any]
|
|
82
|
+
"""
|
|
83
|
+
return dict(param.split('=', 1) for param in params.split('&'))
|
|
84
|
+
|
|
85
|
+
def search(self, resource_type: str, params: Optional[Any] = None) -> Generator[Bundle, None, None]:
|
|
86
|
+
"""Perform a FHIR search and yield every result page as a Bundle
|
|
87
|
+
|
|
88
|
+
Pages are followed by the Bundle's 'next' link (standard FHIR paging), or, if next_param is configured and a
|
|
89
|
+
page is full, by requesting the next offset. A server that returns the same page again for the offset stops
|
|
90
|
+
the search with a warning. Without next_param, a warning is logged if the Bundle's total shows that results
|
|
91
|
+
are missing.
|
|
92
|
+
|
|
93
|
+
:param resource_type: The resource type to search for.
|
|
94
|
+
:type resource_type: str
|
|
95
|
+
:param params: Search parameters as a dictionary (a list value repeats the parameter) or as a query string.
|
|
96
|
+
'_count' defaults to the client's page_size.
|
|
97
|
+
:type params: Dict[str, Any] or str, optional
|
|
98
|
+
:return: A generator that yields the result pages.
|
|
99
|
+
:rtype: Generator[Bundle, None, None]
|
|
100
|
+
"""
|
|
101
|
+
if params is None:
|
|
102
|
+
params = {}
|
|
103
|
+
elif isinstance(params, str):
|
|
104
|
+
params = self._parse_params(params)
|
|
105
|
+
params['_count'] = params.get('_count', self.page_size)
|
|
106
|
+
# The page size actually requested: a _count given by the caller overrides the client's page_size
|
|
107
|
+
count = params['_count'][0] if isinstance(params['_count'], list) else params['_count']
|
|
108
|
+
page_size = int(count)
|
|
109
|
+
|
|
110
|
+
url = urljoin(self.base_url, resource_type)
|
|
111
|
+
path = resource_type
|
|
112
|
+
logger.debug(f"Search request with path {path} and params {params}")
|
|
113
|
+
|
|
114
|
+
previous_entries = None
|
|
115
|
+
received = 0
|
|
116
|
+
while path:
|
|
117
|
+
response = self.get(path, params=params)
|
|
118
|
+
bundle_dict = response.json()
|
|
119
|
+
entries = bundle_dict.get('entry') or []
|
|
120
|
+
# A server ignoring the offset parameter returns the same page again, which would otherwise loop forever
|
|
121
|
+
if entries and entries == previous_entries:
|
|
122
|
+
logger.warning(f"Server returned the same page again for paging parameter '{self.next_param}'. It "
|
|
123
|
+
f"probably does not support this parameter. Stopping the search.")
|
|
124
|
+
break
|
|
125
|
+
previous_entries = entries
|
|
126
|
+
received += len(entries)
|
|
127
|
+
resource_class = get_fhir_model_class('Bundle')
|
|
128
|
+
bundle = resource_class.model_validate_json(json.dumps(bundle_dict))
|
|
129
|
+
|
|
130
|
+
yield bundle
|
|
131
|
+
|
|
132
|
+
if self._is_standard_paging(bundle_dict):
|
|
133
|
+
# Standard FHIR paging
|
|
134
|
+
next_url = self._get_next_url(bundle_dict)
|
|
135
|
+
if next_url:
|
|
136
|
+
parsed_next_url = urlparse(next_url)
|
|
137
|
+
path = parsed_next_url.path
|
|
138
|
+
params = parse_qs(parsed_next_url.query) # Correctly parse query parameters
|
|
139
|
+
else:
|
|
140
|
+
path = None
|
|
141
|
+
elif self.next_param and len(entries) == page_size:
|
|
142
|
+
# Non-standard offset paging, only if configured
|
|
143
|
+
current_offset = int(params.get(self.next_param, 0))
|
|
144
|
+
next_offset = current_offset + page_size
|
|
145
|
+
|
|
146
|
+
params = self._construct_next_params(params, next_offset)
|
|
147
|
+
else:
|
|
148
|
+
total = bundle_dict.get('total')
|
|
149
|
+
if self.next_param is None and isinstance(total, int) and total > received:
|
|
150
|
+
logger.warning(f"Search for {resource_type} returned {received} of {total} results without a next "
|
|
151
|
+
f"link. The server probably doesn't support standard FHIR paging, configure "
|
|
152
|
+
f"next_param to use offset paging.")
|
|
153
|
+
path = None
|
|
154
|
+
if path:
|
|
155
|
+
logger.debug(f"Next URL has path '{path}' and params '{params}'")
|
|
156
|
+
|
|
157
|
+
def search_all(self, resource_type: str, params: Optional[Dict[str, Any]] = None) -> Bundle:
|
|
158
|
+
"""Perform a FHIR search and return a single Bundle containing all entries.
|
|
159
|
+
|
|
160
|
+
:param resource_type: The resource type to search for.
|
|
161
|
+
:type resource_type: str
|
|
162
|
+
:param params: Search parameters, as for search().
|
|
163
|
+
:type params: Dict[str, Any], optional
|
|
164
|
+
:return: A searchset Bundle with the entries of all result pages.
|
|
165
|
+
:rtype: Bundle
|
|
166
|
+
"""
|
|
167
|
+
# Initialize an empty Bundle to store all entries
|
|
168
|
+
all_entries_bundle = Bundle(
|
|
169
|
+
type='searchset',
|
|
170
|
+
total=0,
|
|
171
|
+
entry=[]
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
# Call the original search function
|
|
175
|
+
for page_bundle in self.search(resource_type, params):
|
|
176
|
+
# Add entries from each page to the all_entries_bundle
|
|
177
|
+
if page_bundle.entry:
|
|
178
|
+
all_entries_bundle.entry.extend(page_bundle.entry)
|
|
179
|
+
|
|
180
|
+
# Copy metadata from the last page
|
|
181
|
+
all_entries_bundle.type = page_bundle.type
|
|
182
|
+
all_entries_bundle.total = page_bundle.total
|
|
183
|
+
|
|
184
|
+
# Update other relevant fields
|
|
185
|
+
if hasattr(page_bundle, 'meta'):
|
|
186
|
+
all_entries_bundle.meta = page_bundle.meta
|
|
187
|
+
if hasattr(page_bundle, 'link'):
|
|
188
|
+
all_entries_bundle.link = page_bundle.link
|
|
189
|
+
|
|
190
|
+
# If total wasn't set in the original bundles, use the length of entries
|
|
191
|
+
if all_entries_bundle.total is None or all_entries_bundle.total == 0:
|
|
192
|
+
all_entries_bundle.total = len(all_entries_bundle.entry)
|
|
193
|
+
|
|
194
|
+
return all_entries_bundle
|
|
195
|
+
|
|
196
|
+
def get_resource(self, resource_type: str, id: str, **kwargs: Any) -> Any:
|
|
197
|
+
"""Retrieve a single resource
|
|
198
|
+
|
|
199
|
+
:param resource_type: The resource type to search for.
|
|
200
|
+
:type resource_type: str
|
|
201
|
+
:param id: The id of the resource to retrieve.
|
|
202
|
+
:type id: str
|
|
203
|
+
:param kwargs: Additional arguments to pass to the request.
|
|
204
|
+
:return: The resource retrieved.
|
|
205
|
+
:rtype: Any
|
|
206
|
+
"""
|
|
207
|
+
response = self.get(f"{resource_type}/{id}", **kwargs)
|
|
208
|
+
resource_dict = response.json()
|
|
209
|
+
resource_class = get_fhir_model_class(resource_type)
|
|
210
|
+
return resource_class.model_validate_json(json.dumps(resource_dict))
|
|
211
|
+
|
|
212
|
+
def post_resource(self, resource: Any, **kwargs: Any) -> Any:
|
|
213
|
+
"""Create a new resource
|
|
214
|
+
|
|
215
|
+
:param resource: The resource to create.
|
|
216
|
+
:type resource: Any
|
|
217
|
+
:param kwargs: Additional arguments to pass to the request.
|
|
218
|
+
:return: The created resource.
|
|
219
|
+
:rtype: Any
|
|
220
|
+
"""
|
|
221
|
+
resource_type = resource.get_resource_type()
|
|
222
|
+
path = None if resource_type == "Bundle" else resource_type
|
|
223
|
+
response = self.post(path=path, json=json.loads(resource.model_dump_json()), **kwargs)
|
|
224
|
+
created_resource_dict = response.json()
|
|
225
|
+
resource_class = get_fhir_model_class(resource_type)
|
|
226
|
+
return resource_class.model_validate_json(json.dumps(created_resource_dict))
|
|
227
|
+
|
|
228
|
+
# Alias for post_resource()
|
|
229
|
+
create_resource = post_resource
|
|
230
|
+
|
|
231
|
+
def put_resource(self, resource: Any, **kwargs: Any) -> Any:
|
|
232
|
+
"""Update an existing resource
|
|
233
|
+
|
|
234
|
+
:param resource: The resource to update.
|
|
235
|
+
:type resource: Any
|
|
236
|
+
:param kwargs: Additional arguments to pass to the request.
|
|
237
|
+
:return: The updated resource.
|
|
238
|
+
:rtype: Any
|
|
239
|
+
"""
|
|
240
|
+
resource_type = resource.get_resource_type()
|
|
241
|
+
resource_id = resource.id
|
|
242
|
+
response = self.put(f"{resource_type}/{resource_id}",
|
|
243
|
+
json=json.loads(resource.model_dump_json()), **kwargs)
|
|
244
|
+
updated_resource_dict = response.json()
|
|
245
|
+
resource_class = get_fhir_model_class(resource_type)
|
|
246
|
+
return resource_class.model_validate_json(json.dumps(updated_resource_dict))
|
|
247
|
+
|
|
248
|
+
# Alias for put_resource()
|
|
249
|
+
update_resource = put_resource
|
|
250
|
+
|
|
251
|
+
def delete_resource(self, resource_type: str, id: str, **kwargs: Any) -> None:
|
|
252
|
+
"""Delete a resource
|
|
253
|
+
|
|
254
|
+
:param resource_type: The resource type to search for.
|
|
255
|
+
:type resource_type: str
|
|
256
|
+
:param kwargs: Additional arguments to pass to the request.
|
|
257
|
+
:param id: The id of the resource to delete.
|
|
258
|
+
:type id: str
|
|
259
|
+
"""
|
|
260
|
+
self.delete(f"{resource_type}/{id}", **kwargs)
|
|
261
|
+
|
|
262
|
+
def post_bundle(self, bundle: Bundle, **kwargs: Any) -> Bundle:
|
|
263
|
+
"""Execute a transaction or batch Bundle
|
|
264
|
+
|
|
265
|
+
:param bundle: The Bundle to execute.
|
|
266
|
+
:type bundle: Bundle
|
|
267
|
+
:param kwargs: Additional arguments to pass to the request.
|
|
268
|
+
:return: The executed Bundle.
|
|
269
|
+
:rtype: Bundle
|
|
270
|
+
"""
|
|
271
|
+
response = self.post("", json=json.loads(bundle.model_dump_json()), **kwargs)
|
|
272
|
+
result_bundle_dict = response.json()
|
|
273
|
+
resource_class = get_fhir_model_class('Bundle')
|
|
274
|
+
return resource_class.model_validate_json(json.dumps(result_bundle_dict))
|
|
275
|
+
|
|
276
|
+
# Alias for post_bundle()
|
|
277
|
+
execute_bundle = post_bundle
|
|
278
|
+
|
|
279
|
+
def get_capability_statement(self) -> Any:
|
|
280
|
+
"""Retrieve the server's capability statement
|
|
281
|
+
|
|
282
|
+
:return: The server's capability statement.
|
|
283
|
+
:rtype: Any
|
|
284
|
+
"""
|
|
285
|
+
response = self.get("metadata")
|
|
286
|
+
capability_statement_dict = response.json()
|
|
287
|
+
resource_class = get_fhir_model_class('CapabilityStatement')
|
|
288
|
+
return resource_class.model_validate_json(json.dumps(capability_statement_dict))
|
|
289
|
+
|
|
290
|
+
def execute_operation(self, operation_name: str, resource_type: Optional[str] = None,
|
|
291
|
+
id: Optional[str] = None, parameters: Optional[Dict[str, Any]] = None) -> Any:
|
|
292
|
+
"""Execute a FHIR operation
|
|
293
|
+
|
|
294
|
+
:param operation_name: The operation to execute.
|
|
295
|
+
:type operation_name: str
|
|
296
|
+
:param resource_type: The resource type to search for.
|
|
297
|
+
:type resource_type: str
|
|
298
|
+
:param id: The id of the resource to execute.
|
|
299
|
+
:type id: str
|
|
300
|
+
:param parameters: Optional parameters to pass to the parent HTTPClient.
|
|
301
|
+
:type parameters: Dict[str, Any]
|
|
302
|
+
:return: The executed operation.
|
|
303
|
+
:rtype: Any
|
|
304
|
+
"""
|
|
305
|
+
path = f"${operation_name}"
|
|
306
|
+
if resource_type and id:
|
|
307
|
+
path = f"{resource_type}/{id}/" + path
|
|
308
|
+
elif resource_type:
|
|
309
|
+
path = f"{resource_type}/" + path
|
|
310
|
+
|
|
311
|
+
response = self.post(path, json=parameters)
|
|
312
|
+
result_dict = response.json()
|
|
313
|
+
resource_class = get_fhir_model_class('Parameters')
|
|
314
|
+
return resource_class.model_validate_json(json.dumps(result_dict))
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
"""Reading FHIR resources from files: JSON, NDJSON and FHIR XML, each optionally gzip compressed."""
|
|
2
|
+
import gzip
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
import os
|
|
6
|
+
import zlib
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import IO, Iterable, Iterator, Literal, Optional, Tuple, Union
|
|
10
|
+
|
|
11
|
+
from fhir.resources.R4B import get_fhir_model_class
|
|
12
|
+
from fhir.resources.R4B.resource import Resource
|
|
13
|
+
from pydantic import ValidationError
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
# File formats by extension, before an optional .gz
|
|
18
|
+
FORMATS = {".ndjson": "ndjson", ".json": "json", ".xml": "xml"}
|
|
19
|
+
FHIR_NAMESPACE = "http://hl7.org/fhir"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class FHIRFileError(Exception):
|
|
23
|
+
"""An unreadable or invalid file, NDJSON line or resource. The message starts with the path (and line)."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class FHIRFileItem:
|
|
28
|
+
"""A resource and where it was read: the file, and for NDJSON the line (from 1), otherwise None."""
|
|
29
|
+
path: Path
|
|
30
|
+
line: Optional[int]
|
|
31
|
+
resource: Resource
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class FHIRFileReader:
|
|
35
|
+
"""
|
|
36
|
+
Reads FHIR R4B resources from files. The format comes from the file extension (case-insensitive): .json (a
|
|
37
|
+
resource), .ndjson (a resource per line, empty lines are skipped) or .xml (a resource in FHIR XML, needs the extra
|
|
38
|
+
dic-helper-toolkit[xml]), each optionally followed by .gz for gzip compression. Text is UTF-8, optionally with BOM.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
def __init__(self, paths: Union[str, os.PathLike, Iterable[Union[str, os.PathLike]]], unpack_bundles: bool = True,
|
|
42
|
+
on_error: Literal["raise", "skip"] = "raise") -> None:
|
|
43
|
+
"""
|
|
44
|
+
Initialize the FHIRFileReader. The files are only read by resources().
|
|
45
|
+
|
|
46
|
+
:param paths: The files to read, in this order. A single path can be given on its own.
|
|
47
|
+
:type paths: Union[str, os.PathLike, Iterable[Union[str, os.PathLike]]]
|
|
48
|
+
:param unpack_bundles: Return the resources of a Bundle's entries instead of the Bundle (only the outermost
|
|
49
|
+
Bundle; entries without a resource are left out).
|
|
50
|
+
:type unpack_bundles: bool
|
|
51
|
+
:param on_error: 'raise': an unreadable or invalid file, line or resource raises FHIRFileError. 'skip': it is
|
|
52
|
+
logged as a warning instead, and reading continues with the next line (NDJSON) or the next file.
|
|
53
|
+
:type on_error: Literal["raise", "skip"]
|
|
54
|
+
:raises ValueError: If on_error is neither 'raise' nor 'skip'.
|
|
55
|
+
"""
|
|
56
|
+
if on_error not in ("raise", "skip"):
|
|
57
|
+
raise ValueError(f"on_error must be 'raise' or 'skip', not {on_error!r}")
|
|
58
|
+
if isinstance(paths, (str, os.PathLike)):
|
|
59
|
+
paths = [paths]
|
|
60
|
+
self.paths = [Path(path) for path in paths]
|
|
61
|
+
self.unpack_bundles = unpack_bundles
|
|
62
|
+
self.on_error = on_error
|
|
63
|
+
|
|
64
|
+
def resources(self) -> Iterator[FHIRFileItem]:
|
|
65
|
+
"""
|
|
66
|
+
Read the files.
|
|
67
|
+
|
|
68
|
+
:return: The resources with their origin, in the order of the files and within each file.
|
|
69
|
+
:rtype: Iterator[FHIRFileItem]
|
|
70
|
+
:raises FHIRFileError: With on_error 'raise', for an unreadable or invalid file, line or resource.
|
|
71
|
+
:raises ImportError: For an XML file without lxml installed, also with on_error 'skip'.
|
|
72
|
+
"""
|
|
73
|
+
for path in self.paths:
|
|
74
|
+
yield from self._read_file(path)
|
|
75
|
+
|
|
76
|
+
def _read_file(self, path: Path) -> Iterator[FHIRFileItem]:
|
|
77
|
+
"""
|
|
78
|
+
Read the resources of one file in its format. Errors of the whole file go to _fail().
|
|
79
|
+
|
|
80
|
+
:param path: The file to read.
|
|
81
|
+
:type path: Path
|
|
82
|
+
:return: The resources of the file with their origin.
|
|
83
|
+
:rtype: Iterator[FHIRFileItem]
|
|
84
|
+
"""
|
|
85
|
+
try:
|
|
86
|
+
file_format, compressed = self._format(path)
|
|
87
|
+
with self._open(path, compressed) as file:
|
|
88
|
+
if file_format == "ndjson":
|
|
89
|
+
yield from self._read_ndjson(path, file)
|
|
90
|
+
else:
|
|
91
|
+
content = file.read()
|
|
92
|
+
if file_format == "json":
|
|
93
|
+
resource = self._from_json(self._decode(content, str(path)), str(path))
|
|
94
|
+
else:
|
|
95
|
+
resource = self._from_xml(content, str(path))
|
|
96
|
+
yield from self._items(path, None, resource)
|
|
97
|
+
except FHIRFileError as error:
|
|
98
|
+
self._fail(error)
|
|
99
|
+
except (OSError, EOFError, zlib.error) as error:
|
|
100
|
+
# Can't be opened, or isn't valid gzip
|
|
101
|
+
self._fail(FHIRFileError(f"{path}: cannot read file: {error}"))
|
|
102
|
+
|
|
103
|
+
def _read_ndjson(self, path: Path, file: IO[bytes]) -> Iterator[FHIRFileItem]:
|
|
104
|
+
"""
|
|
105
|
+
Read an NDJSON file line by line. Errors of a line go to _fail(), so with on_error 'skip' only that line is
|
|
106
|
+
left out.
|
|
107
|
+
|
|
108
|
+
:param path: The file, for the origin and for messages.
|
|
109
|
+
:type path: Path
|
|
110
|
+
:param file: The opened file, binary and already decompressed.
|
|
111
|
+
:type file: IO[bytes]
|
|
112
|
+
:return: The resources of the lines with their origin.
|
|
113
|
+
:rtype: Iterator[FHIRFileItem]
|
|
114
|
+
"""
|
|
115
|
+
for number, raw in enumerate(file, start=1):
|
|
116
|
+
location = f"{path}:{number}"
|
|
117
|
+
try:
|
|
118
|
+
text = self._decode(raw, location).strip()
|
|
119
|
+
if not text:
|
|
120
|
+
continue
|
|
121
|
+
resource = self._from_json(text, location)
|
|
122
|
+
except FHIRFileError as error:
|
|
123
|
+
self._fail(error)
|
|
124
|
+
continue
|
|
125
|
+
yield from self._items(path, number, resource)
|
|
126
|
+
|
|
127
|
+
def _items(self, path: Path, line: Optional[int], resource: Resource) -> Iterator[FHIRFileItem]:
|
|
128
|
+
"""
|
|
129
|
+
The results for a resource read from a file: the resources of its entries for a Bundle with unpack_bundles,
|
|
130
|
+
otherwise the resource itself.
|
|
131
|
+
|
|
132
|
+
:param path: The file the resource was read from.
|
|
133
|
+
:type path: Path
|
|
134
|
+
:param line: The NDJSON line the resource was read from, None for other formats.
|
|
135
|
+
:type line: Optional[int]
|
|
136
|
+
:param resource: The resource.
|
|
137
|
+
:type resource: Resource
|
|
138
|
+
:return: The resources with their origin.
|
|
139
|
+
:rtype: Iterator[FHIRFileItem]
|
|
140
|
+
"""
|
|
141
|
+
if self.unpack_bundles and resource.get_resource_type() == "Bundle":
|
|
142
|
+
for entry in resource.entry or []:
|
|
143
|
+
if entry.resource is not None:
|
|
144
|
+
yield FHIRFileItem(path, line, entry.resource)
|
|
145
|
+
else:
|
|
146
|
+
yield FHIRFileItem(path, line, resource)
|
|
147
|
+
|
|
148
|
+
def _fail(self, error: FHIRFileError) -> None:
|
|
149
|
+
"""
|
|
150
|
+
Handle an error according to on_error: raise it, or log it as a warning.
|
|
151
|
+
|
|
152
|
+
:param error: The error, its message names the file (and line).
|
|
153
|
+
:type error: FHIRFileError
|
|
154
|
+
:raises FHIRFileError: With on_error 'raise'.
|
|
155
|
+
"""
|
|
156
|
+
if self.on_error == "raise":
|
|
157
|
+
raise error
|
|
158
|
+
logger.warning(str(error))
|
|
159
|
+
|
|
160
|
+
@staticmethod
|
|
161
|
+
def _format(path: Path) -> Tuple[str, bool]:
|
|
162
|
+
"""
|
|
163
|
+
The format of a file and whether it is gzip compressed, from its extension (case-insensitive).
|
|
164
|
+
|
|
165
|
+
:param path: The file.
|
|
166
|
+
:type path: Path
|
|
167
|
+
:return: The format ('json', 'ndjson' or 'xml') and whether the file ends with .gz.
|
|
168
|
+
:rtype: Tuple[str, bool]
|
|
169
|
+
:raises FHIRFileError: For any other extension.
|
|
170
|
+
"""
|
|
171
|
+
name = path.name.lower()
|
|
172
|
+
compressed = name.endswith(".gz")
|
|
173
|
+
if compressed:
|
|
174
|
+
name = name.removesuffix(".gz")
|
|
175
|
+
for extension, file_format in FORMATS.items():
|
|
176
|
+
if name.endswith(extension):
|
|
177
|
+
return file_format, compressed
|
|
178
|
+
raise FHIRFileError(f"{path}: unknown file type, expected .json, .ndjson or .xml, optionally with .gz")
|
|
179
|
+
|
|
180
|
+
@staticmethod
|
|
181
|
+
def _open(path: Path, compressed: bool) -> IO[bytes]:
|
|
182
|
+
"""
|
|
183
|
+
Open a file for reading bytes, decompressing it if it is gzip compressed.
|
|
184
|
+
|
|
185
|
+
:param path: The file.
|
|
186
|
+
:type path: Path
|
|
187
|
+
:param compressed: Whether the file is gzip compressed.
|
|
188
|
+
:type compressed: bool
|
|
189
|
+
:return: The opened file.
|
|
190
|
+
:rtype: IO[bytes]
|
|
191
|
+
:raises OSError: If the file can't be opened.
|
|
192
|
+
"""
|
|
193
|
+
return gzip.open(path, "rb") if compressed else open(path, "rb")
|
|
194
|
+
|
|
195
|
+
@staticmethod
|
|
196
|
+
def _decode(content: bytes, location: str) -> str:
|
|
197
|
+
"""
|
|
198
|
+
Decode UTF-8 text, with or without a byte order mark.
|
|
199
|
+
|
|
200
|
+
:param content: The bytes of a file or an NDJSON line.
|
|
201
|
+
:type content: bytes
|
|
202
|
+
:param location: The file (and line) for the error message.
|
|
203
|
+
:type location: str
|
|
204
|
+
:return: The text, without byte order mark.
|
|
205
|
+
:rtype: str
|
|
206
|
+
:raises FHIRFileError: If the content is not UTF-8.
|
|
207
|
+
"""
|
|
208
|
+
try:
|
|
209
|
+
# utf-8-sig removes a BOM, and reads text without BOM like utf-8
|
|
210
|
+
return content.decode("utf-8-sig")
|
|
211
|
+
except UnicodeDecodeError as error:
|
|
212
|
+
raise FHIRFileError(f"{location}: not UTF-8: {error}") from None
|
|
213
|
+
|
|
214
|
+
@staticmethod
|
|
215
|
+
def _model_class(resource_type: object, location: str) -> type:
|
|
216
|
+
"""
|
|
217
|
+
The fhir.resources R4B model class of a resource type.
|
|
218
|
+
|
|
219
|
+
:param resource_type: The resource type, e.g. 'Patient'.
|
|
220
|
+
:type resource_type: object
|
|
221
|
+
:param location: The file (and line) for the error message.
|
|
222
|
+
:type location: str
|
|
223
|
+
:return: The model class.
|
|
224
|
+
:rtype: type
|
|
225
|
+
:raises FHIRFileError: If it is not a FHIR resource type, also for data types like 'HumanName'.
|
|
226
|
+
"""
|
|
227
|
+
try:
|
|
228
|
+
model_class = get_fhir_model_class(str(resource_type))
|
|
229
|
+
except ValueError:
|
|
230
|
+
model_class = None
|
|
231
|
+
if model_class is None or not model_class.has_resource_base():
|
|
232
|
+
raise FHIRFileError(f"{location}: unknown resource type {resource_type!r}")
|
|
233
|
+
return model_class
|
|
234
|
+
|
|
235
|
+
def _from_json(self, text: str, location: str) -> Resource:
|
|
236
|
+
"""
|
|
237
|
+
Parse and validate a resource in JSON.
|
|
238
|
+
|
|
239
|
+
:param text: The JSON text of a file or an NDJSON line.
|
|
240
|
+
:type text: str
|
|
241
|
+
:param location: The file (and line) for error messages.
|
|
242
|
+
:type location: str
|
|
243
|
+
:return: The resource.
|
|
244
|
+
:rtype: Resource
|
|
245
|
+
:raises FHIRFileError: For invalid JSON, a missing or unknown resourceType, or an invalid resource.
|
|
246
|
+
"""
|
|
247
|
+
try:
|
|
248
|
+
data = json.loads(text)
|
|
249
|
+
except json.JSONDecodeError as error:
|
|
250
|
+
raise FHIRFileError(f"{location}: invalid JSON: {error}") from None
|
|
251
|
+
if not isinstance(data, dict) or "resourceType" not in data:
|
|
252
|
+
raise FHIRFileError(f"{location}: not a FHIR resource, resourceType is missing")
|
|
253
|
+
model_class = self._model_class(data["resourceType"], location)
|
|
254
|
+
try:
|
|
255
|
+
return model_class.model_validate(data)
|
|
256
|
+
except ValidationError as error:
|
|
257
|
+
raise FHIRFileError(f"{location}: invalid {data['resourceType']}: {_problems(error)}") from None
|
|
258
|
+
|
|
259
|
+
def _from_xml(self, content: bytes, location: str) -> Resource:
|
|
260
|
+
"""
|
|
261
|
+
Parse and validate a resource in FHIR XML. The resource type is the name of the root element, which must be in
|
|
262
|
+
the FHIR namespace. External entities are not resolved.
|
|
263
|
+
|
|
264
|
+
:param content: The bytes of the file, XML parses the encoding itself.
|
|
265
|
+
:type content: bytes
|
|
266
|
+
:param location: The file for error messages.
|
|
267
|
+
:type location: str
|
|
268
|
+
:return: The resource.
|
|
269
|
+
:rtype: Resource
|
|
270
|
+
:raises FHIRFileError: For invalid XML, a root element outside the FHIR namespace, an unknown resource type or
|
|
271
|
+
an invalid resource.
|
|
272
|
+
:raises ImportError: If lxml is not installed.
|
|
273
|
+
"""
|
|
274
|
+
try:
|
|
275
|
+
from lxml import etree
|
|
276
|
+
except ImportError:
|
|
277
|
+
raise ImportError("Reading FHIR XML needs lxml, install dic-helper-toolkit[xml]") from None
|
|
278
|
+
# No external entities and no network access while parsing
|
|
279
|
+
parser = etree.XMLParser(resolve_entities=False, no_network=True)
|
|
280
|
+
try:
|
|
281
|
+
root = etree.fromstring(content, parser)
|
|
282
|
+
except etree.XMLSyntaxError as error:
|
|
283
|
+
raise FHIRFileError(f"{location}: invalid XML: {error}") from None
|
|
284
|
+
name = etree.QName(root)
|
|
285
|
+
if name.namespace != FHIR_NAMESPACE:
|
|
286
|
+
raise FHIRFileError(f"{location}: not a FHIR resource, the root element is not in the namespace "
|
|
287
|
+
f"{FHIR_NAMESPACE}")
|
|
288
|
+
model_class = self._model_class(name.localname, location)
|
|
289
|
+
try:
|
|
290
|
+
return model_class.model_validate_xml(content, xmlparser=parser)
|
|
291
|
+
except ValidationError as error:
|
|
292
|
+
raise FHIRFileError(f"{location}: invalid {name.localname}: {_problems(error)}") from None
|
|
293
|
+
except ValueError as error:
|
|
294
|
+
raise FHIRFileError(f"{location}: invalid {name.localname}: {error}") from None
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def _problems(error: ValidationError) -> str:
|
|
298
|
+
"""
|
|
299
|
+
The problems of a validation error on one line, e.g. 'birthDate: Date value string does not match spec regex.'.
|
|
300
|
+
|
|
301
|
+
:param error: The validation error of a resource.
|
|
302
|
+
:type error: ValidationError
|
|
303
|
+
:return: Every problem as '<element path>: <message>', separated by '; '.
|
|
304
|
+
:rtype: str
|
|
305
|
+
"""
|
|
306
|
+
return "; ".join(f"{'.'.join(str(part) for part in problem['loc'])}: {problem['msg'].removeprefix('Value error, ')}"
|
|
307
|
+
for problem in error.errors())
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
import requests
|
|
2
|
+
import ssl
|
|
3
|
+
import logging
|
|
4
|
+
from typing import Optional, Dict, Any
|
|
5
|
+
from requests.auth import HTTPBasicAuth
|
|
6
|
+
from requests_oauthlib import OAuth2Session
|
|
7
|
+
from oauthlib.oauth2 import LegacyApplicationClient, BackendApplicationClient, TokenExpiredError
|
|
8
|
+
from urllib.parse import urljoin
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class NoRFC5280HTTPAdapter(requests.adapters.HTTPAdapter):
|
|
15
|
+
"""
|
|
16
|
+
HTTP adapter that verifies certificates without Python's strict RFC 5280 checks (``VERIFY_X509_STRICT``).
|
|
17
|
+
|
|
18
|
+
Certificate chain and hostname verification stay enabled; only the additional strictness checks enabled by
|
|
19
|
+
default since Python 3.13 are relaxed. Applies to direct connections as well as connections through proxies.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
def __init__(self, *args, ca_file: Optional[str] = None, **kwargs):
|
|
23
|
+
"""Initialize the adapter with an SSL context without the strict RFC 5280 checks.
|
|
24
|
+
|
|
25
|
+
:param args: Positional arguments to pass to requests' HTTPAdapter.
|
|
26
|
+
:param ca_file: Path to a CA certificate file; if given, only this file is trusted, otherwise the system's CAs.
|
|
27
|
+
:type ca_file: str, optional
|
|
28
|
+
:param kwargs: Keyword arguments to pass to requests' HTTPAdapter.
|
|
29
|
+
"""
|
|
30
|
+
self._ssl_context = ssl.create_default_context(cafile=ca_file)
|
|
31
|
+
self._ssl_context.verify_flags &= ~ssl.VERIFY_X509_STRICT
|
|
32
|
+
super().__init__(*args, **kwargs)
|
|
33
|
+
|
|
34
|
+
def init_poolmanager(self, *args, **kwargs):
|
|
35
|
+
"""Create the pool manager for direct connections with this adapter's SSL context.
|
|
36
|
+
|
|
37
|
+
:param args: Positional arguments to pass to requests' HTTPAdapter.init_poolmanager.
|
|
38
|
+
:param kwargs: Keyword arguments to pass to requests' HTTPAdapter.init_poolmanager.
|
|
39
|
+
"""
|
|
40
|
+
kwargs["ssl_context"] = self._ssl_context
|
|
41
|
+
super().init_poolmanager(*args, **kwargs)
|
|
42
|
+
|
|
43
|
+
def proxy_manager_for(self, proxy, **proxy_kwargs):
|
|
44
|
+
"""Return the pool manager for connections through a proxy, with this adapter's SSL context.
|
|
45
|
+
|
|
46
|
+
:param proxy: The proxy URL.
|
|
47
|
+
:type proxy: str
|
|
48
|
+
:param proxy_kwargs: Keyword arguments to pass to requests' HTTPAdapter.proxy_manager_for.
|
|
49
|
+
:return: The proxy manager.
|
|
50
|
+
:rtype: urllib3.ProxyManager
|
|
51
|
+
"""
|
|
52
|
+
proxy_kwargs["ssl_context"] = self._ssl_context
|
|
53
|
+
return super().proxy_manager_for(proxy, **proxy_kwargs)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class HTTPClient:
|
|
57
|
+
"""
|
|
58
|
+
A client for making HTTP requests with support for OAuth2 authentication.
|
|
59
|
+
|
|
60
|
+
This client can handle basic authentication, OAuth2 Resource Owner Password Credentials flow, and OAuth2 Client
|
|
61
|
+
Credentials flow.
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
def __init__(self, base_url: str, username: Optional[str] = None, password: Optional[str] = None,
|
|
65
|
+
auth_url: Optional[str] = None, realm: Optional[str] = None,
|
|
66
|
+
client_id: Optional[str] = None, client_secret: Optional[str] = None,
|
|
67
|
+
ca_file: Optional[str] = None, rfc5280: bool = True) -> None:
|
|
68
|
+
"""Initialize the HTTPClient.
|
|
69
|
+
|
|
70
|
+
:param base_url: The base URL for API requests.
|
|
71
|
+
:type base_url: str
|
|
72
|
+
:param username: Username for authentication.
|
|
73
|
+
:type username: str, optional
|
|
74
|
+
:param password: Password for authentication.
|
|
75
|
+
:type password: str, optional
|
|
76
|
+
:param auth_url: URL for OAuth2 authentication.
|
|
77
|
+
:type auth_url: str, optional
|
|
78
|
+
:param realm: Realm for OAuth2 authentication.
|
|
79
|
+
:type realm: str, optional
|
|
80
|
+
:param client_id: Client ID for OAuth2 authentication.
|
|
81
|
+
:type client_id: str, optional
|
|
82
|
+
:param client_secret: Client secret for OAuth2 authentication.
|
|
83
|
+
:type client_secret: str, optional
|
|
84
|
+
:param ca_file: Path to CA certificate file for SSL verification.
|
|
85
|
+
:type ca_file: str, optional
|
|
86
|
+
:param rfc5280: Enforce Python's strict RFC 5280 certificate checks (``VERIFY_X509_STRICT``). Defaults to
|
|
87
|
+
True. False relaxes only these checks so that certificates from non-conforming CAs are accepted;
|
|
88
|
+
chain and hostname verification remain active either way.
|
|
89
|
+
:type rfc5280: bool, optional
|
|
90
|
+
"""
|
|
91
|
+
self.base_url: str = self._normalize_url(base_url)
|
|
92
|
+
self.username: Optional[str] = username
|
|
93
|
+
self.password: Optional[str] = password
|
|
94
|
+
self.auth_url: Optional[str] = self._normalize_url(auth_url) if auth_url else None
|
|
95
|
+
self.realm: Optional[str] = realm
|
|
96
|
+
self.client_id: Optional[str] = client_id
|
|
97
|
+
self.client_secret: Optional[str] = client_secret
|
|
98
|
+
self.ca_file: Optional[str] = ca_file
|
|
99
|
+
self.rfc5280: bool = rfc5280
|
|
100
|
+
self.session: Optional[requests.Session] = None
|
|
101
|
+
self.token: Optional[Dict[str, Any]] = None
|
|
102
|
+
self.token_url: Optional[str] = None
|
|
103
|
+
self._create_session()
|
|
104
|
+
|
|
105
|
+
@staticmethod
|
|
106
|
+
def _normalize_url(url: str) -> str:
|
|
107
|
+
"""
|
|
108
|
+
Ensure the URL ends with a forward slash.
|
|
109
|
+
|
|
110
|
+
:param url: The URL to normalize.
|
|
111
|
+
:type url: str
|
|
112
|
+
:return: The normalized URL.
|
|
113
|
+
:rtype: str
|
|
114
|
+
"""
|
|
115
|
+
if url.endswith('/'):
|
|
116
|
+
return url
|
|
117
|
+
else:
|
|
118
|
+
return f"{url}/"
|
|
119
|
+
|
|
120
|
+
def _get_openid_configuration(self) -> Dict[str, Any]:
|
|
121
|
+
"""
|
|
122
|
+
Retrieve the OpenID configuration from the authentication server.
|
|
123
|
+
|
|
124
|
+
:return: The OpenID configuration.
|
|
125
|
+
:rtype: dict
|
|
126
|
+
:raises requests.HTTPError: If the request fails.
|
|
127
|
+
"""
|
|
128
|
+
config_url = urljoin(urljoin(self.auth_url, f"realms/{self.realm}/"),
|
|
129
|
+
".well-known/openid-configuration")
|
|
130
|
+
response = self.session.get(config_url)
|
|
131
|
+
response.raise_for_status()
|
|
132
|
+
return response.json()
|
|
133
|
+
|
|
134
|
+
def _fetch_token(self) -> Dict[str, Any]:
|
|
135
|
+
"""
|
|
136
|
+
Fetch an OAuth2 token from the authentication server.
|
|
137
|
+
|
|
138
|
+
:return: The OAuth2 token.
|
|
139
|
+
:rtype: dict
|
|
140
|
+
:raises oauthlib.oauth2.OAuth2Error: If token fetching fails.
|
|
141
|
+
"""
|
|
142
|
+
if self.username and self.password:
|
|
143
|
+
# Resource Owner Password Credentials flow
|
|
144
|
+
return self.session.fetch_token(
|
|
145
|
+
token_url=self.token_url,
|
|
146
|
+
username=self.username,
|
|
147
|
+
password=self.password,
|
|
148
|
+
client_id=self.client_id,
|
|
149
|
+
client_secret=self.client_secret
|
|
150
|
+
)
|
|
151
|
+
else:
|
|
152
|
+
# Client Credentials flow
|
|
153
|
+
# TODO: untested, needs to be tested, after KeyCloak update
|
|
154
|
+
return self.session.fetch_token(
|
|
155
|
+
token_url=self.token_url,
|
|
156
|
+
client_id=self.client_id,
|
|
157
|
+
client_secret=self.client_secret
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
def _create_session(self) -> None:
|
|
161
|
+
"""
|
|
162
|
+
Create and configure the HTTP session based on the authentication method.
|
|
163
|
+
"""
|
|
164
|
+
oauth = self.auth_url and self.realm and self.client_id and self.client_secret
|
|
165
|
+
if oauth:
|
|
166
|
+
if self.username and self.password:
|
|
167
|
+
self.session = OAuth2Session(client=LegacyApplicationClient(client_id=self.client_id))
|
|
168
|
+
else:
|
|
169
|
+
self.session = OAuth2Session(client=BackendApplicationClient(client_id=self.client_id))
|
|
170
|
+
else:
|
|
171
|
+
self.session = requests.Session()
|
|
172
|
+
if self.username and self.password:
|
|
173
|
+
self.session.auth = HTTPBasicAuth(self.username, self.password)
|
|
174
|
+
|
|
175
|
+
if self.ca_file:
|
|
176
|
+
self.session.verify = self.ca_file
|
|
177
|
+
|
|
178
|
+
if not self.rfc5280:
|
|
179
|
+
self.session.mount('https://', NoRFC5280HTTPAdapter(ca_file=self.ca_file))
|
|
180
|
+
|
|
181
|
+
if oauth:
|
|
182
|
+
self.token_url = self._get_openid_configuration()['token_endpoint']
|
|
183
|
+
self.token = self._fetch_token()
|
|
184
|
+
|
|
185
|
+
def _token_updater(self, token: Dict[str, Any]) -> None:
|
|
186
|
+
"""
|
|
187
|
+
Update the stored token when it's refreshed.
|
|
188
|
+
|
|
189
|
+
:param token: The new token.
|
|
190
|
+
:type token: dict
|
|
191
|
+
"""
|
|
192
|
+
logger.debug(f"Token updater called with token {token}")
|
|
193
|
+
self.token = token
|
|
194
|
+
|
|
195
|
+
def _make_request(self, method: str, path: str, **kwargs: Any) -> requests.Response:
|
|
196
|
+
"""
|
|
197
|
+
Make an HTTP request, handling token expiration if necessary.
|
|
198
|
+
|
|
199
|
+
:param method: The HTTP method (GET, POST, etc.).
|
|
200
|
+
:type method: str
|
|
201
|
+
:param path: The path to request.
|
|
202
|
+
:type path: str
|
|
203
|
+
:param kwargs: Additional arguments to pass to the request.
|
|
204
|
+
:return: The HTTP response.
|
|
205
|
+
:rtype: requests.Response
|
|
206
|
+
:raises requests.HTTPError: If the request fails.
|
|
207
|
+
"""
|
|
208
|
+
url = urljoin(self.base_url, path)
|
|
209
|
+
try:
|
|
210
|
+
response = self.session.request(method, url, **kwargs)
|
|
211
|
+
except TokenExpiredError:
|
|
212
|
+
logger.debug(f"Token expired for {url}, Re-fetching...")
|
|
213
|
+
self.token = self._fetch_token()
|
|
214
|
+
response = self.session.request(method, url, **kwargs)
|
|
215
|
+
|
|
216
|
+
if response.status_code == 401:
|
|
217
|
+
logger.debug(f"Token expired for {url}, Re-fetching...")
|
|
218
|
+
self.token = self._fetch_token()
|
|
219
|
+
response = self.session.request(method, url, **kwargs)
|
|
220
|
+
|
|
221
|
+
response.raise_for_status()
|
|
222
|
+
return response
|
|
223
|
+
|
|
224
|
+
def get(self, path: str, **kwargs: Any) -> requests.Response:
|
|
225
|
+
"""
|
|
226
|
+
Send a GET request.
|
|
227
|
+
|
|
228
|
+
:param path: The path to request.
|
|
229
|
+
:type path: str
|
|
230
|
+
:param kwargs: Additional arguments to pass to the request.
|
|
231
|
+
:return: The HTTP response.
|
|
232
|
+
:rtype: requests.Response
|
|
233
|
+
"""
|
|
234
|
+
return self._make_request('GET', path, **kwargs)
|
|
235
|
+
|
|
236
|
+
def post(self, path: str, **kwargs: Any) -> requests.Response:
|
|
237
|
+
"""
|
|
238
|
+
Send a POST request.
|
|
239
|
+
|
|
240
|
+
:param path: The path to request.
|
|
241
|
+
:type path: str
|
|
242
|
+
:param kwargs: Additional arguments to pass to the request.
|
|
243
|
+
:return: The HTTP response.
|
|
244
|
+
:rtype: requests.Response
|
|
245
|
+
"""
|
|
246
|
+
return self._make_request('POST', path, **kwargs)
|
|
247
|
+
|
|
248
|
+
def put(self, path: str, **kwargs: Any) -> requests.Response:
|
|
249
|
+
"""
|
|
250
|
+
Send a PUT request.
|
|
251
|
+
|
|
252
|
+
:param path: The path to request.
|
|
253
|
+
:type path: str
|
|
254
|
+
:param kwargs: Additional arguments to pass to the request.
|
|
255
|
+
:return: The HTTP response.
|
|
256
|
+
:rtype: requests.Response
|
|
257
|
+
"""
|
|
258
|
+
return self._make_request('PUT', path, **kwargs)
|
|
259
|
+
|
|
260
|
+
def delete(self, path: str, **kwargs: Any) -> requests.Response:
|
|
261
|
+
"""
|
|
262
|
+
Send a DELETE request.
|
|
263
|
+
|
|
264
|
+
:param path: The path to request.
|
|
265
|
+
:type path: str
|
|
266
|
+
:param kwargs: Additional arguments to pass to the request.
|
|
267
|
+
:return: The HTTP response.
|
|
268
|
+
:rtype: requests.Response
|
|
269
|
+
"""
|
|
270
|
+
return self._make_request('DELETE', path, **kwargs)
|
|
271
|
+
|
|
272
|
+
def patch(self, path: str, **kwargs: Any) -> requests.Response:
|
|
273
|
+
"""
|
|
274
|
+
Send a PATCH request.
|
|
275
|
+
|
|
276
|
+
:param path: The path to request.
|
|
277
|
+
:type path: str
|
|
278
|
+
:param kwargs: Additional arguments to pass to the request.
|
|
279
|
+
:return: The HTTP response.
|
|
280
|
+
:rtype: requests.Response
|
|
281
|
+
"""
|
|
282
|
+
return self._make_request('PATCH', path, **kwargs)
|
|
283
|
+
|
|
284
|
+
def request(self, method: str, path: str, **kwargs: Any) -> requests.Response:
|
|
285
|
+
"""
|
|
286
|
+
Send a custom HTTP request.
|
|
287
|
+
|
|
288
|
+
This method allows sending any HTTP request method and is used internally
|
|
289
|
+
by other convenience methods like get(), post(), etc.
|
|
290
|
+
|
|
291
|
+
:param method: The HTTP method (GET, POST, PUT, DELETE, etc.).
|
|
292
|
+
:type method: str
|
|
293
|
+
:param path: The path to request.
|
|
294
|
+
:type path: str
|
|
295
|
+
:param kwargs: Additional arguments to pass to the request.
|
|
296
|
+
:return: The HTTP response.
|
|
297
|
+
:rtype: requests.Response
|
|
298
|
+
:raises requests.HTTPError: If the request fails.
|
|
299
|
+
"""
|
|
300
|
+
return self._make_request(method, path, **kwargs)
|