diff --git a/.fern/metadata.json b/.fern/metadata.json index 508188d..5f5d026 100644 --- a/.fern/metadata.json +++ b/.fern/metadata.json @@ -1,17 +1,17 @@ { - "cliVersion": "5.118.0", + "cliVersion": "5.121.0", "generatorName": "fernapi/fern-python-sdk", - "generatorVersion": "5.18.1", + "generatorVersion": "5.30.0", "generatorConfig": { "client_class_name": "PhenomlClient", "wire_tests": { "enabled": true } }, - "originGitCommit": "26224ef37e22adb507f28c21e8991d4a27822bde", + "originGitCommit": "e555f47326197aad510a783942045e7c53476fc5", "originGitCommitIsDirty": true, "invokedBy": "ci", "requestedVersion": "AUTO", "ciProvider": "unknown", - "sdkVersion": "17.0.0" -} + "sdkVersion": "17.1.0" +} \ No newline at end of file diff --git a/.fern/replay.lock b/.fern/replay.lock index 25dc6cb..ed15218 100644 --- a/.fern/replay.lock +++ b/.fern/replay.lock @@ -120,21 +120,36 @@ generations: cli_version: unknown generator_versions: fernapi/fern-python-sdk: 5.18.1 -current_generation: 3ac2feef8a9595d7dea2a99b024322ae61959133 + - commit_sha: 618aa073944e4bca430657c9e239d6827157df79 + tree_hash: c0526d155b3fb81c65d2d77286733db11f1f57b7 + timestamp: 2026-09-14T15:53:01.388Z + cli_version: unknown + generator_versions: + fernapi/fern-python-sdk: 5.30.0 +current_generation: 618aa073944e4bca430657c9e239d6827157df79 patches: - id: patch-6516695e - content_hash: sha256:5caeff601ccd2db4bda36068ff6c1ac7dba46f1c049cccc367a7db553613acad + content_hash: sha256:4b07085503a73b0d7551a0483d04ec6c821df4b63f77762193d83acc0307f6db original_commit: 6516695ecaba47ae4bcc8119acca86a1113adeeb original_message: "Release 15.0.2: restore bundled openapi.json packaging (#169)" original_author: Gavin Sharp - base_generation: 3ac2feef8a9595d7dea2a99b024322ae61959133 + base_generation: 618aa073944e4bca430657c9e239d6827157df79 files: - pyproject.toml patch_content: | diff --git a/pyproject.toml b/pyproject.toml - index cea8b20..f3f1f73 100644 + index 1dc545d..4b6ff5e 100644 --- a/pyproject.toml +++ b/pyproject.toml + @@ -4,7 +4,7 @@ dynamic = ["version"] + + [tool.poetry] + name = "phenoml" + -version = "0.0.0.dev0" + +version = "17.1.0" + description = "" + readme = "README.md" + authors = [] @@ -31,6 +31,9 @@ classifiers = [ packages = [ { include = "phenoml", from = "src"} @@ -153,7 +168,7 @@ patches: [tool.poetry] name = "phenoml" - version = "17.0.0" + version = "17.1.0" description = "" readme = "README.md" authors = [] @@ -191,7 +206,7 @@ patches: python = "^3.10" aiohttp = { version = ">=3.14.1,<4", optional = true, python = ">=3.10"} httpx = ">=0.21.2" - httpx-aiohttp = { version = "0.1.8", optional = true, python = ">=3.10"} + httpx-aiohttp = { version = "^0.1.8", optional = true, python = ">=3.10"} pydantic = ">= 1.9.2" pydantic-core = ">=2.18.2,<3.0.0" typing_extensions = ">= 4.0.0" diff --git a/changelog.md b/changelog.md index 2a9ecbb..dfe0407 100644 --- a/changelog.md +++ b/changelog.md @@ -1,3 +1,20 @@ +## [17.1.0] - 2026-09-14 +### Added +- **`client.lang2fhir_batch`** — new sync and async client for asynchronous batch FHIR extraction supporting the full job lifecycle (`create`, `upload_item`, `finalize`, `cancel`, `get`, `get_results`, `get_result`, `list`), along with supporting Pydantic models (`BatchJob`, `BatchError`, `BatchCounts`, `BatchItemStatus`, `UploadItemResponse`, `JobDetailResponse`, `ResultsPageResponse`, `JobListResponse`). +- **`client.implementation_guides.create_version(...)` and `get_version(...)`** — new sync and async methods to publish and retrieve immutable exact canonical FHIR package versions beneath a guide family; `ConflictError` (HTTP 409) is raised on duplicate versions; new models `FhirImplementationGuide` and `ImplementationGuideVersionDetail` are exported from `phenoml.implementation_guides`. +- **`client.profiles.versions`** — new sync and async client for immutable retained StructureDefinition versions with `list`, `create`, `get`, and `delete` methods, plus `ProfileVersionCreateRequest` and `ProfileVersionListResponse` models. +- **`profiles_update` and `profiles_delete`** — new `PUT` and `DELETE` operations on the profiles client for in-place profile replacement and permanent deletion. +- **New optional fields** — `ImplementationGuideSummary.canonical_url` and `ImplementationGuideSummary.version_count`; `ProfileSummary.status`, `ProfileSummary.date`, and `ProfileSummary.canonical` added to their respective summary models. + +### Changed +- **`token` parameter on `PhenomlClient` and `AsyncPhenomlClient`** — now accepts `Union[str, Callable[[], str]]`, allowing a plain bearer token string to be passed directly without wrapping it in a callable. +- **`lang2fhir_batch` active-job limit** — the previous 4-active-job cap has been removed; HTTP 409 is no longer raised by `create`, and item-level retry errors now reflect a 30-minute per-attempt platform bound. +- **`OAuthTokenProvider`** — `grant_type="client_credentials"` is now explicitly sent on every token refresh (sync and async). +- **HTTP transport** — TCP keepalive is now applied to all connections, and requests with no body no longer send a spurious `Content-Type` header. + +### Fixed +- **Agent chat SSE stream** — empty SSE data frames are now skipped instead of raising a parse error in sync and async streaming responses. + ## [17.0.0] - 2026-09-09 ### Breaking Changes - **`ProfileSummary`** — `id`, `source`, `resource_type`, `url`, `version`, `fhir_version`, `implementation_guide`, `created_at`, and `updated_at` are now required; remove `None` guards for these fields. diff --git a/code-examples.json b/code-examples.json index 79472cc..92406ca 100644 --- a/code-examples.json +++ b/code-examples.json @@ -2,8 +2,8 @@ "metadata": { "language": "python", "packageName": "phenoml", - "sdkVersion": "17.0.0", - "specCommit": "26224ef37e22adb507f28c21e8991d4a27822bde", + "sdkVersion": "17.1.0", + "specCommit": "e555f47326197aad510a783942045e7c53476fc5", "generatorName": "fernapi/fern-python-sdk" }, "renderRules": { @@ -3957,6 +3957,77 @@ ] } }, + "POST /fhir/implementation-guides/{name}/versions": { + "httpMethod": "POST", + "httpPath": "/fhir/implementation-guides/{name}/versions", + "request": { + "body": null + }, + "response": { + "body": null + }, + "render": { + "callTemplate": "client.implementation_guides.implementation_guides.create_version(name={{name}}, {{__body__}})", + "params": [ + { + "name": "name", + "kind": "string" + } + ], + "body": { + "fieldSeparator": ", ", + "fields": [ + { + "jsonKey": "implementation_guide", + "fieldTemplate": "implementation_guide={{value}}", + "kind": "object", + "required": true + }, + { + "jsonKey": "profile_refs", + "fieldTemplate": "profile_refs={{value}}", + "kind": "list", + "required": true, + "items": { + "jsonKey": "", + "fieldTemplate": "{{value}}", + "kind": "string", + "required": true + } + }, + { + "jsonKey": "profile_context", + "fieldTemplate": "profile_context={{value}}", + "kind": "string", + "required": false + } + ] + } + } + }, + "GET /fhir/implementation-guides/{name}/versions/{version}": { + "httpMethod": "GET", + "httpPath": "/fhir/implementation-guides/{name}/versions/{version}", + "request": { + "body": null + }, + "response": { + "body": null + }, + "render": { + "callTemplate": "client.implementation_guides.implementation_guides.get_version(name={{name}}, version={{version}})", + "params": [ + { + "name": "name", + "kind": "string" + }, + { + "name": "version", + "kind": "string" + } + ] + } + }, "GET /fhir/profiles": { "httpMethod": "GET", "httpPath": "/fhir/profiles", diff --git a/poetry.lock b/poetry.lock index 3656b36..9d4e0e6 100644 --- a/poetry.lock +++ b/poetry.lock @@ -688,15 +688,15 @@ zstd = ["zstandard (>=0.18.0)"] [[package]] name = "httpx-aiohttp" -version = "0.1.8" +version = "0.1.12" description = "Aiohttp transport for HTTPX" optional = true python-versions = ">=3.8" groups = ["main"] markers = "extra == \"aiohttp\"" files = [ - {file = "httpx_aiohttp-0.1.8-py3-none-any.whl", hash = "sha256:b7bd958d1331f3759a38a0ba22ad29832cb63ca69498c17735228055bf78fa7e"}, - {file = "httpx_aiohttp-0.1.8.tar.gz", hash = "sha256:756c5e74cdb568c3248ba63fe82bfe8bbe64b928728720f7eaac64b3cf46f308"}, + {file = "httpx_aiohttp-0.1.12-py3-none-any.whl", hash = "sha256:5b0eac39a7f360fa7867a60bcb46bb1024eada9c01cbfecdb54dc1edb3fb7141"}, + {file = "httpx_aiohttp-0.1.12.tar.gz", hash = "sha256:81feec51fd82c0ecfa0e9aaf1b1a6c2591260d5e2bcbeb7eb0277a78e610df2c"}, ] [package.dependencies] @@ -1710,4 +1710,4 @@ aiohttp = ["aiohttp", "httpx-aiohttp"] [metadata] lock-version = "2.1" python-versions = "^3.10" -content-hash = "65ef513ef69a32344fa2133df094babe6493482ea7586d0e19edbcb2f4bbe7c8" +content-hash = "d6f20007544f9ff1b04d92f47b351e1eda2f66672b4c62350d35c919bc2ae9f3" diff --git a/pyproject.toml b/pyproject.toml index 17a6e91..4b6ff5e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ dynamic = ["version"] [tool.poetry] name = "phenoml" -version = "17.0.0" +version = "17.1.0" description = "" readme = "README.md" authors = [] @@ -42,7 +42,7 @@ Repository = 'https://github.com/phenoml/phenoml-python-sdk' python = "^3.10" aiohttp = { version = ">=3.14.1,<4", optional = true, python = ">=3.10"} httpx = ">=0.21.2" -httpx-aiohttp = { version = "0.1.8", optional = true, python = ">=3.10"} +httpx-aiohttp = { version = "^0.1.8", optional = true, python = ">=3.10"} pydantic = ">= 1.9.2" pydantic-core = ">=2.18.2,<3.0.0" typing_extensions = ">= 4.0.0" diff --git a/reference.md b/reference.md index 99385a6..63620b5 100644 --- a/reference.md +++ b/reference.md @@ -4689,11 +4689,9 @@ client.implementation_guides.implementation_guides.update(
-Deletes the stored metadata for an implementation guide — its -profile_context and timestamps. Member profiles keep their -implementation_guide assignment, so a guide still referenced by at least -one profile continues to appear in listings, just without context or -timestamps. +Deletes the stored name-level metadata and any exact canonical package +versions beneath the guide. Legacy member profile assignments are not +changed.
@@ -4751,6 +4749,184 @@ client.implementation_guides.implementation_guides.delete( + + + + +
client.implementation_guides.implementation_guides.create_version(...) -> ImplementationGuideVersionDetail +
+
+ +#### 📝 Description + +
+
+ +
+
+ +Publishes an exact package beneath this guide family. PR 2 temporarily +permits one exact package version per guide family; publishing another +version returns `409 Conflict` until multi-version package support lands. +
+
+
+
+ +#### 🔌 Usage + +
+
+ +
+
+ +```python +from phenoml import PhenomlClient +from phenoml.environment import PhenomlClientEnvironment +from phenoml.implementation_guides import FhirImplementationGuide + +client = PhenomlClient( + client_id="", + client_secret="", + environment=PhenomlClientEnvironment.DEFAULT, +) + +client.implementation_guides.implementation_guides.create_version( + name="name", + implementation_guide=FhirImplementationGuide( + resource_type="ImplementationGuide", + url="url", + version="version", + ), + profile_refs=[ + "profile_refs" + ], +) + +``` +
+
+
+
+ +#### ⚙️ Parameters + +
+
+ +
+
+ +**name:** `str` + +
+
+ +
+
+ +**implementation_guide:** `FhirImplementationGuide` + +
+
+ +
+
+ +**profile_refs:** `typing.List[str]` — Exact canonical `url|version` references to builtin or custom profiles. A package can contain at most 250 references. + +
+
+ +
+
+ +**profile_context:** `typing.Optional[str]` — Natural-language profile-selection context for this package. + +
+
+ +
+
+ +**request_options:** `typing.Optional[RequestOptions]` — Request-specific configuration. + +
+
+
+
+ + +
+
+
+ +
client.implementation_guides.implementation_guides.get_version(...) -> ImplementationGuideVersionDetail +
+
+ +#### 🔌 Usage + +
+
+ +
+
+ +```python +from phenoml import PhenomlClient +from phenoml.environment import PhenomlClientEnvironment + +client = PhenomlClient( + client_id="", + client_secret="", + environment=PhenomlClientEnvironment.DEFAULT, +) + +client.implementation_guides.implementation_guides.get_version( + name="name", + version="1.0.0", +) + +``` +
+
+
+
+ +#### ⚙️ Parameters + +
+
+ +
+
+ +**name:** `str` + +
+
+ +
+
+ +**version:** `str` — The authored ImplementationGuide.version. It may contain letters, numbers, and the punctuation characters `.`, `_`, `~`, `+`, and `-`; it cannot be exactly `.` or `..`. + +
+
+ +
+
+ +**request_options:** `typing.Optional[RequestOptions]` — Request-specific configuration. + +
+
+
+
+ +
@@ -5540,10 +5716,8 @@ credential. A `request_id` whose job was canceled or failed before it finalized is released for a fresh replay; once a job is finalized, its `request_id` keeps resolving to it even after cancellation. -An instance may hold at most 4 active (pending or processing) jobs at -once; a create past that limit returns `409`. The limit is instance-wide -— jobs are shared across the instance's credentials — so another -credential's jobs count against it. +There is no limit on how many jobs an instance may hold at once; how many +items run in parallel is a property of the instance, not of the job count. @@ -5880,8 +6054,8 @@ client.lang2fhir_batch.finalize(
-Drives a job to the terminal `canceled` state on request, freeing its -active-job slot immediately. Takes no request body. +Drives a job to the terminal `canceled` state on request. Takes no +request body. Cancel does not delete the job: the job record and any results already produced are preserved for the normal retention window, the same as a diff --git a/src/phenoml/agent/chat/client.py b/src/phenoml/agent/chat/client.py index 9939bf6..6cce68f 100644 --- a/src/phenoml/agent/chat/client.py +++ b/src/phenoml/agent/chat/client.py @@ -418,7 +418,7 @@ async def stream( async def main() -> None: - response = await client.agent.chat.stream( + response = client.agent.chat.stream( phenoml_on_behalf_of="Patient/550e8400-e29b-41d4-a716-446655440000", phenoml_fhir_provider="550e8400-e29b-41d4-a716-446655440000:eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c...", message="What is the patient's current condition?", diff --git a/src/phenoml/agent/chat/raw_client.py b/src/phenoml/agent/chat/raw_client.py index b163d97..221e657 100644 --- a/src/phenoml/agent/chat/raw_client.py +++ b/src/phenoml/agent/chat/raw_client.py @@ -277,6 +277,8 @@ def _iter(): for _sse in _event_source.iter_sse(): if _sse.data == None: return + if len(_sse.data) == 0: + continue try: yield typing.cast( AgentChatStreamEvent, @@ -766,6 +768,8 @@ async def _iter(): async for _sse in _event_source.aiter_sse(): if _sse.data == None: return + if len(_sse.data) == 0: + continue try: yield typing.cast( AgentChatStreamEvent, diff --git a/src/phenoml/client.py b/src/phenoml/client.py index 87cfa7e..8073193 100644 --- a/src/phenoml/client.py +++ b/src/phenoml/client.py @@ -63,8 +63,8 @@ class PhenomlClient: base_url : typing.Optional[str] The base url to use for requests from the client. - token : typing.Callable[[], str] - Authenticate by providing a callable that returns a pre-generated bearer token. In this mode, OAuth client credentials are not required. + token : typing.Union[str, typing.Callable[[], str]] + Authenticate by providing a pre-generated bearer token, or a callable that returns one. In this mode, OAuth client credentials are not required. timeout : typing.Optional[float] The timeout to be used, in seconds, for requests. By default the timeout is 60 seconds, unless a custom httpx client is used, in which case this default is not enforced. @@ -130,7 +130,7 @@ def __init__( follow_redirects: typing.Optional[bool] = True, httpx_client: typing.Optional[httpx.Client] = None, logging: typing.Optional[typing.Union[LogConfig, Logger]] = None, - token: typing.Callable[[], str], + token: typing.Union[str, typing.Callable[[], str]], ): ... def __init__( self, @@ -141,7 +141,7 @@ def __init__( headers: typing.Optional[typing.Dict[str, str]] = None, client_id: typing.Optional[str] = os.getenv("PHENOML_CLIENT_ID"), client_secret: typing.Optional[str] = os.getenv("PHENOML_CLIENT_SECRET"), - token: typing.Optional[typing.Callable[[], str]] = None, + token: typing.Optional[typing.Union[str, typing.Callable[[], str]]] = None, _token_getter_override: typing.Optional[typing.Callable[[], str]] = None, timeout: typing.Optional[float] = None, max_retries: typing.Optional[int] = None, @@ -155,7 +155,12 @@ def __init__( _defaulted_max_retries = max_retries if max_retries is not None else 2 if instance_url is not None: _instance_url = instance_url if instance_url is not None else "experiment.app.pheno.ml" - base_url = "https://{instanceUrl}".format(instanceUrl=_instance_url) + _environment_url_templates = { + PhenomlClientEnvironment.DEFAULT: "https://{instanceUrl}", + } + _url_template = _environment_url_templates.get(environment, "https://{instanceUrl}") + if base_url is None: + base_url = _url_template.format(instanceUrl=_instance_url) if token is not None: self._client_wrapper = SyncClientWrapper( base_url=_get_base_url(base_url=base_url, environment=environment), @@ -398,8 +403,8 @@ class AsyncPhenomlClient: base_url : typing.Optional[str] The base url to use for requests from the client. - token : typing.Callable[[], str] - Authenticate by providing a callable that returns a pre-generated bearer token. In this mode, OAuth client credentials are not required. + token : typing.Union[str, typing.Callable[[], str]] + Authenticate by providing a pre-generated bearer token, or a callable that returns one. In this mode, OAuth client credentials are not required. timeout : typing.Optional[float] The timeout to be used, in seconds, for requests. By default the timeout is 60 seconds, unless a custom httpx client is used, in which case this default is not enforced. @@ -465,7 +470,7 @@ def __init__( follow_redirects: typing.Optional[bool] = True, httpx_client: typing.Optional[httpx.AsyncClient] = None, logging: typing.Optional[typing.Union[LogConfig, Logger]] = None, - token: typing.Callable[[], str], + token: typing.Union[str, typing.Callable[[], str]], ): ... def __init__( self, @@ -476,7 +481,7 @@ def __init__( headers: typing.Optional[typing.Dict[str, str]] = None, client_id: typing.Optional[str] = os.getenv("PHENOML_CLIENT_ID"), client_secret: typing.Optional[str] = os.getenv("PHENOML_CLIENT_SECRET"), - token: typing.Optional[typing.Callable[[], str]] = None, + token: typing.Optional[typing.Union[str, typing.Callable[[], str]]] = None, _token_getter_override: typing.Optional[typing.Callable[[], str]] = None, timeout: typing.Optional[float] = None, max_retries: typing.Optional[int] = None, @@ -490,7 +495,12 @@ def __init__( _defaulted_max_retries = max_retries if max_retries is not None else 2 if instance_url is not None: _instance_url = instance_url if instance_url is not None else "experiment.app.pheno.ml" - base_url = "https://{instanceUrl}".format(instanceUrl=_instance_url) + _environment_url_templates = { + PhenomlClientEnvironment.DEFAULT: "https://{instanceUrl}", + } + _url_template = _environment_url_templates.get(environment, "https://{instanceUrl}") + if base_url is None: + base_url = _url_template.format(instanceUrl=_instance_url) if token is not None: self._client_wrapper = AsyncClientWrapper( base_url=_get_base_url(base_url=base_url, environment=environment), diff --git a/src/phenoml/core/client_wrapper.py b/src/phenoml/core/client_wrapper.py index dc25c0d..9e27324 100644 --- a/src/phenoml/core/client_wrapper.py +++ b/src/phenoml/core/client_wrapper.py @@ -33,12 +33,12 @@ def get_headers(self) -> typing.Dict[str, str]: import platform headers: typing.Dict[str, str] = { - "User-Agent": "phenoml/17.0.0", + "User-Agent": "phenoml/0.0.0-fern-placeholder", "X-Fern-Language": "Python", "X-Fern-Runtime": f"python/{platform.python_version()}", "X-Fern-Platform": f"{platform.system().lower()}/{platform.release()}", "X-Fern-SDK-Name": "phenoml", - "X-Fern-SDK-Version": "17.0.0", + "X-Fern-SDK-Version": "17.1.0", **(self.get_custom_headers() or {}), } token = self._get_token() diff --git a/src/phenoml/core/http_client.py b/src/phenoml/core/http_client.py index 124dce2..3e5a786 100644 --- a/src/phenoml/core/http_client.py +++ b/src/phenoml/core/http_client.py @@ -3,6 +3,7 @@ import asyncio import email.utils import re +import socket import time import typing from contextlib import asynccontextmanager, contextmanager @@ -23,6 +24,39 @@ JITTER_FACTOR = 0.2 # 20% random jitter +def get_keepalive_socket_options( + idle: int = 60, + intvl: int = 30, + cnt: int = 5, +) -> typing.List[typing.Tuple[int, int, int]]: + """ + Build TCP keepalive socket options for the current platform. + + Keepalive probes keep otherwise-idle connections alive so that long, + non-streaming requests survive idle-connection reaping by a firewall, + load balancer, or NAT. The available socket constants are OS-dependent, + so each option is guarded and only emitted when the platform defines it: + + - ``SO_KEEPALIVE`` is portable (Linux/macOS/Windows). + - The idle-before-first-probe knob is ``TCP_KEEPIDLE`` on Linux and modern + Windows, but ``TCP_KEEPALIVE`` on macOS. + - ``TCP_KEEPINTVL`` / ``TCP_KEEPCNT`` exist on Linux/macOS/modern Windows. + + Passing these tuples to ``httpx.HTTPTransport(socket_options=...)`` / + ``httpx.AsyncHTTPTransport(socket_options=...)`` applies them to every + connection the transport opens. + """ + opts: typing.List[typing.Tuple[int, int, int]] = [(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1)] + idle_const = getattr(socket, "TCP_KEEPIDLE", None) or getattr(socket, "TCP_KEEPALIVE", None) + if idle_const: + opts.append((socket.IPPROTO_TCP, idle_const, idle)) + if hasattr(socket, "TCP_KEEPINTVL"): + opts.append((socket.IPPROTO_TCP, socket.TCP_KEEPINTVL, intvl)) + if hasattr(socket, "TCP_KEEPCNT"): + opts.append((socket.IPPROTO_TCP, socket.TCP_KEEPCNT, cnt)) + return opts + + def _parse_retry_after(response_headers: httpx.Headers) -> typing.Optional[float]: """ This function parses the `Retry-After` header in a HTTP response and returns the number of seconds to wait. @@ -238,7 +272,16 @@ def get_request_body( data: typing.Optional[typing.Any], request_options: typing.Optional[RequestOptions], omit: typing.Optional[typing.Any], + optional_body: bool = False, ) -> typing.Tuple[typing.Optional[typing.Any], typing.Optional[typing.Any]]: + # A whole body left at the sentinel was never passed by the caller, so it is absent + # rather than empty: the request carries no content and no `Content-Type`. + if omit is not None: + if json is omit: + json = None + if data is omit: + data = None + json_body = None data_body = None if data is not None: @@ -254,14 +297,36 @@ def get_request_body( # Only collapse empty dict to None when the body was not explicitly provided # and there are no additional body parameters. This preserves explicit empty # bodies (e.g., when an endpoint has a request body type but all fields are optional). - if json_body == {} and json is None and not has_additional_body_parameters: + # `optional_body` marks an endpoint whose body the API does not require, where a body + # that ends up empty means the caller passed none of its properties, so the request is + # sent with no content and no `Content-Type`. + if json_body == {} and (json is None or optional_body) and not has_additional_body_parameters: json_body = None - if data_body == {} and data is None and not has_additional_body_parameters: + if data_body == {} and (data is None or optional_body) and not has_additional_body_parameters: data_body = None return json_body, data_body +def drop_content_type_without_body( + headers: typing.Dict[str, typing.Any], + *, + json_body: typing.Optional[typing.Any], + data_body: typing.Optional[typing.Any], + optional_body: bool, +) -> typing.Dict[str, typing.Any]: + """Strip ``Content-Type`` from a request that carries no body. + + ``get_request_body`` drops the body of an ``optional_body`` endpoint when the caller + supplied none of it, but the endpoint still passes the content type it would have used. + A request that sends nothing must not advertise a media type, so a server that branches + on the header sees a bodyless call for what it is. + """ + if not optional_body or json_body is not None or data_body is not None: + return headers + return {key: value for key, value in headers.items() if key.lower() != "content-type"} + + class HttpClient: def __init__( self, @@ -309,6 +374,7 @@ def request( request_options: typing.Optional[RequestOptions] = None, retries: int = 0, omit: typing.Optional[typing.Any] = None, + optional_body: bool = False, force_multipart: typing.Optional[bool] = None, ) -> httpx.Response: base_url = self.get_base_url(base_url) @@ -321,7 +387,9 @@ def request( ) timeout = _timeout if _timeout is not None else httpx.USE_CLIENT_DEFAULT - json_body, data_body = get_request_body(json=json, data=data, request_options=request_options, omit=omit) + json_body, data_body = get_request_body( + json=json, data=data, request_options=request_options, omit=omit, optional_body=optional_body + ) request_files: typing.Optional[RequestFiles] = ( convert_file_dict_to_httpx_tuples(remove_omit_from_dict(remove_none_from_dict(files), omit)) @@ -364,6 +432,9 @@ def request( } ) ) + _request_headers = drop_content_type_without_body( + _request_headers, json_body=json_body, data_body=data_body, optional_body=optional_body + ) if self.logger.is_debug(): self.logger.debug( @@ -472,6 +543,7 @@ def stream( request_options: typing.Optional[RequestOptions] = None, retries: int = 0, omit: typing.Optional[typing.Any] = None, + optional_body: bool = False, force_multipart: typing.Optional[bool] = None, ) -> typing.Iterator[httpx.Response]: base_url = self.get_base_url(base_url) @@ -493,7 +565,9 @@ def stream( if (request_files is None or len(request_files) == 0) and force_multipart: request_files = FORCE_MULTIPART - json_body, data_body = get_request_body(json=json, data=data, request_options=request_options, omit=omit) + json_body, data_body = get_request_body( + json=json, data=data, request_options=request_options, omit=omit, optional_body=optional_body + ) data_body = _maybe_filter_none_from_multipart_data(data_body, request_files, force_multipart) @@ -527,6 +601,9 @@ def stream( } ) ) + _request_headers = drop_content_type_without_body( + _request_headers, json_body=json_body, data_body=data_body, optional_body=optional_body + ) if self.logger.is_debug(): self.logger.debug( @@ -604,6 +681,7 @@ async def request( request_options: typing.Optional[RequestOptions] = None, retries: int = 0, omit: typing.Optional[typing.Any] = None, + optional_body: bool = False, force_multipart: typing.Optional[bool] = None, ) -> httpx.Response: base_url = self.get_base_url(base_url) @@ -625,7 +703,9 @@ async def request( if (request_files is None or len(request_files) == 0) and force_multipart: request_files = FORCE_MULTIPART - json_body, data_body = get_request_body(json=json, data=data, request_options=request_options, omit=omit) + json_body, data_body = get_request_body( + json=json, data=data, request_options=request_options, omit=omit, optional_body=optional_body + ) data_body = _maybe_filter_none_from_multipart_data(data_body, request_files, force_multipart) @@ -662,6 +742,9 @@ async def request( } ) ) + _request_headers = drop_content_type_without_body( + _request_headers, json_body=json_body, data_body=data_body, optional_body=optional_body + ) if self.logger.is_debug(): self.logger.debug( @@ -770,6 +853,7 @@ async def stream( request_options: typing.Optional[RequestOptions] = None, retries: int = 0, omit: typing.Optional[typing.Any] = None, + optional_body: bool = False, force_multipart: typing.Optional[bool] = None, ) -> typing.AsyncIterator[httpx.Response]: base_url = self.get_base_url(base_url) @@ -791,7 +875,9 @@ async def stream( if (request_files is None or len(request_files) == 0) and force_multipart: request_files = FORCE_MULTIPART - json_body, data_body = get_request_body(json=json, data=data, request_options=request_options, omit=omit) + json_body, data_body = get_request_body( + json=json, data=data, request_options=request_options, omit=omit, optional_body=optional_body + ) data_body = _maybe_filter_none_from_multipart_data(data_body, request_files, force_multipart) @@ -828,6 +914,9 @@ async def stream( } ) ) + _request_headers = drop_content_type_without_body( + _request_headers, json_body=json_body, data_body=data_body, optional_body=optional_body + ) if self.logger.is_debug(): self.logger.debug( diff --git a/src/phenoml/core/http_response.py b/src/phenoml/core/http_response.py index 00bb109..9aa1e18 100644 --- a/src/phenoml/core/http_response.py +++ b/src/phenoml/core/http_response.py @@ -24,6 +24,10 @@ def headers(self) -> Dict[str, str]: def status_code(self) -> int: return self._response.status_code + @property + def response(self) -> httpx.Response: + return self._response + class HttpResponse(Generic[T], BaseHttpResponse): """HTTP response wrapper that exposes response headers and data.""" diff --git a/src/phenoml/core/jsonable_encoder.py b/src/phenoml/core/jsonable_encoder.py index 5b0902e..f638cc9 100644 --- a/src/phenoml/core/jsonable_encoder.py +++ b/src/phenoml/core/jsonable_encoder.py @@ -15,6 +15,7 @@ from pathlib import PurePath from types import GeneratorType from typing import Any, Callable, Dict, List, Optional, Set, Union +from urllib.parse import quote import pydantic from .datetime_utils import serialize_datetime @@ -118,3 +119,15 @@ def encode_path_param(obj: Any) -> str: if isinstance(obj, bool): return "true" if obj else "false" return str(jsonable_encoder(obj)) + + +def quote_path_param(obj: Any) -> str: + """Encode a value for use in a URL path segment, percent-encoding it. + + Same as encode_path_param, except the result is percent-encoded so + that a value containing "/" or ".." cannot change which endpoint + the request resolves to. + """ + if isinstance(obj, bool): + return "true" if obj else "false" + return quote(str(jsonable_encoder(obj)), safe="") diff --git a/src/phenoml/core/oauth_token_provider.py b/src/phenoml/core/oauth_token_provider.py index 0360d17..370bb86 100644 --- a/src/phenoml/core/oauth_token_provider.py +++ b/src/phenoml/core/oauth_token_provider.py @@ -31,7 +31,9 @@ def get_token(self) -> str: return self._refresh() def _refresh(self) -> str: - token_response = self._auth_client.get_token(client_id=self._client_id, client_secret=self._client_secret) + token_response = self._auth_client.get_token( + client_id=self._client_id, client_secret=self._client_secret, grant_type="client_credentials" + ) self._access_token = token_response.access_token self._expires_at = self._get_expires_at( expires_in_seconds=token_response.expires_in, buffer_in_minutes=self.BUFFER_IN_MINUTES @@ -62,7 +64,9 @@ async def get_token(self) -> str: return await self._refresh() async def _refresh(self) -> str: - token_response = await self._auth_client.get_token(client_id=self._client_id, client_secret=self._client_secret) + token_response = await self._auth_client.get_token( + client_id=self._client_id, client_secret=self._client_secret, grant_type="client_credentials" + ) self._access_token = token_response.access_token self._expires_at = self._get_expires_at( expires_in_seconds=token_response.expires_in, buffer_in_minutes=self.BUFFER_IN_MINUTES diff --git a/src/phenoml/core/pydantic_utilities.py b/src/phenoml/core/pydantic_utilities.py index 6587f5e..70816b9 100644 --- a/src/phenoml/core/pydantic_utilities.py +++ b/src/phenoml/core/pydantic_utilities.py @@ -5,6 +5,7 @@ import inspect import json import logging +import weakref from collections import defaultdict from dataclasses import asdict from typing import ( @@ -184,6 +185,58 @@ def _get_type_adapter(type_: Type[Any]) -> Any: return adapter +_field_alias_cache: "weakref.WeakKeyDictionary[type, Tuple[Dict[str, str], Tuple[str, ...]]]" = ( + weakref.WeakKeyDictionary() +) + + +def _get_field_aliases(model: type) -> Tuple[Dict[str, str], Tuple[str, ...]]: + """ + Map of field name to Pydantic alias for the fields whose alias differs from their name, together with the + keys that are ambiguous (an alias of one field and the name of another). Computed once per model class. + """ + cached = _field_alias_cache.get(model) + if cached is None: + fields: Mapping[str, Any] = ( + getattr(model, "model_fields", {}) if IS_PYDANTIC_V2 else getattr(model, "__fields__", {}) + ) + name_to_alias: Dict[str, str] = {} + for name, field in fields.items(): + alias = getattr(field, "alias", None) + if alias is not None and alias != name: + name_to_alias[name] = alias + cached = (name_to_alias, tuple(alias for alias in name_to_alias.values() if alias in fields)) + _field_alias_cache[model] = cached + return cached + + +def _coerce_keys_to_aliases(model: type, data: Any) -> Any: + """ + Accept Python field names in input by rewriting them to their Pydantic aliases, + while avoiding silent collisions when a key could refer to multiple fields. + """ + if not isinstance(data, Mapping): + return data + + name_to_alias, ambiguous_keys = _get_field_aliases(model) + for key in ambiguous_keys: + if key in data and name_to_alias.get(key, key) not in data: + raise ValueError( + f"Ambiguous input key '{key}': it is both a field name and an alias. " + "Provide the explicit alias key to disambiguate." + ) + + if not name_to_alias or not any(name in data for name in name_to_alias): + return data if isinstance(data, dict) else dict(data) + + rewritten: Dict[str, Any] = dict(data) + for name, alias in name_to_alias.items(): + if name in data and alias not in rewritten: + rewritten[alias] = rewritten.pop(name) + + return rewritten + + def parse_obj_as(type_: Type[T], object_: Any) -> T: # convert_and_respect_annotation_metadata is required for TypedDict aliasing. # @@ -193,20 +246,7 @@ def parse_obj_as(type_: Type[T], object_: Any) -> T: # - If the model encodes aliasing only via FieldMetadata annotations, then we MUST pre-dealias because Pydantic # will not recognize those aliases during validation. if inspect.isclass(type_) and issubclass(type_, pydantic.BaseModel): - has_pydantic_aliases = False - if IS_PYDANTIC_V2: - for field_name, field_info in getattr(type_, "model_fields", {}).items(): # type: ignore[attr-defined] - alias = getattr(field_info, "alias", None) - if alias is not None and alias != field_name: - has_pydantic_aliases = True - break - else: - for field in getattr(type_, "__fields__", {}).values(): - alias = getattr(field, "alias", None) - name = getattr(field, "name", None) - if alias is not None and name is not None and alias != name: - has_pydantic_aliases = True - break + has_pydantic_aliases = bool(_get_field_aliases(type_)[0]) dealiased_object = ( object_ @@ -239,39 +279,7 @@ class UniversalBaseModel(pydantic.BaseModel): @pydantic.model_validator(mode="before") # type: ignore[attr-defined] @classmethod def _coerce_field_names_to_aliases(cls, data: Any) -> Any: - """ - Accept Python field names in input by rewriting them to their Pydantic aliases, - while avoiding silent collisions when a key could refer to multiple fields. - """ - if not isinstance(data, Mapping): - return data - - fields = getattr(cls, "model_fields", {}) # type: ignore[attr-defined] - name_to_alias: Dict[str, str] = {} - alias_to_name: Dict[str, str] = {} - - for name, field_info in fields.items(): - alias = getattr(field_info, "alias", None) or name - name_to_alias[name] = alias - if alias != name: - alias_to_name[alias] = name - - # Detect ambiguous keys: a key that is an alias for one field and a name for another. - ambiguous_keys = set(alias_to_name.keys()).intersection(set(name_to_alias.keys())) - for key in ambiguous_keys: - if key in data and name_to_alias[key] not in data: - raise ValueError( - f"Ambiguous input key '{key}': it is both a field name and an alias. " - "Provide the explicit alias key to disambiguate." - ) - - original_keys = set(data.keys()) - rewritten: Dict[str, Any] = dict(data) - for name, alias in name_to_alias.items(): - if alias != name and name in original_keys and alias not in rewritten: - rewritten[alias] = rewritten.pop(name) - - return rewritten + return _coerce_keys_to_aliases(cls, data) @pydantic.model_serializer(mode="plain", when_used="json") # type: ignore[attr-defined] def serialize_model(self) -> Any: # type: ignore[name-defined] @@ -287,37 +295,7 @@ class Config: @pydantic.root_validator(pre=True) def _coerce_field_names_to_aliases(cls, values: Any) -> Any: - """ - Pydantic v1 equivalent of _coerce_field_names_to_aliases. - """ - if not isinstance(values, Mapping): - return values - - fields = getattr(cls, "__fields__", {}) - name_to_alias: Dict[str, str] = {} - alias_to_name: Dict[str, str] = {} - - for name, field in fields.items(): - alias = getattr(field, "alias", None) or name - name_to_alias[name] = alias - if alias != name: - alias_to_name[alias] = name - - ambiguous_keys = set(alias_to_name.keys()).intersection(set(name_to_alias.keys())) - for key in ambiguous_keys: - if key in values and name_to_alias[key] not in values: - raise ValueError( - f"Ambiguous input key '{key}': it is both a field name and an alias. " - "Provide the explicit alias key to disambiguate." - ) - - original_keys = set(values.keys()) - rewritten: Dict[str, Any] = dict(values) - for name, alias in name_to_alias.items(): - if alias != name and name in original_keys and alias not in rewritten: - rewritten[alias] = rewritten.pop(name) - - return rewritten + return _coerce_keys_to_aliases(cls, values) # type: ignore[arg-type] @classmethod def model_construct(cls: Type["Model"], _fields_set: Optional[Set[str]] = None, **values: Any) -> "Model": diff --git a/src/phenoml/implementation_guides/__init__.py b/src/phenoml/implementation_guides/__init__.py index 2d19295..8b08ecc 100644 --- a/src/phenoml/implementation_guides/__init__.py +++ b/src/phenoml/implementation_guides/__init__.py @@ -6,15 +6,31 @@ from importlib import import_module if typing.TYPE_CHECKING: - from .types import ImplementationGuideDetail, ImplementationGuideListResponse, ImplementationGuideSummary - from .errors import BadRequestError, ForbiddenError, InternalServerError, NotFoundError, UnauthorizedError + from .types import ( + FhirImplementationGuide, + ImplementationGuideDetail, + ImplementationGuideListResponse, + ImplementationGuideSummary, + ImplementationGuideVersionDetail, + ) + from .errors import ( + BadRequestError, + ConflictError, + ForbiddenError, + InternalServerError, + NotFoundError, + UnauthorizedError, + ) from . import implementation_guides _dynamic_imports: typing.Dict[str, str] = { "BadRequestError": ".errors", + "ConflictError": ".errors", + "FhirImplementationGuide": ".types", "ForbiddenError": ".errors", "ImplementationGuideDetail": ".types", "ImplementationGuideListResponse": ".types", "ImplementationGuideSummary": ".types", + "ImplementationGuideVersionDetail": ".types", "InternalServerError": ".errors", "NotFoundError": ".errors", "UnauthorizedError": ".errors", @@ -45,10 +61,13 @@ def __dir__(): __all__ = [ "BadRequestError", + "ConflictError", + "FhirImplementationGuide", "ForbiddenError", "ImplementationGuideDetail", "ImplementationGuideListResponse", "ImplementationGuideSummary", + "ImplementationGuideVersionDetail", "InternalServerError", "NotFoundError", "UnauthorizedError", diff --git a/src/phenoml/implementation_guides/errors/__init__.py b/src/phenoml/implementation_guides/errors/__init__.py index 306a114..29aa057 100644 --- a/src/phenoml/implementation_guides/errors/__init__.py +++ b/src/phenoml/implementation_guides/errors/__init__.py @@ -7,12 +7,14 @@ if typing.TYPE_CHECKING: from .bad_request_error import BadRequestError + from .conflict_error import ConflictError from .forbidden_error import ForbiddenError from .internal_server_error import InternalServerError from .not_found_error import NotFoundError from .unauthorized_error import UnauthorizedError _dynamic_imports: typing.Dict[str, str] = { "BadRequestError": ".bad_request_error", + "ConflictError": ".conflict_error", "ForbiddenError": ".forbidden_error", "InternalServerError": ".internal_server_error", "NotFoundError": ".not_found_error", @@ -41,4 +43,11 @@ def __dir__(): return sorted(lazy_attrs) -__all__ = ["BadRequestError", "ForbiddenError", "InternalServerError", "NotFoundError", "UnauthorizedError"] +__all__ = [ + "BadRequestError", + "ConflictError", + "ForbiddenError", + "InternalServerError", + "NotFoundError", + "UnauthorizedError", +] diff --git a/src/phenoml/implementation_guides/errors/conflict_error.py b/src/phenoml/implementation_guides/errors/conflict_error.py new file mode 100644 index 0000000..abe1a9f --- /dev/null +++ b/src/phenoml/implementation_guides/errors/conflict_error.py @@ -0,0 +1,10 @@ +# This file was auto-generated by Fern from our API Definition. + +import typing + +from ...core.api_error import ApiError + + +class ConflictError(ApiError): + def __init__(self, body: typing.Any, headers: typing.Optional[typing.Dict[str, str]] = None): + super().__init__(status_code=409, headers=headers, body=body) diff --git a/src/phenoml/implementation_guides/implementation_guides/client.py b/src/phenoml/implementation_guides/implementation_guides/client.py index 6cc3374..ba604eb 100644 --- a/src/phenoml/implementation_guides/implementation_guides/client.py +++ b/src/phenoml/implementation_guides/implementation_guides/client.py @@ -4,9 +4,11 @@ from ...core.client_wrapper import AsyncClientWrapper, SyncClientWrapper from ...core.request_options import RequestOptions +from ..types.fhir_implementation_guide import FhirImplementationGuide from ..types.implementation_guide_detail import ImplementationGuideDetail from ..types.implementation_guide_list_response import ImplementationGuideListResponse from ..types.implementation_guide_summary import ImplementationGuideSummary +from ..types.implementation_guide_version_detail import ImplementationGuideVersionDetail from .raw_client import AsyncRawImplementationGuidesClient, RawImplementationGuidesClient # this is used as the default value for optional parameters @@ -136,11 +138,9 @@ def update( def delete(self, name: str, *, request_options: typing.Optional[RequestOptions] = None) -> None: """ - Deletes the stored metadata for an implementation guide — its - profile_context and timestamps. Member profiles keep their - implementation_guide assignment, so a guide still referenced by at least - one profile continues to appear in listings, just without context or - timestamps. + Deletes the stored name-level metadata and any exact canonical package + versions beneath the guide. Legacy member profile assignments are not + changed. Parameters ---------- @@ -169,6 +169,102 @@ def delete(self, name: str, *, request_options: typing.Optional[RequestOptions] _response = self._raw_client.delete(name, request_options=request_options) return _response.data + def create_version( + self, + name: str, + *, + implementation_guide: FhirImplementationGuide, + profile_refs: typing.Sequence[str], + profile_context: typing.Optional[str] = OMIT, + request_options: typing.Optional[RequestOptions] = None, + ) -> ImplementationGuideVersionDetail: + """ + Publishes an exact package beneath this guide family. PR 2 temporarily + permits one exact package version per guide family; publishing another + version returns `409 Conflict` until multi-version package support lands. + + Parameters + ---------- + name : str + + implementation_guide : FhirImplementationGuide + + profile_refs : typing.Sequence[str] + Exact canonical `url|version` references to builtin or custom profiles. A package can contain at most 250 references. + + profile_context : typing.Optional[str] + Natural-language profile-selection context for this package. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + ImplementationGuideVersionDetail + Canonical package published + + Examples + -------- + from phenoml import PhenomlClient + from phenoml.implementation_guides import FhirImplementationGuide + + client = PhenomlClient( + client_id="YOUR_CLIENT_ID", + client_secret="YOUR_CLIENT_SECRET", + ) + client.implementation_guides.implementation_guides.create_version( + name="name", + implementation_guide=FhirImplementationGuide( + url="url", + version="version", + ), + profile_refs=["profile_refs"], + ) + """ + _response = self._raw_client.create_version( + name, + implementation_guide=implementation_guide, + profile_refs=profile_refs, + profile_context=profile_context, + request_options=request_options, + ) + return _response.data + + def get_version( + self, name: str, version: str, *, request_options: typing.Optional[RequestOptions] = None + ) -> ImplementationGuideVersionDetail: + """ + Parameters + ---------- + name : str + + version : str + The authored ImplementationGuide.version. It may contain letters, numbers, and the punctuation characters `.`, `_`, `~`, `+`, and `-`; it cannot be exactly `.` or `..`. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + ImplementationGuideVersionDetail + Exact canonical package + + Examples + -------- + from phenoml import PhenomlClient + + client = PhenomlClient( + client_id="YOUR_CLIENT_ID", + client_secret="YOUR_CLIENT_SECRET", + ) + client.implementation_guides.implementation_guides.get_version( + name="name", + version="1.0.0", + ) + """ + _response = self._raw_client.get_version(name, version, request_options=request_options) + return _response.data + class AsyncImplementationGuidesClient: def __init__(self, *, client_wrapper: AsyncClientWrapper): @@ -321,11 +417,9 @@ async def main() -> None: async def delete(self, name: str, *, request_options: typing.Optional[RequestOptions] = None) -> None: """ - Deletes the stored metadata for an implementation guide — its - profile_context and timestamps. Member profiles keep their - implementation_guide assignment, so a guide still referenced by at least - one profile continues to appear in listings, just without context or - timestamps. + Deletes the stored name-level metadata and any exact canonical package + versions beneath the guide. Legacy member profile assignments are not + changed. Parameters ---------- @@ -361,3 +455,115 @@ async def main() -> None: """ _response = await self._raw_client.delete(name, request_options=request_options) return _response.data + + async def create_version( + self, + name: str, + *, + implementation_guide: FhirImplementationGuide, + profile_refs: typing.Sequence[str], + profile_context: typing.Optional[str] = OMIT, + request_options: typing.Optional[RequestOptions] = None, + ) -> ImplementationGuideVersionDetail: + """ + Publishes an exact package beneath this guide family. PR 2 temporarily + permits one exact package version per guide family; publishing another + version returns `409 Conflict` until multi-version package support lands. + + Parameters + ---------- + name : str + + implementation_guide : FhirImplementationGuide + + profile_refs : typing.Sequence[str] + Exact canonical `url|version` references to builtin or custom profiles. A package can contain at most 250 references. + + profile_context : typing.Optional[str] + Natural-language profile-selection context for this package. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + ImplementationGuideVersionDetail + Canonical package published + + Examples + -------- + import asyncio + + from phenoml import AsyncPhenomlClient + from phenoml.implementation_guides import FhirImplementationGuide + + client = AsyncPhenomlClient( + client_id="YOUR_CLIENT_ID", + client_secret="YOUR_CLIENT_SECRET", + ) + + + async def main() -> None: + await client.implementation_guides.implementation_guides.create_version( + name="name", + implementation_guide=FhirImplementationGuide( + url="url", + version="version", + ), + profile_refs=["profile_refs"], + ) + + + asyncio.run(main()) + """ + _response = await self._raw_client.create_version( + name, + implementation_guide=implementation_guide, + profile_refs=profile_refs, + profile_context=profile_context, + request_options=request_options, + ) + return _response.data + + async def get_version( + self, name: str, version: str, *, request_options: typing.Optional[RequestOptions] = None + ) -> ImplementationGuideVersionDetail: + """ + Parameters + ---------- + name : str + + version : str + The authored ImplementationGuide.version. It may contain letters, numbers, and the punctuation characters `.`, `_`, `~`, `+`, and `-`; it cannot be exactly `.` or `..`. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + ImplementationGuideVersionDetail + Exact canonical package + + Examples + -------- + import asyncio + + from phenoml import AsyncPhenomlClient + + client = AsyncPhenomlClient( + client_id="YOUR_CLIENT_ID", + client_secret="YOUR_CLIENT_SECRET", + ) + + + async def main() -> None: + await client.implementation_guides.implementation_guides.get_version( + name="name", + version="1.0.0", + ) + + + asyncio.run(main()) + """ + _response = await self._raw_client.get_version(name, version, request_options=request_options) + return _response.data diff --git a/src/phenoml/implementation_guides/implementation_guides/raw_client.py b/src/phenoml/implementation_guides/implementation_guides/raw_client.py index 8700e84..1eb8291 100644 --- a/src/phenoml/implementation_guides/implementation_guides/raw_client.py +++ b/src/phenoml/implementation_guides/implementation_guides/raw_client.py @@ -10,14 +10,18 @@ from ...core.parse_error import ParsingError from ...core.pydantic_utilities import parse_obj_as from ...core.request_options import RequestOptions +from ...core.serialization import convert_and_respect_annotation_metadata from ..errors.bad_request_error import BadRequestError +from ..errors.conflict_error import ConflictError from ..errors.forbidden_error import ForbiddenError from ..errors.internal_server_error import InternalServerError from ..errors.not_found_error import NotFoundError from ..errors.unauthorized_error import UnauthorizedError +from ..types.fhir_implementation_guide import FhirImplementationGuide from ..types.implementation_guide_detail import ImplementationGuideDetail from ..types.implementation_guide_list_response import ImplementationGuideListResponse from ..types.implementation_guide_summary import ImplementationGuideSummary +from ..types.implementation_guide_version_detail import ImplementationGuideVersionDetail from pydantic import ValidationError # this is used as the default value for optional parameters @@ -308,11 +312,9 @@ def update( def delete(self, name: str, *, request_options: typing.Optional[RequestOptions] = None) -> HttpResponse[None]: """ - Deletes the stored metadata for an implementation guide — its - profile_context and timestamps. Member profiles keep their - implementation_guide assignment, so a guide still referenced by at least - one profile continues to appear in listings, just without context or - timestamps. + Deletes the stored name-level metadata and any exact canonical package + versions beneath the guide. Legacy member profile assignments are not + changed. Parameters ---------- @@ -398,6 +400,173 @@ def delete(self, name: str, *, request_options: typing.Optional[RequestOptions] ) raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + def create_version( + self, + name: str, + *, + implementation_guide: FhirImplementationGuide, + profile_refs: typing.Sequence[str], + profile_context: typing.Optional[str] = OMIT, + request_options: typing.Optional[RequestOptions] = None, + ) -> HttpResponse[ImplementationGuideVersionDetail]: + """ + Publishes an exact package beneath this guide family. PR 2 temporarily + permits one exact package version per guide family; publishing another + version returns `409 Conflict` until multi-version package support lands. + + Parameters + ---------- + name : str + + implementation_guide : FhirImplementationGuide + + profile_refs : typing.Sequence[str] + Exact canonical `url|version` references to builtin or custom profiles. A package can contain at most 250 references. + + profile_context : typing.Optional[str] + Natural-language profile-selection context for this package. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + HttpResponse[ImplementationGuideVersionDetail] + Canonical package published + """ + _response = self._client_wrapper.httpx_client.request( + f"fhir/implementation-guides/{encode_path_param(name)}/versions", + method="POST", + json={ + "implementation_guide": convert_and_respect_annotation_metadata( + object_=implementation_guide, annotation=FhirImplementationGuide, direction="write" + ), + "profile_refs": profile_refs, + "profile_context": profile_context, + }, + headers={ + "content-type": "application/json", + }, + request_options=request_options, + omit=OMIT, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + ImplementationGuideVersionDetail, + parse_obj_as( + type_=ImplementationGuideVersionDetail, # type: ignore + object_=_response.json(), + ), + ) + return HttpResponse(response=_response, data=_data) + if _response.status_code == 400: + raise BadRequestError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 404: + raise NotFoundError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 409: + raise ConflictError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + except ValidationError as e: + raise ParsingError( + status_code=_response.status_code, headers=dict(_response.headers), body=_response.json(), cause=e + ) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + def get_version( + self, name: str, version: str, *, request_options: typing.Optional[RequestOptions] = None + ) -> HttpResponse[ImplementationGuideVersionDetail]: + """ + Parameters + ---------- + name : str + + version : str + The authored ImplementationGuide.version. It may contain letters, numbers, and the punctuation characters `.`, `_`, `~`, `+`, and `-`; it cannot be exactly `.` or `..`. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + HttpResponse[ImplementationGuideVersionDetail] + Exact canonical package + """ + _response = self._client_wrapper.httpx_client.request( + f"fhir/implementation-guides/{encode_path_param(name)}/versions/{encode_path_param(version)}", + method="GET", + request_options=request_options, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + ImplementationGuideVersionDetail, + parse_obj_as( + type_=ImplementationGuideVersionDetail, # type: ignore + object_=_response.json(), + ), + ) + return HttpResponse(response=_response, data=_data) + if _response.status_code == 400: + raise BadRequestError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 404: + raise NotFoundError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + except ValidationError as e: + raise ParsingError( + status_code=_response.status_code, headers=dict(_response.headers), body=_response.json(), cause=e + ) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + class AsyncRawImplementationGuidesClient: def __init__(self, *, client_wrapper: AsyncClientWrapper): @@ -685,11 +854,9 @@ async def delete( self, name: str, *, request_options: typing.Optional[RequestOptions] = None ) -> AsyncHttpResponse[None]: """ - Deletes the stored metadata for an implementation guide — its - profile_context and timestamps. Member profiles keep their - implementation_guide assignment, so a guide still referenced by at least - one profile continues to appear in listings, just without context or - timestamps. + Deletes the stored name-level metadata and any exact canonical package + versions beneath the guide. Legacy member profile assignments are not + changed. Parameters ---------- @@ -774,3 +941,170 @@ async def delete( status_code=_response.status_code, headers=dict(_response.headers), body=_response.json(), cause=e ) raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + async def create_version( + self, + name: str, + *, + implementation_guide: FhirImplementationGuide, + profile_refs: typing.Sequence[str], + profile_context: typing.Optional[str] = OMIT, + request_options: typing.Optional[RequestOptions] = None, + ) -> AsyncHttpResponse[ImplementationGuideVersionDetail]: + """ + Publishes an exact package beneath this guide family. PR 2 temporarily + permits one exact package version per guide family; publishing another + version returns `409 Conflict` until multi-version package support lands. + + Parameters + ---------- + name : str + + implementation_guide : FhirImplementationGuide + + profile_refs : typing.Sequence[str] + Exact canonical `url|version` references to builtin or custom profiles. A package can contain at most 250 references. + + profile_context : typing.Optional[str] + Natural-language profile-selection context for this package. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + AsyncHttpResponse[ImplementationGuideVersionDetail] + Canonical package published + """ + _response = await self._client_wrapper.httpx_client.request( + f"fhir/implementation-guides/{encode_path_param(name)}/versions", + method="POST", + json={ + "implementation_guide": convert_and_respect_annotation_metadata( + object_=implementation_guide, annotation=FhirImplementationGuide, direction="write" + ), + "profile_refs": profile_refs, + "profile_context": profile_context, + }, + headers={ + "content-type": "application/json", + }, + request_options=request_options, + omit=OMIT, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + ImplementationGuideVersionDetail, + parse_obj_as( + type_=ImplementationGuideVersionDetail, # type: ignore + object_=_response.json(), + ), + ) + return AsyncHttpResponse(response=_response, data=_data) + if _response.status_code == 400: + raise BadRequestError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 404: + raise NotFoundError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 409: + raise ConflictError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + except ValidationError as e: + raise ParsingError( + status_code=_response.status_code, headers=dict(_response.headers), body=_response.json(), cause=e + ) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + async def get_version( + self, name: str, version: str, *, request_options: typing.Optional[RequestOptions] = None + ) -> AsyncHttpResponse[ImplementationGuideVersionDetail]: + """ + Parameters + ---------- + name : str + + version : str + The authored ImplementationGuide.version. It may contain letters, numbers, and the punctuation characters `.`, `_`, `~`, `+`, and `-`; it cannot be exactly `.` or `..`. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + AsyncHttpResponse[ImplementationGuideVersionDetail] + Exact canonical package + """ + _response = await self._client_wrapper.httpx_client.request( + f"fhir/implementation-guides/{encode_path_param(name)}/versions/{encode_path_param(version)}", + method="GET", + request_options=request_options, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + ImplementationGuideVersionDetail, + parse_obj_as( + type_=ImplementationGuideVersionDetail, # type: ignore + object_=_response.json(), + ), + ) + return AsyncHttpResponse(response=_response, data=_data) + if _response.status_code == 400: + raise BadRequestError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 404: + raise NotFoundError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + except ValidationError as e: + raise ParsingError( + status_code=_response.status_code, headers=dict(_response.headers), body=_response.json(), cause=e + ) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) diff --git a/src/phenoml/implementation_guides/types/__init__.py b/src/phenoml/implementation_guides/types/__init__.py index 2b67df5..6c2a89f 100644 --- a/src/phenoml/implementation_guides/types/__init__.py +++ b/src/phenoml/implementation_guides/types/__init__.py @@ -6,13 +6,17 @@ from importlib import import_module if typing.TYPE_CHECKING: + from .fhir_implementation_guide import FhirImplementationGuide from .implementation_guide_detail import ImplementationGuideDetail from .implementation_guide_list_response import ImplementationGuideListResponse from .implementation_guide_summary import ImplementationGuideSummary + from .implementation_guide_version_detail import ImplementationGuideVersionDetail _dynamic_imports: typing.Dict[str, str] = { + "FhirImplementationGuide": ".fhir_implementation_guide", "ImplementationGuideDetail": ".implementation_guide_detail", "ImplementationGuideListResponse": ".implementation_guide_list_response", "ImplementationGuideSummary": ".implementation_guide_summary", + "ImplementationGuideVersionDetail": ".implementation_guide_version_detail", } @@ -37,4 +41,10 @@ def __dir__(): return sorted(lazy_attrs) -__all__ = ["ImplementationGuideDetail", "ImplementationGuideListResponse", "ImplementationGuideSummary"] +__all__ = [ + "FhirImplementationGuide", + "ImplementationGuideDetail", + "ImplementationGuideListResponse", + "ImplementationGuideSummary", + "ImplementationGuideVersionDetail", +] diff --git a/src/phenoml/implementation_guides/types/fhir_implementation_guide.py b/src/phenoml/implementation_guides/types/fhir_implementation_guide.py new file mode 100644 index 0000000..949b9df --- /dev/null +++ b/src/phenoml/implementation_guides/types/fhir_implementation_guide.py @@ -0,0 +1,38 @@ +# This file was auto-generated by Fern from our API Definition. + +import typing + +import pydantic +import typing_extensions +from ...core.pydantic_utilities import IS_PYDANTIC_V2, UniversalBaseModel +from ...core.serialization import FieldMetadata + + +class FhirImplementationGuide(UniversalBaseModel): + """ + A complete authored FHIR ImplementationGuide JSON resource. + """ + + resource_type: typing_extensions.Annotated[ + typing.Literal["ImplementationGuide"], FieldMetadata(alias="resourceType"), pydantic.Field(alias="resourceType") + ] = "ImplementationGuide" + id: typing.Optional[str] = None + url: str + version: str + name: typing.Optional[str] = None + status: typing.Optional[str] = None + package_id: typing_extensions.Annotated[ + typing.Optional[str], FieldMetadata(alias="packageId"), pydantic.Field(alias="packageId") + ] = None + fhir_version: typing_extensions.Annotated[ + typing.Optional[typing.List[str]], FieldMetadata(alias="fhirVersion"), pydantic.Field(alias="fhirVersion") + ] = None + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/src/phenoml/implementation_guides/types/implementation_guide_summary.py b/src/phenoml/implementation_guides/types/implementation_guide_summary.py index bd3364d..a6489ab 100644 --- a/src/phenoml/implementation_guides/types/implementation_guide_summary.py +++ b/src/phenoml/implementation_guides/types/implementation_guide_summary.py @@ -9,7 +9,7 @@ class ImplementationGuideSummary(UniversalBaseModel): """ - Metadata for an implementation guide. This is an instance-local grouping record, not a complete FHIR ImplementationGuide resource. + Metadata for an implementation guide. Canonical fields are present only for published canonical packages; metadata-only legacy records omit them. """ name: typing.Optional[str] = pydantic.Field(default=None) @@ -27,6 +27,16 @@ class ImplementationGuideSummary(UniversalBaseModel): The number of custom profiles in this implementation guide. """ + canonical_url: typing.Optional[str] = pydantic.Field(default=None) + """ + Canonical FHIR ImplementationGuide URL, when the family has an exact package. + """ + + version_count: typing.Optional[int] = pydantic.Field(default=None) + """ + Number of retained exact package versions. + """ + created_at: typing.Optional[dt.datetime] = pydantic.Field(default=None) """ Present only for guides that have stored metadata (a profile_context has been set). Omitted for guides that exist solely because a profile references them. diff --git a/src/phenoml/implementation_guides/types/implementation_guide_version_detail.py b/src/phenoml/implementation_guides/types/implementation_guide_version_detail.py new file mode 100644 index 0000000..a5c2e66 --- /dev/null +++ b/src/phenoml/implementation_guides/types/implementation_guide_version_detail.py @@ -0,0 +1,29 @@ +# This file was auto-generated by Fern from our API Definition. + +import datetime as dt +import typing + +import pydantic +from ...core.pydantic_utilities import IS_PYDANTIC_V2, UniversalBaseModel +from .fhir_implementation_guide import FhirImplementationGuide + + +class ImplementationGuideVersionDetail(UniversalBaseModel): + name: str + url: str + version: str + profile_context: str + profiles: typing.List[str] + profile_refs: typing.List[str] + implementation_guide: FhirImplementationGuide + created_at: dt.datetime + updated_at: dt.datetime + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/src/phenoml/lang2fhir_batch/client.py b/src/phenoml/lang2fhir_batch/client.py index d641755..3f8457a 100644 --- a/src/phenoml/lang2fhir_batch/client.py +++ b/src/phenoml/lang2fhir_batch/client.py @@ -90,10 +90,8 @@ def create( finalized is released for a fresh replay; once a job is finalized, its `request_id` keeps resolving to it even after cancellation. - An instance may hold at most 4 active (pending or processing) jobs at - once; a create past that limit returns `409`. The limit is instance-wide - — jobs are shared across the instance's credentials — so another - credential's jobs count against it. + There is no limit on how many jobs an instance may hold at once; how many + items run in parallel is a property of the instance, not of the job count. Parameters ---------- @@ -281,8 +279,8 @@ def finalize(self, job_id: str, *, request_options: typing.Optional[RequestOptio def cancel(self, job_id: str, *, request_options: typing.Optional[RequestOptions] = None) -> BatchJob: """ - Drives a job to the terminal `canceled` state on request, freeing its - active-job slot immediately. Takes no request body. + Drives a job to the terminal `canceled` state on request. Takes no + request body. Cancel does not delete the job: the job record and any results already produced are preserved for the normal retention window, the same as a @@ -554,10 +552,8 @@ async def create( finalized is released for a fresh replay; once a job is finalized, its `request_id` keeps resolving to it even after cancellation. - An instance may hold at most 4 active (pending or processing) jobs at - once; a create past that limit returns `409`. The limit is instance-wide - — jobs are shared across the instance's credentials — so another - credential's jobs count against it. + There is no limit on how many jobs an instance may hold at once; how many + items run in parallel is a property of the instance, not of the job count. Parameters ---------- @@ -769,8 +765,8 @@ async def main() -> None: async def cancel(self, job_id: str, *, request_options: typing.Optional[RequestOptions] = None) -> BatchJob: """ - Drives a job to the terminal `canceled` state on request, freeing its - active-job slot immediately. Takes no request body. + Drives a job to the terminal `canceled` state on request. Takes no + request body. Cancel does not delete the job: the job record and any results already produced are preserved for the normal retention window, the same as a diff --git a/src/phenoml/lang2fhir_batch/raw_client.py b/src/phenoml/lang2fhir_batch/raw_client.py index 8873fac..26fc905 100644 --- a/src/phenoml/lang2fhir_batch/raw_client.py +++ b/src/phenoml/lang2fhir_batch/raw_client.py @@ -161,10 +161,8 @@ def create( finalized is released for a fresh replay; once a job is finalized, its `request_id` keeps resolving to it even after cancellation. - An instance may hold at most 4 active (pending or processing) jobs at - once; a create past that limit returns `409`. The limit is instance-wide - — jobs are shared across the instance's credentials — so another - credential's jobs count against it. + There is no limit on how many jobs an instance may hold at once; how many + items run in parallel is a property of the instance, not of the job count. Parameters ---------- @@ -224,17 +222,6 @@ def create( ), ), ) - if _response.status_code == 409: - raise ConflictError( - headers=dict(_response.headers), - body=typing.cast( - typing.Any, - parse_obj_as( - type_=typing.Any, # type: ignore - object_=_response.json(), - ), - ), - ) if _response.status_code == 499: raise ClientClosedRequestError( headers=dict(_response.headers), @@ -630,8 +617,8 @@ def finalize( def cancel(self, job_id: str, *, request_options: typing.Optional[RequestOptions] = None) -> HttpResponse[BatchJob]: """ - Drives a job to the terminal `canceled` state on request, freeing its - active-job slot immediately. Takes no request body. + Drives a job to the terminal `canceled` state on request. Takes no + request body. Cancel does not delete the job: the job record and any results already produced are preserved for the normal retention window, the same as a @@ -1265,10 +1252,8 @@ async def create( finalized is released for a fresh replay; once a job is finalized, its `request_id` keeps resolving to it even after cancellation. - An instance may hold at most 4 active (pending or processing) jobs at - once; a create past that limit returns `409`. The limit is instance-wide - — jobs are shared across the instance's credentials — so another - credential's jobs count against it. + There is no limit on how many jobs an instance may hold at once; how many + items run in parallel is a property of the instance, not of the job count. Parameters ---------- @@ -1328,17 +1313,6 @@ async def create( ), ), ) - if _response.status_code == 409: - raise ConflictError( - headers=dict(_response.headers), - body=typing.cast( - typing.Any, - parse_obj_as( - type_=typing.Any, # type: ignore - object_=_response.json(), - ), - ), - ) if _response.status_code == 499: raise ClientClosedRequestError( headers=dict(_response.headers), @@ -1736,8 +1710,8 @@ async def cancel( self, job_id: str, *, request_options: typing.Optional[RequestOptions] = None ) -> AsyncHttpResponse[BatchJob]: """ - Drives a job to the terminal `canceled` state on request, freeing its - active-job slot immediately. Takes no request body. + Drives a job to the terminal `canceled` state on request. Takes no + request body. Cancel does not delete the job: the job record and any results already produced are preserved for the normal retention window, the same as a diff --git a/src/phenoml/lang2fhir_batch/types/batch_error.py b/src/phenoml/lang2fhir_batch/types/batch_error.py index 29efa45..63e4ede 100644 --- a/src/phenoml/lang2fhir_batch/types/batch_error.py +++ b/src/phenoml/lang2fhir_batch/types/batch_error.py @@ -15,12 +15,12 @@ class BatchError(UniversalBaseModel): """ Short stable token to branch on. Item-level kinds: `invalid_input` (the stored body was not a valid create/document request), - `processing_failed` (the conversion failed), `budget_exceeded` (the - item ran past its time budget — 600s for a document item, 450s for a - create item), `result_too_large` (the result exceeded the storage - cap), `input_unavailable` (the input could not be read), and - `retries_exhausted` / `attempts_exhausted` (buried after too many - failed attempts). + `processing_failed` (the conversion failed), `result_too_large` (the + result exceeded the storage cap), `input_unavailable` (the input + could not be read), and `retries_exhausted` / `attempts_exhausted` + (buried after too many interrupted attempts; an item has no + per-item time budget, but a single attempt is bounded by the + platform at 30 minutes, and an item cut off three times is buried). Job-level kinds: `timeout` (the job did not finish within 36 hours of creation). """ diff --git a/src/phenoml/lang2fhir_batch/types/batch_job.py b/src/phenoml/lang2fhir_batch/types/batch_job.py index a574933..91bd46b 100644 --- a/src/phenoml/lang2fhir_batch/types/batch_job.py +++ b/src/phenoml/lang2fhir_batch/types/batch_job.py @@ -31,8 +31,8 @@ class BatchJob(UniversalBaseModel): failure (the job could not run at all), distinct from individual item failures, which never fail the job. `canceled` is a caller-requested cancellation via `POST /lang2fhir/batch/{job_id}/cancel`; like the - other terminal states it frees the job's active-job slot and keeps any - results already produced readable for the retention window. + other terminal states it keeps any results already produced readable + for the retention window. """ finalized: bool = pydantic.Field() diff --git a/src/phenoml/openapi/openapi.json b/src/phenoml/openapi/openapi.json index 0a3b0ed..0c1f41c 100644 --- a/src/phenoml/openapi/openapi.json +++ b/src/phenoml/openapi/openapi.json @@ -2,7 +2,7 @@ "openapi": "3.0.3", "info": { "title": "Phenoml API", - "version": "26224ef37e22adb507f28c21e8991d4a27822bde" + "version": "e555f47326197aad510a783942045e7c53476fc5" }, "x-services": [ { @@ -4586,7 +4586,7 @@ "post": { "operationId": "batch_create", "summary": "Create a batch job", - "description": "Opens an empty batch job. Items arrive on later upload calls and the set\nis sealed at finalize.\n\nSupplying `request_id` makes the create idempotent on that token: a\nretried submit whose response was lost returns the original job rather\nthan opening a second one. This dedupe is scoped to the calling\ncredential. A `request_id` whose job was canceled or failed before it\nfinalized is released for a fresh replay; once a job is finalized, its\n`request_id` keeps resolving to it even after cancellation.\n\nAn instance may hold at most 4 active (pending or processing) jobs at\nonce; a create past that limit returns `409`. The limit is instance-wide\n\u2014 jobs are shared across the instance's credentials \u2014 so another\ncredential's jobs count against it.\n", + "description": "Opens an empty batch job. Items arrive on later upload calls and the set\nis sealed at finalize.\n\nSupplying `request_id` makes the create idempotent on that token: a\nretried submit whose response was lost returns the original job rather\nthan opening a second one. This dedupe is scoped to the calling\ncredential. A `request_id` whose job was canceled or failed before it\nfinalized is released for a fresh replay; once a job is finalized, its\n`request_id` keeps resolving to it even after cancellation.\n\nThere is no limit on how many jobs an instance may hold at once; how many\nitems run in parallel is a property of the instance, not of the job count.\n", "requestBody": { "required": true, "content": { @@ -4617,9 +4617,6 @@ "401": { "description": "Unauthorized" }, - "409": { - "description": "The instance is at its 4-job active-batch limit; wait for one to finish" - }, "499": { "description": "Client closed request before response was ready" }, @@ -4860,7 +4857,7 @@ "post": { "operationId": "batch_cancel", "summary": "Cancel a batch job", - "description": "Drives a job to the terminal `canceled` state on request, freeing its\nactive-job slot immediately. Takes no request body.\n\nCancel does not delete the job: the job record and any results already\nproduced are preserved for the normal retention window, the same as a\n`completed` or `failed` job. Items stop being processed and keep the state\nthey held at cancellation, so a canceled job's `counts` may show\nunfinished items that never resolve.\n\nCancel is idempotent: canceling an already-`canceled` job returns `200`\nwith the job. Canceling a job that has already `completed` or `failed` is\na `409`.\n", + "description": "Drives a job to the terminal `canceled` state on request. Takes no\nrequest body.\n\nCancel does not delete the job: the job record and any results already\nproduced are preserved for the normal retention window, the same as a\n`completed` or `failed` job. Items stop being processed and keep the state\nthey held at cancellation, so a canceled job's `counts` may show\nunfinished items that never resolve.\n\nCancel is idempotent: canceling an already-`canceled` job returns `200`\nwith the job. Canceling a job that has already `completed` or `failed` is\na `409`.\n", "parameters": [ { "name": "job_id", @@ -6574,11 +6571,11 @@ "tags": [ "FHIR Artifacts / Implementation Guides" ], - "summary": "Delete an implementation guide's metadata", - "description": "Deletes the stored metadata for an implementation guide \u2014 its\nprofile_context and timestamps. Member profiles keep their\nimplementation_guide assignment, so a guide still referenced by at least\none profile continues to appear in listings, just without context or\ntimestamps.\n", + "summary": "Delete an implementation guide family", + "description": "Deletes the stored name-level metadata and any exact canonical package\nversions beneath the guide. Legacy member profile assignments are not\nchanged.\n", "responses": { "204": { - "description": "Implementation guide metadata successfully deleted" + "description": "Implementation guide family successfully deleted" }, "400": { "description": "The name is reserved or malformed" @@ -6590,7 +6587,7 @@ "description": "Forbidden - custom profiles are only available on dedicated instances" }, "404": { - "description": "No implementation guide metadata exists for this name" + "description": "No implementation guide family exists for this name" }, "500": { "description": "Server error" @@ -6604,6 +6601,117 @@ "x-service": "fhir_artifacts" } }, + "/fhir/implementation-guides/{name}/versions": { + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "post": { + "operationId": "implementation-guides_createVersion", + "tags": [ + "FHIR Artifacts / Implementation Guides" + ], + "summary": "Publish an exact custom-profile package", + "description": "Publishes an exact package beneath this guide family. PR 2 temporarily\npermits one exact package version per guide family; publishing another\nversion returns `409 Conflict` until multi-version package support lands.\n", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/implementation-guides_CreateCanonicalImplementationGuideRequest" + } + } + } + }, + "responses": { + "201": { + "description": "Canonical package published", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/implementation-guides_ImplementationGuideVersionDetail" + } + } + } + }, + "400": { + "description": "Invalid ImplementationGuide, profile reference, version, or context" + }, + "404": { + "description": "An exact profile reference was not found" + }, + "409": { + "description": "A package family URL or exact version already exists" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "x-service": "fhir_artifacts" + } + }, + "/fhir/implementation-guides/{name}/versions/{version}": { + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "version", + "in": "path", + "required": true, + "description": "The authored ImplementationGuide.version. It may contain letters, numbers, and the punctuation characters `.`, `_`, `~`, `+`, and `-`; it cannot be exactly `.` or `..`.\n", + "schema": { + "type": "string", + "pattern": "^[A-Za-z0-9._~+-]+$" + }, + "example": "1.0.0" + } + ], + "get": { + "operationId": "implementation-guides_getVersion", + "tags": [ + "FHIR Artifacts / Implementation Guides" + ], + "summary": "Get an exact custom-profile package", + "responses": { + "200": { + "description": "Exact canonical package", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/implementation-guides_ImplementationGuideVersionDetail" + } + } + } + }, + "400": { + "description": "Invalid guide name or version" + }, + "404": { + "description": "Package version not found" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "x-service": "fhir_artifacts" + } + }, "/fhir/profiles": { "get": { "operationId": "profiles_list", @@ -6832,6 +6940,9 @@ "404": { "description": "Profile not found" }, + "409": { + "description": "Profile is pinned by an exact implementation guide package" + }, "500": { "description": "Server error" } @@ -7050,6 +7161,9 @@ "404": { "description": "Profile or version not found" }, + "409": { + "description": "Profile version is pinned by an exact implementation guide package" + }, "500": { "description": "Server error" } @@ -11803,7 +11917,7 @@ "properties": { "kind": { "type": "string", - "description": "Short stable token to branch on. Item-level kinds: `invalid_input`\n(the stored body was not a valid create/document request),\n`processing_failed` (the conversion failed), `budget_exceeded` (the\nitem ran past its time budget \u2014 600s for a document item, 450s for a\ncreate item), `result_too_large` (the result exceeded the storage\ncap), `input_unavailable` (the input could not be read), and\n`retries_exhausted` / `attempts_exhausted` (buried after too many\nfailed attempts).\nJob-level kinds: `timeout` (the job did not finish within 36 hours\nof creation).\n", + "description": "Short stable token to branch on. Item-level kinds: `invalid_input`\n(the stored body was not a valid create/document request),\n`processing_failed` (the conversion failed), `result_too_large` (the\nresult exceeded the storage cap), `input_unavailable` (the input\ncould not be read), and `retries_exhausted` / `attempts_exhausted`\n(buried after too many interrupted attempts; an item has no\nper-item time budget, but a single attempt is bounded by the\nplatform at 30 minutes, and an item cut off three times is buried).\nJob-level kinds: `timeout` (the job did not finish within 36 hours\nof creation).\n", "example": "processing_failed" }, "message": { @@ -11838,7 +11952,7 @@ }, "status": { "type": "string", - "description": "Job status. `completed` means every item has finished \u2014 some may have\nfailed, so check `counts` for the split. `failed` is a whole-job\nfailure (the job could not run at all), distinct from individual item\nfailures, which never fail the job. `canceled` is a caller-requested\ncancellation via `POST /lang2fhir/batch/{job_id}/cancel`; like the\nother terminal states it frees the job's active-job slot and keeps any\nresults already produced readable for the retention window.\n", + "description": "Job status. `completed` means every item has finished \u2014 some may have\nfailed, so check `counts` for the split. `failed` is a whole-job\nfailure (the job could not run at all), distinct from individual item\nfailures, which never fail the job. `canceled` is a caller-requested\ncancellation via `POST /lang2fhir/batch/{job_id}/cancel`; like the\nother terminal states it keeps any results already produced readable\nfor the retention window.\n", "enum": [ "pending", "processing", @@ -13056,7 +13170,7 @@ }, "implementation-guides_ImplementationGuideSummary": { "type": "object", - "description": "Metadata for an implementation guide. This is an instance-local grouping record, not a complete FHIR ImplementationGuide resource.\n", + "description": "Metadata for an implementation guide. Canonical fields are present only for published canonical packages; metadata-only legacy records omit them.\n", "properties": { "name": { "type": "string", @@ -13073,6 +13187,14 @@ "description": "The number of custom profiles in this implementation guide.", "example": 3 }, + "canonical_url": { + "type": "string", + "description": "Canonical FHIR ImplementationGuide URL, when the family has an exact package." + }, + "version_count": { + "type": "integer", + "description": "Number of retained exact package versions." + }, "created_at": { "type": "string", "format": "date-time", @@ -13119,6 +13241,125 @@ } ] }, + "implementation-guides_CreateCanonicalImplementationGuideRequest": { + "type": "object", + "required": [ + "implementation_guide", + "profile_refs" + ], + "properties": { + "implementation_guide": { + "$ref": "#/components/schemas/implementation-guides_FHIRImplementationGuide" + }, + "profile_refs": { + "type": "array", + "minItems": 1, + "maxItems": 250, + "description": "Exact canonical `url|version` references to builtin or custom profiles. A package can contain at most 250 references.\n", + "items": { + "type": "string" + } + }, + "profile_context": { + "type": "string", + "maxLength": 2000, + "description": "Natural-language profile-selection context for this package." + } + } + }, + "implementation-guides_ImplementationGuideVersionDetail": { + "type": "object", + "required": [ + "name", + "url", + "version", + "profile_context", + "profiles", + "profile_refs", + "implementation_guide", + "created_at", + "updated_at" + ], + "properties": { + "name": { + "type": "string" + }, + "url": { + "type": "string" + }, + "version": { + "type": "string" + }, + "profile_context": { + "type": "string" + }, + "profiles": { + "type": "array", + "items": { + "type": "string" + } + }, + "profile_refs": { + "type": "array", + "items": { + "type": "string" + } + }, + "implementation_guide": { + "$ref": "#/components/schemas/implementation-guides_FHIRImplementationGuide" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + } + } + }, + "implementation-guides_FHIRImplementationGuide": { + "type": "object", + "description": "A complete authored FHIR ImplementationGuide JSON resource.", + "required": [ + "resourceType", + "url", + "version" + ], + "additionalProperties": true, + "properties": { + "resourceType": { + "type": "string", + "enum": [ + "ImplementationGuide" + ] + }, + "id": { + "type": "string" + }, + "url": { + "type": "string" + }, + "version": { + "type": "string" + }, + "name": { + "type": "string" + }, + "status": { + "type": "string" + }, + "packageId": { + "type": "string" + }, + "fhirVersion": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, "implementation-guides_UpdateImplementationGuideRequest": { "type": "object", "properties": { diff --git a/src/phenoml/profiles/profiles/raw_client.py b/src/phenoml/profiles/profiles/raw_client.py index 907ac1d..54ef7fc 100644 --- a/src/phenoml/profiles/profiles/raw_client.py +++ b/src/phenoml/profiles/profiles/raw_client.py @@ -527,6 +527,17 @@ def delete(self, id: str, *, request_options: typing.Optional[RequestOptions] = ), ), ) + if _response.status_code == 409: + raise ConflictError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) if _response.status_code == 500: raise InternalServerError( headers=dict(_response.headers), @@ -1051,6 +1062,17 @@ async def delete( ), ), ) + if _response.status_code == 409: + raise ConflictError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) if _response.status_code == 500: raise InternalServerError( headers=dict(_response.headers), diff --git a/src/phenoml/profiles/versions/raw_client.py b/src/phenoml/profiles/versions/raw_client.py index 0a1e0c1..7f77e6e 100644 --- a/src/phenoml/profiles/versions/raw_client.py +++ b/src/phenoml/profiles/versions/raw_client.py @@ -429,6 +429,17 @@ def delete( ), ), ) + if _response.status_code == 409: + raise ConflictError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) if _response.status_code == 500: raise InternalServerError( headers=dict(_response.headers), @@ -853,6 +864,17 @@ async def delete( ), ), ) + if _response.status_code == 409: + raise ConflictError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) if _response.status_code == 500: raise InternalServerError( headers=dict(_response.headers), diff --git a/tests/utils/test_http_client.py b/tests/utils/test_http_client.py index 732c2f9..4cf2992 100644 --- a/tests/utils/test_http_client.py +++ b/tests/utils/test_http_client.py @@ -11,6 +11,7 @@ HttpClient, _build_url, _should_retry, + drop_content_type_without_body, get_request_body, remove_none_from_dict, ) @@ -121,6 +122,67 @@ def test_explicit_empty_json_body_is_preserved() -> None: assert data_body2 == {} +def test_omitted_body_sends_no_content() -> None: + """A body left at the sentinel was never passed, so nothing is sent. + + This is how an endpoint whose body may be omitted stays bodyless, as opposed to + sending an empty ``{}``. An explicit ``None`` still reaches the wire as ``null``. + """ + omit = cast(Any, ...) + unrelated_request_options: RequestOptions = {"max_retries": 3} + + json_body, data_body = get_request_body(json=omit, data=None, request_options=unrelated_request_options, omit=omit) + assert json_body is None + assert data_body is None + + json_body2, data_body2 = get_request_body( + json=None, data=omit, request_options=unrelated_request_options, omit=omit + ) + assert json_body2 is None + assert data_body2 is None + + # an explicitly passed body is untouched + json_body3, _ = get_request_body(json={"hello": "world"}, data=None, request_options=None, omit=omit) + assert json_body3 == {"hello": "world"} + + +def test_optional_body_sends_no_content_when_every_property_is_omitted() -> None: + """An endpoint that inlines an omittable body sends nothing once every property is omitted. + + The body reaches the client as a dict of sentinels rather than as a single argument, so + ``optional_body`` is what tells the client that an empty result means "no body at all". + """ + omit = cast(Any, ...) + + json_body, data_body = get_request_body( + json={"amount": omit, "source": omit}, data=None, request_options=None, omit=omit, optional_body=True + ) + assert json_body is None + assert data_body is None + + # a body the API requires still goes out as `{}` + required_json_body, _ = get_request_body( + json={"amount": omit, "source": omit}, data=None, request_options=None, omit=omit + ) + assert required_json_body == {} + + # a property the caller did pass keeps the body + populated_json_body, _ = get_request_body( + json={"amount": 1, "source": omit}, data=None, request_options=None, omit=omit, optional_body=True + ) + assert populated_json_body == {"amount": 1} + + # additional body parameters keep the body, since the caller asked for them + with_additional_body_parameters, _ = get_request_body( + json={"amount": omit}, + data=None, + request_options={"additional_body_parameters": {"custom": "value"}}, + omit=omit, + optional_body=True, + ) + assert with_additional_body_parameters == {"custom": "value"} + + def test_json_body_preserves_none_values() -> None: """Test that JSON bodies preserve None values (they become JSON null).""" json_body, data_body = get_request_body( @@ -672,28 +734,20 @@ def _make_response(status_code: int) -> httpx.Response: return httpx.Response(status_code=status_code, content=b"") -@pytest.mark.parametrize( - "status_code", - [408, 409, 429, 500, 501, 502, 503, 504, 599], -) +RETRYABLE_STATUS_CODES = [408, 409, 429, 500, 501, 502, 503, 504, 599] +NON_RETRYABLE_STATUS_CODES = [200, 201, 301, 400, 401, 403, 404] + + +@pytest.mark.parametrize("status_code", RETRYABLE_STATUS_CODES) def test_should_retry_retryable_status_codes(status_code: int) -> None: - """Legacy mode: retries on 408, 409, 429, and all >= 500.""" assert _should_retry(_make_response(status_code)) is True -@pytest.mark.parametrize( - "status_code", - [200, 201, 301, 400, 401, 403, 404], -) +@pytest.mark.parametrize("status_code", NON_RETRYABLE_STATUS_CODES) def test_should_not_retry_non_retryable_status_codes(status_code: int) -> None: assert _should_retry(_make_response(status_code)) is False -def test_should_retry_599_upper_boundary() -> None: - """Legacy mode retries on >= 500, which includes 599.""" - assert _should_retry(_make_response(599)) is True - - # --------------------------------------------------------------------------- # RequestOptions timeout resolution tests (timeout / deprecated timeout_in_seconds) # --------------------------------------------------------------------------- @@ -759,3 +813,33 @@ async def test_async_request_options_timeout_takes_precedence() -> None: ) await http_client.request(path="/test", method="GET", request_options={"timeout": 30, "timeout_in_seconds": 45}) assert dummy_client.last_request_kwargs["timeout"] == 30 + + +def test_drop_content_type_without_body_omits_header_for_bodyless_optional_call() -> None: + """An optional-body endpoint the caller left empty must not advertise a media type. + + `get_request_body` drops the body, but the endpoint still hands over the content type it + would have used, so a server that branches on the header would see a JSON request carrying + nothing at all. + """ + headers = {"content-type": "application/json", "authorization": "Bearer x"} + + assert drop_content_type_without_body(headers, json_body=None, data_body=None, optional_body=True) == { + "authorization": "Bearer x" + } + + +def test_drop_content_type_without_body_keeps_header_when_a_body_is_sent() -> None: + headers = {"content-type": "application/json"} + + assert ( + drop_content_type_without_body(headers, json_body={"amount": 60}, data_body=None, optional_body=True) == headers + ) + assert drop_content_type_without_body(headers, json_body=None, data_body="raw", optional_body=True) == headers + + +def test_drop_content_type_without_body_leaves_required_body_endpoints_alone() -> None: + """Without the opt-in, an endpoint keeps the header it has always sent.""" + headers = {"Content-Type": "application/json"} + + assert drop_content_type_without_body(headers, json_body=None, data_body=None, optional_body=False) == headers diff --git a/tests/wire/test_implementationGuides_implementationGuides.py b/tests/wire/test_implementationGuides_implementationGuides.py index f65a229..f0cef92 100644 --- a/tests/wire/test_implementationGuides_implementationGuides.py +++ b/tests/wire/test_implementationGuides_implementationGuides.py @@ -1,5 +1,7 @@ from .conftest import get_client, verify_request_count +from phenoml.implementation_guides import FhirImplementationGuide + def test_implementationGuides_implementationGuides_list_() -> None: """Test list endpoint with WireMock""" @@ -37,3 +39,30 @@ def test_implementationGuides_implementationGuides_delete() -> None: name="acme-cardiology", ) verify_request_count(test_id, "DELETE", "/fhir/implementation-guides/acme-cardiology", None, 1) + + +def test_implementationGuides_implementationGuides_create_version() -> None: + """Test createVersion endpoint with WireMock""" + test_id = "implementation_guides.implementation_guides.create_version.0" + client = get_client(test_id) + client.implementation_guides.implementation_guides.create_version( + name="name", + implementation_guide=FhirImplementationGuide( + resource_type="ImplementationGuide", + url="url", + version="version", + ), + profile_refs=["profile_refs"], + ) + verify_request_count(test_id, "POST", "/fhir/implementation-guides/name/versions", None, 1) + + +def test_implementationGuides_implementationGuides_get_version() -> None: + """Test getVersion endpoint with WireMock""" + test_id = "implementation_guides.implementation_guides.get_version.0" + client = get_client(test_id) + client.implementation_guides.implementation_guides.get_version( + name="name", + version="1.0.0", + ) + verify_request_count(test_id, "GET", "/fhir/implementation-guides/name/versions/1.0.0", None, 1) diff --git a/wiremock/wiremock-mappings.json b/wiremock/wiremock-mappings.json index 9d3fc77..94e1e06 100644 --- a/wiremock/wiremock-mappings.json +++ b/wiremock/wiremock-mappings.json @@ -5,7 +5,12 @@ "name": "Create a new agent - default", "request": { "urlPathTemplate": "/agent/create", - "method": "POST" + "method": "POST", + "headers": { + "Authorization": { + "matches": "Bearer .+" + } + } }, "response": { "status": 201, @@ -32,6 +37,11 @@ "request": { "urlPathTemplate": "/agent/list", "method": "GET", + "headers": { + "Authorization": { + "matches": "Bearer .+" + } + }, "queryParameters": { "tags": { "equalTo": "tags" @@ -64,6 +74,11 @@ "request": { "urlPathTemplate": "/agent/{id}", "method": "GET", + "headers": { + "Authorization": { + "matches": "Bearer .+" + } + }, "pathParameters": { "id": { "equalTo": "id" @@ -95,6 +110,11 @@ "request": { "urlPathTemplate": "/agent/{id}", "method": "PUT", + "headers": { + "Authorization": { + "matches": "Bearer .+" + } + }, "pathParameters": { "id": { "equalTo": "id" @@ -126,6 +146,11 @@ "request": { "urlPathTemplate": "/agent/{id}", "method": "DELETE", + "headers": { + "Authorization": { + "matches": "Bearer .+" + } + }, "pathParameters": { "id": { "equalTo": "id" @@ -157,6 +182,11 @@ "request": { "urlPathTemplate": "/agent/{id}", "method": "PATCH", + "headers": { + "Authorization": { + "matches": "Bearer .+" + } + }, "pathParameters": { "id": { "equalTo": "id" @@ -187,7 +217,12 @@ "name": "Chat with agent - Query Patient Condition", "request": { "urlPathTemplate": "/agent/chat", - "method": "POST" + "method": "POST", + "headers": { + "Authorization": { + "matches": "Bearer .+" + } + } }, "response": { "status": 200, @@ -213,7 +248,12 @@ "name": "Chat with agent (streaming) - default", "request": { "urlPathTemplate": "/agent/stream-chat", - "method": "POST" + "method": "POST", + "headers": { + "Authorization": { + "matches": "Bearer .+" + } + } }, "response": { "status": 200, @@ -240,6 +280,11 @@ "request": { "urlPathTemplate": "/agent/chat/messages", "method": "GET", + "headers": { + "Authorization": { + "matches": "Bearer .+" + } + }, "queryParameters": { "chat_session_id": { "equalTo": "chat_session_id" @@ -280,7 +325,12 @@ "name": "Create agent prompt - default", "request": { "urlPathTemplate": "/agent/prompts", - "method": "POST" + "method": "POST", + "headers": { + "Authorization": { + "matches": "Bearer .+" + } + } }, "response": { "status": 201, @@ -306,7 +356,12 @@ "name": "List agent prompts - default", "request": { "urlPathTemplate": "/agent/prompts/list", - "method": "GET" + "method": "GET", + "headers": { + "Authorization": { + "matches": "Bearer .+" + } + } }, "response": { "status": 200, @@ -334,6 +389,11 @@ "request": { "urlPathTemplate": "/agent/prompts/{id}", "method": "GET", + "headers": { + "Authorization": { + "matches": "Bearer .+" + } + }, "pathParameters": { "id": { "equalTo": "id" @@ -365,6 +425,11 @@ "request": { "urlPathTemplate": "/agent/prompts/{id}", "method": "PUT", + "headers": { + "Authorization": { + "matches": "Bearer .+" + } + }, "pathParameters": { "id": { "equalTo": "id" @@ -396,6 +461,11 @@ "request": { "urlPathTemplate": "/agent/prompts/{id}", "method": "DELETE", + "headers": { + "Authorization": { + "matches": "Bearer .+" + } + }, "pathParameters": { "id": { "equalTo": "id" @@ -427,6 +497,11 @@ "request": { "urlPathTemplate": "/agent/prompts/{id}", "method": "PATCH", + "headers": { + "Authorization": { + "matches": "Bearer .+" + } + }, "pathParameters": { "id": { "equalTo": "id" @@ -483,7 +558,12 @@ "name": "Analyze text for patient cohort criteria - default", "request": { "urlPathTemplate": "/cohort", - "method": "POST" + "method": "POST", + "headers": { + "Authorization": { + "matches": "Bearer .+" + } + } }, "response": { "status": 200, @@ -509,7 +589,12 @@ "name": "Upload custom code system - JSON codes (preferred)", "request": { "urlPathTemplate": "/construe/upload", - "method": "POST" + "method": "POST", + "headers": { + "Authorization": { + "matches": "Bearer .+" + } + } }, "response": { "status": 202, @@ -535,7 +620,12 @@ "name": "List available code systems - default", "request": { "urlPathTemplate": "/construe/codes/systems", - "method": "GET" + "method": "GET", + "headers": { + "Authorization": { + "matches": "Bearer .+" + } + } }, "response": { "status": 200, @@ -563,6 +653,11 @@ "request": { "urlPathTemplate": "/construe/codes/systems/{codesystem}", "method": "GET", + "headers": { + "Authorization": { + "matches": "Bearer .+" + } + }, "pathParameters": { "codesystem": { "equalTo": "ICD-10-CM" @@ -599,6 +694,11 @@ "request": { "urlPathTemplate": "/construe/codes/systems/{codesystem}", "method": "DELETE", + "headers": { + "Authorization": { + "matches": "Bearer .+" + } + }, "pathParameters": { "codesystem": { "equalTo": "CUSTOM_CODES" @@ -635,6 +735,11 @@ "request": { "urlPathTemplate": "/construe/codes/systems/{codesystem}/export", "method": "GET", + "headers": { + "Authorization": { + "matches": "Bearer .+" + } + }, "pathParameters": { "codesystem": { "equalTo": "CUSTOM_CODES" @@ -670,7 +775,12 @@ "name": "Submit feedback on extraction results - default", "request": { "urlPathTemplate": "/construe/feedback", - "method": "POST" + "method": "POST", + "headers": { + "Authorization": { + "matches": "Bearer .+" + } + } }, "response": { "status": 201, @@ -696,7 +806,12 @@ "name": "Extract medical codes from text - Basic Extraction", "request": { "urlPathTemplate": "/construe/extract", - "method": "POST" + "method": "POST", + "headers": { + "Authorization": { + "matches": "Bearer .+" + } + } }, "response": { "status": 200, @@ -722,7 +837,12 @@ "name": "[Alpha] Extract medical codes with phenocr - HPO Phenotype Extraction", "request": { "urlPathTemplate": "/construe/phenocr", - "method": "POST" + "method": "POST", + "headers": { + "Authorization": { + "matches": "Bearer .+" + } + } }, "response": { "status": 200, @@ -748,7 +868,12 @@ "name": "Crosswalk a code to target code systems - ICD-10-CM to HPO", "request": { "urlPathTemplate": "/construe/codes/crosswalk", - "method": "POST" + "method": "POST", + "headers": { + "Authorization": { + "matches": "Bearer .+" + } + } }, "response": { "status": 200, @@ -775,6 +900,11 @@ "request": { "urlPathTemplate": "/construe/codes/{codesystem}", "method": "GET", + "headers": { + "Authorization": { + "matches": "Bearer .+" + } + }, "pathParameters": { "codesystem": { "equalTo": "ICD-10-CM" @@ -817,6 +947,11 @@ "request": { "urlPathTemplate": "/construe/codes/{codesystem}/{codeID}", "method": "GET", + "headers": { + "Authorization": { + "matches": "Bearer .+" + } + }, "pathParameters": { "codesystem": { "equalTo": "ICD-10-CM" @@ -856,6 +991,11 @@ "request": { "urlPathTemplate": "/construe/codes/{codesystem}/search/semantic", "method": "GET", + "headers": { + "Authorization": { + "matches": "Bearer .+" + } + }, "pathParameters": { "codesystem": { "equalTo": "ICD-10-CM" @@ -898,6 +1038,11 @@ "request": { "urlPathTemplate": "/construe/codes/{codesystem}/search/text", "method": "GET", + "headers": { + "Authorization": { + "matches": "Bearer .+" + } + }, "pathParameters": { "codesystem": { "equalTo": "ICD-10-CM" @@ -940,6 +1085,11 @@ "request": { "urlPathTemplate": "/fhir-provider/{fhir_provider_id}/fhir/{fhir_path}", "method": "GET", + "headers": { + "Authorization": { + "matches": "Bearer .+" + } + }, "pathParameters": { "fhir_provider_id": { "equalTo": "550e8400-e29b-41d4-a716-446655440000" @@ -974,6 +1124,11 @@ "request": { "urlPathTemplate": "/fhir-provider/{fhir_provider_id}/fhir/{fhir_path}", "method": "POST", + "headers": { + "Authorization": { + "matches": "Bearer .+" + } + }, "pathParameters": { "fhir_provider_id": { "equalTo": "550e8400-e29b-41d4-a716-446655440000" @@ -1008,6 +1163,11 @@ "request": { "urlPathTemplate": "/fhir-provider/{fhir_provider_id}/fhir/{fhir_path}", "method": "PUT", + "headers": { + "Authorization": { + "matches": "Bearer .+" + } + }, "pathParameters": { "fhir_provider_id": { "equalTo": "550e8400-e29b-41d4-a716-446655440000" @@ -1042,6 +1202,11 @@ "request": { "urlPathTemplate": "/fhir-provider/{fhir_provider_id}/fhir/{fhir_path}", "method": "DELETE", + "headers": { + "Authorization": { + "matches": "Bearer .+" + } + }, "pathParameters": { "fhir_provider_id": { "equalTo": "550e8400-e29b-41d4-a716-446655440000" @@ -1076,6 +1241,11 @@ "request": { "urlPathTemplate": "/fhir-provider/{fhir_provider_id}/fhir/{fhir_path}", "method": "PATCH", + "headers": { + "Authorization": { + "matches": "Bearer .+" + } + }, "pathParameters": { "fhir_provider_id": { "equalTo": "550e8400-e29b-41d4-a716-446655440000" @@ -1110,6 +1280,11 @@ "request": { "urlPathTemplate": "/fhir-provider/{fhir_provider_id}/fhir", "method": "POST", + "headers": { + "Authorization": { + "matches": "Bearer .+" + } + }, "pathParameters": { "fhir_provider_id": { "equalTo": "550e8400-e29b-41d4-a716-446655440000" @@ -1140,7 +1315,12 @@ "name": "Map FHIR resources to OMOP CDM v5.4 - Mapping result", "request": { "urlPathTemplate": "/fhir2omop/create", - "method": "POST" + "method": "POST", + "headers": { + "Authorization": { + "matches": "Bearer .+" + } + } }, "response": { "status": 200, @@ -1166,7 +1346,12 @@ "name": "Create FHIR provider - default", "request": { "urlPathTemplate": "/fhir-provider", - "method": "POST" + "method": "POST", + "headers": { + "Authorization": { + "matches": "Bearer .+" + } + } }, "response": { "status": 201, @@ -1192,7 +1377,12 @@ "name": "List FHIR providers - default", "request": { "urlPathTemplate": "/fhir-provider/list", - "method": "GET" + "method": "GET", + "headers": { + "Authorization": { + "matches": "Bearer .+" + } + } }, "response": { "status": 200, @@ -1220,6 +1410,11 @@ "request": { "urlPathTemplate": "/fhir-provider/{fhir_provider_id}", "method": "GET", + "headers": { + "Authorization": { + "matches": "Bearer .+" + } + }, "pathParameters": { "fhir_provider_id": { "equalTo": "fhir_provider_id" @@ -1251,6 +1446,11 @@ "request": { "urlPathTemplate": "/fhir-provider/{fhir_provider_id}", "method": "DELETE", + "headers": { + "Authorization": { + "matches": "Bearer .+" + } + }, "pathParameters": { "fhir_provider_id": { "equalTo": "fhir_provider_id" @@ -1282,6 +1482,11 @@ "request": { "urlPathTemplate": "/fhir-provider/{fhir_provider_id}/add-auth-config", "method": "PATCH", + "headers": { + "Authorization": { + "matches": "Bearer .+" + } + }, "pathParameters": { "fhir_provider_id": { "equalTo": "1716d214-de93-43a4-aa6b-a878d864e2ad" @@ -1313,6 +1518,11 @@ "request": { "urlPathTemplate": "/fhir-provider/{fhir_provider_id}/set-active-auth-config", "method": "PATCH", + "headers": { + "Authorization": { + "matches": "Bearer .+" + } + }, "pathParameters": { "fhir_provider_id": { "equalTo": "1716d214-de93-43a4-aa6b-a878d864e2ad" @@ -1344,6 +1554,11 @@ "request": { "urlPathTemplate": "/fhir-provider/{fhir_provider_id}/remove-auth-config", "method": "PATCH", + "headers": { + "Authorization": { + "matches": "Bearer .+" + } + }, "pathParameters": { "fhir_provider_id": { "equalTo": "1716d214-de93-43a4-aa6b-a878d864e2ad" @@ -1374,11 +1589,16 @@ "name": "List implementation guides - default", "request": { "urlPathTemplate": "/fhir/implementation-guides", - "method": "GET" + "method": "GET", + "headers": { + "Authorization": { + "matches": "Bearer .+" + } + } }, "response": { "status": 200, - "body": "{\n \"implementation_guides\": [\n {\n \"name\": \"acme-cardiology\",\n \"profile_context\": \"When the text mentions phenotypic features, prefer the hpo-observation profile over Condition.\",\n \"profile_count\": 3,\n \"created_at\": \"2024-01-15T09:30:00Z\",\n \"updated_at\": \"2024-01-15T09:30:00Z\"\n }\n ]\n}", + "body": "{\n \"implementation_guides\": [\n {\n \"name\": \"acme-cardiology\",\n \"profile_context\": \"When the text mentions phenotypic features, prefer the hpo-observation profile over Condition.\",\n \"profile_count\": 3,\n \"canonical_url\": \"canonical_url\",\n \"version_count\": 1,\n \"created_at\": \"2024-01-15T09:30:00Z\",\n \"updated_at\": \"2024-01-15T09:30:00Z\"\n }\n ]\n}", "headers": { "Content-Type": "application/json" } @@ -1402,6 +1622,11 @@ "request": { "urlPathTemplate": "/fhir/implementation-guides/{name}", "method": "GET", + "headers": { + "Authorization": { + "matches": "Bearer .+" + } + }, "pathParameters": { "name": { "equalTo": "acme-cardiology" @@ -1410,7 +1635,7 @@ }, "response": { "status": 200, - "body": "{\n \"name\": \"acme-cardiology\",\n \"profile_context\": \"When the text mentions phenotypic features, prefer the hpo-observation profile over Condition.\",\n \"profile_count\": 3,\n \"created_at\": \"2024-01-15T09:30:00Z\",\n \"updated_at\": \"2024-01-15T09:30:00Z\",\n \"profiles\": [\n \"custom-patient\",\n \"acme-vital-signs\"\n ]\n}", + "body": "{\n \"name\": \"acme-cardiology\",\n \"profile_context\": \"When the text mentions phenotypic features, prefer the hpo-observation profile over Condition.\",\n \"profile_count\": 3,\n \"canonical_url\": \"canonical_url\",\n \"version_count\": 1,\n \"created_at\": \"2024-01-15T09:30:00Z\",\n \"updated_at\": \"2024-01-15T09:30:00Z\",\n \"profiles\": [\n \"custom-patient\",\n \"acme-vital-signs\"\n ]\n}", "headers": { "Content-Type": "application/json" } @@ -1433,6 +1658,11 @@ "request": { "urlPathTemplate": "/fhir/implementation-guides/{name}", "method": "PUT", + "headers": { + "Authorization": { + "matches": "Bearer .+" + } + }, "pathParameters": { "name": { "equalTo": "acme-cardiology" @@ -1441,7 +1671,7 @@ }, "response": { "status": 200, - "body": "{\n \"name\": \"acme-cardiology\",\n \"profile_context\": \"When the text mentions phenotypic features, prefer the hpo-observation profile over Condition.\",\n \"profile_count\": 3,\n \"created_at\": \"2024-01-15T09:30:00Z\",\n \"updated_at\": \"2024-01-15T09:30:00Z\"\n}", + "body": "{\n \"name\": \"acme-cardiology\",\n \"profile_context\": \"When the text mentions phenotypic features, prefer the hpo-observation profile over Condition.\",\n \"profile_count\": 3,\n \"canonical_url\": \"canonical_url\",\n \"version_count\": 1,\n \"created_at\": \"2024-01-15T09:30:00Z\",\n \"updated_at\": \"2024-01-15T09:30:00Z\"\n}", "headers": { "Content-Type": "application/json" } @@ -1459,11 +1689,16 @@ } }, { - "id": "44652922-4793-4275-840e-4dc1b1bde786", - "name": "Delete an implementation guide's metadata - default", + "id": "2678a6e7-10b0-4621-ad5c-67980574a8f8", + "name": "Delete an implementation guide family - default", "request": { "urlPathTemplate": "/fhir/implementation-guides/{name}", "method": "DELETE", + "headers": { + "Authorization": { + "matches": "Bearer .+" + } + }, "pathParameters": { "name": { "equalTo": "acme-cardiology" @@ -1477,7 +1712,82 @@ "Content-Type": "application/json" } }, - "uuid": "44652922-4793-4275-840e-4dc1b1bde786", + "uuid": "2678a6e7-10b0-4621-ad5c-67980574a8f8", + "persistent": true, + "priority": 3, + "metadata": { + "mocklab": { + "created": { + "at": "2020-01-01T00:00:00.000Z", + "via": "SYSTEM" + } + } + } + }, + { + "id": "a7a30315-de87-4485-a1e4-15eeea48b530", + "name": "Publish an exact custom-profile package - default", + "request": { + "urlPathTemplate": "/fhir/implementation-guides/{name}/versions", + "method": "POST", + "headers": { + "Authorization": { + "matches": "Bearer .+" + } + }, + "pathParameters": { + "name": { + "equalTo": "name" + } + } + }, + "response": { + "status": 201, + "body": "{\n \"name\": \"name\",\n \"url\": \"url\",\n \"version\": \"version\",\n \"profile_context\": \"profile_context\",\n \"profiles\": [\n \"profiles\"\n ],\n \"profile_refs\": [\n \"profile_refs\"\n ],\n \"implementation_guide\": {\n \"resourceType\": \"ImplementationGuide\",\n \"id\": \"id\",\n \"url\": \"url\",\n \"version\": \"version\",\n \"name\": \"name\",\n \"status\": \"status\",\n \"packageId\": \"packageId\",\n \"fhirVersion\": [\n \"fhirVersion\"\n ]\n },\n \"created_at\": \"2024-01-15T09:30:00Z\",\n \"updated_at\": \"2024-01-15T09:30:00Z\"\n}", + "headers": { + "Content-Type": "application/json" + } + }, + "uuid": "a7a30315-de87-4485-a1e4-15eeea48b530", + "persistent": true, + "priority": 3, + "metadata": { + "mocklab": { + "created": { + "at": "2020-01-01T00:00:00.000Z", + "via": "SYSTEM" + } + } + } + }, + { + "id": "bfd69c60-e799-438e-a780-2c4c1fd1a9a8", + "name": "Get an exact custom-profile package - default", + "request": { + "urlPathTemplate": "/fhir/implementation-guides/{name}/versions/{version}", + "method": "GET", + "headers": { + "Authorization": { + "matches": "Bearer .+" + } + }, + "pathParameters": { + "name": { + "equalTo": "name" + }, + "version": { + "equalTo": "1.0.0" + } + } + }, + "response": { + "status": 200, + "body": "{\n \"name\": \"name\",\n \"url\": \"url\",\n \"version\": \"version\",\n \"profile_context\": \"profile_context\",\n \"profiles\": [\n \"profiles\"\n ],\n \"profile_refs\": [\n \"profile_refs\"\n ],\n \"implementation_guide\": {\n \"resourceType\": \"ImplementationGuide\",\n \"id\": \"id\",\n \"url\": \"url\",\n \"version\": \"version\",\n \"name\": \"name\",\n \"status\": \"status\",\n \"packageId\": \"packageId\",\n \"fhirVersion\": [\n \"fhirVersion\"\n ]\n },\n \"created_at\": \"2024-01-15T09:30:00Z\",\n \"updated_at\": \"2024-01-15T09:30:00Z\"\n}", + "headers": { + "Content-Type": "application/json" + } + }, + "uuid": "bfd69c60-e799-438e-a780-2c4c1fd1a9a8", "persistent": true, "priority": 3, "metadata": { @@ -1494,7 +1804,12 @@ "name": "Create FHIR resource from text - Condition Resource", "request": { "urlPathTemplate": "/lang2fhir/create", - "method": "POST" + "method": "POST", + "headers": { + "Authorization": { + "matches": "Bearer .+" + } + } }, "response": { "status": 200, @@ -1520,7 +1835,12 @@ "name": "Extract multiple FHIR resources from text - Full Patient Record", "request": { "urlPathTemplate": "/lang2fhir/create/multi", - "method": "POST" + "method": "POST", + "headers": { + "Authorization": { + "matches": "Bearer .+" + } + } }, "response": { "status": 200, @@ -1546,7 +1866,12 @@ "name": "Generate FHIR search parameters from text - Search Appointments", "request": { "urlPathTemplate": "/lang2fhir/search", - "method": "POST" + "method": "POST", + "headers": { + "Authorization": { + "matches": "Bearer .+" + } + } }, "response": { "status": 200, @@ -1572,7 +1897,12 @@ "name": "Upload custom FHIR profile (deprecated) - default", "request": { "urlPathTemplate": "/lang2fhir/profile/upload", - "method": "POST" + "method": "POST", + "headers": { + "Authorization": { + "matches": "Bearer .+" + } + } }, "response": { "status": 201, @@ -1598,7 +1928,12 @@ "name": "Convert document to FHIR resource - default", "request": { "urlPathTemplate": "/lang2fhir/document", - "method": "POST" + "method": "POST", + "headers": { + "Authorization": { + "matches": "Bearer .+" + } + } }, "response": { "status": 200, @@ -1624,7 +1959,12 @@ "name": "Extract multiple FHIR resources from a document - default", "request": { "urlPathTemplate": "/lang2fhir/document/multi", - "method": "POST" + "method": "POST", + "headers": { + "Authorization": { + "matches": "Bearer .+" + } + } }, "response": { "status": 200, @@ -1651,6 +1991,11 @@ "request": { "urlPathTemplate": "/lang2fhir/batch", "method": "GET", + "headers": { + "Authorization": { + "matches": "Bearer .+" + } + }, "queryParameters": { "cursor": { "equalTo": "cursor" @@ -1685,7 +2030,12 @@ "name": "Create a batch job - default", "request": { "urlPathTemplate": "/lang2fhir/batch", - "method": "POST" + "method": "POST", + "headers": { + "Authorization": { + "matches": "Bearer .+" + } + } }, "response": { "status": 202, @@ -1712,6 +2062,11 @@ "request": { "urlPathTemplate": "/lang2fhir/batch/{job_id}/items", "method": "POST", + "headers": { + "Authorization": { + "matches": "Bearer .+" + } + }, "pathParameters": { "job_id": { "equalTo": "job_id" @@ -1743,6 +2098,11 @@ "request": { "urlPathTemplate": "/lang2fhir/batch/{job_id}/finalize", "method": "POST", + "headers": { + "Authorization": { + "matches": "Bearer .+" + } + }, "pathParameters": { "job_id": { "equalTo": "job_id" @@ -1774,6 +2134,11 @@ "request": { "urlPathTemplate": "/lang2fhir/batch/{job_id}/cancel", "method": "POST", + "headers": { + "Authorization": { + "matches": "Bearer .+" + } + }, "pathParameters": { "job_id": { "equalTo": "job_id" @@ -1805,6 +2170,11 @@ "request": { "urlPathTemplate": "/lang2fhir/batch/{job_id}", "method": "GET", + "headers": { + "Authorization": { + "matches": "Bearer .+" + } + }, "pathParameters": { "job_id": { "equalTo": "job_id" @@ -1844,6 +2214,11 @@ "request": { "urlPathTemplate": "/lang2fhir/batch/{job_id}/results", "method": "GET", + "headers": { + "Authorization": { + "matches": "Bearer .+" + } + }, "pathParameters": { "job_id": { "equalTo": "job_id" @@ -1883,6 +2258,11 @@ "request": { "urlPathTemplate": "/lang2fhir/batch/{job_id}/results/{item_id}", "method": "GET", + "headers": { + "Authorization": { + "matches": "Bearer .+" + } + }, "pathParameters": { "job_id": { "equalTo": "job_id" @@ -1917,6 +2297,11 @@ "request": { "urlPathTemplate": "/fhir/profiles", "method": "GET", + "headers": { + "Authorization": { + "matches": "Bearer .+" + } + }, "queryParameters": { "url": { "equalTo": "http://phenoml.com/fhir/StructureDefinition/custom-patient|1.0.0" @@ -1948,7 +2333,12 @@ "name": "Upload a custom FHIR profile - default", "request": { "urlPathTemplate": "/fhir/profiles", - "method": "POST" + "method": "POST", + "headers": { + "Authorization": { + "matches": "Bearer .+" + } + } }, "response": { "status": 201, @@ -1975,6 +2365,11 @@ "request": { "urlPathTemplate": "/fhir/profiles/{id}", "method": "GET", + "headers": { + "Authorization": { + "matches": "Bearer .+" + } + }, "pathParameters": { "id": { "equalTo": "custom-patient" @@ -2006,6 +2401,11 @@ "request": { "urlPathTemplate": "/fhir/profiles/{id}", "method": "PUT", + "headers": { + "Authorization": { + "matches": "Bearer .+" + } + }, "pathParameters": { "id": { "equalTo": "custom-patient" @@ -2037,6 +2437,11 @@ "request": { "urlPathTemplate": "/fhir/profiles/{id}", "method": "DELETE", + "headers": { + "Authorization": { + "matches": "Bearer .+" + } + }, "pathParameters": { "id": { "equalTo": "custom-patient" @@ -2068,6 +2473,11 @@ "request": { "urlPathTemplate": "/fhir/profiles/{id}/versions", "method": "GET", + "headers": { + "Authorization": { + "matches": "Bearer .+" + } + }, "pathParameters": { "id": { "equalTo": "custom-patient" @@ -2099,6 +2509,11 @@ "request": { "urlPathTemplate": "/fhir/profiles/{id}/versions", "method": "POST", + "headers": { + "Authorization": { + "matches": "Bearer .+" + } + }, "pathParameters": { "id": { "equalTo": "custom-patient" @@ -2130,6 +2545,11 @@ "request": { "urlPathTemplate": "/fhir/profiles/{id}/versions/{version}", "method": "GET", + "headers": { + "Authorization": { + "matches": "Bearer .+" + } + }, "pathParameters": { "id": { "equalTo": "custom-patient" @@ -2164,6 +2584,11 @@ "request": { "urlPathTemplate": "/fhir/profiles/{id}/versions/{version}", "method": "DELETE", + "headers": { + "Authorization": { + "matches": "Bearer .+" + } + }, "pathParameters": { "id": { "equalTo": "custom-patient" @@ -2197,7 +2622,12 @@ "name": "List all summary templates - default", "request": { "urlPathTemplate": "/fhir2summary/templates", - "method": "GET" + "method": "GET", + "headers": { + "Authorization": { + "matches": "Bearer .+" + } + } }, "response": { "status": 200, @@ -2224,7 +2654,12 @@ "name": "Create a new summary template - Discharge Summary Template", "request": { "urlPathTemplate": "/fhir2summary/template", - "method": "POST" + "method": "POST", + "headers": { + "Authorization": { + "matches": "Bearer .+" + } + } }, "response": { "status": 200, @@ -2251,6 +2686,11 @@ "request": { "urlPathTemplate": "/fhir2summary/template/{id}", "method": "GET", + "headers": { + "Authorization": { + "matches": "Bearer .+" + } + }, "pathParameters": { "id": { "equalTo": "id" @@ -2282,6 +2722,11 @@ "request": { "urlPathTemplate": "/fhir2summary/template/{id}", "method": "PUT", + "headers": { + "Authorization": { + "matches": "Bearer .+" + } + }, "pathParameters": { "id": { "equalTo": "id" @@ -2313,6 +2758,11 @@ "request": { "urlPathTemplate": "/fhir2summary/template/{id}", "method": "DELETE", + "headers": { + "Authorization": { + "matches": "Bearer .+" + } + }, "pathParameters": { "id": { "equalTo": "id" @@ -2343,7 +2793,12 @@ "name": "Generate a summary from FHIR resources - Narrative Summary", "request": { "urlPathTemplate": "/fhir2summary/create", - "method": "POST" + "method": "POST", + "headers": { + "Authorization": { + "matches": "Bearer .+" + } + } }, "response": { "status": 200, @@ -2369,7 +2824,12 @@ "name": "Create FHIR resource from text and store it - default", "request": { "urlPathTemplate": "/tools/lang2fhir-and-create", - "method": "POST" + "method": "POST", + "headers": { + "Authorization": { + "matches": "Bearer .+" + } + } }, "response": { "status": 200, @@ -2395,7 +2855,12 @@ "name": "Extract and store multiple FHIR resources - default", "request": { "urlPathTemplate": "/tools/lang2fhir-and-create-multi", - "method": "POST" + "method": "POST", + "headers": { + "Authorization": { + "matches": "Bearer .+" + } + } }, "response": { "status": 200, @@ -2421,7 +2886,12 @@ "name": "Search FHIR resources from natural language - default", "request": { "urlPathTemplate": "/tools/lang2fhir-and-search", - "method": "POST" + "method": "POST", + "headers": { + "Authorization": { + "matches": "Bearer .+" + } + } }, "response": { "status": 200, @@ -2447,7 +2917,12 @@ "name": "Analyze patient cohorts - default", "request": { "urlPathTemplate": "/tools/cohort", - "method": "POST" + "method": "POST", + "headers": { + "Authorization": { + "matches": "Bearer .+" + } + } }, "response": { "status": 200, @@ -2473,7 +2948,12 @@ "name": "Create MCP server - default", "request": { "urlPathTemplate": "/tools/mcp-server/create", - "method": "POST" + "method": "POST", + "headers": { + "Authorization": { + "matches": "Bearer .+" + } + } }, "response": { "status": 200, @@ -2499,7 +2979,12 @@ "name": "List MCP servers - default", "request": { "urlPathTemplate": "/tools/mcp-server/list", - "method": "GET" + "method": "GET", + "headers": { + "Authorization": { + "matches": "Bearer .+" + } + } }, "response": { "status": 200, @@ -2527,6 +3012,11 @@ "request": { "urlPathTemplate": "/tools/mcp-server/{mcp_server_id}", "method": "GET", + "headers": { + "Authorization": { + "matches": "Bearer .+" + } + }, "pathParameters": { "mcp_server_id": { "equalTo": "mcp_server_id" @@ -2558,6 +3048,11 @@ "request": { "urlPathTemplate": "/tools/mcp-server/{mcp_server_id}", "method": "DELETE", + "headers": { + "Authorization": { + "matches": "Bearer .+" + } + }, "pathParameters": { "mcp_server_id": { "equalTo": "mcp_server_id" @@ -2589,6 +3084,11 @@ "request": { "urlPathTemplate": "/tools/mcp-server/{mcp_server_id}/list", "method": "GET", + "headers": { + "Authorization": { + "matches": "Bearer .+" + } + }, "pathParameters": { "mcp_server_id": { "equalTo": "mcp_server_id" @@ -2620,6 +3120,11 @@ "request": { "urlPathTemplate": "/tools/mcp-server/tool/{mcp_server_tool_id}", "method": "GET", + "headers": { + "Authorization": { + "matches": "Bearer .+" + } + }, "pathParameters": { "mcp_server_tool_id": { "equalTo": "mcp_server_tool_id" @@ -2651,6 +3156,11 @@ "request": { "urlPathTemplate": "/tools/mcp-server/tool/{mcp_server_tool_id}", "method": "DELETE", + "headers": { + "Authorization": { + "matches": "Bearer .+" + } + }, "pathParameters": { "mcp_server_tool_id": { "equalTo": "mcp_server_tool_id" @@ -2682,6 +3192,11 @@ "request": { "urlPathTemplate": "/workflows", "method": "GET", + "headers": { + "Authorization": { + "matches": "Bearer .+" + } + }, "queryParameters": { "verbose": { "equalTo": "true" @@ -2714,6 +3229,11 @@ "request": { "urlPathTemplate": "/workflows", "method": "POST", + "headers": { + "Authorization": { + "matches": "Bearer .+" + } + }, "queryParameters": { "verbose": { "equalTo": "true" @@ -2745,6 +3265,11 @@ "request": { "urlPathTemplate": "/workflows/{id}", "method": "GET", + "headers": { + "Authorization": { + "matches": "Bearer .+" + } + }, "pathParameters": { "id": { "equalTo": "id" @@ -2781,6 +3306,11 @@ "request": { "urlPathTemplate": "/workflows/{id}", "method": "PUT", + "headers": { + "Authorization": { + "matches": "Bearer .+" + } + }, "pathParameters": { "id": { "equalTo": "id" @@ -2817,6 +3347,11 @@ "request": { "urlPathTemplate": "/workflows/{id}", "method": "DELETE", + "headers": { + "Authorization": { + "matches": "Bearer .+" + } + }, "pathParameters": { "id": { "equalTo": "id" @@ -2848,6 +3383,11 @@ "request": { "urlPathTemplate": "/workflows/{id}/execute", "method": "POST", + "headers": { + "Authorization": { + "matches": "Bearer .+" + } + }, "pathParameters": { "id": { "equalTo": "7a8b9c0d-1234-5678-abcd-ef9876543210" @@ -2875,6 +3415,6 @@ } ], "meta": { - "total": 94 + "total": 96 } } \ No newline at end of file