diff --git a/.fern/metadata.json b/.fern/metadata.json index f6f62f4e..60cd5e8c 100644 --- a/.fern/metadata.json +++ b/.fern/metadata.json @@ -1,7 +1,7 @@ { - "cliVersion": "5.151.0", + "cliVersion": "5.188.1", "generatorName": "fernapi/fern-java-sdk", - "generatorVersion": "4.23.2", + "generatorVersion": "4.32.4", "generatorConfig": { "enable-inline-types": false, "client-class-name": "PhenomlClient", @@ -10,10 +10,10 @@ "enable-wire-tests": true, "publish-to": "central" }, - "originGitCommit": "49f1258330df448935b5304a1d8c405a2834d227", + "originGitCommit": "aa8767e4b2d618273a8ffe13773f517f18a4d3fa", "originGitCommitIsDirty": true, "invokedBy": "ci", "requestedVersion": "AUTO", "ciProvider": "unknown", - "sdkVersion": "18.1.0" + "sdkVersion": "19.0.0" } \ No newline at end of file diff --git a/.fern/replay.lock b/.fern/replay.lock index 5b11ce64..fd74842b 100644 --- a/.fern/replay.lock +++ b/.fern/replay.lock @@ -78,5 +78,11 @@ generations: cli_version: unknown generator_versions: fernapi/fern-java-sdk: 4.23.2 -current_generation: 9624856576c6cf3de012e11c4cee373f987d6bc3 + - commit_sha: 43f3e9827d6b25637cd25bbb70a35fa240062b15 + tree_hash: f7407dd7a06118b11fd98f2b6daccda7316a0b30 + timestamp: 2026-10-30T20:47:13.475Z + cli_version: unknown + generator_versions: + fernapi/fern-java-sdk: 4.32.4 +current_generation: 43f3e9827d6b25637cd25bbb70a35fa240062b15 patches: [] diff --git a/build.gradle b/build.gradle index fc94bf29..70031691 100644 --- a/build.gradle +++ b/build.gradle @@ -58,7 +58,7 @@ java { group = 'com.phenoml.maven' -version = '18.1.0' +version = '19.0.0' jar { dependsOn(":generatePomFileForMavenPublication") @@ -89,7 +89,7 @@ publishing { maven(MavenPublication) { groupId = 'com.phenoml.maven' artifactId = 'phenoml-java-sdk' - version = '18.1.0' + version = '19.0.0' from components.java pom { name = 'phenoml' diff --git a/changelog.md b/changelog.md index bec46766..ced37cef 100644 --- a/changelog.md +++ b/changelog.md @@ -1,3 +1,25 @@ +## [19.0.0] - 2026-10-30 +### Breaking Changes +- **`ResourceReviewRemediated.getAction()`** — now returns `Optional` instead of `Optional`; replace string comparisons with `ResourceReviewRemediatedAction` enum constants, and update `Builder.action(...)` call sites to pass `ResourceReviewRemediatedAction` instead of `String`. + +### Added +- **`RequestOptions.Builder.addBodyProperty()` / `.bodyProperties()`** — inject arbitrary key/value pairs into JSON or form-urlencoded request bodies, acting as the full body for endpoints with no defined body schema. +- **`RetryInterceptor.newAsyncCall()`** — async retry factory that schedules backoff on a daemon thread, freeing OkHttp dispatcher slots and enabling immediate cancellation. +- **`Stream.fromSseWithEventDiscrimination()`** and **`SseEventParser.parseEventLevelUnion()`** — new SSE stream factories supporting event-type-discriminated union deserialization from the full SSE envelope. +- **New FHIR-to-OMOP response types** — `DeviceExposureRow`, `DeviceExposureContext`, `DeviceExposureLink`, `DeviceExposureEvidence`, `SpecimenRow`, `AllergyCodingEvidence`, `AllergyMappingResolution`, and related enum types added to the `fhir2omop` package; `OmopTables` now exposes `getDeviceExposure()` and `getSpecimen()`. +- **New optional fields across OMOP row and summary types** — `DrugExposureRow.getQuantity()`, `MeasurementRow.getUnitSourceConceptId()`, `VisitOccurrenceRow.getVisitSourceConceptId()`, `ProviderRow.getNpi()`, `Summary.getRowsForReview()`, `MappingEntry.getSourceCodingIndex()`, and `MappingEntry.getResolution()` added. +- See full changelog for all changes + +### Changed +- **`additionalProperties` deserialization** — all request and response builder types now filter known JSON field names out of `additionalProperties`, preventing duplicate capture of recognized fields. +- **`CreateMultiRequest.getSmallNote()`** — new optional boolean field that bills at the small-note rate; `text` must be ≤ 15,360 bytes UTF-8 when `true`. +- **`CreateMultiRequest` / `DocumentMultiRequest` field documentation** — `getImplementationGuide()` and `getDetectionEffort()` docs updated; `deep` and `standard` detection effort now produce identical results and the field is deprecated. +- **`DocumentMultiResponse.getPageClassifications()`** — now populated on every document request, not only when `page_filter` or `split_classifications` is explicitly set. +- **`MappingPath` and `MappingPathMappingStatus`** — new response type and open enum describing a single OMOP concept-mapping chain step, with `ALREADY_STANDARD`, `MAPPED`, and `POLICY_MAPPED` constants. + +### Fixed +- **`@JsonAnySetter` guard on agent builder types** — known field names are no longer accidentally routed into `additionalProperties` during JSON deserialization. + ## [18.1.0] - 2026-10-09 ### Added - **`ReferenceDiagnostic.getOmopTable()` and `getOmopId()`** — new optional fields on `ReferenceDiagnostic` that identify the OMOP table name and primary key of the affected row when a reference diagnostic is emitted alongside an OMOP response. diff --git a/code-examples.json b/code-examples.json index 95e7a279..bf873aad 100644 --- a/code-examples.json +++ b/code-examples.json @@ -2,8 +2,8 @@ "metadata": { "language": "java", "packageName": "com.phenoml.maven:phenoml-java-sdk", - "sdkVersion": "18.1.0", - "specCommit": "49f1258330df448935b5304a1d8c405a2834d227", + "sdkVersion": "19.0.0", + "specCommit": "aa8767e4b2d618273a8ffe13773f517f18a4d3fa", "generatorName": "fernapi/fern-java-sdk" }, "renderRules": { @@ -196,310 +196,6 @@ } } }, - "GET /agent/{id}": { - "httpMethod": "GET", - "httpPath": "/agent/{id}", - "request": { - "body": null - }, - "response": { - "body": { - "success": true, - "message": "Agent retrieved successfully", - "data": { - "id": "agent_123", - "name": "Medical Assistant", - "description": "An AI assistant for medical information processing", - "prompts": [ - "prompt_123", - "prompt_456" - ], - "tools": [ - "mcp_server_123", - "mcp_server_456" - ], - "workflows": [ - "workflow_123", - "workflow_456" - ], - "tags": [ - "medical", - "fhir" - ], - "provider": "7002b0b4-8d09-445a-bf65-0fafdaf26c35" - } - } - }, - "render": { - "callTemplate": "client.agent().get({{id}})", - "params": [ - { - "name": "id", - "kind": "string" - } - ] - } - }, - "PUT /agent/{id}": { - "httpMethod": "PUT", - "httpPath": "/agent/{id}", - "request": { - "body": { - "name": "Medical Assistant", - "description": "Updated description for the medical assistant", - "prompts": [ - "prompt_123" - ], - "provider": "7002b0b4-8d09-445a-bf65-0fafdaf26c35", - "tags": [ - "medical", - "fhir", - "updated" - ] - } - }, - "response": { - "body": { - "success": true, - "message": "Agent updated successfully", - "data": { - "id": "agent_123", - "name": "Medical Assistant", - "description": "An AI assistant for medical information processing", - "prompts": [ - "prompt_123", - "prompt_456" - ], - "tools": [ - "mcp_server_123", - "mcp_server_456" - ], - "workflows": [ - "workflow_123", - "workflow_456" - ], - "tags": [ - "medical", - "fhir" - ], - "provider": "7002b0b4-8d09-445a-bf65-0fafdaf26c35" - } - } - }, - "render": { - "callTemplate": "client.agent().update({{id}}, AgentCreateRequest.builder(){{__body__}}.build())", - "params": [ - { - "name": "id", - "kind": "string" - } - ], - "body": { - "fieldSeparator": "", - "fields": [ - { - "jsonKey": "name", - "fieldTemplate": ".name({{value}})", - "kind": "string", - "required": true - }, - { - "jsonKey": "prompts", - "fieldTemplate": ".prompts({{value}})", - "kind": "list", - "required": true, - "items": { - "jsonKey": "", - "fieldTemplate": "{{value}}", - "kind": "string", - "required": true - } - }, - { - "jsonKey": "provider", - "fieldTemplate": ".provider({{value}})", - "kind": "object", - "required": true - }, - { - "jsonKey": "description", - "fieldTemplate": ".description({{value}})", - "kind": "string", - "required": false - }, - { - "jsonKey": "tools", - "fieldTemplate": ".tools({{value}})", - "kind": "list", - "required": false, - "items": { - "jsonKey": "", - "fieldTemplate": "{{value}}", - "kind": "string", - "required": true - } - }, - { - "jsonKey": "workflows", - "fieldTemplate": ".workflows({{value}})", - "kind": "list", - "required": false, - "items": { - "jsonKey": "", - "fieldTemplate": "{{value}}", - "kind": "string", - "required": true - } - }, - { - "jsonKey": "tags", - "fieldTemplate": ".tags({{value}})", - "kind": "list", - "required": false, - "items": { - "jsonKey": "", - "fieldTemplate": "{{value}}", - "kind": "string", - "required": true - } - } - ] - } - } - }, - "DELETE /agent/{id}": { - "httpMethod": "DELETE", - "httpPath": "/agent/{id}", - "request": { - "body": null - }, - "response": { - "body": { - "success": true, - "message": "Agent deleted successfully" - } - }, - "render": { - "callTemplate": "client.agent().delete({{id}})", - "params": [ - { - "name": "id", - "kind": "string" - } - ] - } - }, - "PATCH /agent/{id}": { - "httpMethod": "PATCH", - "httpPath": "/agent/{id}", - "request": { - "body": [ - { - "op": "replace", - "path": "/description", - "value": "patched description" - }, - { - "op": "add", - "path": "/tags/-", - "value": "updated" - } - ] - }, - "response": { - "body": { - "success": true, - "message": "Agent patched successfully", - "data": { - "id": "agent_123", - "name": "Medical Assistant", - "description": "An AI assistant for medical information processing", - "prompts": [ - "prompt_123", - "prompt_456" - ], - "tools": [ - "mcp_server_123", - "mcp_server_456" - ], - "workflows": [ - "workflow_123", - "workflow_456" - ], - "tags": [ - "medical", - "fhir" - ], - "provider": "7002b0b4-8d09-445a-bf65-0fafdaf26c35" - } - } - }, - "render": { - "callTemplate": "client.agent().patch({{id}}, {{__body__}})", - "params": [ - { - "name": "id", - "kind": "string" - } - ], - "body": { - "fieldSeparator": "", - "fields": [ - { - "jsonKey": "", - "fieldTemplate": "{{value}}", - "kind": "list", - "required": true, - "passthroughBody": true, - "items": { - "jsonKey": "", - "fieldTemplate": "{{value}}", - "kind": "object", - "required": true, - "nested": { - "fieldSeparator": "", - "fields": [ - { - "jsonKey": "op", - "fieldTemplate": ".op({{value}})", - "kind": "enum", - "required": true, - "enumValues": [ - "add", - "remove", - "replace", - "move", - "copy", - "test" - ] - }, - { - "jsonKey": "path", - "fieldTemplate": ".path({{value}})", - "kind": "string", - "required": true - }, - { - "jsonKey": "value", - "fieldTemplate": ".value({{value}})", - "kind": "object", - "required": false - }, - { - "jsonKey": "from", - "fieldTemplate": ".from({{value}})", - "kind": "string", - "required": false - } - ], - "wrap": "JSONPatchOperation.builder(){{__body__}}.build()" - } - } - } - ] - } - } - }, "POST /agent/chat": { "httpMethod": "POST", "httpPath": "/agent/chat", @@ -806,254 +502,20 @@ "params": [] } }, - "GET /agent/prompts/{id}": { - "httpMethod": "GET", - "httpPath": "/agent/prompts/{id}", + "POST /v2/auth/token": { + "httpMethod": "POST", + "httpPath": "/v2/auth/token", "request": { - "body": null + "body": { + "client_id": "your_client_id", + "client_secret": "your_client_secret" + } }, "response": { "body": { - "success": true, - "message": "Prompt retrieved successfully", - "data": { - "id": "prompt_123", - "name": "Medical Assistant System Prompt", - "description": "System prompt for medical assistant agent", - "content": "You are a helpful medical assistant...", - "is_default": false, - "tags": [ - "medical", - "system" - ] - } - } - }, - "render": { - "callTemplate": "client.agent().prompts().get({{id}})", - "params": [ - { - "name": "id", - "kind": "string" - } - ] - } - }, - "PUT /agent/prompts/{id}": { - "httpMethod": "PUT", - "httpPath": "/agent/prompts/{id}", - "request": { - "body": { - "name": "Medical Assistant System Prompt", - "description": "Updated system prompt", - "content": "You are a helpful medical assistant. Always cite ICD-10 codes when discussing diagnoses.", - "is_default": false, - "tags": [ - "medical", - "system", - "updated" - ] - } - }, - "response": { - "body": { - "success": true, - "message": "Prompt updated successfully", - "data": { - "id": "prompt_123", - "name": "Medical Assistant System Prompt", - "description": "System prompt for medical assistant agent", - "content": "You are a helpful medical assistant...", - "is_default": false, - "tags": [ - "medical", - "system" - ] - } - } - }, - "render": { - "callTemplate": "client.agent().prompts().update({{id}}, AgentPromptsUpdateRequest.builder(){{__body__}}.build())", - "params": [ - { - "name": "id", - "kind": "string" - } - ], - "body": { - "fieldSeparator": "", - "fields": [ - { - "jsonKey": "name", - "fieldTemplate": ".name({{value}})", - "kind": "string", - "required": false - }, - { - "jsonKey": "description", - "fieldTemplate": ".description({{value}})", - "kind": "string", - "required": false - }, - { - "jsonKey": "content", - "fieldTemplate": ".content({{value}})", - "kind": "string", - "required": false - }, - { - "jsonKey": "is_default", - "fieldTemplate": ".isDefault({{value}})", - "kind": "boolean", - "required": false - }, - { - "jsonKey": "tags", - "fieldTemplate": ".tags({{value}})", - "kind": "list", - "required": false, - "items": { - "jsonKey": "", - "fieldTemplate": "{{value}}", - "kind": "string", - "required": true - } - } - ] - } - } - }, - "PATCH /agent/prompts/{id}": { - "httpMethod": "PATCH", - "httpPath": "/agent/prompts/{id}", - "request": { - "body": [ - { - "op": "replace", - "path": "/content", - "value": "Updated prompt content." - } - ] - }, - "response": { - "body": { - "success": true, - "message": "Prompt patched successfully", - "data": { - "id": "prompt_123", - "name": "Medical Assistant System Prompt", - "description": "System prompt for medical assistant agent", - "content": "You are a helpful medical assistant...", - "is_default": false, - "tags": [ - "medical", - "system" - ] - } - } - }, - "render": { - "callTemplate": "client.agent().prompts().patch({{id}}, {{__body__}})", - "params": [ - { - "name": "id", - "kind": "string" - } - ], - "body": { - "fieldSeparator": "", - "fields": [ - { - "jsonKey": "", - "fieldTemplate": "{{value}}", - "kind": "list", - "required": true, - "passthroughBody": true, - "items": { - "jsonKey": "", - "fieldTemplate": "{{value}}", - "kind": "object", - "required": true, - "nested": { - "fieldSeparator": "", - "fields": [ - { - "jsonKey": "op", - "fieldTemplate": ".op({{value}})", - "kind": "enum", - "required": true, - "enumValues": [ - "add", - "remove", - "replace", - "move", - "copy", - "test" - ] - }, - { - "jsonKey": "path", - "fieldTemplate": ".path({{value}})", - "kind": "string", - "required": true - }, - { - "jsonKey": "value", - "fieldTemplate": ".value({{value}})", - "kind": "object", - "required": false - }, - { - "jsonKey": "from", - "fieldTemplate": ".from({{value}})", - "kind": "string", - "required": false - } - ], - "wrap": "JSONPatchOperation.builder(){{__body__}}.build()" - } - } - } - ] - } - } - }, - "DELETE /agent/prompts/{id}": { - "httpMethod": "DELETE", - "httpPath": "/agent/prompts/{id}", - "request": { - "body": null - }, - "response": { - "body": { - "success": true, - "message": "Prompt deleted successfully" - } - }, - "render": { - "callTemplate": "client.agent().prompts().delete({{id}})", - "params": [ - { - "name": "id", - "kind": "string" - } - ] - } - }, - "POST /v2/auth/token": { - "httpMethod": "POST", - "httpPath": "/v2/auth/token", - "request": { - "body": { - "client_id": "your_client_id", - "client_secret": "your_client_secret" - } - }, - "response": { - "body": { - "access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...", - "token_type": "Bearer", - "expires_in": 172800 + "access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...", + "token_type": "Bearer", + "expires_in": 172800 } }, "render": { @@ -1331,6 +793,7 @@ "enumValues": [ "none", "simple", + "semantic_mapping", "medication_search", "chunk_code_jaccard_similarity" ] @@ -1571,120 +1034,6 @@ "params": [] } }, - "GET /construe/codes/systems/{codesystem}": { - "httpMethod": "GET", - "httpPath": "/construe/codes/systems/{codesystem}", - "request": { - "body": null - }, - "response": { - "body": { - "name": "ICD-10-CM", - "version": "2025", - "code_count": 97584, - "builtin": true, - "status": "ready", - "created_at": "2026-02-10T18:33:23Z", - "updated_at": "2026-02-10T18:33:23Z" - } - }, - "render": { - "callTemplate": "client.construe().codeSystems().find({{codesystem}}, FindRequest.builder(){{__body__}}.build())", - "params": [ - { - "name": "codesystem", - "kind": "string" - } - ], - "body": { - "fieldSeparator": "", - "fields": [ - { - "jsonKey": "version", - "fieldTemplate": ".version({{value}})", - "kind": "string", - "required": false - } - ] - } - } - }, - "DELETE /construe/codes/systems/{codesystem}": { - "httpMethod": "DELETE", - "httpPath": "/construe/codes/systems/{codesystem}", - "request": { - "body": null - }, - "response": { - "body": { - "message": "code system deleted successfully" - } - }, - "render": { - "callTemplate": "client.construe().codeSystems().delete({{codesystem}}, DeleteRequest.builder(){{__body__}}.build())", - "params": [ - { - "name": "codesystem", - "kind": "string" - } - ], - "body": { - "fieldSeparator": "", - "fields": [ - { - "jsonKey": "version", - "fieldTemplate": ".version({{value}})", - "kind": "string", - "required": false - } - ] - } - } - }, - "GET /construe/codes/systems/{codesystem}/export": { - "httpMethod": "GET", - "httpPath": "/construe/codes/systems/{codesystem}/export", - "request": { - "body": null - }, - "response": { - "body": { - "name": "CUSTOM_CODES", - "version": "1.0", - "format": "json", - "codes": [ - { - "code": "X001", - "description": "Example custom code 1" - }, - { - "code": "X002", - "description": "Example custom code 2" - } - ] - } - }, - "render": { - "callTemplate": "client.construe().codeSystems().export({{codesystem}}, ExportRequest.builder(){{__body__}}.build())", - "params": [ - { - "name": "codesystem", - "kind": "string" - } - ], - "body": { - "fieldSeparator": "", - "fields": [ - { - "jsonKey": "version", - "fieldTemplate": ".version({{value}})", - "kind": "string", - "required": false - } - ] - } - } - }, "POST /construe/codes/crosswalk": { "httpMethod": "POST", "httpPath": "/construe/codes/crosswalk", @@ -1750,190 +1099,9 @@ } } }, - "GET /construe/codes/{codesystem}": { - "httpMethod": "GET", - "httpPath": "/construe/codes/{codesystem}", - "request": { - "body": null - }, - "response": { - "body": { - "system": { - "name": "ICD-10-CM", - "version": "2025" - }, - "codes": [ - { - "code": "A00", - "description": "Cholera" - }, - { - "code": "A000", - "description": "Cholera due to Vibrio cholerae 01, biovar cholerae" - }, - { - "code": "A001", - "description": "Cholera due to Vibrio cholerae 01, biovar eltor" - }, - { - "code": "A009", - "description": "Cholera, unspecified" - }, - { - "code": "A01", - "description": "Typhoid and paratyphoid fevers" - } - ], - "next_cursor": "QTAx", - "has_more": true - } - }, - "render": { - "callTemplate": "client.construe().codes().list({{codesystem}}, CodesListRequest.builder(){{__body__}}.build())", - "params": [ - { - "name": "codesystem", - "kind": "string" - } - ], - "body": { - "fieldSeparator": "", - "fields": [ - { - "jsonKey": "version", - "fieldTemplate": ".version({{value}})", - "kind": "string", - "required": false - }, - { - "jsonKey": "cursor", - "fieldTemplate": ".cursor({{value}})", - "kind": "string", - "required": false - }, - { - "jsonKey": "limit", - "fieldTemplate": ".limit({{value}})", - "kind": "number", - "required": false - } - ] - } - } - }, - "GET /construe/codes/{codesystem}/{code_id}": { - "httpMethod": "GET", - "httpPath": "/construe/codes/{codesystem}/{code_id}", - "request": { - "body": null - }, - "response": { - "body": { - "system": { - "name": "ICD-10-CM", - "version": "2025" - }, - "code": "E1165", - "description": "Type 2 diabetes mellitus with hyperglycemia" - } - }, - "render": { - "callTemplate": "client.construe().codes().lookup({{codesystem}}, {{codeID}}, LookupRequest.builder(){{__body__}}.build())", - "params": [ - { - "name": "codesystem", - "kind": "string" - }, - { - "name": "codeID", - "kind": "string" - } - ], - "body": { - "fieldSeparator": "", - "fields": [ - { - "jsonKey": "version", - "fieldTemplate": ".version({{value}})", - "kind": "string", - "required": false - } - ] - } - } - }, - "GET /construe/codes/{codesystem}/search/semantic": { - "httpMethod": "GET", - "httpPath": "/construe/codes/{codesystem}/search/semantic", - "request": { - "body": null - }, - "response": { - "body": { - "system": { - "name": "ICD-10-CM", - "version": "2025" - }, - "results": [ - { - "code": "R06.00", - "description": "Dyspnea, unspecified" - }, - { - "code": "R06.01", - "description": "Orthopnea" - }, - { - "code": "G47.33", - "description": "Obstructive sleep apnea" - }, - { - "code": "R06.83", - "description": "Snoring" - }, - { - "code": "J45.20", - "description": "Mild intermittent asthma, uncomplicated" - } - ] - } - }, - "render": { - "callTemplate": "client.construe().codes().searchSemantic({{codesystem}}, SearchSemanticRequest.builder(){{__body__}}.build())", - "params": [ - { - "name": "codesystem", - "kind": "string" - } - ], - "body": { - "fieldSeparator": "", - "fields": [ - { - "jsonKey": "text", - "fieldTemplate": ".text({{value}})", - "kind": "string", - "required": true - }, - { - "jsonKey": "version", - "fieldTemplate": ".version({{value}})", - "kind": "string", - "required": false - }, - { - "jsonKey": "limit", - "fieldTemplate": ".limit({{value}})", - "kind": "number", - "required": false - } - ] - } - } - }, - "POST /construe/feedback": { - "httpMethod": "POST", - "httpPath": "/construe/feedback", + "POST /construe/feedback": { + "httpMethod": "POST", + "httpPath": "/construe/feedback", "request": { "body": { "text": "Patient has type 2 diabetes with hyperglycemia", @@ -2293,68 +1461,6 @@ } } }, - "GET /construe/codes/{codesystem}/search/text": { - "httpMethod": "GET", - "httpPath": "/construe/codes/{codesystem}/search/text", - "request": { - "body": null - }, - "response": { - "body": { - "system": { - "name": "ICD-10-CM", - "version": "2025" - }, - "results": [ - { - "code": "E11.65", - "description": "Type 2 diabetes mellitus with hyperglycemia" - }, - { - "code": "E11.649", - "description": "Type 2 diabetes mellitus with hypoglycemia without coma" - }, - { - "code": "E11.69", - "description": "Type 2 diabetes mellitus with other specified complication" - } - ], - "found": 3 - } - }, - "render": { - "callTemplate": "client.construe().codes().searchText({{codesystem}}, SearchTextRequest.builder(){{__body__}}.build())", - "params": [ - { - "name": "codesystem", - "kind": "string" - } - ], - "body": { - "fieldSeparator": "", - "fields": [ - { - "jsonKey": "q", - "fieldTemplate": ".q({{value}})", - "kind": "string", - "required": true - }, - { - "jsonKey": "version", - "fieldTemplate": ".version({{value}})", - "kind": "string", - "required": false - }, - { - "jsonKey": "limit", - "fieldTemplate": ".limit({{value}})", - "kind": "number", - "required": false - } - ] - } - } - }, "POST /fhir2omop/create": { "httpMethod": "POST", "httpPath": "/fhir2omop/create", @@ -2526,7 +1632,7 @@ "selected": true } ], - "vocab_version": "v20260227", + "vocab_version": "v20260829", "summary": { "codes_already_standard": 2, "codes_normalized": 1, @@ -2557,364 +1663,64 @@ } } }, - "GET /fhir-provider/{fhir_provider_id}/fhir/{fhir_path}": { - "httpMethod": "GET", - "httpPath": "/fhir-provider/{fhir_provider_id}/fhir/{fhir_path}", - "request": { - "body": null - }, - "response": { - "body": { - "resourceType": "Bundle", - "total": 2, - "entry": [ - { - "resource": { - "resourceType": "Patient", - "id": "123", - "name": [ - { - "family": "Doe", - "given": [ - "John" - ] - } - ] - } - } - ] - } - }, - "render": { - "callTemplate": "client.fhir().search({{fhir_provider_id}}, {{fhir_path}})", - "params": [ - { - "name": "fhir_provider_id", - "kind": "string" - }, - { - "name": "fhir_path", - "kind": "string" - } - ] - } - }, - "POST /fhir-provider/{fhir_provider_id}/fhir/{fhir_path}": { + "POST /fhir-provider": { "httpMethod": "POST", - "httpPath": "/fhir-provider/{fhir_provider_id}/fhir/{fhir_path}", + "httpPath": "/fhir-provider", "request": { "body": { - "resourceType": "Patient" - } - }, - "response": { - "body": { - "resourceType": "Patient", - "id": "456", - "meta": { - "versionId": "1", - "lastUpdated": "2024-01-15T10:30:00Z" - } - } - }, - "render": { - "callTemplate": "client.fhir().create({{fhir_provider_id}}, {{fhir_path}}, {{__body__}})", - "params": [ - { - "name": "fhir_provider_id", - "kind": "string" - }, - { - "name": "fhir_path", - "kind": "string" + "name": "Epic Sandbox", + "description": "Epic sandbox environment for testing", + "provider": "epic", + "base_url": "https://fhir.epic.com/interconnect-fhir-oauth/api/FHIR/R4", + "auth": { + "auth_method": "client_secret", + "client_id": "your-client-id", + "client_secret": "your-client-secret", + "role": "read" } - ], - "body": { - "fieldSeparator": "", - "fields": [ - { - "jsonKey": "", - "fieldTemplate": "{{value}}", - "kind": "object", - "required": true, - "passthroughBody": true - } - ] - } - } - }, - "PUT /fhir-provider/{fhir_provider_id}/fhir/{fhir_path}": { - "httpMethod": "PUT", - "httpPath": "/fhir-provider/{fhir_provider_id}/fhir/{fhir_path}", - "request": { - "body": { - "resourceType": "Patient", - "id": "123" } }, "response": { "body": { - "resourceType": "Patient", - "id": "123", - "meta": { - "versionId": "2", - "lastUpdated": "2024-02-20T11:00:00Z" + "success": true, + "message": "Fhir provider created successfully", + "data": { + "id": "1716d214-de93-43a4-aa6b-a878d864e2ad", + "name": "Epic Sandbox", + "description": "Epic sandbox environment for testing", + "provider": "epic", + "base_url": "https://fhir.epic.com/interconnect-fhir-oauth/api/FHIR/R4", + "auth_configs": { + "auth-config-123": { + "auth_config_id": "auth-config-123", + "auth_method": "client_secret", + "is_active_auth_config": true, + "created_at": "2024-01-15T10:30:00Z", + "updated_at": "2024-01-15T10:30:00Z", + "scopes": "system/Patient.read system/Observation.read", + "client_id": "your-client-id" + } + }, + "last_updated": "2024-01-15T10:30:00Z" } } }, "render": { - "callTemplate": "client.fhir().upsert({{fhir_provider_id}}, {{fhir_path}}, {{__body__}})", - "params": [ - { - "name": "fhir_provider_id", - "kind": "string" - }, - { - "name": "fhir_path", - "kind": "string" - } - ], + "callTemplate": "client.fhirProvider().create(FhirProviderCreateRequest.builder(){{__body__}}.build())", + "params": [], "body": { "fieldSeparator": "", "fields": [ { - "jsonKey": "", - "fieldTemplate": "{{value}}", - "kind": "object", - "required": true, - "passthroughBody": true - } - ] - } - } - }, - "PATCH /fhir-provider/{fhir_provider_id}/fhir/{fhir_path}": { - "httpMethod": "PATCH", - "httpPath": "/fhir-provider/{fhir_provider_id}/fhir/{fhir_path}", - "request": { - "body": [ - { - "op": "replace", - "path": "/name/0/family", - "value": "NewFamilyName" - } - ] - }, - "response": { - "body": { - "resourceType": "Patient", - "id": "123", - "meta": { - "versionId": "3", - "lastUpdated": "2024-02-20T11:30:00Z" - } - } - }, - "render": { - "callTemplate": "client.fhir().patch({{fhir_provider_id}}, {{fhir_path}}, {{__body__}})", - "params": [ - { - "name": "fhir_provider_id", - "kind": "string" - }, - { - "name": "fhir_path", - "kind": "string" - } - ], - "body": { - "fieldSeparator": "", - "fields": [ - { - "jsonKey": "", - "fieldTemplate": "{{value}}", - "kind": "list", - "required": true, - "passthroughBody": true, - "items": { - "jsonKey": "", - "fieldTemplate": "{{value}}", - "kind": "object", - "required": true - } - } - ] - } - } - }, - "DELETE /fhir-provider/{fhir_provider_id}/fhir/{fhir_path}": { - "httpMethod": "DELETE", - "httpPath": "/fhir-provider/{fhir_provider_id}/fhir/{fhir_path}", - "request": { - "body": null - }, - "response": { - "body": { - "resourceType": "OperationOutcome", - "issue": [ - { - "severity": "information", - "code": "informational", - "details": { - "text": "Resource deleted successfully" - } - } - ] - } - }, - "render": { - "callTemplate": "client.fhir().delete({{fhir_provider_id}}, {{fhir_path}})", - "params": [ - { - "name": "fhir_provider_id", - "kind": "string" - }, - { - "name": "fhir_path", - "kind": "string" - } - ] - } - }, - "POST /fhir-provider/{fhir_provider_id}/fhir": { - "httpMethod": "POST", - "httpPath": "/fhir-provider/{fhir_provider_id}/fhir", - "request": { - "body": { - "resourceType": "Bundle", - "type": "transaction", - "entry": [ - { - "request": { - "method": "POST", - "url": "Patient" - }, - "resource": { - "resourceType": "Patient", - "name": [ - { - "family": "Doe", - "given": [ - "John" - ] - } - ] - } - }, - { - "request": { - "method": "POST", - "url": "Observation" - }, - "resource": { - "resourceType": "Observation", - "status": "final", - "subject": { - "reference": "Patient/123" - } - } - } - ] - } - }, - "response": { - "body": { - "resourceType": "Bundle", - "type": "transaction-response", - "entry": [ - { - "response": { - "status": "201 Created", - "location": "Patient/456" - }, - "resource": { - "resourceType": "Patient", - "id": "456" - } - } - ] - } - }, - "render": { - "callTemplate": "client.fhir().executeBundle({{fhir_provider_id}}, {{__body__}})", - "params": [ - { - "name": "fhir_provider_id", - "kind": "string" - } - ], - "body": { - "fieldSeparator": "", - "fields": [ - { - "jsonKey": "", - "fieldTemplate": "{{value}}", - "kind": "object", - "required": true, - "passthroughBody": true - } - ] - } - } - }, - "POST /fhir-provider": { - "httpMethod": "POST", - "httpPath": "/fhir-provider", - "request": { - "body": { - "name": "Epic Sandbox", - "description": "Epic sandbox environment for testing", - "provider": "epic", - "base_url": "https://fhir.epic.com/interconnect-fhir-oauth/api/FHIR/R4", - "auth": { - "auth_method": "client_secret", - "client_id": "your-client-id", - "client_secret": "your-client-secret", - "role": "read" - } - } - }, - "response": { - "body": { - "success": true, - "message": "Fhir provider created successfully", - "data": { - "id": "1716d214-de93-43a4-aa6b-a878d864e2ad", - "name": "Epic Sandbox", - "description": "Epic sandbox environment for testing", - "provider": "epic", - "base_url": "https://fhir.epic.com/interconnect-fhir-oauth/api/FHIR/R4", - "auth_configs": { - "auth-config-123": { - "auth_config_id": "auth-config-123", - "auth_method": "client_secret", - "is_active_auth_config": true, - "created_at": "2024-01-15T10:30:00Z", - "updated_at": "2024-01-15T10:30:00Z", - "scopes": "system/Patient.read system/Observation.read", - "client_id": "your-client-id" - } - }, - "last_updated": "2024-01-15T10:30:00Z" - } - } - }, - "render": { - "callTemplate": "client.fhirProvider().create(FhirProviderCreateRequest.builder(){{__body__}}.build())", - "params": [], - "body": { - "fieldSeparator": "", - "fields": [ - { - "jsonKey": "name", - "fieldTemplate": ".name({{value}})", - "kind": "string", - "required": true - }, - { - "jsonKey": "provider", - "fieldTemplate": ".provider({{value}})", - "kind": "enum", + "jsonKey": "name", + "fieldTemplate": ".name({{value}})", + "kind": "string", + "required": true + }, + { + "jsonKey": "provider", + "fieldTemplate": ".provider({{value}})", + "kind": "enum", "required": true, "enumValues": [ "aidbox", @@ -2935,349 +1741,31 @@ "athenahealth": "Provider.ATHENAHEALTH", "canvas": "Provider.CANVAS", "cerner": "Provider.CERNER", - "elation": "Provider.ELATION", - "epic": "Provider.EPIC", - "google_healthcare": "Provider.GOOGLE_HEALTHCARE", - "hapi": "Provider.HAPI", - "meditech": "Provider.MEDITECH", - "medplum": "Provider.MEDPLUM", - "phenostore": "Provider.PHENOSTORE", - "sandbox": "Provider.SANDBOX" - } - }, - { - "jsonKey": "base_url", - "fieldTemplate": ".baseUrl({{value}})", - "kind": "string", - "required": true - }, - { - "jsonKey": "auth", - "fieldTemplate": ".auth({{value}})", - "kind": "object", - "required": true - }, - { - "jsonKey": "description", - "fieldTemplate": ".description({{value}})", - "kind": "string", - "required": false - } - ] - } - } - }, - "GET /fhir-provider/list": { - "httpMethod": "GET", - "httpPath": "/fhir-provider/list", - "request": { - "body": null - }, - "response": { - "body": { - "success": true, - "message": "Fhir providers retrieved successfully", - "fhir_providers": [ - { - "id": "1716d214-de93-43a4-aa6b-a878d864e2ad", - "name": "Epic Sandbox", - "description": "Epic sandbox environment for testing", - "provider": "epic", - "base_url": "https://fhir.epic.com/interconnect-fhir-oauth/api/FHIR/R4", - "auth_configs": { - "auth-config-123": { - "auth_config_id": "auth-config-123", - "auth_method": "jwt", - "is_active_auth_config": true, - "created_at": "2024-01-15T10:30:00Z", - "updated_at": "2024-01-15T10:30:00Z", - "scopes": "system/Patient.read system/Observation.read", - "client_id": "your-client-id" - } - }, - "last_updated": "2024-01-15T10:30:00Z" - }, - { - "id": "4da196da-5cf1-4574-9e65-f97d7ea20b38", - "name": "PhenoML Sandbox", - "description": "Shared sandbox environment for experimentation", - "provider": "sandbox" - } - ] - } - }, - "render": { - "callTemplate": "client.fhirProvider().list()", - "params": [] - } - }, - "GET /fhir-provider/{fhir_provider_id}": { - "httpMethod": "GET", - "httpPath": "/fhir-provider/{fhir_provider_id}", - "request": { - "body": null - }, - "response": { - "body": { - "success": true, - "message": "Fhir provider retrieved successfully", - "data": { - "id": "1716d214-de93-43a4-aa6b-a878d864e2ad", - "name": "Epic Sandbox", - "description": "Epic sandbox environment for testing", - "provider": "epic", - "base_url": "https://fhir.epic.com/interconnect-fhir-oauth/api/FHIR/R4", - "auth_configs": { - "auth-config-123": { - "auth_config_id": "auth-config-123", - "auth_method": "jwt", - "is_active_auth_config": true, - "created_at": "2024-01-15T10:30:00Z", - "updated_at": "2024-01-15T14:45:00Z", - "scopes": "system/Patient.read system/Observation.read", - "client_id": "your-client-id" - } - }, - "last_updated": "2024-01-15T14:45:00Z" - } - } - }, - "render": { - "callTemplate": "client.fhirProvider().get({{fhir_provider_id}})", - "params": [ - { - "name": "fhir_provider_id", - "kind": "string" - } - ] - } - }, - "DELETE /fhir-provider/{fhir_provider_id}": { - "httpMethod": "DELETE", - "httpPath": "/fhir-provider/{fhir_provider_id}", - "request": { - "body": null - }, - "response": { - "body": { - "success": true, - "message": "Fhir provider deleted successfully" - } - }, - "render": { - "callTemplate": "client.fhirProvider().delete({{fhir_provider_id}})", - "params": [ - { - "name": "fhir_provider_id", - "kind": "string" - } - ] - } - }, - "PATCH /fhir-provider/{fhir_provider_id}/add-auth-config": { - "httpMethod": "PATCH", - "httpPath": "/fhir-provider/{fhir_provider_id}/add-auth-config", - "request": { - "body": { - "auth_method": "client_secret", - "client_id": "your-other-client-id", - "client_secret": "your-other-client-secret", - "role": "read" - } - }, - "response": { - "body": { - "success": true, - "message": "Fhir provider auth config added successfully", - "data": { - "id": "1716d214-de93-43a4-aa6b-a878d864e2ad", - "name": "Epic Sandbox", - "description": "Epic sandbox environment for testing", - "provider": "epic", - "base_url": "https://fhir.epic.com/interconnect-fhir-oauth/api/FHIR/R4", - "auth_configs": { - "auth-config-123": { - "auth_config_id": "auth-config-123", - "auth_method": "jwt", - "is_active_auth_config": true, - "created_at": "2024-01-15T10:30:00Z", - "updated_at": "2024-01-15T14:45:00Z", - "scopes": "system/Patient.read system/Observation.read", - "client_id": "your-client-id" - }, - "auth-config-456": { - "auth_config_id": "auth-config-456", - "auth_method": "client_secret", - "is_active_auth_config": false, - "created_at": "2024-02-20T09:00:00Z", - "updated_at": "2024-02-20T09:00:00Z", - "scopes": "system/Patient.read", - "client_id": "your-other-client-id" - } - }, - "last_updated": "2024-02-20T09:00:00Z" - } - } - }, - "render": { - "callTemplate": "client.fhirProvider().authConfig().add({{fhir_provider_id}}, {{__body__}})", - "params": [ - { - "name": "fhir_provider_id", - "kind": "string" - } - ], - "body": { - "fieldSeparator": "", - "fields": [ - { - "jsonKey": "", - "fieldTemplate": "{{value}}", - "kind": "object", - "required": true, - "passthroughBody": true - } - ] - } - } - }, - "PATCH /fhir-provider/{fhir_provider_id}/set-active-auth-config": { - "httpMethod": "PATCH", - "httpPath": "/fhir-provider/{fhir_provider_id}/set-active-auth-config", - "request": { - "body": { - "auth_config_id": "auth-config-456" - } - }, - "response": { - "body": { - "success": true, - "message": "FHIR provider auth config set as active successfully", - "data": { - "id": "1716d214-de93-43a4-aa6b-a878d864e2ad", - "name": "Epic Sandbox", - "description": "Epic sandbox environment for testing", - "provider": "epic", - "base_url": "https://fhir.epic.com/interconnect-fhir-oauth/api/FHIR/R4", - "auth_configs": { - "auth-config-123": { - "auth_config_id": "auth-config-123", - "auth_method": "jwt", - "is_active_auth_config": false, - "created_at": "2024-01-15T10:30:00Z", - "updated_at": "2024-02-20T11:00:00Z", - "scopes": "system/Patient.read system/Observation.read", - "client_id": "your-client-id" - }, - "auth-config-456": { - "auth_config_id": "auth-config-456", - "auth_method": "client_secret", - "is_active_auth_config": true, - "created_at": "2024-02-20T09:00:00Z", - "updated_at": "2024-02-20T11:00:00Z", - "scopes": "system/Patient.read", - "client_id": "your-other-client-id" - } - }, - "last_updated": "2024-02-20T11:00:00Z" - } - } - }, - "render": { - "callTemplate": "client.fhirProvider().authConfig().setActive({{fhir_provider_id}}, FhirProviderSetActiveAuthConfigRequest.builder(){{__body__}}.build())", - "params": [ - { - "name": "fhir_provider_id", - "kind": "string" - } - ], - "body": { - "fieldSeparator": "", - "fields": [ - { - "jsonKey": "auth_config_id", - "fieldTemplate": ".authConfigId({{value}})", - "kind": "string", - "required": true - } - ] - } - } - }, - "PATCH /fhir-provider/{fhir_provider_id}/remove-auth-config": { - "httpMethod": "PATCH", - "httpPath": "/fhir-provider/{fhir_provider_id}/remove-auth-config", - "request": { - "body": { - "auth_config_id": "auth-config-456" - } - }, - "response": { - "body": { - "success": true, - "message": "Fhir provider auth config deleted successfully", - "data": { - "id": "1716d214-de93-43a4-aa6b-a878d864e2ad", - "name": "Epic Sandbox", - "description": "Epic sandbox environment for testing", - "provider": "epic", - "base_url": "https://fhir.epic.com/interconnect-fhir-oauth/api/FHIR/R4", - "auth_configs": { - "auth-config-123": { - "auth_config_id": "auth-config-123", - "auth_method": "jwt", - "is_active_auth_config": true, - "created_at": "2024-01-15T10:30:00Z", - "updated_at": "2024-01-15T10:30:00Z", - "scopes": "system/Patient.read system/Observation.read", - "client_id": "your-client-id" - } - }, - "last_updated": "2024-02-20T11:30:00Z" - } - } - }, - "render": { - "callTemplate": "client.fhirProvider().authConfig().remove({{fhir_provider_id}}, FhirProviderRemoveAuthConfigRequest.builder(){{__body__}}.build())", - "params": [ - { - "name": "fhir_provider_id", - "kind": "string" - } - ], - "body": { - "fieldSeparator": "", - "fields": [ - { - "jsonKey": "auth_config_id", - "fieldTemplate": ".authConfigId({{value}})", - "kind": "string", - "required": true - } - ] - } - } - }, - "POST /lang2fhir/batch": { - "httpMethod": "POST", - "httpPath": "/lang2fhir/batch", - "request": { - "body": { - "request_id": "submit-2025-09-02-batch-001" - } - }, - "response": { - "body": null - }, - "render": { - "callTemplate": "client.lang2FhirBatch().create(CreateBatchRequest.builder(){{__body__}}.build())", - "params": [], - "body": { - "fieldSeparator": "", - "fields": [ + "elation": "Provider.ELATION", + "epic": "Provider.EPIC", + "google_healthcare": "Provider.GOOGLE_HEALTHCARE", + "hapi": "Provider.HAPI", + "meditech": "Provider.MEDITECH", + "medplum": "Provider.MEDPLUM", + "phenostore": "Provider.PHENOSTORE", + "sandbox": "Provider.SANDBOX" + } + }, { - "jsonKey": "request_id", - "fieldTemplate": ".requestId({{value}})", + "jsonKey": "base_url", + "fieldTemplate": ".baseUrl({{value}})", + "kind": "string", + "required": true + }, + { + "jsonKey": "auth", + "fieldTemplate": ".auth({{value}})", + "kind": "object", + "required": true + }, + { + "jsonKey": "description", + "fieldTemplate": ".description({{value}})", "kind": "string", "required": false } @@ -3285,133 +1773,80 @@ } } }, - "GET /lang2fhir/batch": { + "GET /fhir-provider/list": { "httpMethod": "GET", - "httpPath": "/lang2fhir/batch", + "httpPath": "/fhir-provider/list", "request": { "body": null }, "response": { - "body": null - }, - "render": { - "callTemplate": "client.lang2FhirBatch().list(ListRequest.builder(){{__body__}}.build())", - "params": [], "body": { - "fieldSeparator": "", - "fields": [ + "success": true, + "message": "Fhir providers retrieved successfully", + "fhir_providers": [ { - "jsonKey": "cursor", - "fieldTemplate": ".cursor({{value}})", - "kind": "string", - "required": false + "id": "1716d214-de93-43a4-aa6b-a878d864e2ad", + "name": "Epic Sandbox", + "description": "Epic sandbox environment for testing", + "provider": "epic", + "base_url": "https://fhir.epic.com/interconnect-fhir-oauth/api/FHIR/R4", + "auth_configs": { + "auth-config-123": { + "auth_config_id": "auth-config-123", + "auth_method": "jwt", + "is_active_auth_config": true, + "created_at": "2024-01-15T10:30:00Z", + "updated_at": "2024-01-15T10:30:00Z", + "scopes": "system/Patient.read system/Observation.read", + "client_id": "your-client-id" + } + }, + "last_updated": "2024-01-15T10:30:00Z" }, { - "jsonKey": "limit", - "fieldTemplate": ".limit({{value}})", - "kind": "number", - "required": false + "id": "4da196da-5cf1-4574-9e65-f97d7ea20b38", + "name": "PhenoML Sandbox", + "description": "Shared sandbox environment for experimentation", + "provider": "sandbox" } ] } - } - }, - "POST /lang2fhir/batch/{job_id}/items": { - "httpMethod": "POST", - "httpPath": "/lang2fhir/batch/{job_id}/items", - "request": { - "body": null - }, - "response": { - "body": null - }, - "render": { - "callTemplate": "client.lang2FhirBatch().uploadItem({{job_id}})", - "params": [ - { - "name": "job_id", - "kind": "string" - } - ] - } - }, - "POST /lang2fhir/batch/{job_id}/finalize": { - "httpMethod": "POST", - "httpPath": "/lang2fhir/batch/{job_id}/finalize", - "request": { - "body": null - }, - "response": { - "body": null }, "render": { - "callTemplate": "client.lang2FhirBatch().finalize({{job_id}})", - "params": [ - { - "name": "job_id", - "kind": "string" - } - ] + "callTemplate": "client.fhirProvider().list()", + "params": [] } }, - "POST /lang2fhir/batch/{job_id}/cancel": { + "POST /lang2fhir/batch": { "httpMethod": "POST", - "httpPath": "/lang2fhir/batch/{job_id}/cancel", - "request": { - "body": null - }, - "response": { - "body": null - }, - "render": { - "callTemplate": "client.lang2FhirBatch().cancel({{job_id}})", - "params": [ - { - "name": "job_id", - "kind": "string" - } - ] - } - }, - "GET /lang2fhir/batch/{job_id}": { - "httpMethod": "GET", - "httpPath": "/lang2fhir/batch/{job_id}", + "httpPath": "/lang2fhir/batch", "request": { - "body": null + "body": { + "request_id": "submit-2025-09-02-batch-001" + } }, "response": { "body": null }, "render": { - "callTemplate": "client.lang2FhirBatch().get({{job_id}}, GetRequest.builder(){{__body__}}.build())", - "params": [ - { - "name": "job_id", - "kind": "string" - } - ], + "callTemplate": "client.lang2FhirBatch().create(CreateBatchRequest.builder(){{__body__}}.build())", + "params": [], "body": { "fieldSeparator": "", "fields": [ { - "jsonKey": "cursor", - "fieldTemplate": ".cursor({{value}})", + "jsonKey": "request_id", + "fieldTemplate": ".requestId({{value}})", "kind": "string", "required": false - }, - { - "jsonKey": "limit", - "fieldTemplate": ".limit({{value}})", - "kind": "number", - "required": false } ] } } }, - "GET /lang2fhir/batch/{job_id}/results": { + "GET /lang2fhir/batch": { "httpMethod": "GET", - "httpPath": "/lang2fhir/batch/{job_id}/results", + "httpPath": "/lang2fhir/batch", "request": { "body": null }, @@ -3419,13 +1854,8 @@ "body": null }, "render": { - "callTemplate": "client.lang2FhirBatch().getResults({{job_id}}, GetResultsRequest.builder(){{__body__}}.build())", - "params": [ - { - "name": "job_id", - "kind": "string" - } - ], + "callTemplate": "client.lang2FhirBatch().list(ListRequest.builder(){{__body__}}.build())", + "params": [], "body": { "fieldSeparator": "", "fields": [ @@ -3445,29 +1875,6 @@ } } }, - "GET /lang2fhir/batch/{job_id}/results/{item_id}": { - "httpMethod": "GET", - "httpPath": "/lang2fhir/batch/{job_id}/results/{item_id}", - "request": { - "body": null - }, - "response": { - "body": null - }, - "render": { - "callTemplate": "client.lang2FhirBatch().getResult({{job_id}}, {{item_id}})", - "params": [ - { - "name": "job_id", - "kind": "string" - }, - { - "name": "item_id", - "kind": "string" - } - ] - } - }, "POST /cohort": { "httpMethod": "POST", "httpPath": "/cohort", @@ -3592,6 +1999,10 @@ "observation-clinical-result", "observation-lab", "patient", + "phenoml-hpo-condition", + "phenoml-hpo-phenotypic-observation", + "phenoml-hpo-head-circumference-tape-measure", + "phenoml-hpo-head-circumference-percentile", "procedure", "questionnaire", "questionnaireresponse", @@ -3704,85 +2115,7 @@ "jsonKey": "primary_patient", "fieldTemplate": ".primaryPatient({{value}})", "kind": "object", - "required": false, - "nested": { - "fieldSeparator": "", - "fields": [ - { - "jsonKey": "identifier", - "fieldTemplate": ".identifier({{value}})", - "kind": "object", - "required": false, - "nested": { - "fieldSeparator": "", - "fields": [ - { - "jsonKey": "system", - "fieldTemplate": ".system({{value}})", - "kind": "string", - "required": true - }, - { - "jsonKey": "value", - "fieldTemplate": ".value({{value}})", - "kind": "string", - "required": true - } - ], - "wrap": "PatientReference.builder(){{__body__}}.build()" - } - }, - { - "jsonKey": "name", - "fieldTemplate": ".name({{value}})", - "kind": "object", - "required": false, - "nested": { - "fieldSeparator": "", - "fields": [ - { - "jsonKey": "family", - "fieldTemplate": ".family({{value}})", - "kind": "string", - "required": false - }, - { - "jsonKey": "given", - "fieldTemplate": ".given({{value}})", - "kind": "list", - "required": false, - "items": { - "jsonKey": "", - "fieldTemplate": "{{value}}", - "kind": "string", - "required": true - } - } - ], - "wrap": "PrimaryPatientName.builder(){{__body__}}.build()" - } - }, - { - "jsonKey": "birthDate", - "fieldTemplate": ".birthDate({{value}})", - "kind": "string", - "required": false - }, - { - "jsonKey": "gender", - "fieldTemplate": ".gender({{value}})", - "kind": "enum", - "required": false, - "enumValues": [ - "male", - "female", - "other", - "unknown" - ] - } - ], - "wrap": "PrimaryPatient.builder(){{__body__}}.build()" - } + "required": false }, { "jsonKey": "patient_reference", @@ -3886,6 +2219,12 @@ ], "wrap": "ResourceReview.builder(){{__body__}}.build()" } + }, + { + "jsonKey": "small_note", + "fieldTemplate": ".smallNote({{value}})", + "kind": "boolean", + "required": false } ] } @@ -4323,88 +2662,10 @@ "required": false }, { - "jsonKey": "primary_patient", - "fieldTemplate": ".primaryPatient({{value}})", - "kind": "object", - "required": false, - "nested": { - "fieldSeparator": "", - "fields": [ - { - "jsonKey": "identifier", - "fieldTemplate": ".identifier({{value}})", - "kind": "object", - "required": false, - "nested": { - "fieldSeparator": "", - "fields": [ - { - "jsonKey": "system", - "fieldTemplate": ".system({{value}})", - "kind": "string", - "required": true - }, - { - "jsonKey": "value", - "fieldTemplate": ".value({{value}})", - "kind": "string", - "required": true - } - ], - "wrap": "PatientReference.builder(){{__body__}}.build()" - } - }, - { - "jsonKey": "name", - "fieldTemplate": ".name({{value}})", - "kind": "object", - "required": false, - "nested": { - "fieldSeparator": "", - "fields": [ - { - "jsonKey": "family", - "fieldTemplate": ".family({{value}})", - "kind": "string", - "required": false - }, - { - "jsonKey": "given", - "fieldTemplate": ".given({{value}})", - "kind": "list", - "required": false, - "items": { - "jsonKey": "", - "fieldTemplate": "{{value}}", - "kind": "string", - "required": true - } - } - ], - "wrap": "PrimaryPatientName.builder(){{__body__}}.build()" - } - }, - { - "jsonKey": "birthDate", - "fieldTemplate": ".birthDate({{value}})", - "kind": "string", - "required": false - }, - { - "jsonKey": "gender", - "fieldTemplate": ".gender({{value}})", - "kind": "enum", - "required": false, - "enumValues": [ - "male", - "female", - "other", - "unknown" - ] - } - ], - "wrap": "PrimaryPatient.builder(){{__body__}}.build()" - } + "jsonKey": "primary_patient", + "fieldTemplate": ".primaryPatient({{value}})", + "kind": "object", + "required": false }, { "jsonKey": "patient_reference", @@ -4689,147 +2950,6 @@ } } }, - "GET /fhir2summary/template/{id}": { - "httpMethod": "GET", - "httpPath": "/fhir2summary/template/{id}", - "request": { - "body": null - }, - "response": { - "body": { - "success": true, - "template": { - "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", - "name": "Discharge Summary", - "template": "Patient <>, age <>, was admitted on <> with <>.", - "target_resources": [ - "Patient", - "Condition", - "MedicationRequest" - ], - "mode": "narrative", - "created_at": "2024-01-15T10:30:00Z", - "updated_at": "2024-01-15T10:30:00Z" - } - } - }, - "render": { - "callTemplate": "client.summary().templates().get({{id}})", - "params": [ - { - "name": "id", - "kind": "string" - } - ] - } - }, - "PUT /fhir2summary/template/{id}": { - "httpMethod": "PUT", - "httpPath": "/fhir2summary/template/{id}", - "request": { - "body": { - "name": "Discharge Summary", - "template": "Patient {{Patient.name[0].text}} was discharged on {{Encounter[0].period.end}} with {{MedicationRequest[0].medicationCodeableConcept.coding[0].display}} {{MedicationRequest[0].dosageInstruction[0].text}}.", - "target_resources": [ - "Patient", - "Encounter", - "MedicationRequest" - ], - "mode": "narrative" - } - }, - "response": { - "body": { - "success": true, - "message": "Template updated successfully", - "template": { - "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", - "name": "Discharge Summary", - "template": "Patient <> was discharged on <> with <> <>.", - "target_resources": [ - "Patient", - "Encounter", - "MedicationRequest" - ], - "mode": "narrative", - "created_at": "2024-01-15T10:30:00Z", - "updated_at": "2024-02-20T11:00:00Z" - } - } - }, - "render": { - "callTemplate": "client.summary().templates().update({{id}}, UpdateSummaryTemplateRequest.builder(){{__body__}}.build())", - "params": [ - { - "name": "id", - "kind": "string" - } - ], - "body": { - "fieldSeparator": "", - "fields": [ - { - "jsonKey": "name", - "fieldTemplate": ".name({{value}})", - "kind": "string", - "required": true - }, - { - "jsonKey": "template", - "fieldTemplate": ".template({{value}})", - "kind": "string", - "required": true - }, - { - "jsonKey": "target_resources", - "fieldTemplate": ".targetResources({{value}})", - "kind": "list", - "required": true, - "items": { - "jsonKey": "", - "fieldTemplate": "{{value}}", - "kind": "string", - "required": true - } - }, - { - "jsonKey": "mode", - "fieldTemplate": ".mode({{value}})", - "kind": "string", - "required": true - }, - { - "jsonKey": "description", - "fieldTemplate": ".description({{value}})", - "kind": "string", - "required": false - } - ] - } - } - }, - "DELETE /fhir2summary/template/{id}": { - "httpMethod": "DELETE", - "httpPath": "/fhir2summary/template/{id}", - "request": { - "body": null - }, - "response": { - "body": { - "success": true, - "message": "Template deleted successfully" - } - }, - "render": { - "callTemplate": "client.summary().templates().delete({{id}})", - "params": [ - { - "name": "id", - "kind": "string" - } - ] - } - }, "POST /fhir2summary/create": { "httpMethod": "POST", "httpPath": "/fhir2summary/create", @@ -4884,217 +3004,24 @@ "fields": [ { "jsonKey": "fhir_resources", - "fieldTemplate": ".fhirResources({{value}})", - "kind": "object", - "required": true - }, - { - "jsonKey": "mode", - "fieldTemplate": ".mode({{value}})", - "kind": "enum", - "required": false, - "enumValues": [ - "narrative", - "flatten", - "ips" - ] - }, - { - "jsonKey": "template_id", - "fieldTemplate": ".templateId({{value}})", - "kind": "string", - "required": false - } - ] - } - } - }, - "GET /fhir/implementation-guides": { - "httpMethod": "GET", - "httpPath": "/fhir/implementation-guides", - "request": { - "body": null - }, - "response": { - "body": null - }, - "render": { - "callTemplate": "client.implementationGuides().implementationguides().list()", - "params": [] - } - }, - "GET /fhir/implementation-guides/{name}": { - "httpMethod": "GET", - "httpPath": "/fhir/implementation-guides/{name}", - "request": { - "body": null - }, - "response": { - "body": null - }, - "render": { - "callTemplate": "client.implementationGuides().implementationguides().get({{name}})", - "params": [ - { - "name": "name", - "kind": "string" - } - ] - } - }, - "PUT /fhir/implementation-guides/{name}": { - "httpMethod": "PUT", - "httpPath": "/fhir/implementation-guides/{name}", - "request": { - "body": null - }, - "response": { - "body": null - }, - "render": { - "callTemplate": "client.implementationGuides().implementationguides().update({{name}}, UpdateImplementationGuideRequest.builder(){{__body__}}.build())", - "params": [ - { - "name": "name", - "kind": "string" - } - ], - "body": { - "fieldSeparator": "", - "fields": [ - { - "jsonKey": "profile_context", - "fieldTemplate": ".profileContext({{value}})", - "kind": "string", - "required": false - } - ] - } - } - }, - "DELETE /fhir/implementation-guides/{name}": { - "httpMethod": "DELETE", - "httpPath": "/fhir/implementation-guides/{name}", - "request": { - "body": null - }, - "response": { - "body": null - }, - "render": { - "callTemplate": "client.implementationGuides().implementationguides().delete({{name}})", - "params": [ - { - "name": "name", - "kind": "string" - } - ] - } - }, - "POST /fhir/implementation-guides/{name}/versions": { - "httpMethod": "POST", - "httpPath": "/fhir/implementation-guides/{name}/versions", - "request": { - "body": null - }, - "response": { - "body": null - }, - "render": { - "callTemplate": "client.implementationGuides().implementationguides().createVersion({{name}}, CreateCanonicalImplementationGuideRequest.builder(){{__body__}}.build())", - "params": [ - { - "name": "name", - "kind": "string" - } - ], - "body": { - "fieldSeparator": "", - "fields": [ - { - "jsonKey": "implementation_guide", - "fieldTemplate": ".implementationGuide({{value}})", - "kind": "object", - "required": true, - "nested": { - "fieldSeparator": "", - "fields": [ - { - "jsonKey": "resourceType", - "fieldTemplate": ".resourceType({{value}})", - "kind": "enum", - "required": true, - "enumValues": [ - "ImplementationGuide" - ] - }, - { - "jsonKey": "url", - "fieldTemplate": ".url({{value}})", - "kind": "string", - "required": true - }, - { - "jsonKey": "version", - "fieldTemplate": ".version({{value}})", - "kind": "string", - "required": true - }, - { - "jsonKey": "id", - "fieldTemplate": ".id({{value}})", - "kind": "string", - "required": false - }, - { - "jsonKey": "name", - "fieldTemplate": ".name({{value}})", - "kind": "string", - "required": false - }, - { - "jsonKey": "status", - "fieldTemplate": ".status({{value}})", - "kind": "string", - "required": false - }, - { - "jsonKey": "packageId", - "fieldTemplate": ".packageId({{value}})", - "kind": "string", - "required": false - }, - { - "jsonKey": "fhirVersion", - "fieldTemplate": ".fhirVersion({{value}})", - "kind": "list", - "required": false, - "items": { - "jsonKey": "", - "fieldTemplate": "{{value}}", - "kind": "string", - "required": true - } - } - ], - "wrap": "FHIRImplementationGuide.builder(){{__body__}}.build()" - } + "fieldTemplate": ".fhirResources({{value}})", + "kind": "object", + "required": true }, { - "jsonKey": "profile_refs", - "fieldTemplate": ".profileRefs({{value}})", - "kind": "list", - "required": true, - "items": { - "jsonKey": "", - "fieldTemplate": "{{value}}", - "kind": "string", - "required": true - } + "jsonKey": "mode", + "fieldTemplate": ".mode({{value}})", + "kind": "enum", + "required": false, + "enumValues": [ + "narrative", + "flatten", + "ips" + ] }, { - "jsonKey": "profile_context", - "fieldTemplate": ".profileContext({{value}})", + "jsonKey": "template_id", + "fieldTemplate": ".templateId({{value}})", "kind": "string", "required": false } @@ -5102,9 +3029,9 @@ } } }, - "GET /fhir/implementation-guides/{name}/versions/{version}": { + "GET /fhir/implementation-guides": { "httpMethod": "GET", - "httpPath": "/fhir/implementation-guides/{name}/versions/{version}", + "httpPath": "/fhir/implementation-guides", "request": { "body": null }, @@ -5112,17 +3039,8 @@ "body": null }, "render": { - "callTemplate": "client.implementationGuides().implementationguides().getVersion({{name}}, {{version}})", - "params": [ - { - "name": "name", - "kind": "string" - }, - { - "name": "version", - "kind": "string" - } - ] + "callTemplate": "client.implementationGuides().implementationguides().list()", + "params": [] } }, "GET /fhir/profiles": { @@ -5181,176 +3099,6 @@ } } }, - "GET /fhir/profiles/{id}": { - "httpMethod": "GET", - "httpPath": "/fhir/profiles/{id}", - "request": { - "body": null - }, - "response": { - "body": null - }, - "render": { - "callTemplate": "client.profiles().profiles().get({{id}})", - "params": [ - { - "name": "id", - "kind": "string" - } - ] - } - }, - "PUT /fhir/profiles/{id}": { - "httpMethod": "PUT", - "httpPath": "/fhir/profiles/{id}", - "request": { - "body": null - }, - "response": { - "body": null - }, - "render": { - "callTemplate": "client.profiles().profiles().update({{id}}, ProfileUploadRequest.builder(){{__body__}}.build())", - "params": [ - { - "name": "id", - "kind": "string" - } - ], - "body": { - "fieldSeparator": "", - "fields": [ - { - "jsonKey": "structure_definition", - "fieldTemplate": ".structureDefinition({{value}})", - "kind": "object", - "required": true - }, - { - "jsonKey": "implementation_guide", - "fieldTemplate": ".implementationGuide({{value}})", - "kind": "string", - "required": false - } - ] - } - } - }, - "DELETE /fhir/profiles/{id}": { - "httpMethod": "DELETE", - "httpPath": "/fhir/profiles/{id}", - "request": { - "body": null - }, - "response": { - "body": null - }, - "render": { - "callTemplate": "client.profiles().profiles().delete({{id}})", - "params": [ - { - "name": "id", - "kind": "string" - } - ] - } - }, - "GET /fhir/profiles/{id}/versions": { - "httpMethod": "GET", - "httpPath": "/fhir/profiles/{id}/versions", - "request": { - "body": null - }, - "response": { - "body": null - }, - "render": { - "callTemplate": "client.profiles().versions().list({{id}})", - "params": [ - { - "name": "id", - "kind": "string" - } - ] - } - }, - "POST /fhir/profiles/{id}/versions": { - "httpMethod": "POST", - "httpPath": "/fhir/profiles/{id}/versions", - "request": { - "body": null - }, - "response": { - "body": null - }, - "render": { - "callTemplate": "client.profiles().versions().create({{id}}, {{__body__}})", - "params": [ - { - "name": "id", - "kind": "string" - } - ], - "body": { - "fieldSeparator": "", - "fields": [ - { - "jsonKey": "", - "fieldTemplate": "{{value}}", - "kind": "object", - "required": true, - "passthroughBody": true - } - ] - } - } - }, - "GET /fhir/profiles/{id}/versions/{version}": { - "httpMethod": "GET", - "httpPath": "/fhir/profiles/{id}/versions/{version}", - "request": { - "body": null - }, - "response": { - "body": null - }, - "render": { - "callTemplate": "client.profiles().versions().get({{id}}, {{version}})", - "params": [ - { - "name": "id", - "kind": "string" - }, - { - "name": "version", - "kind": "string" - } - ] - } - }, - "DELETE /fhir/profiles/{id}/versions/{version}": { - "httpMethod": "DELETE", - "httpPath": "/fhir/profiles/{id}/versions/{version}", - "request": { - "body": null - }, - "response": { - "body": null - }, - "render": { - "callTemplate": "client.profiles().versions().delete({{id}}, {{version}})", - "params": [ - { - "name": "id", - "kind": "string" - }, - { - "name": "version", - "kind": "string" - } - ] - } - }, "POST /tools/lang2fhir-and-create": { "httpMethod": "POST", "httpPath": "/tools/lang2fhir-and-create", @@ -5676,163 +3424,26 @@ } }, "POST /tools/mcp-server/create": { - "httpMethod": "POST", - "httpPath": "/tools/mcp-server/create", - "request": { - "body": { - "name": "My MCP Server", - "mcp_server_url": "https://mcp.example.com" - } - }, - "response": { - "body": { - "success": true, - "message": "MCP Server and Tools created successfully", - "mcp_servers": [ - { - "id": "123", - "name": "My MCP Server", - "description": "My MCP Server is a server that provides MCP services", - "mcp_server_url": "https://mcp.example.com" - } - ], - "mcp_server_tools": [ - { - "id": "tool-001", - "name": "search_endpoints", - "description": "Search across the documented endpoints", - "input_schema": { - "query": "string" - }, - "mcp_server_id": "123", - "mcp_server_url": "https://mcp.example.com" - }, - { - "id": "tool-002", - "name": "get_endpoint", - "description": "Fetch a single endpoint by ID", - "input_schema": { - "endpoint_id": "string" - }, - "mcp_server_id": "123", - "mcp_server_url": "https://mcp.example.com" - } - ] - } - }, - "render": { - "callTemplate": "client.tools().mcpServers().create(McpServerCreateRequest.builder(){{__body__}}.build())", - "params": [], - "body": { - "fieldSeparator": "", - "fields": [ - { - "jsonKey": "name", - "fieldTemplate": ".name({{value}})", - "kind": "string", - "required": true - }, - { - "jsonKey": "mcp_server_url", - "fieldTemplate": ".mcpServerUrl({{value}})", - "kind": "string", - "required": true - } - ] - } - } - }, - "GET /tools/mcp-server/list": { - "httpMethod": "GET", - "httpPath": "/tools/mcp-server/list", - "request": { - "body": null - }, - "response": { - "body": { - "success": true, - "message": "MCP Servers retrieved successfully", - "mcp_servers": [ - { - "id": "123", - "name": "My MCP Server", - "description": "My MCP Server is a server that provides MCP services", - "mcp_server_url": "https://mcp.example.com" - }, - { - "id": "456", - "name": "Another MCP Server", - "description": "A second MCP server", - "mcp_server_url": "https://other.mcp.example.com" - } - ] - } - }, - "render": { - "callTemplate": "client.tools().mcpServers().list()", - "params": [] - } - }, - "GET /tools/mcp-server/{mcp_server_id}": { - "httpMethod": "GET", - "httpPath": "/tools/mcp-server/{mcp_server_id}", - "request": { - "body": null - }, - "response": { - "body": { - "success": true, - "message": "MCP Server retrieved successfully", - "data": { - "id": "123", - "name": "My MCP Server", - "description": "My MCP Server is a server that provides MCP services", - "mcp_server_url": "https://mcp.example.com" - } - } - }, - "render": { - "callTemplate": "client.tools().mcpServers().get({{mcp_server_id}})", - "params": [ - { - "name": "mcp_server_id", - "kind": "string" - } - ] - } - }, - "DELETE /tools/mcp-server/{mcp_server_id}": { - "httpMethod": "DELETE", - "httpPath": "/tools/mcp-server/{mcp_server_id}", - "request": { - "body": null - }, - "response": { - "body": { - "success": true, - "message": "MCP Server Configuration and Tools deleted successfully" - } - }, - "render": { - "callTemplate": "client.tools().mcpServers().delete({{mcp_server_id}})", - "params": [ - { - "name": "mcp_server_id", - "kind": "string" - } - ] - } - }, - "GET /tools/mcp-server/{mcp_server_id}/list": { - "httpMethod": "GET", - "httpPath": "/tools/mcp-server/{mcp_server_id}/list", + "httpMethod": "POST", + "httpPath": "/tools/mcp-server/create", "request": { - "body": null + "body": { + "name": "My MCP Server", + "mcp_server_url": "https://mcp.example.com" + } }, "response": { "body": { "success": true, - "message": "MCP Server tools retrieved successfully", + "message": "MCP Server and Tools created successfully", + "mcp_servers": [ + { + "id": "123", + "name": "My MCP Server", + "description": "My MCP Server is a server that provides MCP services", + "mcp_server_url": "https://mcp.example.com" + } + ], "mcp_server_tools": [ { "id": "tool-001", @@ -5858,68 +3469,56 @@ } }, "render": { - "callTemplate": "client.tools().mcpTools().list({{mcp_server_id}})", - "params": [ - { - "name": "mcp_server_id", - "kind": "string" - } - ] - } - }, - "GET /tools/mcp-server/tool/{mcp_server_tool_id}": { - "httpMethod": "GET", - "httpPath": "/tools/mcp-server/tool/{mcp_server_tool_id}", - "request": { - "body": null - }, - "response": { + "callTemplate": "client.tools().mcpServers().create(McpServerCreateRequest.builder(){{__body__}}.build())", + "params": [], "body": { - "success": true, - "message": "MCP Server tool retrieved successfully", - "data": { - "id": "123", - "name": "My MCP Server Tool", - "description": "My MCP Server Tool is a tool that provides MCP services", - "input_schema": { - "name": "string", - "age": "number" - }, - "mcp_server_id": "123", - "mcp_server_url": "https://mcp.example.com" - } + "fieldSeparator": "", + "fields": [ + { + "jsonKey": "name", + "fieldTemplate": ".name({{value}})", + "kind": "string", + "required": true + }, + { + "jsonKey": "mcp_server_url", + "fieldTemplate": ".mcpServerUrl({{value}})", + "kind": "string", + "required": true + } + ] } - }, - "render": { - "callTemplate": "client.tools().mcpTools().get({{mcp_server_tool_id}})", - "params": [ - { - "name": "mcp_server_tool_id", - "kind": "string" - } - ] } }, - "DELETE /tools/mcp-server/tool/{mcp_server_tool_id}": { - "httpMethod": "DELETE", - "httpPath": "/tools/mcp-server/tool/{mcp_server_tool_id}", + "GET /tools/mcp-server/list": { + "httpMethod": "GET", + "httpPath": "/tools/mcp-server/list", "request": { "body": null }, "response": { "body": { "success": true, - "message": "MCP Server tool deleted successfully" + "message": "MCP Servers retrieved successfully", + "mcp_servers": [ + { + "id": "123", + "name": "My MCP Server", + "description": "My MCP Server is a server that provides MCP services", + "mcp_server_url": "https://mcp.example.com" + }, + { + "id": "456", + "name": "Another MCP Server", + "description": "A second MCP server", + "mcp_server_url": "https://other.mcp.example.com" + } + ] } }, "render": { - "callTemplate": "client.tools().mcpTools().delete({{mcp_server_tool_id}})", - "params": [ - { - "name": "mcp_server_tool_id", - "kind": "string" - } - ] + "callTemplate": "client.tools().mcpTools().list()", + "params": [] } }, "POST /transcribe": { @@ -6101,308 +3700,6 @@ ] } } - }, - "GET /workflows/{id}": { - "httpMethod": "GET", - "httpPath": "/workflows/{id}", - "request": { - "body": null - }, - "response": { - "body": { - "success": true, - "workflow": { - "id": "7a8b9c0d-1234-5678-abcd-ef9876543210", - "name": "Patient Data Mapping Workflow", - "workflow_instructions": "Given diagnosis data, find the patient and create a condition record", - "sample_data": { - "patient_last_name": "Rippin", - "patient_first_name": "Clay", - "diagnosis_code": "I10", - "encounter_date": "2024-01-15" - }, - "config": { - "fhir_provider_ids": [ - "550e8400-e29b-41d4-a716-446655440000" - ], - "dynamic_generation": false - }, - "graph": { - "steps": [ - { - "id": "step_1_id", - "name": "Find Patient", - "description": "Search for an existing patient by first name and last name", - "type": "search", - "provider_id": "550e8400-e29b-41d4-a716-446655440000" - }, - { - "id": "step_2_id", - "name": "Create Condition", - "description": "Create a FHIR Condition resource for the patient", - "type": "create", - "provider_id": "550e8400-e29b-41d4-a716-446655440000" - } - ] - }, - "created_at": "2024-01-15T09:30:00Z", - "updated_at": "2024-01-15T09:30:00Z" - } - } - }, - "render": { - "callTemplate": "client.workflows().get({{id}}, GetRequest.builder(){{__body__}}.build())", - "params": [ - { - "name": "id", - "kind": "string" - } - ], - "body": { - "fieldSeparator": "", - "fields": [ - { - "jsonKey": "verbose", - "fieldTemplate": ".verbose({{value}})", - "kind": "boolean", - "required": false - } - ] - } - } - }, - "PUT /workflows/{id}": { - "httpMethod": "PUT", - "httpPath": "/workflows/{id}", - "request": { - "body": { - "name": "Patient Data Mapping Workflow (v2)", - "workflow_instructions": "Given diagnosis data, find the patient and create a condition record linked to their encounter", - "sample_data": { - "patient_last_name": "Rippin", - "patient_first_name": "Clay", - "diagnosis_code": "I10", - "encounter_date": "2024-01-15" - }, - "fhir_provider_id": "550e8400-e29b-41d4-a716-446655440000" - } - }, - "response": { - "body": { - "success": true, - "message": "Workflow updated successfully", - "workflow": { - "id": "7a8b9c0d-1234-5678-abcd-ef9876543210", - "name": "Patient Data Mapping Workflow (v2)", - "workflow_instructions": "Given diagnosis data, find the patient and create a condition record linked to their encounter", - "sample_data": { - "patient_last_name": "Rippin", - "patient_first_name": "Clay", - "diagnosis_code": "I10", - "encounter_date": "2024-01-15" - }, - "config": { - "fhir_provider_ids": [ - "550e8400-e29b-41d4-a716-446655440000" - ], - "dynamic_generation": false - }, - "graph": { - "steps": [ - { - "id": "step_1_id", - "name": "Find Patient", - "description": "Search for an existing patient by first name and last name", - "type": "search", - "provider_id": "550e8400-e29b-41d4-a716-446655440000" - } - ] - }, - "created_at": "2024-01-15T09:30:00Z", - "updated_at": "2024-02-20T11:00:00Z" - } - } - }, - "render": { - "callTemplate": "client.workflows().update({{id}}, UpdateWorkflowRequest.builder(){{__body__}}.build())", - "params": [ - { - "name": "id", - "kind": "string" - } - ], - "body": { - "fieldSeparator": "", - "fields": [ - { - "jsonKey": "name", - "fieldTemplate": ".name({{value}})", - "kind": "string", - "required": true - }, - { - "jsonKey": "workflow_instructions", - "fieldTemplate": ".workflowInstructions({{value}})", - "kind": "string", - "required": true - }, - { - "jsonKey": "sample_data", - "fieldTemplate": ".sampleData({{value}})", - "kind": "object", - "required": true - }, - { - "jsonKey": "fhir_provider_id", - "fieldTemplate": ".fhirProviderId({{value}})", - "kind": "object", - "required": true - }, - { - "jsonKey": "dynamic_generation", - "fieldTemplate": ".dynamicGeneration({{value}})", - "kind": "boolean", - "required": false - }, - { - "jsonKey": "verbose", - "fieldTemplate": ".verbose({{value}})", - "kind": "boolean", - "required": false - } - ] - } - } - }, - "DELETE /workflows/{id}": { - "httpMethod": "DELETE", - "httpPath": "/workflows/{id}", - "request": { - "body": null - }, - "response": { - "body": { - "success": true, - "message": "Workflow deleted successfully" - } - }, - "render": { - "callTemplate": "client.workflows().delete({{id}})", - "params": [ - { - "name": "id", - "kind": "string" - } - ] - } - }, - "POST /workflows/{id}/execute": { - "httpMethod": "POST", - "httpPath": "/workflows/{id}/execute", - "request": { - "body": { - "input_data": { - "patient_last_name": "Johnson", - "patient_first_name": "Mary", - "diagnosis_code": "M79.3", - "encounter_date": "2024-03-20" - } - } - }, - "response": { - "body": { - "success": true, - "message": "Workflow executed successfully", - "results": { - "steps": { - "step_1_id": { - "type": "search_result", - "original_query": "find patient by first name Mary and last name Johnson", - "resource_type": "Patient", - "search_params": { - "family": "Johnson", - "given": "Mary" - }, - "results": [ - { - "resourceType": "Patient", - "id": "patient-123", - "name": [ - { - "family": "Johnson", - "given": [ - "Mary" - ], - "use": "usual" - } - ], - "gender": "female", - "birthDate": "1961-01-01" - } - ] - }, - "step_2_id": { - "type": "create_result", - "id": "condition-456", - "resource_type": "Condition", - "original_desc": "create condition with diagnosis code M79.3 for the patient from step_1_id", - "preview": false, - "created_resource": { - "resourceType": "Condition", - "id": "condition-456", - "clinicalStatus": { - "coding": [ - { - "system": "http://terminology.hl7.org/CodeSystem/condition-clinical", - "code": "active" - } - ] - }, - "code": { - "coding": [ - { - "system": "http://hl7.org/fhir/sid/icd-10", - "code": "M79.3", - "display": "Panniculitis, unspecified" - } - ] - }, - "subject": { - "reference": "Patient/patient-123" - } - } - } - } - }, - "preview": false - } - }, - "render": { - "callTemplate": "client.workflows().execute({{id}}, ExecuteWorkflowRequest.builder(){{__body__}}.build())", - "params": [ - { - "name": "id", - "kind": "string" - } - ], - "body": { - "fieldSeparator": "", - "fields": [ - { - "jsonKey": "input_data", - "fieldTemplate": ".inputData({{value}})", - "kind": "object", - "required": true - }, - { - "jsonKey": "preview", - "fieldTemplate": ".preview({{value}})", - "kind": "boolean", - "required": false - } - ] - } - } } } } diff --git a/reference.md b/reference.md index e72775f6..4cc161f2 100644 --- a/reference.md +++ b/reference.md @@ -3201,12 +3201,46 @@ Multiple FHIR provider integrations can be provided as comma-separated values.
Maps a FHIR R4 resource or Bundle into OMOP Common Data Model v5.4 rows, -grouped by destination table in `tables`. +grouped by destination table in `tables`. Uncertain-status and unresolved- +primary-concept clinical rows stay in those tables and are identified by +the optional `rows_for_review` index for consumer review and filtering. +Status-based retention covers uncertain supplied Condition verification, +unknown/missing/unreadable Encounter status, and explicit `unknown` status +on MedicationStatement, Observation, and Procedure. Other status exclusions +follow the resource-specific eligibility policy below. Set `vocab_version` to select the OMOP vocabulary release used for coded -concept resolution. If omitted or empty, the API uses its default -release. The response's `vocab_version`, when present, identifies the -release used. Specify a release explicitly when reproducibility matters. +and inferred concept resolution. If omitted or empty, the API uses its +default release. The response's `vocab_version`, when present, identifies +the release used. Specify a release explicitly when reproducibility matters. + +Supported concept fields use semantic mapping for text-only inputs and +unresolved source codings, with whole-term equivalence validation. +CodeableConcept text takes precedence over coding display. A single +equivalent suggestion must be validated before a real OMOP concept ID is +returned. The suggested vocabulary/code must +itself be an active standard concept valid for the destination; broader +crosswalk targets are not accepted for inferred suggestions. +Deterministic mappings take precedence; ambiguous or domain-mismatched +deterministic results are not reinterpreted. Units, routes, visits, +demographics, and collection sites use their field-specific resolution +policies. Semantic mapping returns MAPPED with a real concept ID on +success, or UNMAPPED with concept ID 0 when no standard concept is +selected. A bare code is not treated as a description. No source coding +is invented. + +Contextual allergy finding decisions follow their dedicated policy and +are not replaced by description-based mapping. + +Every inferred mapping has +`mappings.resolution.semantic_mapping.status: MAPPED`, with the OMOP +vocabulary release. Filter linked rows using the mapping's table/row ID +to exclude inferred results. +Original source coding and clinical-status eligibility are preserved. +For clinical rows covered by `rows_for_review`, successful inference +does not remove uncertain-status rows from the index; unresolved primary +concepts also remain indexed. +All shaped rows appear once in `tables`. Standards basis: [FHIR R4 (v4.0.1)](https://hl7.org/fhir/R4/) defines the accepted source elements and [OMOP CDM @@ -3231,47 +3265,275 @@ Current resource coverage: first address can produce `location`, and more than one supplied race produces `observation` race rows (see Patient demographics below) - `observation_period` -> one request-local derived row per person, - spanning the populated dates of that person's visit, clinical, and - death rows; this is not enrollment or capture-completeness evidence + spanning dates from that person's visit and clinical rows not listed + in `rows_for_review`, plus Specimen and Death rows. Source end dates + contribute even when the output table has no end column. No period + row is returned for a person without qualifying dates; this is not + enrollment or capture-completeness evidence - `Location` -> `location` and `care_site` - `Organization` -> `care_site`; its first address can produce `location` - `HealthcareService` -> `care_site` - `Practitioner` and `PractitionerRole` -> `provider` - `Encounter` -> `visit_occurrence` -- `Condition` -> `condition_occurrence` -- `Procedure` -> `procedure_occurrence` -- `MedicationRequest`, `MedicationStatement`, and - `MedicationAdministration` -> `drug_exposure` -- `Immunization` -> `drug_exposure` -- `Observation` -> `measurement` or `observation`. For coded - Observations, the resolved OMOP concept domain selects the table; value - form only breaks ties. For text-only Observations, numeric values route - to `measurement` and nonnumeric values to `observation`. -- `AllergyIntolerance` -> `observation` +- `Specimen` -> `specimen`, with an explicit Patient subject; missing + collection date or material retains a partial row with diagnostics +- `DeviceUseStatement` -> `device_exposure`, using referenced `Device.type` +- `Condition` -> domain-selected clinical table; default `condition_occurrence` +- `Procedure` -> domain-selected clinical table; default `procedure_occurrence` +- `MedicationRequest`, `MedicationStatement`, `MedicationAdministration`, and + `MedicationDispense` -> domain-selected clinical table; default `drug_exposure` +- `Immunization` -> domain-selected clinical table; default `drug_exposure` +- `Observation` -> domain-selected clinical table. Numeric values prefer + `measurement` and other values prefer `observation` when the code is + valid for both tables. + If no supported standard concept is selected, numeric values retain the + `measurement` default and nonnumeric values retain `observation`. +- `AllergyIntolerance` -> `observation` for supported contextual + allergy/intolerance mappings; other coded statements use domain-based + placement excluding `drug_exposure` and `device_exposure`; default `observation` + +Clinical placement follows the informative Vulcan IG's vocabulary-driven +domain assignment. Supported destinations are `condition_occurrence`, +`procedure_occurrence`, `drug_exposure`, `device_exposure`, `measurement`, and `observation`. +Resolved clinical records are returned in the table corresponding to the +domain of their mapped OMOP standard concept. The source resource +determines eligibility, field values, and the default table when no +supported mapping is available. +`AllergyIntolerance` records do not produce `drug_exposure` or +`device_exposure` rows. Device placement requires eligible use evidence: +prescriptions, uncertain diagnoses, and explicit negative Observation +results (false, SNOMED Negative, Known absent, or No, or interpretation +code `NEG` from +`http://terminology.hl7.org/CodeSystem/v3-ObservationInterpretation`) +do not accept Device placement, even when other supplied values are positive. +A recorder, asserter, or information source is retained as context rather +than treated as the device event's provider. + +Coded `AllergyIntolerance.code` statements are interpreted using their +original codings, optional `type`, and `category`. Supported substances +produce an Observation finding separately from the causative substance +in `value_as_concept_id`. Drug-domain substances are valid values and do +not imply drug exposure or medication allergy. The finding is selected +by an explicit mapping policy, not a published substance-to-finding +crosswalk. Contextual findings report `MAPPED`, +`resolution.via: CONTEXTUAL`. `observation_source_concept_id` retains +the known OMOP identity of the original coding written to +`observation_source_value`, including when the finding is unresolved; +it is `0` when no source identity is known. The configured finding is +never substituted for the original source concept. + +This adapts the informative R5 [Vulcan Value-as-Concept +pattern](https://hl7.org/fhir/uv/omop/INFORMATIVE1/en/ValueAsConceptPattern.html) +to R4's substance/product codes, optional `type`, and repeating `category`. +It selects a contextual finding and resolves the substance separately; +published `Maps to value` decomposition of a composite finding is not performed. + +For `type: allergy`, medication (optionally with biologic) maps to +Allergy to drug (`439224`); other category sets use Allergic disposition +(`43530807`). For `type: intolerance`, medication (optionally biologic) +uses Intolerance to drug (`4240582`), food alone uses Intolerance to food +(`4340252`), and other sets use Intolerance to substance (`36684107`). +All selected concepts must be active and standard in the requested +vocabulary release. Limited reviewed substance mappings can derive an +absent category: for example, peanut and egg extracts supply food context, and +Penicillin G supplies medication context. No category is inferred solely +from Drug-domain membership. + +With absent `type`, a supported substance maps to a mechanism-neutral +propensity finding: medication (optionally with biologic) uses Propensity +to adverse reactions to drug (`4305025`), food alone uses Propensity to +adverse reactions to food (`4303802`), and other category sets or an +unknown category use Propensity to adverse reaction (`4172024`). The +original type remains absent; no allergy or intolerance mechanism is +inferred. The substance value is resolved independently. Existing +supported findings can supply their own mechanism and negative +statements retain their meaning. Conflicting evidence leaves the +affected concept unmapped. +A single identified substance can still populate the value when only +its category context conflicts. Distinct conflicting substances, +positive/negative mixtures, finding/substance mixtures, and multiple +standard substance targets do not select a substance value by input +order. Equivalent supported translations may share one value; +unresolved translations are disclosed and are not used as substances. +Finding-only, value-only, and neither-resolved partial rows are retained. + +`mappings.resolution.allergy` retains supplied type/categories, coded +clinical/verification statuses, the date-source element, per-coding +interpretation, derived categories, and reported information loss. The +finding and substance have separate mappings linked to the same row and +fields. Broader findings retain source specificity in this metadata. +Codes outside contextual policy coverage retain existing domain-based +mapping, with the contextual coverage outcome reported separately. +Text-only inputs retain their text-only behavior. Reaction-substance +fallback and published `Maps to value` relationships are not applied. +Null placeholders in repeating FHIR category primitives carry no category +value and are omitted from category interpretation and audit values. +An invalid supplied type/category drops only the affected AllergyIntolerance +before concept resolution. Other resources are still processed. The +`dropped` entry identifies the resource and reports +`allergy_type_unrecognized` or `allergy_category_unrecognized`. + +For example, a Condition coded with ICD10CM `Z79.4` can produce a mapped +`observation` row. `Z86.73` and ICD-O-3 `8140/3` also map to concepts in +the Observation domain. Use `mappings.omop_table` and `mappings.omop_id` +to locate each result; the FHIR resource type alone does not determine +the output table. + +Clinical rows include available person/visit/provider links, concept and +source concept IDs, source value, source type provenance, and start +timing. Dates follow the source resource's timing rules below, regardless +of the output table. For example, a Condition follows its +`onset[x]` / `recordedDate` timing rule even when returned as an +`observation`. End dates are populated only in tables with end columns; +for rows not listed in `rows_for_review`, they also contribute to the +request-local observation period when the output table has no end column. + +Source-specific fields are populated only for the corresponding source +and destination: Condition status in `condition_occurrence`, medication +dosage/supply and Immunization lot details in `drug_exposure`, and +Observation values/units/ranges in `measurement` or `observation` where +those columns exist. Mapping notes explain the output table and which +fields are populated. Coded medication/immunization routes on rows outside +`drug_exposure`, and Observation values on rows outside `measurement` / +`observation`, are retained in `mappings` linked to the output row, with +`omop_field` omitted and `selected: false`. Coded routes are checked +against the OMOP Route domain; those Observation values are preserved +without concept resolution. + +Note destinations are unsupported. If no standard concept +valid for a supported destination is found, the resource produces a row +in its default table with concept ID `0` and `UNMAPPED` status. Its +source concept ID is also `0`, except that allergy rows retain a known +original source identity from their coding evidence. A `DOMAIN_MISMATCH` +outcome is identified in the mapping +note. Text-only clinical inputs can also produce rows in another supported +table when semantic mapping selects a standard concept valid for that destination; +without a validated match, they use their default tables. Domain-routed +clinical records produce one row each even when their code has multiple +vocabulary mapping targets; DeviceUseStatement event granularity is +described below. Vocabulary `Maps to value` mappings are not applied. `Medication` is reference data for medication resources; it does not create its own row because OMOP CDM has no Medication table. Administrative linkages (provider, care site, and location) are best-effort and limited to references supplied in the request. `Patient.managingOrganization` is a -record custodian, not a care-delivery site. Provider specialty is not -mapped. Recorded `Practitioner.gender` is distinct from Person +record custodian, not a care-delivery site. Provider specialties resolve +independently to standard Provider concepts. Recorded `Practitioner.gender` +is distinct from Person demographics: `male` and `female` resolve to validated OMOP Gender concepts in `provider.gender_concept_id`; `other`, `unknown`, and absent gender remain unmapped. `Address.country` is resolved to `location.country_concept_id`, and CMS Place of Service codings in `Location.type` are resolved to `care_site.place_of_service_concept_id`. +Within one request, Practitioners with the same single distinct supplied +NPI value merge into one provider row only when all supplied mapped identity +attributes in the entire NPI group agree. The NPI system may be +`http://hl7.org/fhir/sid/us-npi` (preferred in FHIR R4), +`urn:oid:2.16.840.1.113883.4.6`, or bare `2.16.840.1.113883.4.6`; +these forms are equivalent for matching. OID forms are accepted for compatibility. +The same matching rule applies to Practitioners contained in valid role +references. NPI values are compared exactly as supplied, without format +or checksum validation. Blank values and unknown markers `UNK` and +`UNKNOWN` are treated as missing and do not populate `npi` or participate +in matching. Marker detection ignores case and surrounding whitespace; +all other values are preserved without trimming. Other identifier systems and names +do not trigger a merge. Missing attributes can enrich a compatible merged row. +Compatibility compares the name rendered from each Practitioner's first +`name` entry, its gender, its first non-empty DEA identifier, and its parsed +birth year. Differences in birth month/day within one year do not conflict. +Rendered names are compared exactly: `Jane Smith` and `Dr. Jane Smith` +conflict even when they share an NPI. +If any of these attributes conflict, every resource in that NPI group stays +separate, including resources missing the conflicting attribute. Source +attributes and unambiguous structural reference links are preserved; no +conflicting value is selected. Each affected row has `CONFLICTING` +diagnostics at `name`, `gender`, `identifier`, or `birthDate`, linked by +`omop_table: provider` and `omop_id`. All source gender mappings link to +their retained or merged provider row. +Repeated copies of one NPI on a resource are allowed. A resource with +multiple distinct NPIs stays separate, leaves `npi` unset, preserves its +source-resource identity in `provider_source_value`, and reports a +`CONFLICTING` diagnostic at `identifier` linked by +`omop_table: provider` and `omop_id`. +Row IDs are per response. The API does not merge across requests. For a +Practitioner with one NPI, `provider_source_value` is that NPI, even when +its group conflicts. Consumers can use it to deduplicate compatible +providers across requests, but must preserve rows flagged by identity +conflict diagnostics and check compatibility across responses. Without +one unambiguous NPI, source-resource identity is preserved without a +person-level deduplication guarantee. A `PractitionerRole` that identifies one supplied `Practitioner` aliases that canonical provider: by a top-level structural reference, a -parent-contained `#id` reference, or an exact `identifier.system` and -`identifier.value` match against a top-level Practitioner. No remote +parent-contained `#id` reference, or an `identifier.system` and +`identifier.value` match against a top-level Practitioner. Identifier +matching uses the equivalent NPI systems and missing-value rules above; +all other systems and all supplied identifiers match exactly. No remote identifier lookup is performed. When `Reference.type` is present it must -be `Practitioner`; duplicate contained IDs and identifier matches are -ambiguous. An explicit reference that is unresolved, ambiguous, or -unsupported retains a role-fallback provider row and is returned in -`diagnostics`. `provider_role_contexts` preserves role-specific +be `Practitioner`; duplicate contained IDs and identifier matches to +different canonical providers are ambiguous. An explicit reference that +is unresolved, ambiguous, or unsupported retains a role-fallback provider +row and is returned in `diagnostics`. `provider_role_contexts` preserves role-specific specialty and care-site context that a canonical OMOP provider row cannot represent together. +A role whose practitioner reference has only a blank or unknown NPI +identifier follows the missing-identifier behavior: its fallback row has +an `UNSUPPORTED` diagnostic explaining that a usable identifier or literal +reference is required. An unambiguous literal reference still links to +its Practitioner when the NPI is missing or unknown. Original supplied +unknown markers remain in the role's `practitioner_identifier` metadata. + +Each `PractitionerRole.specialty` entry resolves independently using its +supplied codings and explicit text from that entry. NUCC codings resolve +directly; other supported codings may use the UMLS bridge. Text fallback +is permitted when every coding has a nonblank code and a well-formed, +unsupported system identifier. Supported systems, mixed supported and +unsupported systems, and incomplete or malformed codings block fallback. +A supported coding remains authoritative even when the text differs. +Within one specialty entry, complete codings from well-formed unsupported +systems do not block supported codings that independently resolve to the +same standard Provider concept. Unsupported companions remain unsupported +in the coding audit and acquire no crosswalk. Missing or malformed +companions, failures of recognized codings, and disagreements between +supported codings still block selection. For example, NUCC `208000000X` alongside a complete local +`PEDS` coding can resolve to Pediatric Medicine, with or without text +and in either coding order. This uses the supported coding's mapping +and source concept, rather than text fallback. +Coding displays do not supply text fallback evidence. +Unsupported system identifiers must be absolute URIs without embedded +whitespace, user information, or fragments. HTTP(S) identifiers require +a host; URNs require a namespace and a nonempty namespace-specific value. +Bare labels such as `Epic` do not qualify. +Text uses deterministic vocabulary assertions in order: standard Provider +concept name, English OMOP synonym, then an English UMLS string on a +CUI with a NUCC atom, then a NUCC preferred-term label with its grouping +prefix removed. The last layer retains the entire classification and +specialization label, including qualifications. An ambiguous layer stops +resolution. No semantic search or ML matching is used. +Each role's input remains visible in `mappings`, including repeated +identical inputs across one provider's roles. +`specialty_concept_id` is populated only when every distinct supplied +input resolves to the same standard Provider concept. With no inputs, +or an unresolved, ambiguous, or conflicting input, it stays `0`. +A role without a specialty contributes no input. The endpoint applies +no frequency or hierarchy selection. Each input's outcome and matching +route (layer, matched string, and UMLS CUI/SAB when applicable) appear +in `mappings` with `omop_field: specialty_concept_id`. An individually +resolved input can coexist with a provider concept of `0` when another +input fails or disagrees. Specialty mappings have `selected: false`: +the source text remains in `specialty_source_value` when supplied source +values agree, and every original value remains in role contexts and mappings. +Specialty mapping evidence includes `specialty_resolution` with the text +fallback decision and each coding's independent outcome. A successful +text fallback reports `MAPPED` and source concept ID `0`; its local +codings remain unsupported in that audit. For example, complete local +codings with explicit `Pediatrics` text can resolve to Pediatric Medicine +through `NUCC_LABEL`. Adding a different or unresolved specialty still +keeps the provider's singular concept at `0`. +Compound phrasings without a vocabulary assertion remain unresolved. +For example, `Pulmonology` stays unresolved while `Pulmonologist` +resolves to Pulmonary Disease. Results depend on `vocab_version`. + Patient demographics: - Sex at birth, race, and ethnicity are read from these US Core extensions, with their US Core 6.1.0 structures and value sets, on @@ -3331,23 +3593,148 @@ Patient demographics: `0`; and Vulcan's suggested `observation` rows for multiple ethnicities are not produced. -`DiagnosticReport`, `ServiceRequest`, `CarePlan`, `DocumentReference`, -`Composition`, `Specimen`, `DeviceUseStatement`, `Coverage`, `Claim`, and -other unsupported resource types are accepted in a Bundle but ignored: they +`Specimen` produces a `specimen` row only with an explicitly resolvable +top-level Patient subject. Missing collection dates or material retain +useful partial rows with linked diagnostics; those rows may require +downstream completion before loading into a strict CDM instance. +References support `Patient/id` and exact Bundle +fullUrl matches (including URNs and absolute URLs); contained, logical, +display-only, missing, non-Patient, and unresolved subjects never use the +sole Patient fallback. Reference.type, when supplied, must identify Patient. +Unsafe patient attribution and ineligible status drop only that Specimen +with `dropped` and `diagnostics`; a missing Patient in the entire request +remains a 400. Missing material is concept ID 0 with a `type` diagnostic +and no terminology mapping/summary count. Supplied unmapped material is +also 0, with its separate vocabulary mapping outcome and retained source code. +Material (`type`) resolves against the Specimen domain, coded UCUM +collection quantity units against Unit, and `collection.bodySite` against +Spec Anatomic Site. Supplied unmapped optional concepts are 0; absent +unit and site IDs are explicit null. Text-only body sites preserve their +source text with site concept ID 0 and an `UNMAPPED` mapping; they do not +produce a text-based concept suggestion. Material descriptions follow +the semantic-mapping policy above. Original source codes and text-only +descriptions are preserved. +Numeric quantity is copied without +unit conversion; invalid or comparator-bearing quantities are omitted +with diagnostics without dropping the row. Missing optional data is allowed. +Collection time uses `collection.collectedDateTime` or +`collection.collectedPeriod.start`; end-only, partial, invalid, and absent +dates leave specimen_date/specimen_datetime omitted with linked diagnostics. +Both collected forms together are conflicting: retain the row without +selecting a date. Period end and receivedTime never substitute for collection +start. `specimen_source_contexts.collection_timing` preserves raw dateTime, +Period boundaries, receivedTime, and the selected source path when a date +is populated, including partial precision and original timezone offsets. +`specimen_collection_date_missing`, `specimen_collection_date_partial`, and +`specimen_collection_date_unusable` distinguish absent, partial, and invalid +source dates; `specimen_collection_multiple_forms` reports conflicting choices. +`specimen_type_concept_id: 32817` is EHR provenance, independent of material +and availability. Status absent/available/unavailable/unsatisfactory is +supported; entered-in-error and unrecognized statuses drop the specimen. +`specimen_source_contexts.status` preserves the supplied availability +status for emitted rows; it is omitted when no status was supplied. +Disease status is not inferred. Source identity precedence is resource id, +then first identifier with a nonempty value in source array order, then +accessionIdentifier.value, then Bundle fullUrl. No identity is fabricated; +`specimen_source_contexts` preserves all valued identifier system/value +pairs, accession identity, resource id/fullUrl, and the selected source path. +Parent/derived specimens emit independently when each has its own Patient; +missing date/material remains partial rather than being inherited. Parent +and Measurement-to-Specimen links are not represented. Consuming CDM +loaders own required-date completion, durable IDs, and dataset completeness. +Source baseline is FHIR R4 4.0.1, with supported common fields reviewed against +US Core Specimen 7.0.0; full profile conformance is not validated here. + +**Device use and exposure** + +DeviceUseStatement accepts active, completed, stopped, and on-hold use; +intended, entered-in-error, missing, and unrecognized statuses are omitted +with source-specific diagnostics. Its subject must explicitly resolve to +an emitted Patient. Group, contained-Patient, identifier-only, external +fetching, and sole-Patient attribution are unsupported for this path. +Device references support exact Type/id and Bundle fullUrl/URN matches, +plus unambiguous contained Devices scoped to the referring statement. +Missing, wrong-type, unresolved, or ambiguous Device details retain a +known-patient use row with device concept 0 and diagnostics. Missing type +evidence has no vocabulary mapping entry; supplied unmapped type evidence +has a mapping outcome. Inventory-only Device records emit no exposure. + +timingDateTime supplies the start only; timingPeriod preserves supplied +boundaries independently. Each timingTiming.event produces a separate +row, including a partial row for an unusable event, with its zero-based +event_index in device_exposure_contexts.exposures. Repeat rules without events +retain one partial row; events plus rules produce only the event rows. +No recurrence, continuous min/max interval, status-derived end date, or +recordedOn fallback is inferred. Year/month precision and malformed +individual dates leave affected columns absent. Competing timing choices +or a provably reversed Period leave both bounds absent. Supplied precision, +offsets, schedules, and omission/conflict reasons remain in source context +and diagnostics. Other clinical paths retain their own timing semantics. + +device_exposure_contexts contains one entry per source record that produced +device exposures. Its exposures array links all rows derived from that +record by device_exposure_id and, for Timing events, event_index. Shared +timing, device evidence, and quantity_basis appear once per source record. +Contexts are not combined by resource id or device identity; distinct +records remain separate even when their identifiers match or are absent. + +device_source_value uses original type/clinical coding, never inventory +or resource identity. An unambiguous Device.udiCarrier.deviceIdentifier +supplies unique_device_id. Competing identifiers leave it absent and are +preserved with issuer/jurisdiction/carrier context. production_id remains +absent; carriers, lot, serial, manufacture, and expiry evidence remain in +device_exposure_contexts. + +DeviceUseStatement rows use device_type_concept_id 32817 (EHR), with +provider_id, visit_occurrence_id, and visit_detail_id unset. +Routed rows retain their source classification, explicit visit links to +emitted same-patient visits, and compatible performer links; +Condition/Procedure recorder/asserter and +MedicationStatement informationSource do not supply device provider_id. +Supplied source, encounter, context, recorder, asserter, performer, and +informationSource values on DeviceUseStatement are retained only in +device_exposure_contexts. + +Device exposure quantity is reported as follows: + +- When the source has no quantity, dose, rate, or numeric Observation + result field, quantity is 1 and device_exposure_contexts.quantity_basis + is default_absent_count. +- When such a field is supplied, including a null or malformed value, + quantity is omitted and quantity_basis is unsupported_supplied_evidence. + The original values remain in device_exposure_contexts.evidence, and + an UNSUPPORTED diagnostic identifies each supplied field. + +For example, an Observation with valueQuantity.value of 2 returned as a +device exposure has no quantity; the value 2 remains in its source context. +timingTiming.repeat.count remains in source context and does not set +quantity. unit_concept_id and unit_source_concept_id are always null. + +Distinct eligible statements and routed clinical records remain separate, +including overlapping use and equal Timing events. derivedFrom is retained +as supporting context, not duplicate identity. Row counts therefore need +not count distinct uses or physical devices. IDs are request-local exposure +identities. Before loading a CDM dataset, callers must assign durable IDs +and populate any required fields omitted from partial rows. + +Unsupported resource types are accepted in a Bundle but ignored: they create no row and no `dropped` entry. `dropped` is reserved for supported row-producing resources that cannot safely produce a positive OMOP row: required subject/patient, clinical code/text, or medication data may be unusable, or the resource may explicitly negate or fail the documented clinical-event eligibility policy. A single-Patient Bundle uses the sole -Patient only when `subject`/`patient` is absent. An explicit subject/patient +Patient only when `subject`/`patient` is absent, except DeviceUseStatement +and Specimen, which require explicit Patient subjects. An explicit subject/patient reference that is unresolved, ambiguous, or unsupported drops the clinical resource in every request scope. Eligibility distinguishes clinical validity, performed/taken evidence, and -administrative workflow state. Checks run before terminology resolution; -entered-in-error and explicitly non-performed events never produce a positive -clinical-event row, and an unrecognized required status fails closed. Condition -requires absent or confirmed canonical-HL7 `verificationStatus`; clinical course is not a +administrative workflow state. Entered-in-error and explicitly +non-performed events never produce a positive clinical-event row, and +unrecognized required statuses are dropped except the retained Condition and +Encounter cases below. Condition accepts absent or confirmed canonical-HL7 +`verificationStatus` without a status retention reason; uncertain or unreadable +supplied verification is retained and indexed. Clinical course is not a diagnosis-role mapping. Procedures accept completed or stopped events. Medication requests are prescription evidence only: `doNotPerform`, drafts, cancellations, and non-authorizing intents are dropped. Medication statements @@ -3356,72 +3743,148 @@ accept completed, in-progress, on-hold, or stopped events. An on-hold administration also needs an `effectiveDateTime` or `effectivePeriod.start` that supplies start evidence; a valid partial date remains an undated row. An on-hold administration is started evidence that is temporarily paused and -expected to continue. Immunizations require completed -status. Observations require final, +expected to continue. MedicationDispense accepts completed records, +including completed partial fills. Stopped records require a valid +`whenHandedOver`, including partial dates, as evidence of actual handover. +Preparation, in-progress (ready for pickup), on-hold, cancelled, declined, +entered-in-error, unknown, missing, and unrecognized statuses are dropped. +A completed dispense with missing or unusable handover timing remains a +partial row; preparation time never substitutes for handover. +Immunizations require completed +status. Observations accept final, amended, or corrected status; registered, -preliminary, cancelled, entered-in-error, unknown, and missing statuses are +preliminary, cancelled, entered-in-error, and missing statuses are dropped. AllergyIntolerance accepts absent, unconfirmed, or confirmed canonical-HL7 `verificationStatus` as a reported allergy, but drops refuted, -entered-in-error, and unreadable supplied verification statuses. Encounter +entered-in-error, and unreadable supplied verification statuses. Invalid +supplied allergy type/category also drops that resource, for both coded +and text-only statements. Encounter accepts arrived, triaged, in-progress, onleave, or finished status, but drops -planned, cancelled, entered-in-error, unknown, and missing statuses. An eligible +planned, cancelled, and entered-in-error statuses. Unknown, missing, and unreadable +Encounter statuses are retained and indexed without imputation. An eligible encounter without `Period.end` remains a partial visit; no end date is invented. - -Coded Observation routing is selected from the resolved OMOP concept -domain. Numeric and nonnumeric `value[x]` forms establish the preferred -target only when the code is valid for both tables. A text-only -Observation has no resolver target, so numeric values route to -`measurement` and nonnumeric values to `observation`. Numeric values -populate `value_as_number` in the selected row; other non-coded values +MedicationStatement, Observation, and Procedure with explicit `unknown` +status are also retained and indexed in `rows_for_review` when otherwise +shapeable. Missing or unreadable statuses on these three resource types +remain dropped. Retention preserves source status and does not establish +that an event occurred or medication was taken. Device placement still +requires eligible use evidence; unknown-status records cannot produce +`device_exposure` rows. + +The mapped OMOP concept's domain determines the clinical output table. +For Observations, numeric and nonnumeric `value[x]` forms determine the +preferred table only when the code is valid for both `measurement` and +`observation`. Unresolved Observations retain `measurement` rows for +numeric values and `observation` rows for other values. In these two +tables, numeric values populate `value_as_number`; other non-coded values populate `value_as_string` for an `observation` or `value_source_value` for a `measurement`. Coded `valueCodeableConcept` values are resolved -against the selected row's `value_as_concept_id` and use the selected +against `value_as_concept_id` in these tables and use the selected bare code in `value_source_value`, leaving an `observation`'s `value_as_string` empty; unmapped or target-invalid coded values remain -`0`. +`0`. Active standard concepts from any OMOP domain are valid for +`value_as_concept_id` per CDM 5.4. For example, a blood-culture Measurement +can record *Staphylococcus aureus* as its coded result, even though the +organism concept belongs to OMOP's Observation domain. +For other clinical output tables, Observation values are retained only +in `mappings`, without concept resolution or an `omop_field`, and with +`selected: false`. Other unsupported `value[x]` forms and Observation components do not populate separate converted values. A numeric comparator (`<`, `<=`, `>`, `>=`) is represented only by a -measurement's `operator_concept_id`; units remain source text and have -`unit_concept_id` of `0`. +measurement's `operator_concept_id`. + +For Observations returned as `measurement` or `observation` rows, +Quantity units resolve independently against the table's +`unit_concept_id`. +Only coded UCUM (`http://unitsofmeasure.org` or its +OID `2.16.840.1.113883.6.8`, bare or `urn:oid:`) is supported. Codes are +case-sensitive: no spelling aliases, text-only guesses, or numeric +conversion are applied. Coded UCUM units map to valid standard OMOP +Unit concepts using the release selected by `vocab_version`. +Mappings are direct or follow an active OMOP `Maps to` relationship; +UCUM units do not use a UMLS bridge. +Unknown codes, +vocabulary gaps, text-only units, missing coding, and unsupported +systems keep the row and use `0`; absent units use JSON `null` (the +field is present). Explicit dimensionless code `1` is a supplied unit +and resolves or remains `0`; it is never treated as absent. +`unit_source_value` contains Quantity.code when supplied, otherwise +Quantity.unit. A separate unit `mappings` entry preserves the original +Quantity.system/code in `source_system`/`source_code` and the exact +human-readable Quantity.unit in `source_name`, with its own outcome. +Quantity.unit provides display text and does not affect unit matching. +Measurement also returns nullable `unit_source_concept_id`, identifying +the OMOP source concept for Quantity.system/code when available, +otherwise `0` for a supplied unit. +Observation has no such column. Resolving a unit does not change +numeric values, comparators, or reference ranges. +Supported coded UCUM units include `resolution` in their mappings, +whether the unit resolves or remains unmapped: +its outcome and reason code distinguish vocabulary gaps, target-domain +mismatches, and ambiguity. Unresolved or ambiguous units retain the row +with concept ID `0`. Resolved mappings retain the selected concept's +direct or crosswalk provenance. Text-only units, units with missing +coding, and unsupported unit systems omit `resolution`; their `note` +explains the unmapped outcome. + +For Observations returned in other clinical tables, units are not resolved +or included as separate mapping outcomes. Device exposure unit IDs remain +null, with supplied quantity and unit evidence retained in +`device_exposure_contexts`. + +Compatibility: absent `unit_concept_id` values previously returned `0` +and now return `null`. Coded units use their code in `unit_source_value` +rather than their human-readable text; the text remains in `mappings`. +The Measurement `unit_source_concept_id` field is new. A standard OMOP `concept_id` is selected for each eligible primary clinical coding after considering all of the resource's supplied codings. An unambiguous -coded medication route is resolved independently to -`drug_exposure.route_concept_id`. Alongside the OMOP rows grouped by +coded medication or immunization route is checked independently against +the OMOP Route domain. For `drug_exposure` rows, `route_concept_id` +contains the resolved concept ID, or `0` if unmapped; for other output +tables the route is reported only in +`mappings`, without `omop_field` and with `selected: false`. +Alongside the OMOP rows grouped by table (`tables`), the response carries `mappings` (an entry for every supported source coding from a resource that shaped a row, linked back to that row; some, such as demographic null flavors, are reported without being resolved), +`rows_for_review` (an index of uncertain-status or primary-concept-0 clinical +rows in tables, with original statuses, reasons, and source/row identity), `provider_role_contexts` (source role details linked to provider rows), `dropped` (resources that could not be shaped into a row), `diagnostics` (explicit references that could not safely create a link, -and conflicting or unsupported Patient demographic extensions), +conflicting Practitioner identity attributes, conflicting or unsupported +Patient demographic extensions, and conflicting Encounter classifications), `vocab_version` (the OMOP vocabulary release codes were resolved against), and a small `summary` of the resolution outcomes. A `concept_id` of `0` is reported, not omitted (OMOP "no matching -concept" semantics): it covers both a coding with no standard match -(`UNMAPPED`) and an unverified suggestion for a text-only resource -(`UNCHECKED`). Visit and unit concept fields currently remain `0`; -Person demographics and Provider recorded gender follow the policies -above. Coded Observation values may populate `value_as_concept_id`. -Concepts set by a fixed convention rather than terminology resolution -are measurement `operator_concept_id`, set from a value comparator (`<`, -`<=`, `>`, `>=`), and the multiple-race concepts described above. Clinical -`*_source_value` fields contain the selected FHIR code (or source text -for text-only resources). +concept" semantics): it covers a supplied concept without a validated +standard match (`UNMAPPED`). +Supported `Encounter.class` or `Encounter.type` values +may populate `visit_concept_id`. Person demographics and Provider +recorded gender follow the policies above. +Coded Observation values and supported allergy substances may populate +`value_as_concept_id`. +Measurement `operator_concept_id` represents a value comparator (`<`, +`<=`, `>`, `>=`); multiple-race concepts follow the conventions described +above. Clinical `*_source_value` fields contain the selected FHIR code +(or source text for text-only resources). The corresponding selected `mappings` entry preserves the coding system and full source-coding provenance. Known OID-form coding systems are accepted as either FHIR OID URNs (for example, `urn:oid:2.16.840.1.113883.6.1` for LOINC) or bare OIDs, and -are normalized to their canonical system URLs before terminology -resolution. `mappings[].source_system` reports that canonical URL, so the -OID and URL forms produce the same mapping. An unknown OID is not -rewritten and may be `UNMAPPED`. +produce the same mappings as their canonical system URLs. Coding +mappings report that canonical URL in `source_system`; Quantity unit +mappings preserve the original Quantity.system, including OID spelling. +An unknown OID remains unchanged in `source_system` and may be +`UNMAPPED`. Other `*_source_value` fields preserve row-specific raw source values, such as resource identifiers, names, units, or status codes. -`MedicationRequest` uses `32838` (EHR prescription) for +`MedicationRequest` uses `32838` (EHR prescription) and +`MedicationDispense` uses `32825` (EHR dispensing record) for `drug_type_concept_id`; other current resources use `32817` (EHR). This is a coarse provenance policy: it does not infer patient-reported, medication-history, or other more-specific type concepts from FHIR @@ -3434,8 +3897,8 @@ Dates and datetimes: (`YYYY-MM-DDTHH:MM:SS`, with fractional seconds to microseconds when supplied): `2024-01-15T23:30:00-05:00` becomes `2024-01-15T23:30:00`. A datetime without a timezone is read as local time. -- A partial date (`2024` or `2024-03`) leaves both columns unset. It - still counts as that source's value, so later sources in the list +- A partial date (`2024` or `2024-03`) leaves both columns unset. + It still counts as that source's value, so later sources in the list below are not used. - Free-text timing (such as `onsetString`), `Age` and `Range` forms, and values that are not dates are ignored, so a later source in the list @@ -3445,11 +3908,18 @@ Dates and datetimes: completed, and a row is not dated from another resource such as its Encounter. - Valid date values never cause a request to be rejected, and the response - does not report which date elements were unusable. An on-hold + reports missing, partial, malformed, or conflicting timing for Specimen, + DeviceUseStatement, and MedicationDispense in `diagnostics` while retaining + partial rows for downstream load completion. Other resources do not report which + date elements were unusable. An on-hold MedicationAdministration is an eligibility exception: it needs a supplied effective start as evidence that administration began. + A stopped MedicationDispense similarly requires supplied handover evidence. -Timing sources, in priority order where several are listed: +Timing sources, in priority order where several are listed. These rules +apply regardless of the clinical output table; end dates are populated +only where the table has an end column, and `verbatim_end_date` is set +only for medication resources returned as `drug_exposure` rows: - `Patient`: `birthDate` sets `year_of_birth`, `month_of_birth`, and `day_of_birth` from the parts it supplies; `birth_datetime` is not set. `deceasedDateTime` sets the `death` dates. @@ -3460,6 +3930,12 @@ Timing sources, in priority order where several are listed: end from `abatementDateTime` or `abatementPeriod.end`. - `Procedure`: start from `performedDateTime` or `performedPeriod.start`; end from `performedPeriod.end` only. +- `DeviceUseStatement`: `timingDateTime` sets the start only; + `timingPeriod.start` and `timingPeriod.end` supply independent bounds; + each `timingTiming.event` produces one start-only row. A schedule + without explicit events produces one row without dates. +- `Specimen`: `collection.collectedDateTime` or `collection.collectedPeriod.start`; + missing day precision retains an undated specimen with diagnostics. - `Observation`: `effectiveDateTime`, `effectivePeriod.start`, or `effectiveInstant`. - `AllergyIntolerance`: `recordedDate`, then `onsetDateTime` or @@ -3476,20 +3952,204 @@ Timing sources, in priority order where several are listed: - `MedicationRequest`: `authoredOn`, the order date, sets the start; it is not evidence of administration. No end is set, and the validity period is not used as an exposure duration. +- `MedicationDispense`: `whenHandedOver` sets the start. Missing, partial, + or invalid handover leaves the start unset with a diagnostic. Comparable + handover/preparation values proving handover before preparation also + leave the start unset with a `CONFLICTING` diagnostic. `whenPrepared`, + authorizing orders, and related administrations never supply timing. + No exposure end or `verbatim_end_date` is inferred, including from + `daysSupply`. Raw handover/preparation values retain precision and offsets + in `medication_dispense_source_contexts.source`. - Differences from the Vulcan maps: `birth_datetime` is not set from `birthDate`, Condition also reads `onsetPeriod.start`, and AllergyIntolerance falls back to its onset when `recordedDate` is missing. -Medication details: +Medication details for resources returned as `drug_exposure` rows: - For `MedicationRequest`, `dispenseRequest.numberOfRepeatsAllowed` sets `refills` and a whole-day `expectedSupplyDuration` sets `days_supply`. +- For `MedicationDispense`, `daysSupply` sets `days_supply` only for a + positive whole number of coded UCUM `d` days without a supplied comparator. + The canonical UCUM URL, bare OID, and OID URN are accepted. Other units, + fractions, text-only units, and malformed values leave it absent with a + diagnostic; absent daysSupply is allowed. Refills are not inherited. - All non-empty dosage text is preserved in `sig`. - Coded dosage routes and `Immunization.route` are target-validated in the OMOP Route domain. Conflicting routes are left unset; route codings shared by every dosage instruction identify the same route. - `Immunization.lotNumber` is preserved in `lot_number`. +MedicationDispense requires an explicit subject resolving to a Patient +supplied in the request; Groups and missing subjects are unsupported. +Source fields are based on FHIR R4 4.0.1 and reviewed against +[US Core MedicationDispense 7.0.0](https://hl7.org/fhir/us/core/STU7/StructureDefinition-us-core-medicationdispense.html). +Full profile conformance is not validated. Although US Core requires +handover on completed records, this API retains such incomplete records +with missing-handover diagnostics as partial conversion output. +Supply either a usable `medicationCodeableConcept` (coded or text-only), +or a `medicationReference` resolving to a Medication with usable code/text. +Both choice forms together are conflicting and drop the dispense. +Exact relative, Bundle fullUrl, and unambiguous contained Medication +references are supported. Unresolved references are not rescued by display +text; missing or ambiguous medication identity drops the dispense with a +diagnostic. A usable but unmapped drug retains concept ID 0 and its +mapping outcome in a row indexed for review. +`context` links only to a supplied Encounter for the same patient. +The first resolvable Practitioner/PractitionerRole in `performer.actor` +supplies `provider_id`; other actor types do not supply a provider. +Failed explicit links have diagnostics. Authorizing prescriptions are +checked for request-local MedicationRequest identity and patient agreement; +failures do not discard the dispense. They never supply medication, +instructions, refills, timing, or prescription provenance to a dispense. +Dispense instructions alone supply `sig` and route; conflicting routes +remain unset with a diagnostic and original instructions in source context. +Orders, dispenses, and administrations emit independently, with separate +source identity and no deduplication merely because they concern the same drug. +Every emitted dispensing drug row has a `whenHandedOver/daysSupply` +diagnostic beginning `medication_dispense_end_date_absent` because the +CDM-required exposure end remains absent. Missing/partial/unusable handover +diagnostics begin `medication_dispense_handover_missing`, +`medication_dispense_handover_partial`, or `medication_dispense_handover_unusable`. + +Encounter details: +- Encounter classifications must supply both `Coding.system` and + `Coding.code` to map to a standard OMOP Visit concept. For HL7 + encounter class codes with system + `http://terminology.hl7.org/CodeSystem/v3-ActCode` or + `http://hl7.org/fhir/v3/ActCode`, the supported mappings are: + - `IMP` (inpatient encounter) -> 9201 (Inpatient Visit) + - `AMB` (ambulatory encounter) -> 9202 (Outpatient Visit) + - `EMER` (emergency encounter) -> 9203 (Emergency Room Visit) + - `VR` (virtual encounter) -> 722455 (Telehealth) + - `HH` (home health encounter) -> 581476 (Home Visit) + + `OBSENC` (observation encounter) has no direct class mapping. Other + class or type codes can be used when they have a valid standard OMOP + Visit mapping in the selected vocabulary release. Unsupported + classifications retain `visit_concept_id` of `0` unless a supported + type supplies the Visit as described below; they never default to + outpatient. +- A mapped `Encounter.class` determines `visit_concept_id`. A mapped + type that disagrees with it produces a `CONFLICTING` diagnostic with + path `class/type`, while the class mapping remains selected. + If class is missing or unmapped, type supplies the Visit only when + all mapped type classifications agree on the same standard Visit + concept. Codings within one type CodeableConcept are alternative + encodings of that classification. When separate type classifications + map to different Visit concepts, no type is selected, + `visit_concept_id` remains `0`, and a `CONFLICTING` diagnostic has + path `type`. +- `visit_source_value` contains the selected class or type code; + `mappings` preserves its coding system and identifies which + classification was selected. `visit_source_concept_id` identifies + the selected original source coding's OMOP concept when available, + and is `0` otherwise, including for the HL7 encounter class codes + listed above. Each eligible Encounter produces one Visit; + `classHistory`, `partOf`, and admission/discharge concepts are not + mapped. + +- Direct `MedicationRequest.dispenseRequest.quantity`, `MedicationDispense.quantity`, + `MedicationAdministration.dosage.dose`, and `Immunization.doseQuantity` + can set `quantity` under the formulation policies below. Quantities + must be positive finite numbers with a supported `system`/`code` pair + and no supplied `comparator`. Ingredient amounts and injection volumes + use `http://unitsofmeasure.org` (or its bare OID or + `urn:oid:2.16.840.1.113883.6.8`) with case-sensitive codes matching or + convertible to vocabulary-backed strength units. Exact unit matches, + including `[U]` for insulin and heparin strengths, are accepted without + a unit conversion. + When codes differ, both source and strength units must belong to the + same supported group: mass (`ug`, `mg`, `g`), volume (`uL`, `mL`, `L`), + or milliequivalent representations (`meq`, `10*-3.eq`). Other conversions, + including `kg`, `ML`, `Mg`, `[drp]`, `mol`, `mmol`, `%`, `1`, and generic + annotations such as `{vial}` or `{syringe}`, leave `quantity` unset with + `unit_mismatch`. Codes are not case-corrected or inferred from display text. + The original source system is preserved in the quantity audit. + Fixed-form counts also accept the explicit codings below. Free-text, + local, invalid, and cross-dimensional units are not interpreted. + +Drug quantity policies follow [CDM v5.4 DRUG_EXPOSURE conventions](https://ohdsi.github.io/CommonDataModel/cdm54.html#drug_exposure). +The selected drug must be a `Clinical Drug` or `Branded Drug`, with one +active dose form and valid vocabulary evidence for the chosen policy. +Strength-based conversions also require one ingredient strength. Supported RxNorm dose forms: +- `fixed_dose_form_count`: Oral Tablet (`317541`), Oral Capsule + (`316965`), Extended Release Oral Tablet (`316945`), Extended + Release Oral Capsule (`316943`), Delayed Release Oral Tablet (`10312`), + or Delayed Release Oral Capsule (`316995`). A source ingredient amount is divided + by the single positive per-unit ingredient amount after conversion to that + strength unit when necessary. + Concentration-based strengths cannot derive counts from ingredient amounts. The + result is a count of the selected dose form, limited to whole units or + multiples of one-half or one-third. Derived counts within a relative + tolerance of 1e-12 of an allowed fraction are normalized to that count + to remove arithmetic roundoff; other fractions remain unsupported. + For example, 1,000 mg for a drug + with 500 mg per tablet produces `quantity: 2` tablets (`DERIVED`). A source + of 1 g is converted to 1,000 mg and produces the same count. + Explicit counts are copied (`DIRECT`) when their coding matches the + selected tablet or capsule form, including the corresponding extended- + and delayed-release forms: `TAB` / `CAP` under + `http://terminology.hl7.org/CodeSystem/v3-orderableDrugForm`, or + `{tbl}` / `{Capsule}` under `http://unitsofmeasure.org`. These exact + case-sensitive count codings identify the supplied form; they are not + dimensionally converted as UCUM units. Direct counts do not require + ingredient strength: combination products and missing/invalid strengths + do not change the supplied count. Ingredient-strength gaps, including + missing ingredient or strength-unit references, do not prevent copying + an explicit count. Drug or dose-form evidence gaps, unclassified or + ambiguous forms, and gaps whose relevance cannot be established still + prevent conversion. The whole/half/third rules still apply. + For example, 20 `TAB` for amoxicillin/clavulanate is 20 tablets, and 30 `TAB` for + a supported tablet produces `quantity: 30`; `CAP` for that tablet is + omitted with `unit_mismatch`. Generic counts (`1`, `{Each}`), other + annotations, packs, and free-text counts are not interpreted. + See the [FHIR R4 count example](https://hl7.org/fhir/R4/medicationrequest0301.html). +- `clinical_injection_ingredient_amount`: Injectable Solution (`316949`), + Injectable Suspension (`316950`), or Injection (`1649574`). A source + ingredient amount is converted to the vocabulary ingredient unit when + necessary; an exact-unit amount is copied directly. Otherwise, a source + amount convertible to the concentration's denominator unit is converted + and multiplied by numerator value / the effective denominator reported in + the audit evidence. Volume conversion requires a positive finite numerator, + dimensionally distinct validated UCUM units, a null raw denominator value, and + `evidence.strength.denominator_basis` with `kind: PER_UNIT` and + `value: 1`. A null denominator alone is not interpreted as 1. Missing, unknown, + non-applicable, quantified, or conflicting denominator evidence does + not support volume conversion. For example, 80 mL with 2 mg/mL + strength and a `PER_UNIT` basis produces `quantity: 160` mg. A supplied + 160 mg amount for the same drug produces `quantity: 160` directly. A source + of 0.08 L converts to 80 mL before producing the same 160 mg result. + This output is already + the ingredient amount: do not apply the drug-dose guide's + `dose = quantity × numerator` formula to these injection rows, which + would multiply by the concentration a second time. Use + `quantity_transformations.result_unit` to identify the output unit. + +Quantified products, packs, prefilled syringes, other liquids and dose +forms, strength-based conversions of combination products, conflicting or incomplete required evidence, and +unsupported source quantities leave `quantity` unset. Other supported +fields and the clinical row are still returned. For example, a quantity +supplied only as `value: 2, unit: tablet` is omitted with +`unsupported_unit`, because a free-text unit cannot establish this policy. +Rate-only administrations and MedicationStatement dosages do not establish +a supported total quantity and are not used for quantity conversion. + +`quantity_transformations` audits each supplied quantity at one of the +three direct paths above on a resource returned as a `drug_exposure` row, +including an explicit null or malformed quantity. Each entry preserves +the source, identifies the destination row, and reports `DIRECT`, `DERIVED`, or +`OMITTED`. Successful entries identify their policy, evidence, result, +and result unit; omitted entries give a stable reason. When a source code +differs from the strength unit, `unit_conversion` records the intermediate +converted amount and target unit. Converted results are `DERIVED`, even +when no further concentration arithmetic is needed. A successful conversion +can also be reported when a later policy check omits the final quantity. +Dose-form and unit +concept IDs refer to the response's `vocab_version`. Missing quantities +produce no entry. Resources returned in other clinical tables have no +quantity entry. Unsupported rates and statement dosages produce no entry, +and resources in `dropped` have no quantity entry or destination row. + Medication codes are resolved whether they appear inline (`medicationCodeableConcept`) or via a `medicationReference` to a contained, relative (`Type/id`), or bundle-entry (`urn:uuid`) `Medication` resource. @@ -3502,11 +4162,12 @@ subject/patient reference cannot be tied to a person; or an event excluded by eligibility — are reported under `dropped` rather than emitted as blank rows. The Bundle must contain at least one Patient resource. -Structural references resolve only to top-level resources supplied in the -request, by `Type/id` or an exactly matching Bundle `fullUrl` (including -`urn:uuid`). Contained references are supported for medication code lookup -and `PractitionerRole.practitioner` enrichment; the latter also supports -exact request-local identifier matching without a remote lookup. Every +References to top-level resources supplied in the request resolve by +`Type/id` or an exactly matching Bundle `fullUrl` (including `urn:uuid`). +Parent-scoped contained references are supported for medication code lookup, +`DeviceUseStatement.device` details, and `PractitionerRole.practitioner` +enrichment; the latter also supports +request-local identifier matching with the NPI rules above, without a remote lookup. Every nonzero structural foreign key targets a row in the same response. Missing optional links remain unset without a diagnostic; an explicit optional reference that is unresolved, ambiguous, conflicting with the row's @@ -3613,8 +4274,8 @@ client.fhir2Omop().create( **vocabVersion:** `Optional` OMOP vocabulary release to use for coded concept resolution. If -omitted or empty, the API uses its default release. Specify a -release explicitly when reproducibility matters. The response's +omitted or empty, the API uses its default release (`v20260829`). +Specify a release explicitly when reproducibility matters. The response's `vocab_version`, when present, identifies the release used.
@@ -3628,11 +4289,11 @@ release explicitly when reproducibility matters. The response's FHIR resources (single resource or Bundle). Must contain at least one Patient resource. Supported row-producing resources are Patient, Location, Organization, HealthcareService, Practitioner, -PractitionerRole, Encounter, Condition, Procedure, MedicationRequest, -MedicationStatement, MedicationAdministration, Immunization, -Observation, and AllergyIntolerance. Standalone Medication resources -are consumed by medication references rather than mapped to their own -table. Unsupported resource types are accepted in a Bundle but ignored. +PractitionerRole, Encounter, Specimen, DeviceUseStatement, Condition, Procedure, +MedicationRequest, MedicationStatement, MedicationAdministration, MedicationDispense, +Immunization, Observation, and AllergyIntolerance. Medication and Device +resources provide referenced details without independently producing rows. +Unsupported resource types are accepted in a Bundle but ignored. @@ -4552,7 +5213,7 @@ client.lang2Fhir().create(
-**resource:** `CreateRequestResource` — Type of FHIR resource to create. Use 'auto' for automatic resource type detection, or specify a supported profile. The default profile set includes US Core profiles and selected base R4 resources; you can also use any custom profile you've uploaded (if you're a develop or launch customer). +**resource:** `CreateRequestResource` — Type of FHIR resource to create. Use 'auto' for automatic resource type detection, or specify a supported profile. The default profile set includes US Core profiles and selected base R4 resources. The built-in PhenoML HPO profiles must be requested by id: `phenoml-hpo-condition` and `phenoml-hpo-phenotypic-observation` code findings with the Human Phenotype Ontology, and `phenoml-hpo-head-circumference-tape-measure` and `phenoml-hpo-head-circumference-percentile` record a head circumference measured as a length or stated as an age-adjusted percentile. You can also use any custom profile you've uploaded (if you're a develop or launch customer).
@@ -4590,7 +5251,9 @@ Resources are linked with proper references (e.g., Conditions reference the Pati The complete JSON request body is limited to 32 MiB (33,554,432 bytes). The application enforces this whole-envelope limit. -**Patient identifier handling.** US Core requires `Patient.identifier` (a business identifier such as an MRN). When the source text contains an identifier, it is extracted with an appropriate URI system. When the source text does not contain a detectable identifier, a synthetic one is generated with `system: "urn:phenoml:lang2fhir-generated-id"` and a UUID `value` so the bundle remains FHIR-valid and US Core conformant. Callers who need a tenant-specific namespace should rewrite the synthetic system after extraction. +**Patient handling.** Every bundle contains exactly one Patient, and every clinical resource references it. When the text names its patient, the Patient is built from the text. When it names several patients, the Patient is the one `primary_patient` names, otherwise the one most of the text is about; the others are not represented as Patients. Otherwise it is built from `primary_patient` when supplied, or is an unidentified Patient carrying only a synthetic identifier. When the text names its patient, the `primary_patient` identifier is added to that Patient unless its name, birth date, or gender contradicts the text's; the response `message` says whether it was added or did not match. + +**Patient identifier handling.** US Core requires `Patient.identifier` (a business identifier such as an MRN). Every Patient carries a synthetic identifier with `system: "urn:phenoml:lang2fhir-generated-id"` and a UUID `value`, so the bundle remains FHIR-valid and US Core conformant. A medical record number stated in the text is added with `system: "urn:phenoml:lang2fhir-document-mrn"`, and the `primary_patient` identifier is added as described above. Callers who need a tenant-specific namespace should rewrite these systems after extraction. @@ -4650,7 +5313,7 @@ client.lang2Fhir().createMulti(
-**primaryPatient:** `Optional` +**primaryPatient:** `Optional` — Context for the patient the text is primarily about. When the text does not name its patient, the bundle's Patient is built from these fields. When it does, the identifier is added to that Patient unless its name, birth date, or gender contradicts these fields.
@@ -4666,7 +5329,7 @@ client.lang2Fhir().createMulti(
-**implementationGuide:** `Optional` — Custom Implementation Guide name. When specified, profiles from this IG are included alongside the default profiles during resource detection. Default profiles are always the base layer; custom IG profiles are additive. +**implementationGuide:** `Optional` — Implementation Guide to generate resources under. `us_core` (the default) and `phenoml_hpo` select a built-in guide. Any other value is a custom Implementation Guide name: its profiles are included alongside the US Core profiles, which are always the base layer. A custom name that matches no uploaded Implementation Guide returns 404.
@@ -4674,7 +5337,7 @@ client.lang2Fhir().createMulti(
-**detectionEffort:** `Optional` — Deprecated; use the default 'standard' value. This field will be removed in a future release. 'standard' runs detection once; 'deep' runs detection multiple times for higher recall. +**detectionEffort:** `Optional` — Deprecated and ignored: both values produce the same result. This field will be removed in a future release.
@@ -4692,6 +5355,14 @@ client.lang2Fhir().createMulti( **resourceReview:** `Optional` + + + +
+
+ +**smallNote:** `Optional` — Bills the request at the lower small-note rate. When true, `text` must be at most 15,360 bytes (15 KiB) when UTF-8 encoded; longer text is rejected with a 400 error. Extraction behavior is the same as a standard request. +
@@ -4803,7 +5474,7 @@ from the StructureDefinition is used as the profile's unique identifier and look To use the uploaded profile with `/lang2fhir/create`, pass this id as the `resource` parameter. Uploads will be rejected if: -- A built-in US Core or R4 base profile already exists with the same id +- A built-in profile (US Core, PhenoML HPO, or R4 base) already exists with the same id, or the id starts with a reserved prefix (`us-core-`, `phenoml-hpo-`) - A custom profile with the same id has already been uploaded - A custom profile with the same url has already been uploaded @@ -4969,7 +5640,11 @@ Resources are linked with proper references (e.g., Conditions reference the Pati The complete JSON request body is limited to 32 MiB (33,554,432 bytes). The application enforces this whole-envelope limit, including base64-encoded document `content`. -**Patient identifier handling.** US Core requires `Patient.identifier` (a business identifier such as an MRN). When the source text contains an identifier, it is extracted with an appropriate URI system. When the source text does not contain a detectable identifier, a synthetic one is generated with `system: "urn:phenoml:lang2fhir-generated-id"` and a UUID `value` so the bundle remains FHIR-valid and US Core conformant. Callers who need a tenant-specific namespace should rewrite the synthetic system after extraction. +**Patient handling.** Every bundle contains exactly one Patient, and every clinical resource references it. When the document names its patient, the Patient is built from the document. When it names several patients, the Patient is the one `primary_patient` names, otherwise the one most of the document is about; the others are not represented as Patients. Otherwise it is built from `primary_patient` when supplied, or is an unidentified Patient carrying only a synthetic identifier. When the document names its patient, the `primary_patient` identifier is added to that Patient unless its name, birth date, or gender contradicts the document's; the response `message` says whether it was added or did not match. + +**Patient identifier handling.** US Core requires `Patient.identifier` (a business identifier such as an MRN). Every Patient carries a synthetic identifier with `system: "urn:phenoml:lang2fhir-generated-id"` and a UUID `value`, so the bundle remains FHIR-valid and US Core conformant. A medical record number stated in the document is added with `system: "urn:phenoml:lang2fhir-document-mrn"`, and the `primary_patient` identifier is added as described above. Callers who need a tenant-specific namespace should rewrite these systems after extraction. + +**Page classification.** When `config` sets neither `split_classifications` nor `page_filter`, pages classified as test requisition forms (lab or diagnostic order forms) are dropped before extraction and reported in `page_classifications`. Supplying either setting replaces this default. **Split classifications (optional).** `config.split_classifications` is a caller-defined list, not a fixed taxonomy. Choose each classification `id` and write a natural-language `description` for the per-page classifier. For each page, the classifier assigns the best-matching classification or leaves the page ungrouped. Classifications with `operation: "group"` keep matching pages and label resources extracted from those pages; classifications with `operation: "drop"` remove matching pages before extraction. The `clinical` and `admin` ids in the example are illustrative, not a fixed set. @@ -5062,7 +5737,7 @@ Generic XML must include an XML declaration; C-CDA documents rooted at `Clinical
-**primaryPatient:** `Optional` +**primaryPatient:** `Optional` — Context for the patient the document is primarily about. When the document does not name its patient, the bundle's Patient is built from these fields. When it does, the identifier is added to that Patient unless its name, birth date, or gender contradicts these fields.
@@ -5078,7 +5753,7 @@ Generic XML must include an XML declaration; C-CDA documents rooted at `Clinical
-**implementationGuide:** `Optional` — Custom Implementation Guide name. When specified, profiles from this IG are included alongside the default profiles during resource detection. Default profiles are always the base layer; custom IG profiles are additive. +**implementationGuide:** `Optional` — Implementation Guide to generate resources under. `us_core` (the default) and `phenoml_hpo` select a built-in guide. Any other value is a custom Implementation Guide name: its profiles are included alongside the US Core profiles, which are always the base layer. A custom name that matches no uploaded Implementation Guide returns 404.
@@ -5086,7 +5761,7 @@ Generic XML must include an XML declaration; C-CDA documents rooted at `Clinical
-**detectionEffort:** `Optional` — Deprecated; use the default 'standard' value. This field will be removed in a future release. 'standard' runs detection once; 'deep' runs detection multiple times for higher recall. +**detectionEffort:** `Optional` — Deprecated and ignored: both values produce the same result. This field will be removed in a future release.
diff --git a/src/main/java/com/phenoml/api/core/BodyProperties.java b/src/main/java/com/phenoml/api/core/BodyProperties.java new file mode 100644 index 00000000..fe4e5807 --- /dev/null +++ b/src/main/java/com/phenoml/api/core/BodyProperties.java @@ -0,0 +1,89 @@ +/** + * This file was auto-generated by Fern from our API Definition. + */ +package com.phenoml.api.core; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonMappingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import java.util.LinkedHashMap; +import java.util.Map; +import okhttp3.RequestBody; + +/** + * Merges the additional body properties set through {@code RequestOptions.Builder#addBodyProperty} into request + * bodies. Properties are sent with the keys exactly as given (no casing transform) and override any generated body + * property with the same key. + * + *

Supported for JSON and {@code application/x-www-form-urlencoded} bodies. Multipart (file upload) and raw bytes + * bodies are sent unchanged. + */ +public final class BodyProperties { + + private BodyProperties() {} + + public static boolean isPresent(Map bodyProperties) { + return bodyProperties != null && !bodyProperties.isEmpty(); + } + + /** + * Returns the value to serialize as the JSON request body. Without body properties the body is returned unchanged. + * Otherwise the body is converted to a JSON object (an empty one when the body is null or absent) and the body + * properties are set on it, overriding existing keys. Bodies that are not JSON objects (arrays, primitives) are + * returned unchanged. + */ + public static Object merge(Object body, Map bodyProperties) throws JsonProcessingException { + if (!isPresent(bodyProperties)) { + return body; + } + JsonNode node; + try { + node = ObjectMappers.JSON_MAPPER.valueToTree(body); + } catch (IllegalArgumentException e) { + throw JsonMappingException.from((com.fasterxml.jackson.core.JsonParser) null, e.getMessage(), e); + } + ObjectNode merged; + if (node == null || node.isNull() || node.isMissingNode()) { + merged = ObjectMappers.JSON_MAPPER.createObjectNode(); + } else if (node.isObject()) { + merged = (ObjectNode) node; + } else { + return body; + } + for (Map.Entry entry : bodyProperties.entrySet()) { + merged.putPOJO(entry.getKey(), entry.getValue()); + } + return merged; + } + + /** + * Returns a JSON request body made of the body properties for an endpoint without a request body, or the fallback + * when there are no body properties. + */ + public static RequestBody toRequestBody(Map bodyProperties, RequestBody fallback) { + if (!isPresent(bodyProperties)) { + return fallback; + } + try { + return RequestBody.create( + ObjectMappers.JSON_MAPPER.writeValueAsBytes(merge(null, bodyProperties)), + MediaTypes.APPLICATION_JSON); + } catch (JsonProcessingException e) { + throw new IllegalArgumentException("Failed to serialize body properties", e); + } + } + + /** Returns the form parameters with the body properties added, overriding existing keys. */ + public static Map mergeFormParams( + Map formParams, Map bodyProperties) { + Map merged = new LinkedHashMap<>(); + if (formParams != null) { + merged.putAll(formParams); + } + if (isPresent(bodyProperties)) { + merged.putAll(bodyProperties); + } + return merged; + } +} diff --git a/src/main/java/com/phenoml/api/core/ClientOptions.java b/src/main/java/com/phenoml/api/core/ClientOptions.java index 7424f346..f54176f3 100644 --- a/src/main/java/com/phenoml/api/core/ClientOptions.java +++ b/src/main/java/com/phenoml/api/core/ClientOptions.java @@ -60,10 +60,10 @@ private ClientOptions( this.headers.putAll(headers); this.headers.putAll(new HashMap() { { - put("User-Agent", "com.phenoml.maven.phenoml-java-sdk/18.1.0"); + put("User-Agent", "com.phenoml.maven.phenoml-java-sdk/19.0.0"); put("X-Fern-Language", "JAVA"); put("X-Fern-SDK-Name", "com.phenoml.maven:phenoml-java-sdk"); - put("X-Fern-SDK-Version", "18.1.0"); + put("X-Fern-SDK-Version", "19.0.0"); } }); this.headerSuppliers = headerSuppliers; diff --git a/src/main/java/com/phenoml/api/core/LogLevel.java b/src/main/java/com/phenoml/api/core/LogLevel.java index 4dabea3c..b61ceb9a 100644 --- a/src/main/java/com/phenoml/api/core/LogLevel.java +++ b/src/main/java/com/phenoml/api/core/LogLevel.java @@ -3,6 +3,8 @@ */ package com.phenoml.api.core; +import java.util.Locale; + /** * Log levels for SDK logging configuration. * Silent by default — no log output unless explicitly configured. @@ -31,6 +33,6 @@ public int getValue() { * @throws IllegalArgumentException if the string does not match any level */ public static LogLevel fromString(String level) { - return LogLevel.valueOf(level.toUpperCase()); + return LogLevel.valueOf(level.toUpperCase(Locale.ROOT)); } } diff --git a/src/main/java/com/phenoml/api/core/LoggingInterceptor.java b/src/main/java/com/phenoml/api/core/LoggingInterceptor.java index f4256974..73fbe92f 100644 --- a/src/main/java/com/phenoml/api/core/LoggingInterceptor.java +++ b/src/main/java/com/phenoml/api/core/LoggingInterceptor.java @@ -6,6 +6,7 @@ import java.io.IOException; import java.util.Arrays; import java.util.HashSet; +import java.util.Locale; import java.util.Set; import okhttp3.Interceptor; import okhttp3.Request; @@ -58,7 +59,7 @@ public Response intercept(Chain chain) throws IOException { sb.append(", "); } sb.append(name).append("="); - if (SENSITIVE_HEADERS.contains(name.toLowerCase())) { + if (SENSITIVE_HEADERS.contains(name.toLowerCase(Locale.ROOT))) { sb.append("[REDACTED]"); } else { sb.append(request.header(name)); @@ -83,7 +84,7 @@ public Response intercept(Chain chain) throws IOException { sb.append(", "); } sb.append(name).append("="); - if (SENSITIVE_HEADERS.contains(name.toLowerCase())) { + if (SENSITIVE_HEADERS.contains(name.toLowerCase(Locale.ROOT))) { sb.append("[REDACTED]"); } else { sb.append(response.header(name)); diff --git a/src/main/java/com/phenoml/api/core/PathSegments.java b/src/main/java/com/phenoml/api/core/PathSegments.java new file mode 100644 index 00000000..08c37534 --- /dev/null +++ b/src/main/java/com/phenoml/api/core/PathSegments.java @@ -0,0 +1,21 @@ +/** + * This file was auto-generated by Fern from our API Definition. + */ +package com.phenoml.api.core; + +public final class PathSegments { + + private PathSegments() {} + + /** + * Returns the value unchanged, rejecting "." and "..". These are dot-segments that URL builders resolve, + * which would otherwise change the endpoint a request is sent to. + */ + public static String validate(String value) { + if (".".equals(value) || "..".equals(value)) { + throw new IllegalArgumentException( + "Invalid path parameter value \"" + value + "\": \".\" and \"..\" are not allowed."); + } + return value; + } +} diff --git a/src/main/java/com/phenoml/api/core/RequestOptions.java b/src/main/java/com/phenoml/api/core/RequestOptions.java index 2ea48ec7..58459a2a 100644 --- a/src/main/java/com/phenoml/api/core/RequestOptions.java +++ b/src/main/java/com/phenoml/api/core/RequestOptions.java @@ -4,6 +4,7 @@ package com.phenoml.api.core; import java.util.HashMap; +import java.util.LinkedHashMap; import java.util.Map; import java.util.Optional; import java.util.concurrent.TimeUnit; @@ -24,6 +25,8 @@ public final class RequestOptions { private final Map> queryParameterSuppliers; + private final Map bodyProperties; + private RequestOptions( Optional timeout, TimeUnit timeoutTimeUnit, @@ -31,7 +34,8 @@ private RequestOptions( Map headers, Map> headerSuppliers, Map queryParameters, - Map> queryParameterSuppliers) { + Map> queryParameterSuppliers, + Map bodyProperties) { this.timeout = timeout; this.timeoutTimeUnit = timeoutTimeUnit; this.maxRetries = maxRetries; @@ -39,6 +43,7 @@ private RequestOptions( this.headerSuppliers = headerSuppliers; this.queryParameters = queryParameters; this.queryParameterSuppliers = queryParameterSuppliers; + this.bodyProperties = bodyProperties; } public Optional getTimeout() { @@ -70,6 +75,13 @@ public Map getQueryParameters() { return queryParameters; } + /** + * Additional properties merged into the request body, keyed by their wire names. + */ + public Map getBodyProperties() { + return new LinkedHashMap<>(this.bodyProperties); + } + public static Builder builder() { return new Builder(); } @@ -89,6 +101,8 @@ public static class Builder { private final Map> queryParameterSuppliers = new HashMap<>(); + private final Map bodyProperties = new LinkedHashMap<>(); + public Builder timeout(Integer timeout) { this.timeout = Optional.of(timeout); return this; @@ -125,6 +139,22 @@ public Builder addQueryParameter(String key, Supplier value) { return this; } + /** + * Adds a property to the JSON or form-urlencoded request body, keyed by its wire name. It overrides any request body property with the same key, and is sent as the whole body for endpoints without one. Multipart (file upload) bodies are not supported. + */ + public Builder addBodyProperty(String key, Object value) { + this.bodyProperties.put(key, value); + return this; + } + + /** + * Adds each entry as a request body property. See {@link #addBodyProperty}. + */ + public Builder bodyProperties(Map bodyProperties) { + this.bodyProperties.putAll(bodyProperties); + return this; + } + public RequestOptions build() { return new RequestOptions( timeout, @@ -133,7 +163,8 @@ public RequestOptions build() { headers, headerSuppliers, queryParameters, - queryParameterSuppliers); + queryParameterSuppliers, + bodyProperties); } } } diff --git a/src/main/java/com/phenoml/api/core/RetryInterceptor.java b/src/main/java/com/phenoml/api/core/RetryInterceptor.java index 035bcd9b..93415061 100644 --- a/src/main/java/com/phenoml/api/core/RetryInterceptor.java +++ b/src/main/java/com/phenoml/api/core/RetryInterceptor.java @@ -4,14 +4,20 @@ package com.phenoml.api.core; import java.io.IOException; +import java.io.InterruptedIOException; import java.time.Duration; import java.time.ZonedDateTime; import java.time.format.DateTimeFormatter; import java.time.format.DateTimeParseException; import java.util.Optional; import java.util.Random; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.ScheduledThreadPoolExecutor; +import java.util.concurrent.TimeUnit; import okhttp3.Call; +import okhttp3.Callback; import okhttp3.Interceptor; +import okhttp3.OkHttpClient; import okhttp3.Request; import okhttp3.Response; import okhttp3.ResponseBody; @@ -63,6 +69,10 @@ public RetryInterceptor( @Override public Response intercept(Chain chain) throws IOException { Request request = chain.request(); + if (request.tag(AsyncAttempt.class) != null) { + // Retries of calls created by newAsyncCall are scheduled off OkHttp's dispatcher threads. + return chain.proceed(request); + } int effectiveMaxRetries = resolveMaxRetries(request); Response response = chain.proceed(request); @@ -88,7 +98,11 @@ private Response retryChain(Response response, Chain chain, int maxRetries) thro Thread.sleep(nextBackoff.get().toMillis()); } catch (InterruptedException e) { response.close(); - throw new IOException("Interrupted while trying request", e); + Thread.currentThread().interrupt(); + InterruptedIOException interrupted = + new InterruptedIOException("Interrupted while waiting to retry request"); + interrupted.initCause(e); + throw interrupted; } finally { callTimeout.ifPresent(AsyncTimeout::enter); } @@ -114,6 +128,229 @@ private Response retryChain(Response response, Chain chain, int maxRetries) thro return response; } + /** + * Creates a call for the async client. If {@code client} has a {@link RetryInterceptor}, retries are scheduled + * after the backoff delay instead of sleeping inside the interceptor, so a call that is waiting to retry does not + * hold one of OkHttp's dispatcher slots (5 per host by default) and stops waiting as soon as it is cancelled. + */ + public static AsyncCall newAsyncCall(OkHttpClient client, Request request) { + for (Interceptor interceptor : client.interceptors()) { + if (interceptor instanceof RetryInterceptor) { + RetryInterceptor retryInterceptor = (RetryInterceptor) interceptor; + return new AsyncRetryingCall( + retryInterceptor, client, request, retryInterceptor.resolveMaxRetries(request)); + } + } + return new AsyncRetryingCall(null, client, request, 0); + } + + /** An async OkHttp call that retries retryable responses without blocking a dispatcher thread. */ + public interface AsyncCall { + void enqueue(Callback callback); + + void cancel(); + } + + /** Marks an attempt of an {@link AsyncCall} so that {@link #intercept(Chain)} does not retry it in place. */ + private static final class AsyncAttempt { + private static final AsyncAttempt INSTANCE = new AsyncAttempt(); + } + + private static final class RetryScheduler { + private static final ScheduledThreadPoolExecutor INSTANCE = create(); + + private static ScheduledThreadPoolExecutor create() { + ScheduledThreadPoolExecutor executor = new ScheduledThreadPoolExecutor(1, runnable -> { + Thread thread = new Thread(runnable, "RetryInterceptor-scheduler"); + thread.setDaemon(true); + return thread; + }); + executor.setRemoveOnCancelPolicy(true); + return executor; + } + } + + private static final class AsyncRetryingCall implements AsyncCall, Callback { + private final OkHttpClient client; + private final Request request; + private final ExponentialBackoff backoff; + private final Object lock = new Object(); + + // All fields below are guarded by lock. + private Callback callback; + private Call currentCall; + private ScheduledFuture pendingRetry; + private Response previousResponse; + private boolean executed; + private boolean canceled; + private boolean completed; + + AsyncRetryingCall(RetryInterceptor retryInterceptor, OkHttpClient client, Request request, int maxRetries) { + this.client = client; + this.request = request.newBuilder() + .tag(AsyncAttempt.class, AsyncAttempt.INSTANCE) + .build(); + this.backoff = retryInterceptor != null ? retryInterceptor.new ExponentialBackoff(maxRetries) : null; + } + + @Override + public void enqueue(Callback callback) { + Call call; + boolean wasCanceled; + synchronized (lock) { + if (executed) { + throw new IllegalStateException("Already Executed"); + } + executed = true; + this.callback = callback; + call = client.newCall(request); + currentCall = call; + wasCanceled = canceled; + } + if (wasCanceled) { + failCanceled(); + return; + } + call.enqueue(this); + } + + private void startRetry() { + Call call; + synchronized (lock) { + pendingRetry = null; + if (completed) { + return; + } + if (canceled) { + call = null; + } else { + call = client.newCall(request); + currentCall = call; + } + } + if (call == null) { + failCanceled(); + return; + } + try { + call.enqueue(this); + } catch (RuntimeException e) { + // Runs on the retry scheduler, where an uncaught exception would leave the callback never invoked. + onFailure(call, new IOException("Failed to enqueue retry attempt", e)); + } + } + + @Override + public void onResponse(Call call, Response response) throws IOException { + if (backoff != null && shouldRetry(response.code())) { + Optional delay = backoff.nextBackoff(response); + if (delay.isPresent()) { + Response buffered = bufferResponse(response); + synchronized (lock) { + if (!canceled && !completed) { + closePreviousResponse(); + previousResponse = buffered; + pendingRetry = RetryScheduler.INSTANCE.schedule( + this::startRetry, delay.get().toMillis(), TimeUnit.MILLISECONDS); + return; + } + } + buffered.close(); + failCanceled(); + return; + } + } + boolean deliver; + synchronized (lock) { + deliver = !canceled && !completed; + if (deliver) { + completed = true; + closePreviousResponse(); + } + } + if (!deliver) { + response.close(); + failCanceled(); + return; + } + callback.onResponse(call, response); + } + + @Override + public void onFailure(Call call, IOException e) { + Response previous; + synchronized (lock) { + if (completed) { + return; + } + completed = true; + // As in the synchronous retry loop, a cancelled attempt (cancel() or the call timeout firing) + // reports its own failure rather than the response received before it. + previous = canceled || call.isCanceled() ? null : previousResponse; + previousResponse = null; + closePreviousResponse(); + } + if (previous == null) { + callback.onFailure(call, e); + return; + } + // A retry attempt failed with a transport error; the response already received from the API is more + // actionable, as in the synchronous retry loop. As with OkHttp, the callback owns the response once it is + // delivered, and is not called again if onResponse throws. + try { + callback.onResponse(call, previous); + } catch (IOException | RuntimeException callbackError) { + previous.close(); + if (callbackError instanceof RuntimeException) { + throw (RuntimeException) callbackError; + } + } + } + + @Override + public void cancel() { + Call call; + boolean wasWaiting; + synchronized (lock) { + if (canceled) { + return; + } + canceled = true; + wasWaiting = pendingRetry != null && pendingRetry.cancel(false); + pendingRetry = null; + closePreviousResponse(); + call = currentCall; + } + if (call != null) { + call.cancel(); + } + if (wasWaiting) { + failCanceled(); + } + } + + /** Completes the call with a cancellation failure, unless the callback has already been invoked. */ + private void failCanceled() { + Call call; + synchronized (lock) { + if (completed) { + return; + } + completed = true; + closePreviousResponse(); + call = currentCall; + } + callback.onFailure(call, new IOException("Canceled")); + } + + private void closePreviousResponse() { + if (previousResponse != null) { + previousResponse.close(); + previousResponse = null; + } + } + } + /** * Pauses the OkHttp call timeout so that it applies to each attempt individually rather than to the whole * retry loop: the backoff wait (e.g. a {@code Retry-After} the size of the call timeout) does not consume the diff --git a/src/main/java/com/phenoml/api/core/SseEventParser.java b/src/main/java/com/phenoml/api/core/SseEventParser.java index dc1d88e5..193c6b67 100644 --- a/src/main/java/com/phenoml/api/core/SseEventParser.java +++ b/src/main/java/com/phenoml/api/core/SseEventParser.java @@ -51,6 +51,27 @@ private SseEventParser() { */ public static T parseEventLevelUnion( String eventType, String data, String id, Long retry, Class unionClass, String discriminatorProperty) { + return parseEventLevelUnion(eventType, data, id, retry, unionClass, discriminatorProperty, null); + } + + /** + * Parse an SSE event using event-level discrimination. + *

+ * Events in {@code envelopeEvents} (or every event, if {@code envelopeEvents} is null) are deserialized from the + * SSE envelope ({@code event}, {@code data}, {@code id}, {@code retry}). Other events are deserialized from the + * JSON {@code data} payload with the discriminator injected. + */ + public static T parseEventLevelUnion( + String eventType, + String data, + String id, + Long retry, + Class unionClass, + String discriminatorProperty, + Set envelopeEvents) { + if (envelopeEvents != null && eventType != null && !envelopeEvents.contains(eventType)) { + return parsePayloadWithInjectedDiscriminator(eventType, data, unionClass, discriminatorProperty); + } try { // Determine if data should be parsed as JSON based on the variant's expected type Object parsedData = parseDataForVariant(eventType, data, unionClass, discriminatorProperty); @@ -74,6 +95,21 @@ public static T parseEventLevelUnion( } } + private static T parsePayloadWithInjectedDiscriminator( + String eventType, String data, Class unionClass, String discriminatorProperty) { + try { + Map payload = new HashMap<>(); + if (data != null && !data.isEmpty()) { + payload.putAll(ObjectMappers.JSON_MAPPER.readValue(data, new TypeReference>() {})); + } + payload.putIfAbsent(discriminatorProperty, eventType); + String payloadJson = ObjectMappers.JSON_MAPPER.writeValueAsString(payload); + return ObjectMappers.JSON_MAPPER.readValue(payloadJson, unionClass); + } catch (Exception e) { + throw new RuntimeException("Failed to parse SSE event with event-level discrimination", e); + } + } + /** * Parse an SSE event using data-level discrimination. *

diff --git a/src/main/java/com/phenoml/api/core/Stream.java b/src/main/java/com/phenoml/api/core/Stream.java index a24bb7cf..24801376 100644 --- a/src/main/java/com/phenoml/api/core/Stream.java +++ b/src/main/java/com/phenoml/api/core/Stream.java @@ -6,9 +6,12 @@ import java.io.Closeable; import java.io.IOException; import java.io.Reader; +import java.util.HashSet; import java.util.Iterator; +import java.util.List; import java.util.NoSuchElementException; import java.util.Scanner; +import java.util.Set; /** * The {@code Stream} class implements {@link Iterable} to provide a simple mechanism for reading and parsing @@ -39,6 +42,7 @@ public enum StreamType { private final String streamTerminator; private final Reader sseReader; private final String discriminatorProperty; + private final Set envelopeEvents; private boolean isClosed = false; /** @@ -56,6 +60,7 @@ public Stream(Class valueType, Reader reader, String delimiter) { this.streamTerminator = null; this.sseReader = null; this.discriminatorProperty = null; + this.envelopeEvents = null; } private Stream(Class valueType, StreamType type, Reader reader, String terminator) { @@ -64,9 +69,20 @@ private Stream(Class valueType, StreamType type, Reader reader, String termin private Stream( Class valueType, StreamType type, Reader reader, String terminator, String discriminatorProperty) { + this(valueType, type, reader, terminator, discriminatorProperty, null); + } + + private Stream( + Class valueType, + StreamType type, + Reader reader, + String terminator, + String discriminatorProperty, + Set envelopeEvents) { this.valueType = valueType; this.streamType = type; this.discriminatorProperty = discriminatorProperty; + this.envelopeEvents = envelopeEvents; if (type == StreamType.JSON) { this.scanner = new Scanner(reader).useDelimiter(terminator); this.messageTerminator = terminator; @@ -128,6 +144,36 @@ public static Stream fromSseWithEventDiscrimination( valueType, StreamType.SSE_EVENT_DISCRIMINATED, sseReader, streamTerminator, discriminatorProperty); } + /** + * Creates a stream from SSE data with event-level discrimination support. + *

+ * Events listed in {@code envelopeEvents} are deserialized from {@code {"": event, "data": data}} + * (union variants that model the SSE envelope); all other events are deserialized from the {@code data} payload + * with the discriminator injected. + * + * @param valueType The class of the objects in the stream. + * @param sseReader The reader that provides the SSE data. + * @param discriminatorProperty The property name used for discrimination (e.g., "event"). + * @param streamTerminator The terminator string that signals end of stream, or null. + * @param envelopeEvents The event names whose union variant models the SSE envelope. + * @param The type of objects in the stream. + * @return A new Stream instance configured for SSE with event-level discrimination. + */ + public static Stream fromSseWithEventDiscrimination( + Class valueType, + Reader sseReader, + String discriminatorProperty, + String streamTerminator, + List envelopeEvents) { + return new Stream<>( + valueType, + StreamType.SSE_EVENT_DISCRIMINATED, + sseReader, + streamTerminator, + discriminatorProperty, + new HashSet<>(envelopeEvents)); + } + @Override public void close() throws IOException { if (!isClosed) { @@ -417,7 +463,8 @@ private boolean readNextMessage() { currentEventId, currentRetry, valueType, - discriminatorProperty); + discriminatorProperty, + envelopeEvents); hasNextItem = true; resetEventState(); return true; @@ -483,7 +530,8 @@ private boolean readNextMessage() { currentEventId, currentRetry, valueType, - discriminatorProperty); + discriminatorProperty, + envelopeEvents); hasNextItem = true; resetEventState(); return true; diff --git a/src/main/java/com/phenoml/api/resources/agent/AsyncAgentClient.java b/src/main/java/com/phenoml/api/resources/agent/AsyncAgentClient.java index e43855a4..599d9f4f 100644 --- a/src/main/java/com/phenoml/api/resources/agent/AsyncAgentClient.java +++ b/src/main/java/com/phenoml/api/resources/agent/AsyncAgentClient.java @@ -4,6 +4,7 @@ package com.phenoml.api.resources.agent; import com.phenoml.api.core.ClientOptions; +import com.phenoml.api.core.PhenomlClientHttpResponse; import com.phenoml.api.core.RequestOptions; import com.phenoml.api.core.Suppliers; import com.phenoml.api.resources.agent.chat.AsyncChatClient; @@ -45,63 +46,128 @@ public AsyncRawAgentClient withRawResponse() { * Creates a new PhenoAgent with specified configuration */ public CompletableFuture create(AgentCreateRequest request) { - return this.rawClient.create(request).thenApply(response -> response.body()); + CompletableFuture> rawFuture = this.rawClient.create(request); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** * Creates a new PhenoAgent with specified configuration */ public CompletableFuture create(AgentCreateRequest request, RequestOptions requestOptions) { - return this.rawClient.create(request, requestOptions).thenApply(response -> response.body()); + CompletableFuture> rawFuture = + this.rawClient.create(request, requestOptions); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** * Retrieves a list of PhenoAgents belonging to the authenticated user */ public CompletableFuture list() { - return this.rawClient.list().thenApply(response -> response.body()); + CompletableFuture> rawFuture = this.rawClient.list(); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** * Retrieves a list of PhenoAgents belonging to the authenticated user */ public CompletableFuture list(RequestOptions requestOptions) { - return this.rawClient.list(requestOptions).thenApply(response -> response.body()); + CompletableFuture> rawFuture = this.rawClient.list(requestOptions); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** * Retrieves a list of PhenoAgents belonging to the authenticated user */ public CompletableFuture list(ListRequest request) { - return this.rawClient.list(request).thenApply(response -> response.body()); + CompletableFuture> rawFuture = this.rawClient.list(request); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** * Retrieves a list of PhenoAgents belonging to the authenticated user */ public CompletableFuture list(ListRequest request, RequestOptions requestOptions) { - return this.rawClient.list(request, requestOptions).thenApply(response -> response.body()); + CompletableFuture> rawFuture = + this.rawClient.list(request, requestOptions); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** * Retrieves a specific agent by its ID */ public CompletableFuture get(String id) { - return this.rawClient.get(id).thenApply(response -> response.body()); + CompletableFuture> rawFuture = this.rawClient.get(id); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** * Retrieves a specific agent by its ID */ public CompletableFuture get(String id, RequestOptions requestOptions) { - return this.rawClient.get(id, requestOptions).thenApply(response -> response.body()); + CompletableFuture> rawFuture = this.rawClient.get(id, requestOptions); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** * Updates an existing agent's configuration */ public CompletableFuture update(String id, AgentCreateRequest request) { - return this.rawClient.update(id, request).thenApply(response -> response.body()); + CompletableFuture> rawFuture = this.rawClient.update(id, request); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** @@ -109,28 +175,58 @@ public CompletableFuture update(String id, AgentCreateRequest req */ public CompletableFuture update( String id, AgentCreateRequest request, RequestOptions requestOptions) { - return this.rawClient.update(id, request, requestOptions).thenApply(response -> response.body()); + CompletableFuture> rawFuture = + this.rawClient.update(id, request, requestOptions); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** * Deletes an existing agent */ public CompletableFuture delete(String id) { - return this.rawClient.delete(id).thenApply(response -> response.body()); + CompletableFuture> rawFuture = this.rawClient.delete(id); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** * Deletes an existing agent */ public CompletableFuture delete(String id, RequestOptions requestOptions) { - return this.rawClient.delete(id, requestOptions).thenApply(response -> response.body()); + CompletableFuture> rawFuture = + this.rawClient.delete(id, requestOptions); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** * Patches an existing agent's configuration */ public CompletableFuture patch(String id, List request) { - return this.rawClient.patch(id, request).thenApply(response -> response.body()); + CompletableFuture> rawFuture = this.rawClient.patch(id, request); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** @@ -138,7 +234,15 @@ public CompletableFuture patch(String id, List patch( String id, List request, RequestOptions requestOptions) { - return this.rawClient.patch(id, request, requestOptions).thenApply(response -> response.body()); + CompletableFuture> rawFuture = + this.rawClient.patch(id, request, requestOptions); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } public AsyncChatClient chat() { diff --git a/src/main/java/com/phenoml/api/resources/agent/AsyncRawAgentClient.java b/src/main/java/com/phenoml/api/resources/agent/AsyncRawAgentClient.java index 83c7e754..c5ba4f0e 100644 --- a/src/main/java/com/phenoml/api/resources/agent/AsyncRawAgentClient.java +++ b/src/main/java/com/phenoml/api/resources/agent/AsyncRawAgentClient.java @@ -4,9 +4,11 @@ package com.phenoml.api.resources.agent; import com.fasterxml.jackson.core.JsonProcessingException; +import com.phenoml.api.core.BodyProperties; import com.phenoml.api.core.ClientOptions; import com.phenoml.api.core.MediaTypes; import com.phenoml.api.core.ObjectMappers; +import com.phenoml.api.core.PathSegments; import com.phenoml.api.core.PhenomlClientApiException; import com.phenoml.api.core.PhenomlClientException; import com.phenoml.api.core.PhenomlClientHttpResponse; @@ -69,7 +71,9 @@ public CompletableFuture> create( RequestBody body; try { body = RequestBody.create( - ObjectMappers.JSON_MAPPER.writeValueAsBytes(request), MediaTypes.APPLICATION_JSON); + ObjectMappers.JSON_MAPPER.writeValueAsBytes(BodyProperties.merge( + request, requestOptions != null ? requestOptions.getBodyProperties() : null)), + MediaTypes.APPLICATION_JSON); } catch (JsonProcessingException e) { throw new PhenomlClientException("Failed to serialize request", e); } @@ -94,7 +98,8 @@ public CompletableFuture> create( .build(); } CompletableFuture> future = new CompletableFuture<>(); - client.newCall(okhttpRequest).enqueue(new Callback() { + RetryInterceptor.AsyncCall okhttpCall = RetryInterceptor.newAsyncCall(client, okhttpRequest); + okhttpCall.enqueue(new Callback() { @Override public void onResponse(@NotNull Call call, @NotNull Response response) throws IOException { try (ResponseBody responseBody = response.body()) { @@ -148,6 +153,11 @@ public void onFailure(@NotNull Call call, @NotNull IOException e) { future.completeExceptionally(new PhenomlClientException("Network error executing HTTP request", e)); } }); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + okhttpCall.cancel(); + } + }); return future; } @@ -209,7 +219,8 @@ public CompletableFuture> list( .build(); } CompletableFuture> future = new CompletableFuture<>(); - client.newCall(okhttpRequest).enqueue(new Callback() { + RetryInterceptor.AsyncCall okhttpCall = RetryInterceptor.newAsyncCall(client, okhttpRequest); + okhttpCall.enqueue(new Callback() { @Override public void onResponse(@NotNull Call call, @NotNull Response response) throws IOException { try (ResponseBody responseBody = response.body()) { @@ -257,6 +268,11 @@ public void onFailure(@NotNull Call call, @NotNull IOException e) { future.completeExceptionally(new PhenomlClientException("Network error executing HTTP request", e)); } }); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + okhttpCall.cancel(); + } + }); return future; } @@ -274,7 +290,7 @@ public CompletableFuture> get(String id HttpUrl.Builder httpUrl = HttpUrl.parse(this.clientOptions.environment().getUrl()) .newBuilder() .addPathSegments("agent") - .addPathSegment(id); + .addPathSegment(PathSegments.validate(id)); if (requestOptions != null) { requestOptions.getQueryParameters().forEach((_key, _value) -> { httpUrl.addQueryParameter(_key, _value); @@ -300,7 +316,8 @@ public CompletableFuture> get(String id .build(); } CompletableFuture> future = new CompletableFuture<>(); - client.newCall(okhttpRequest).enqueue(new Callback() { + RetryInterceptor.AsyncCall okhttpCall = RetryInterceptor.newAsyncCall(client, okhttpRequest); + okhttpCall.enqueue(new Callback() { @Override public void onResponse(@NotNull Call call, @NotNull Response response) throws IOException { try (ResponseBody responseBody = response.body()) { @@ -354,6 +371,11 @@ public void onFailure(@NotNull Call call, @NotNull IOException e) { future.completeExceptionally(new PhenomlClientException("Network error executing HTTP request", e)); } }); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + okhttpCall.cancel(); + } + }); return future; } @@ -372,7 +394,7 @@ public CompletableFuture> update( HttpUrl.Builder httpUrl = HttpUrl.parse(this.clientOptions.environment().getUrl()) .newBuilder() .addPathSegments("agent") - .addPathSegment(id); + .addPathSegment(PathSegments.validate(id)); if (requestOptions != null) { requestOptions.getQueryParameters().forEach((_key, _value) -> { httpUrl.addQueryParameter(_key, _value); @@ -381,7 +403,9 @@ public CompletableFuture> update( RequestBody body; try { body = RequestBody.create( - ObjectMappers.JSON_MAPPER.writeValueAsBytes(request), MediaTypes.APPLICATION_JSON); + ObjectMappers.JSON_MAPPER.writeValueAsBytes(BodyProperties.merge( + request, requestOptions != null ? requestOptions.getBodyProperties() : null)), + MediaTypes.APPLICATION_JSON); } catch (JsonProcessingException e) { throw new PhenomlClientException("Failed to serialize request", e); } @@ -406,7 +430,8 @@ public CompletableFuture> update( .build(); } CompletableFuture> future = new CompletableFuture<>(); - client.newCall(okhttpRequest).enqueue(new Callback() { + RetryInterceptor.AsyncCall okhttpCall = RetryInterceptor.newAsyncCall(client, okhttpRequest); + okhttpCall.enqueue(new Callback() { @Override public void onResponse(@NotNull Call call, @NotNull Response response) throws IOException { try (ResponseBody responseBody = response.body()) { @@ -465,6 +490,11 @@ public void onFailure(@NotNull Call call, @NotNull IOException e) { future.completeExceptionally(new PhenomlClientException("Network error executing HTTP request", e)); } }); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + okhttpCall.cancel(); + } + }); return future; } @@ -483,7 +513,7 @@ public CompletableFuture> delete( HttpUrl.Builder httpUrl = HttpUrl.parse(this.clientOptions.environment().getUrl()) .newBuilder() .addPathSegments("agent") - .addPathSegment(id); + .addPathSegment(PathSegments.validate(id)); if (requestOptions != null) { requestOptions.getQueryParameters().forEach((_key, _value) -> { httpUrl.addQueryParameter(_key, _value); @@ -491,7 +521,10 @@ public CompletableFuture> delete( } Request okhttpRequest = new Request.Builder() .url(httpUrl.build()) - .method("DELETE", null) + .method( + "DELETE", + BodyProperties.toRequestBody( + requestOptions != null ? requestOptions.getBodyProperties() : null, null)) .headers(Headers.of(clientOptions.headers(requestOptions))) .addHeader("Accept", "application/json") .build(); @@ -509,7 +542,8 @@ public CompletableFuture> delete( .build(); } CompletableFuture> future = new CompletableFuture<>(); - client.newCall(okhttpRequest).enqueue(new Callback() { + RetryInterceptor.AsyncCall okhttpCall = RetryInterceptor.newAsyncCall(client, okhttpRequest); + okhttpCall.enqueue(new Callback() { @Override public void onResponse(@NotNull Call call, @NotNull Response response) throws IOException { try (ResponseBody responseBody = response.body()) { @@ -563,6 +597,11 @@ public void onFailure(@NotNull Call call, @NotNull IOException e) { future.completeExceptionally(new PhenomlClientException("Network error executing HTTP request", e)); } }); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + okhttpCall.cancel(); + } + }); return future; } @@ -582,7 +621,7 @@ public CompletableFuture> patch( HttpUrl.Builder httpUrl = HttpUrl.parse(this.clientOptions.environment().getUrl()) .newBuilder() .addPathSegments("agent") - .addPathSegment(id); + .addPathSegment(PathSegments.validate(id)); if (requestOptions != null) { requestOptions.getQueryParameters().forEach((_key, _value) -> { httpUrl.addQueryParameter(_key, _value); @@ -591,7 +630,9 @@ public CompletableFuture> patch( RequestBody body; try { body = RequestBody.create( - ObjectMappers.JSON_MAPPER.writeValueAsBytes(request), MediaType.parse("application/json+patch")); + ObjectMappers.JSON_MAPPER.writeValueAsBytes(BodyProperties.merge( + request, requestOptions != null ? requestOptions.getBodyProperties() : null)), + MediaType.parse("application/json+patch")); } catch (JsonProcessingException e) { throw new PhenomlClientException("Failed to serialize request", e); } @@ -616,7 +657,8 @@ public CompletableFuture> patch( .build(); } CompletableFuture> future = new CompletableFuture<>(); - client.newCall(okhttpRequest).enqueue(new Callback() { + RetryInterceptor.AsyncCall okhttpCall = RetryInterceptor.newAsyncCall(client, okhttpRequest); + okhttpCall.enqueue(new Callback() { @Override public void onResponse(@NotNull Call call, @NotNull Response response) throws IOException { try (ResponseBody responseBody = response.body()) { @@ -675,6 +717,11 @@ public void onFailure(@NotNull Call call, @NotNull IOException e) { future.completeExceptionally(new PhenomlClientException("Network error executing HTTP request", e)); } }); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + okhttpCall.cancel(); + } + }); return future; } } diff --git a/src/main/java/com/phenoml/api/resources/agent/RawAgentClient.java b/src/main/java/com/phenoml/api/resources/agent/RawAgentClient.java index a4c8e449..1ec9b25b 100644 --- a/src/main/java/com/phenoml/api/resources/agent/RawAgentClient.java +++ b/src/main/java/com/phenoml/api/resources/agent/RawAgentClient.java @@ -4,9 +4,11 @@ package com.phenoml.api.resources.agent; import com.fasterxml.jackson.core.JsonProcessingException; +import com.phenoml.api.core.BodyProperties; import com.phenoml.api.core.ClientOptions; import com.phenoml.api.core.MediaTypes; import com.phenoml.api.core.ObjectMappers; +import com.phenoml.api.core.PathSegments; import com.phenoml.api.core.PhenomlClientApiException; import com.phenoml.api.core.PhenomlClientException; import com.phenoml.api.core.PhenomlClientHttpResponse; @@ -64,7 +66,9 @@ public PhenomlClientHttpResponse create(AgentCreateRequest reques RequestBody body; try { body = RequestBody.create( - ObjectMappers.JSON_MAPPER.writeValueAsBytes(request), MediaTypes.APPLICATION_JSON); + ObjectMappers.JSON_MAPPER.writeValueAsBytes(BodyProperties.merge( + request, requestOptions != null ? requestOptions.getBodyProperties() : null)), + MediaTypes.APPLICATION_JSON); } catch (JsonProcessingException e) { throw new PhenomlClientException("Failed to serialize request", e); } @@ -225,7 +229,7 @@ public PhenomlClientHttpResponse get(String id, RequestOptions re HttpUrl.Builder httpUrl = HttpUrl.parse(this.clientOptions.environment().getUrl()) .newBuilder() .addPathSegments("agent") - .addPathSegment(id); + .addPathSegment(PathSegments.validate(id)); if (requestOptions != null) { requestOptions.getQueryParameters().forEach((_key, _value) -> { httpUrl.addQueryParameter(_key, _value); @@ -300,7 +304,7 @@ public PhenomlClientHttpResponse update( HttpUrl.Builder httpUrl = HttpUrl.parse(this.clientOptions.environment().getUrl()) .newBuilder() .addPathSegments("agent") - .addPathSegment(id); + .addPathSegment(PathSegments.validate(id)); if (requestOptions != null) { requestOptions.getQueryParameters().forEach((_key, _value) -> { httpUrl.addQueryParameter(_key, _value); @@ -309,7 +313,9 @@ public PhenomlClientHttpResponse update( RequestBody body; try { body = RequestBody.create( - ObjectMappers.JSON_MAPPER.writeValueAsBytes(request), MediaTypes.APPLICATION_JSON); + ObjectMappers.JSON_MAPPER.writeValueAsBytes(BodyProperties.merge( + request, requestOptions != null ? requestOptions.getBodyProperties() : null)), + MediaTypes.APPLICATION_JSON); } catch (JsonProcessingException e) { throw new PhenomlClientException("Failed to serialize request", e); } @@ -385,7 +391,7 @@ public PhenomlClientHttpResponse delete(String id, RequestOption HttpUrl.Builder httpUrl = HttpUrl.parse(this.clientOptions.environment().getUrl()) .newBuilder() .addPathSegments("agent") - .addPathSegment(id); + .addPathSegment(PathSegments.validate(id)); if (requestOptions != null) { requestOptions.getQueryParameters().forEach((_key, _value) -> { httpUrl.addQueryParameter(_key, _value); @@ -393,7 +399,10 @@ public PhenomlClientHttpResponse delete(String id, RequestOption } Request okhttpRequest = new Request.Builder() .url(httpUrl.build()) - .method("DELETE", null) + .method( + "DELETE", + BodyProperties.toRequestBody( + requestOptions != null ? requestOptions.getBodyProperties() : null, null)) .headers(Headers.of(clientOptions.headers(requestOptions))) .addHeader("Accept", "application/json") .build(); @@ -460,7 +469,7 @@ public PhenomlClientHttpResponse patch( HttpUrl.Builder httpUrl = HttpUrl.parse(this.clientOptions.environment().getUrl()) .newBuilder() .addPathSegments("agent") - .addPathSegment(id); + .addPathSegment(PathSegments.validate(id)); if (requestOptions != null) { requestOptions.getQueryParameters().forEach((_key, _value) -> { httpUrl.addQueryParameter(_key, _value); @@ -469,7 +478,9 @@ public PhenomlClientHttpResponse patch( RequestBody body; try { body = RequestBody.create( - ObjectMappers.JSON_MAPPER.writeValueAsBytes(request), MediaType.parse("application/json+patch")); + ObjectMappers.JSON_MAPPER.writeValueAsBytes(BodyProperties.merge( + request, requestOptions != null ? requestOptions.getBodyProperties() : null)), + MediaType.parse("application/json+patch")); } catch (JsonProcessingException e) { throw new PhenomlClientException("Failed to serialize request", e); } diff --git a/src/main/java/com/phenoml/api/resources/agent/chat/AsyncChatClient.java b/src/main/java/com/phenoml/api/resources/agent/chat/AsyncChatClient.java index 8e498620..e8de1862 100644 --- a/src/main/java/com/phenoml/api/resources/agent/chat/AsyncChatClient.java +++ b/src/main/java/com/phenoml/api/resources/agent/chat/AsyncChatClient.java @@ -4,6 +4,7 @@ package com.phenoml.api.resources.agent.chat; import com.phenoml.api.core.ClientOptions; +import com.phenoml.api.core.PhenomlClientHttpResponse; import com.phenoml.api.core.RequestOptions; import com.phenoml.api.resources.agent.chat.requests.AgentChatRequest; import com.phenoml.api.resources.agent.chat.requests.AgentStreamChatRequest; @@ -34,14 +35,29 @@ public AsyncRawChatClient withRawResponse() { * Send a message to an agent and receive a JSON response. */ public CompletableFuture send(AgentChatRequest request) { - return this.rawClient.send(request).thenApply(response -> response.body()); + CompletableFuture> rawFuture = this.rawClient.send(request); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** * Send a message to an agent and receive a JSON response. */ public CompletableFuture send(AgentChatRequest request, RequestOptions requestOptions) { - return this.rawClient.send(request, requestOptions).thenApply(response -> response.body()); + CompletableFuture> rawFuture = + this.rawClient.send(request, requestOptions); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** @@ -50,7 +66,15 @@ public CompletableFuture send(AgentChatRequest request, Reque * tool_result, message_end, and error. */ public CompletableFuture> stream(AgentStreamChatRequest request) { - return this.rawClient.stream(request).thenApply(response -> response.body()); + CompletableFuture>> rawFuture = + this.rawClient.stream(request); + CompletableFuture> future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** @@ -60,14 +84,30 @@ public CompletableFuture> stream(AgentStreamChatR */ public CompletableFuture> stream( AgentStreamChatRequest request, RequestOptions requestOptions) { - return this.rawClient.stream(request, requestOptions).thenApply(response -> response.body()); + CompletableFuture>> rawFuture = + this.rawClient.stream(request, requestOptions); + CompletableFuture> future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** * Retrieves a list of chat messages for a given chat session */ public CompletableFuture listMessages(ListMessagesRequest request) { - return this.rawClient.listMessages(request).thenApply(response -> response.body()); + CompletableFuture> rawFuture = + this.rawClient.listMessages(request); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** @@ -75,6 +115,14 @@ public CompletableFuture listMessages(ListMessagesRequest */ public CompletableFuture listMessages( ListMessagesRequest request, RequestOptions requestOptions) { - return this.rawClient.listMessages(request, requestOptions).thenApply(response -> response.body()); + CompletableFuture> rawFuture = + this.rawClient.listMessages(request, requestOptions); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } } diff --git a/src/main/java/com/phenoml/api/resources/agent/chat/AsyncRawChatClient.java b/src/main/java/com/phenoml/api/resources/agent/chat/AsyncRawChatClient.java index 7cca1e87..a477bfa2 100644 --- a/src/main/java/com/phenoml/api/resources/agent/chat/AsyncRawChatClient.java +++ b/src/main/java/com/phenoml/api/resources/agent/chat/AsyncRawChatClient.java @@ -4,6 +4,7 @@ package com.phenoml.api.resources.agent.chat; import com.fasterxml.jackson.core.JsonProcessingException; +import com.phenoml.api.core.BodyProperties; import com.phenoml.api.core.ClientOptions; import com.phenoml.api.core.MediaTypes; import com.phenoml.api.core.ObjectMappers; @@ -72,7 +73,9 @@ public CompletableFuture> send( RequestBody body; try { body = RequestBody.create( - ObjectMappers.JSON_MAPPER.writeValueAsBytes(request), MediaTypes.APPLICATION_JSON); + ObjectMappers.JSON_MAPPER.writeValueAsBytes(BodyProperties.merge( + request, requestOptions != null ? requestOptions.getBodyProperties() : null)), + MediaTypes.APPLICATION_JSON); } catch (Exception e) { throw new RuntimeException(e); } @@ -105,7 +108,8 @@ public CompletableFuture> send( .build(); } CompletableFuture> future = new CompletableFuture<>(); - client.newCall(okhttpRequest).enqueue(new Callback() { + RetryInterceptor.AsyncCall okhttpCall = RetryInterceptor.newAsyncCall(client, okhttpRequest); + okhttpCall.enqueue(new Callback() { @Override public void onResponse(@NotNull Call call, @NotNull Response response) throws IOException { try (ResponseBody responseBody = response.body()) { @@ -174,6 +178,11 @@ public void onFailure(@NotNull Call call, @NotNull IOException e) { future.completeExceptionally(new PhenomlClientException("Network error executing HTTP request", e)); } }); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + okhttpCall.cancel(); + } + }); return future; } @@ -205,7 +214,9 @@ public CompletableFuture>> future = new CompletableFuture<>(); - client.newCall(okhttpRequest).enqueue(new Callback() { + RetryInterceptor.AsyncCall okhttpCall = RetryInterceptor.newAsyncCall(client, okhttpRequest); + okhttpCall.enqueue(new Callback() { @Override public void onResponse(@NotNull Call call, @NotNull Response response) throws IOException { try { @@ -308,6 +320,11 @@ public void onFailure(@NotNull Call call, @NotNull IOException e) { future.completeExceptionally(new PhenomlClientException("Network error executing HTTP request", e)); } }); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + okhttpCall.cancel(); + } + }); return future; } @@ -365,7 +382,8 @@ public CompletableFuture> listMe .build(); } CompletableFuture> future = new CompletableFuture<>(); - client.newCall(okhttpRequest).enqueue(new Callback() { + RetryInterceptor.AsyncCall okhttpCall = RetryInterceptor.newAsyncCall(client, okhttpRequest); + okhttpCall.enqueue(new Callback() { @Override public void onResponse(@NotNull Call call, @NotNull Response response) throws IOException { try (ResponseBody responseBody = response.body()) { @@ -424,6 +442,11 @@ public void onFailure(@NotNull Call call, @NotNull IOException e) { future.completeExceptionally(new PhenomlClientException("Network error executing HTTP request", e)); } }); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + okhttpCall.cancel(); + } + }); return future; } } diff --git a/src/main/java/com/phenoml/api/resources/agent/chat/RawChatClient.java b/src/main/java/com/phenoml/api/resources/agent/chat/RawChatClient.java index 41aa3921..b28f4cd3 100644 --- a/src/main/java/com/phenoml/api/resources/agent/chat/RawChatClient.java +++ b/src/main/java/com/phenoml/api/resources/agent/chat/RawChatClient.java @@ -4,6 +4,7 @@ package com.phenoml.api.resources.agent.chat; import com.fasterxml.jackson.core.JsonProcessingException; +import com.phenoml.api.core.BodyProperties; import com.phenoml.api.core.ClientOptions; import com.phenoml.api.core.MediaTypes; import com.phenoml.api.core.ObjectMappers; @@ -67,7 +68,9 @@ public PhenomlClientHttpResponse send(AgentChatRequest reques RequestBody body; try { body = RequestBody.create( - ObjectMappers.JSON_MAPPER.writeValueAsBytes(request), MediaTypes.APPLICATION_JSON); + ObjectMappers.JSON_MAPPER.writeValueAsBytes(BodyProperties.merge( + request, requestOptions != null ? requestOptions.getBodyProperties() : null)), + MediaTypes.APPLICATION_JSON); } catch (Exception e) { throw new RuntimeException(e); } @@ -170,7 +173,9 @@ public PhenomlClientHttpResponse> stream( RequestBody body; try { body = RequestBody.create( - ObjectMappers.JSON_MAPPER.writeValueAsBytes(request), MediaTypes.APPLICATION_JSON); + ObjectMappers.JSON_MAPPER.writeValueAsBytes(BodyProperties.merge( + request, requestOptions != null ? requestOptions.getBodyProperties() : null)), + MediaTypes.APPLICATION_JSON); } catch (Exception e) { throw new RuntimeException(e); } diff --git a/src/main/java/com/phenoml/api/resources/agent/chat/requests/AgentChatRequest.java b/src/main/java/com/phenoml/api/resources/agent/chat/requests/AgentChatRequest.java index 9ae49787..a26acdae 100644 --- a/src/main/java/com/phenoml/api/resources/agent/chat/requests/AgentChatRequest.java +++ b/src/main/java/com/phenoml/api/resources/agent/chat/requests/AgentChatRequest.java @@ -234,7 +234,6 @@ public static final class Builder implements MessageStage, AgentIdStage, _FinalS private Optional phenomlOnBehalfOf = Optional.empty(); - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -388,6 +387,18 @@ public AgentChatRequest build() { additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("message") + || key.equalsIgnoreCase("context") + || key.equalsIgnoreCase("session_id") + || key.equalsIgnoreCase("agent_id") + || key.equalsIgnoreCase("enhanced_reasoning")) { + return; + } + this.additionalProperties.put(key, value); + } + @java.lang.Override public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); diff --git a/src/main/java/com/phenoml/api/resources/agent/chat/requests/AgentStreamChatRequest.java b/src/main/java/com/phenoml/api/resources/agent/chat/requests/AgentStreamChatRequest.java index f2446dac..e236251a 100644 --- a/src/main/java/com/phenoml/api/resources/agent/chat/requests/AgentStreamChatRequest.java +++ b/src/main/java/com/phenoml/api/resources/agent/chat/requests/AgentStreamChatRequest.java @@ -234,7 +234,6 @@ public static final class Builder implements MessageStage, AgentIdStage, _FinalS private Optional phenomlOnBehalfOf = Optional.empty(); - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -388,6 +387,18 @@ public AgentStreamChatRequest build() { additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("message") + || key.equalsIgnoreCase("context") + || key.equalsIgnoreCase("session_id") + || key.equalsIgnoreCase("agent_id") + || key.equalsIgnoreCase("enhanced_reasoning")) { + return; + } + this.additionalProperties.put(key, value); + } + @java.lang.Override public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); diff --git a/src/main/java/com/phenoml/api/resources/agent/chat/requests/ListMessagesRequest.java b/src/main/java/com/phenoml/api/resources/agent/chat/requests/ListMessagesRequest.java index efdd95fb..1477ee3f 100644 --- a/src/main/java/com/phenoml/api/resources/agent/chat/requests/ListMessagesRequest.java +++ b/src/main/java/com/phenoml/api/resources/agent/chat/requests/ListMessagesRequest.java @@ -174,7 +174,6 @@ public static final class Builder implements ChatSessionIdStage, _FinalStage { private Optional numMessages = Optional.empty(); - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -280,6 +279,17 @@ public ListMessagesRequest build() { return new ListMessagesRequest(chatSessionId, numMessages, role, order, additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("chat_session_id") + || key.equalsIgnoreCase("num_messages") + || key.equalsIgnoreCase("role") + || key.equalsIgnoreCase("order")) { + return; + } + this.additionalProperties.put(key, value); + } + @java.lang.Override public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); diff --git a/src/main/java/com/phenoml/api/resources/agent/chat/types/ListMessagesResponse.java b/src/main/java/com/phenoml/api/resources/agent/chat/types/ListMessagesResponse.java index 04185e2c..eca3bd8e 100644 --- a/src/main/java/com/phenoml/api/resources/agent/chat/types/ListMessagesResponse.java +++ b/src/main/java/com/phenoml/api/resources/agent/chat/types/ListMessagesResponse.java @@ -93,7 +93,6 @@ public static final class Builder { private Optional sessionId = Optional.empty(); - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -142,6 +141,16 @@ public ListMessagesResponse build() { return new ListMessagesResponse(messages, total, sessionId, additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("messages") + || key.equalsIgnoreCase("total") + || key.equalsIgnoreCase("session_id")) { + return; + } + this.additionalProperties.put(key, value); + } + public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); return this; diff --git a/src/main/java/com/phenoml/api/resources/agent/prompts/AsyncPromptsClient.java b/src/main/java/com/phenoml/api/resources/agent/prompts/AsyncPromptsClient.java index 0c4bdb25..2929ba7b 100644 --- a/src/main/java/com/phenoml/api/resources/agent/prompts/AsyncPromptsClient.java +++ b/src/main/java/com/phenoml/api/resources/agent/prompts/AsyncPromptsClient.java @@ -4,6 +4,7 @@ package com.phenoml.api.resources.agent.prompts; import com.phenoml.api.core.ClientOptions; +import com.phenoml.api.core.PhenomlClientHttpResponse; import com.phenoml.api.core.RequestOptions; import com.phenoml.api.resources.agent.prompts.requests.AgentPromptsCreateRequest; import com.phenoml.api.resources.agent.prompts.requests.AgentPromptsUpdateRequest; @@ -35,7 +36,14 @@ public AsyncRawPromptsClient withRawResponse() { * Creates a new agent prompt */ public CompletableFuture create(AgentPromptsCreateRequest request) { - return this.rawClient.create(request).thenApply(response -> response.body()); + CompletableFuture> rawFuture = this.rawClient.create(request); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** @@ -43,56 +51,117 @@ public CompletableFuture create(AgentPromptsCreateRequest */ public CompletableFuture create( AgentPromptsCreateRequest request, RequestOptions requestOptions) { - return this.rawClient.create(request, requestOptions).thenApply(response -> response.body()); + CompletableFuture> rawFuture = + this.rawClient.create(request, requestOptions); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** * Retrieves a list of agent prompts belonging to the authenticated user */ public CompletableFuture list() { - return this.rawClient.list().thenApply(response -> response.body()); + CompletableFuture> rawFuture = this.rawClient.list(); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** * Retrieves a list of agent prompts belonging to the authenticated user */ public CompletableFuture list(RequestOptions requestOptions) { - return this.rawClient.list(requestOptions).thenApply(response -> response.body()); + CompletableFuture> rawFuture = + this.rawClient.list(requestOptions); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** * Retrieves a specific prompt by its ID */ public CompletableFuture get(String id) { - return this.rawClient.get(id).thenApply(response -> response.body()); + CompletableFuture> rawFuture = this.rawClient.get(id); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** * Retrieves a specific prompt by its ID */ public CompletableFuture get(String id, RequestOptions requestOptions) { - return this.rawClient.get(id, requestOptions).thenApply(response -> response.body()); + CompletableFuture> rawFuture = + this.rawClient.get(id, requestOptions); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** * Updates an existing prompt */ public CompletableFuture update(String id) { - return this.rawClient.update(id).thenApply(response -> response.body()); + CompletableFuture> rawFuture = this.rawClient.update(id); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** * Updates an existing prompt */ public CompletableFuture update(String id, RequestOptions requestOptions) { - return this.rawClient.update(id, requestOptions).thenApply(response -> response.body()); + CompletableFuture> rawFuture = + this.rawClient.update(id, requestOptions); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** * Updates an existing prompt */ public CompletableFuture update(String id, AgentPromptsUpdateRequest request) { - return this.rawClient.update(id, request).thenApply(response -> response.body()); + CompletableFuture> rawFuture = + this.rawClient.update(id, request); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** @@ -100,28 +169,59 @@ public CompletableFuture update(String id, AgentPromptsUpd */ public CompletableFuture update( String id, AgentPromptsUpdateRequest request, RequestOptions requestOptions) { - return this.rawClient.update(id, request, requestOptions).thenApply(response -> response.body()); + CompletableFuture> rawFuture = + this.rawClient.update(id, request, requestOptions); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** * Deletes a prompt */ public CompletableFuture delete(String id) { - return this.rawClient.delete(id).thenApply(response -> response.body()); + CompletableFuture> rawFuture = this.rawClient.delete(id); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** * Deletes a prompt */ public CompletableFuture delete(String id, RequestOptions requestOptions) { - return this.rawClient.delete(id, requestOptions).thenApply(response -> response.body()); + CompletableFuture> rawFuture = + this.rawClient.delete(id, requestOptions); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** * Patches an existing prompt */ public CompletableFuture patch(String id, List request) { - return this.rawClient.patch(id, request).thenApply(response -> response.body()); + CompletableFuture> rawFuture = + this.rawClient.patch(id, request); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** @@ -129,6 +229,14 @@ public CompletableFuture patch(String id, List patch( String id, List request, RequestOptions requestOptions) { - return this.rawClient.patch(id, request, requestOptions).thenApply(response -> response.body()); + CompletableFuture> rawFuture = + this.rawClient.patch(id, request, requestOptions); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } } diff --git a/src/main/java/com/phenoml/api/resources/agent/prompts/AsyncRawPromptsClient.java b/src/main/java/com/phenoml/api/resources/agent/prompts/AsyncRawPromptsClient.java index e3627b88..7b52f23f 100644 --- a/src/main/java/com/phenoml/api/resources/agent/prompts/AsyncRawPromptsClient.java +++ b/src/main/java/com/phenoml/api/resources/agent/prompts/AsyncRawPromptsClient.java @@ -4,9 +4,11 @@ package com.phenoml.api.resources.agent.prompts; import com.fasterxml.jackson.core.JsonProcessingException; +import com.phenoml.api.core.BodyProperties; import com.phenoml.api.core.ClientOptions; import com.phenoml.api.core.MediaTypes; import com.phenoml.api.core.ObjectMappers; +import com.phenoml.api.core.PathSegments; import com.phenoml.api.core.PhenomlClientApiException; import com.phenoml.api.core.PhenomlClientException; import com.phenoml.api.core.PhenomlClientHttpResponse; @@ -69,7 +71,9 @@ public CompletableFuture> create RequestBody body; try { body = RequestBody.create( - ObjectMappers.JSON_MAPPER.writeValueAsBytes(request), MediaTypes.APPLICATION_JSON); + ObjectMappers.JSON_MAPPER.writeValueAsBytes(BodyProperties.merge( + request, requestOptions != null ? requestOptions.getBodyProperties() : null)), + MediaTypes.APPLICATION_JSON); } catch (JsonProcessingException e) { throw new PhenomlClientException("Failed to serialize request", e); } @@ -94,7 +98,8 @@ public CompletableFuture> create .build(); } CompletableFuture> future = new CompletableFuture<>(); - client.newCall(okhttpRequest).enqueue(new Callback() { + RetryInterceptor.AsyncCall okhttpCall = RetryInterceptor.newAsyncCall(client, okhttpRequest); + okhttpCall.enqueue(new Callback() { @Override public void onResponse(@NotNull Call call, @NotNull Response response) throws IOException { try (ResponseBody responseBody = response.body()) { @@ -148,6 +153,11 @@ public void onFailure(@NotNull Call call, @NotNull IOException e) { future.completeExceptionally(new PhenomlClientException("Network error executing HTTP request", e)); } }); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + okhttpCall.cancel(); + } + }); return future; } @@ -190,7 +200,8 @@ public CompletableFuture> list(Re .build(); } CompletableFuture> future = new CompletableFuture<>(); - client.newCall(okhttpRequest).enqueue(new Callback() { + RetryInterceptor.AsyncCall okhttpCall = RetryInterceptor.newAsyncCall(client, okhttpRequest); + okhttpCall.enqueue(new Callback() { @Override public void onResponse(@NotNull Call call, @NotNull Response response) throws IOException { try (ResponseBody responseBody = response.body()) { @@ -239,6 +250,11 @@ public void onFailure(@NotNull Call call, @NotNull IOException e) { future.completeExceptionally(new PhenomlClientException("Network error executing HTTP request", e)); } }); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + okhttpCall.cancel(); + } + }); return future; } @@ -257,7 +273,7 @@ public CompletableFuture> get( HttpUrl.Builder httpUrl = HttpUrl.parse(this.clientOptions.environment().getUrl()) .newBuilder() .addPathSegments("agent/prompts") - .addPathSegment(id); + .addPathSegment(PathSegments.validate(id)); if (requestOptions != null) { requestOptions.getQueryParameters().forEach((_key, _value) -> { httpUrl.addQueryParameter(_key, _value); @@ -283,7 +299,8 @@ public CompletableFuture> get( .build(); } CompletableFuture> future = new CompletableFuture<>(); - client.newCall(okhttpRequest).enqueue(new Callback() { + RetryInterceptor.AsyncCall okhttpCall = RetryInterceptor.newAsyncCall(client, okhttpRequest); + okhttpCall.enqueue(new Callback() { @Override public void onResponse(@NotNull Call call, @NotNull Response response) throws IOException { try (ResponseBody responseBody = response.body()) { @@ -337,6 +354,11 @@ public void onFailure(@NotNull Call call, @NotNull IOException e) { future.completeExceptionally(new PhenomlClientException("Network error executing HTTP request", e)); } }); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + okhttpCall.cancel(); + } + }); return future; } @@ -371,7 +393,7 @@ public CompletableFuture> update HttpUrl.Builder httpUrl = HttpUrl.parse(this.clientOptions.environment().getUrl()) .newBuilder() .addPathSegments("agent/prompts") - .addPathSegment(id); + .addPathSegment(PathSegments.validate(id)); if (requestOptions != null) { requestOptions.getQueryParameters().forEach((_key, _value) -> { httpUrl.addQueryParameter(_key, _value); @@ -380,7 +402,9 @@ public CompletableFuture> update RequestBody body; try { body = RequestBody.create( - ObjectMappers.JSON_MAPPER.writeValueAsBytes(request), MediaTypes.APPLICATION_JSON); + ObjectMappers.JSON_MAPPER.writeValueAsBytes(BodyProperties.merge( + request, requestOptions != null ? requestOptions.getBodyProperties() : null)), + MediaTypes.APPLICATION_JSON); } catch (JsonProcessingException e) { throw new PhenomlClientException("Failed to serialize request", e); } @@ -405,7 +429,8 @@ public CompletableFuture> update .build(); } CompletableFuture> future = new CompletableFuture<>(); - client.newCall(okhttpRequest).enqueue(new Callback() { + RetryInterceptor.AsyncCall okhttpCall = RetryInterceptor.newAsyncCall(client, okhttpRequest); + okhttpCall.enqueue(new Callback() { @Override public void onResponse(@NotNull Call call, @NotNull Response response) throws IOException { try (ResponseBody responseBody = response.body()) { @@ -464,6 +489,11 @@ public void onFailure(@NotNull Call call, @NotNull IOException e) { future.completeExceptionally(new PhenomlClientException("Network error executing HTTP request", e)); } }); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + okhttpCall.cancel(); + } + }); return future; } @@ -482,7 +512,7 @@ public CompletableFuture> delet HttpUrl.Builder httpUrl = HttpUrl.parse(this.clientOptions.environment().getUrl()) .newBuilder() .addPathSegments("agent/prompts") - .addPathSegment(id); + .addPathSegment(PathSegments.validate(id)); if (requestOptions != null) { requestOptions.getQueryParameters().forEach((_key, _value) -> { httpUrl.addQueryParameter(_key, _value); @@ -490,7 +520,10 @@ public CompletableFuture> delet } Request okhttpRequest = new Request.Builder() .url(httpUrl.build()) - .method("DELETE", null) + .method( + "DELETE", + BodyProperties.toRequestBody( + requestOptions != null ? requestOptions.getBodyProperties() : null, null)) .headers(Headers.of(clientOptions.headers(requestOptions))) .addHeader("Accept", "application/json") .build(); @@ -508,7 +541,8 @@ public CompletableFuture> delet .build(); } CompletableFuture> future = new CompletableFuture<>(); - client.newCall(okhttpRequest).enqueue(new Callback() { + RetryInterceptor.AsyncCall okhttpCall = RetryInterceptor.newAsyncCall(client, okhttpRequest); + okhttpCall.enqueue(new Callback() { @Override public void onResponse(@NotNull Call call, @NotNull Response response) throws IOException { try (ResponseBody responseBody = response.body()) { @@ -562,6 +596,11 @@ public void onFailure(@NotNull Call call, @NotNull IOException e) { future.completeExceptionally(new PhenomlClientException("Network error executing HTTP request", e)); } }); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + okhttpCall.cancel(); + } + }); return future; } @@ -581,7 +620,7 @@ public CompletableFuture> patch( HttpUrl.Builder httpUrl = HttpUrl.parse(this.clientOptions.environment().getUrl()) .newBuilder() .addPathSegments("agent/prompts") - .addPathSegment(id); + .addPathSegment(PathSegments.validate(id)); if (requestOptions != null) { requestOptions.getQueryParameters().forEach((_key, _value) -> { httpUrl.addQueryParameter(_key, _value); @@ -590,7 +629,9 @@ public CompletableFuture> patch( RequestBody body; try { body = RequestBody.create( - ObjectMappers.JSON_MAPPER.writeValueAsBytes(request), MediaType.parse("application/json+patch")); + ObjectMappers.JSON_MAPPER.writeValueAsBytes(BodyProperties.merge( + request, requestOptions != null ? requestOptions.getBodyProperties() : null)), + MediaType.parse("application/json+patch")); } catch (JsonProcessingException e) { throw new PhenomlClientException("Failed to serialize request", e); } @@ -615,7 +656,8 @@ public CompletableFuture> patch( .build(); } CompletableFuture> future = new CompletableFuture<>(); - client.newCall(okhttpRequest).enqueue(new Callback() { + RetryInterceptor.AsyncCall okhttpCall = RetryInterceptor.newAsyncCall(client, okhttpRequest); + okhttpCall.enqueue(new Callback() { @Override public void onResponse(@NotNull Call call, @NotNull Response response) throws IOException { try (ResponseBody responseBody = response.body()) { @@ -674,6 +716,11 @@ public void onFailure(@NotNull Call call, @NotNull IOException e) { future.completeExceptionally(new PhenomlClientException("Network error executing HTTP request", e)); } }); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + okhttpCall.cancel(); + } + }); return future; } } diff --git a/src/main/java/com/phenoml/api/resources/agent/prompts/RawPromptsClient.java b/src/main/java/com/phenoml/api/resources/agent/prompts/RawPromptsClient.java index 5f7f0a21..cbf22328 100644 --- a/src/main/java/com/phenoml/api/resources/agent/prompts/RawPromptsClient.java +++ b/src/main/java/com/phenoml/api/resources/agent/prompts/RawPromptsClient.java @@ -4,9 +4,11 @@ package com.phenoml.api.resources.agent.prompts; import com.fasterxml.jackson.core.JsonProcessingException; +import com.phenoml.api.core.BodyProperties; import com.phenoml.api.core.ClientOptions; import com.phenoml.api.core.MediaTypes; import com.phenoml.api.core.ObjectMappers; +import com.phenoml.api.core.PathSegments; import com.phenoml.api.core.PhenomlClientApiException; import com.phenoml.api.core.PhenomlClientException; import com.phenoml.api.core.PhenomlClientHttpResponse; @@ -64,7 +66,9 @@ public PhenomlClientHttpResponse create( RequestBody body; try { body = RequestBody.create( - ObjectMappers.JSON_MAPPER.writeValueAsBytes(request), MediaTypes.APPLICATION_JSON); + ObjectMappers.JSON_MAPPER.writeValueAsBytes(BodyProperties.merge( + request, requestOptions != null ? requestOptions.getBodyProperties() : null)), + MediaTypes.APPLICATION_JSON); } catch (JsonProcessingException e) { throw new PhenomlClientException("Failed to serialize request", e); } @@ -207,7 +211,7 @@ public PhenomlClientHttpResponse get(String id, RequestOpt HttpUrl.Builder httpUrl = HttpUrl.parse(this.clientOptions.environment().getUrl()) .newBuilder() .addPathSegments("agent/prompts") - .addPathSegment(id); + .addPathSegment(PathSegments.validate(id)); if (requestOptions != null) { requestOptions.getQueryParameters().forEach((_key, _value) -> { httpUrl.addQueryParameter(_key, _value); @@ -296,7 +300,7 @@ public PhenomlClientHttpResponse update( HttpUrl.Builder httpUrl = HttpUrl.parse(this.clientOptions.environment().getUrl()) .newBuilder() .addPathSegments("agent/prompts") - .addPathSegment(id); + .addPathSegment(PathSegments.validate(id)); if (requestOptions != null) { requestOptions.getQueryParameters().forEach((_key, _value) -> { httpUrl.addQueryParameter(_key, _value); @@ -305,7 +309,9 @@ public PhenomlClientHttpResponse update( RequestBody body; try { body = RequestBody.create( - ObjectMappers.JSON_MAPPER.writeValueAsBytes(request), MediaTypes.APPLICATION_JSON); + ObjectMappers.JSON_MAPPER.writeValueAsBytes(BodyProperties.merge( + request, requestOptions != null ? requestOptions.getBodyProperties() : null)), + MediaTypes.APPLICATION_JSON); } catch (JsonProcessingException e) { throw new PhenomlClientException("Failed to serialize request", e); } @@ -381,7 +387,7 @@ public PhenomlClientHttpResponse delete(String id, Reques HttpUrl.Builder httpUrl = HttpUrl.parse(this.clientOptions.environment().getUrl()) .newBuilder() .addPathSegments("agent/prompts") - .addPathSegment(id); + .addPathSegment(PathSegments.validate(id)); if (requestOptions != null) { requestOptions.getQueryParameters().forEach((_key, _value) -> { httpUrl.addQueryParameter(_key, _value); @@ -389,7 +395,10 @@ public PhenomlClientHttpResponse delete(String id, Reques } Request okhttpRequest = new Request.Builder() .url(httpUrl.build()) - .method("DELETE", null) + .method( + "DELETE", + BodyProperties.toRequestBody( + requestOptions != null ? requestOptions.getBodyProperties() : null, null)) .headers(Headers.of(clientOptions.headers(requestOptions))) .addHeader("Accept", "application/json") .build(); @@ -456,7 +465,7 @@ public PhenomlClientHttpResponse patch( HttpUrl.Builder httpUrl = HttpUrl.parse(this.clientOptions.environment().getUrl()) .newBuilder() .addPathSegments("agent/prompts") - .addPathSegment(id); + .addPathSegment(PathSegments.validate(id)); if (requestOptions != null) { requestOptions.getQueryParameters().forEach((_key, _value) -> { httpUrl.addQueryParameter(_key, _value); @@ -465,7 +474,9 @@ public PhenomlClientHttpResponse patch( RequestBody body; try { body = RequestBody.create( - ObjectMappers.JSON_MAPPER.writeValueAsBytes(request), MediaType.parse("application/json+patch")); + ObjectMappers.JSON_MAPPER.writeValueAsBytes(BodyProperties.merge( + request, requestOptions != null ? requestOptions.getBodyProperties() : null)), + MediaType.parse("application/json+patch")); } catch (JsonProcessingException e) { throw new PhenomlClientException("Failed to serialize request", e); } diff --git a/src/main/java/com/phenoml/api/resources/agent/prompts/requests/AgentPromptsCreateRequest.java b/src/main/java/com/phenoml/api/resources/agent/prompts/requests/AgentPromptsCreateRequest.java index 0bd203dd..75765bdd 100644 --- a/src/main/java/com/phenoml/api/resources/agent/prompts/requests/AgentPromptsCreateRequest.java +++ b/src/main/java/com/phenoml/api/resources/agent/prompts/requests/AgentPromptsCreateRequest.java @@ -179,7 +179,6 @@ public static final class Builder implements NameStage, ContentStage, _FinalStag private Optional description = Optional.empty(); - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -281,6 +280,18 @@ public AgentPromptsCreateRequest build() { return new AgentPromptsCreateRequest(name, description, content, isDefault, tags, additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("name") + || key.equalsIgnoreCase("description") + || key.equalsIgnoreCase("content") + || key.equalsIgnoreCase("is_default") + || key.equalsIgnoreCase("tags")) { + return; + } + this.additionalProperties.put(key, value); + } + @java.lang.Override public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); diff --git a/src/main/java/com/phenoml/api/resources/agent/prompts/requests/AgentPromptsUpdateRequest.java b/src/main/java/com/phenoml/api/resources/agent/prompts/requests/AgentPromptsUpdateRequest.java index 67659c65..f73a10e4 100644 --- a/src/main/java/com/phenoml/api/resources/agent/prompts/requests/AgentPromptsUpdateRequest.java +++ b/src/main/java/com/phenoml/api/resources/agent/prompts/requests/AgentPromptsUpdateRequest.java @@ -133,7 +133,6 @@ public static final class Builder { private Optional> tags = Optional.empty(); - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -221,6 +220,18 @@ public AgentPromptsUpdateRequest build() { return new AgentPromptsUpdateRequest(name, description, content, isDefault, tags, additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("name") + || key.equalsIgnoreCase("description") + || key.equalsIgnoreCase("content") + || key.equalsIgnoreCase("is_default") + || key.equalsIgnoreCase("tags")) { + return; + } + this.additionalProperties.put(key, value); + } + public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); return this; diff --git a/src/main/java/com/phenoml/api/resources/agent/prompts/types/PromptsDeleteResponse.java b/src/main/java/com/phenoml/api/resources/agent/prompts/types/PromptsDeleteResponse.java index 9a883b3b..d7b8e101 100644 --- a/src/main/java/com/phenoml/api/resources/agent/prompts/types/PromptsDeleteResponse.java +++ b/src/main/java/com/phenoml/api/resources/agent/prompts/types/PromptsDeleteResponse.java @@ -78,7 +78,6 @@ public static final class Builder { private Optional message = Optional.empty(); - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -115,6 +114,14 @@ public PromptsDeleteResponse build() { return new PromptsDeleteResponse(success, message, additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("success") || key.equalsIgnoreCase("message")) { + return; + } + this.additionalProperties.put(key, value); + } + public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); return this; diff --git a/src/main/java/com/phenoml/api/resources/agent/prompts/types/PromptsListResponse.java b/src/main/java/com/phenoml/api/resources/agent/prompts/types/PromptsListResponse.java index 1407eb88..80de6226 100644 --- a/src/main/java/com/phenoml/api/resources/agent/prompts/types/PromptsListResponse.java +++ b/src/main/java/com/phenoml/api/resources/agent/prompts/types/PromptsListResponse.java @@ -93,7 +93,6 @@ public static final class Builder { private Optional> prompts = Optional.empty(); - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -142,6 +141,14 @@ public PromptsListResponse build() { return new PromptsListResponse(success, message, prompts, additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("success") || key.equalsIgnoreCase("message") || key.equalsIgnoreCase("prompts")) { + return; + } + this.additionalProperties.put(key, value); + } + public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); return this; diff --git a/src/main/java/com/phenoml/api/resources/agent/requests/ListRequest.java b/src/main/java/com/phenoml/api/resources/agent/requests/ListRequest.java index cd760d2f..316e55ff 100644 --- a/src/main/java/com/phenoml/api/resources/agent/requests/ListRequest.java +++ b/src/main/java/com/phenoml/api/resources/agent/requests/ListRequest.java @@ -70,7 +70,6 @@ public static Builder builder() { public static final class Builder { private Optional tags = Optional.empty(); - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -98,6 +97,14 @@ public ListRequest build() { return new ListRequest(tags, additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("tags")) { + return; + } + this.additionalProperties.put(key, value); + } + public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); return this; diff --git a/src/main/java/com/phenoml/api/resources/agent/types/AgentChatResponse.java b/src/main/java/com/phenoml/api/resources/agent/types/AgentChatResponse.java index 8d28b53e..bc80c9f3 100644 --- a/src/main/java/com/phenoml/api/resources/agent/types/AgentChatResponse.java +++ b/src/main/java/com/phenoml/api/resources/agent/types/AgentChatResponse.java @@ -114,7 +114,6 @@ public static final class Builder { private Optional sessionId = Optional.empty(); - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -184,6 +183,17 @@ public AgentChatResponse build() { return new AgentChatResponse(response, success, message, sessionId, additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("response") + || key.equalsIgnoreCase("success") + || key.equalsIgnoreCase("message") + || key.equalsIgnoreCase("session_id")) { + return; + } + this.additionalProperties.put(key, value); + } + public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); return this; diff --git a/src/main/java/com/phenoml/api/resources/agent/types/AgentChatStreamEvent.java b/src/main/java/com/phenoml/api/resources/agent/types/AgentChatStreamEvent.java index 6ace6fbb..96abc44f 100644 --- a/src/main/java/com/phenoml/api/resources/agent/types/AgentChatStreamEvent.java +++ b/src/main/java/com/phenoml/api/resources/agent/types/AgentChatStreamEvent.java @@ -185,7 +185,6 @@ public static final class Builder { private Optional> functionResult = Optional.empty(); - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -327,6 +326,21 @@ public AgentChatStreamEvent build() { additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("type") + || key.equalsIgnoreCase("session_id") + || key.equalsIgnoreCase("content") + || key.equalsIgnoreCase("success") + || key.equalsIgnoreCase("message") + || key.equalsIgnoreCase("function_name") + || key.equalsIgnoreCase("function_args") + || key.equalsIgnoreCase("function_result")) { + return; + } + this.additionalProperties.put(key, value); + } + public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); return this; diff --git a/src/main/java/com/phenoml/api/resources/agent/types/AgentCreateRequest.java b/src/main/java/com/phenoml/api/resources/agent/types/AgentCreateRequest.java index 9e27a956..ef432f59 100644 --- a/src/main/java/com/phenoml/api/resources/agent/types/AgentCreateRequest.java +++ b/src/main/java/com/phenoml/api/resources/agent/types/AgentCreateRequest.java @@ -229,7 +229,6 @@ public static final class Builder implements NameStage, ProviderStage, _FinalSta private Optional description = Optional.empty(); - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -390,6 +389,20 @@ public AgentCreateRequest build() { name, description, prompts, tools, workflows, tags, provider, additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("name") + || key.equalsIgnoreCase("description") + || key.equalsIgnoreCase("prompts") + || key.equalsIgnoreCase("tools") + || key.equalsIgnoreCase("workflows") + || key.equalsIgnoreCase("tags") + || key.equalsIgnoreCase("provider")) { + return; + } + this.additionalProperties.put(key, value); + } + @java.lang.Override public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); diff --git a/src/main/java/com/phenoml/api/resources/agent/types/AgentPromptsResponse.java b/src/main/java/com/phenoml/api/resources/agent/types/AgentPromptsResponse.java index 06356a77..d0aefc2e 100644 --- a/src/main/java/com/phenoml/api/resources/agent/types/AgentPromptsResponse.java +++ b/src/main/java/com/phenoml/api/resources/agent/types/AgentPromptsResponse.java @@ -91,7 +91,6 @@ public static final class Builder { private Optional data = Optional.empty(); - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -140,6 +139,14 @@ public AgentPromptsResponse build() { return new AgentPromptsResponse(success, message, data, additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("success") || key.equalsIgnoreCase("message") || key.equalsIgnoreCase("data")) { + return; + } + this.additionalProperties.put(key, value); + } + public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); return this; diff --git a/src/main/java/com/phenoml/api/resources/agent/types/AgentResponse.java b/src/main/java/com/phenoml/api/resources/agent/types/AgentResponse.java index f57f36ae..7a815ac4 100644 --- a/src/main/java/com/phenoml/api/resources/agent/types/AgentResponse.java +++ b/src/main/java/com/phenoml/api/resources/agent/types/AgentResponse.java @@ -91,7 +91,6 @@ public static final class Builder { private Optional data = Optional.empty(); - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -140,6 +139,14 @@ public AgentResponse build() { return new AgentResponse(success, message, data, additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("success") || key.equalsIgnoreCase("message") || key.equalsIgnoreCase("data")) { + return; + } + this.additionalProperties.put(key, value); + } + public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); return this; diff --git a/src/main/java/com/phenoml/api/resources/agent/types/AgentTemplate.java b/src/main/java/com/phenoml/api/resources/agent/types/AgentTemplate.java index 0ca2b102..3f9dfb1f 100644 --- a/src/main/java/com/phenoml/api/resources/agent/types/AgentTemplate.java +++ b/src/main/java/com/phenoml/api/resources/agent/types/AgentTemplate.java @@ -186,7 +186,6 @@ public static final class Builder { private Optional provider = Optional.empty(); - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -320,6 +319,21 @@ public AgentTemplate build() { id, name, description, prompts, tools, workflows, tags, provider, additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("id") + || key.equalsIgnoreCase("name") + || key.equalsIgnoreCase("description") + || key.equalsIgnoreCase("prompts") + || key.equalsIgnoreCase("tools") + || key.equalsIgnoreCase("workflows") + || key.equalsIgnoreCase("tags") + || key.equalsIgnoreCase("provider")) { + return; + } + this.additionalProperties.put(key, value); + } + public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); return this; diff --git a/src/main/java/com/phenoml/api/resources/agent/types/ChatMessageTemplate.java b/src/main/java/com/phenoml/api/resources/agent/types/ChatMessageTemplate.java index 0fbae896..ef52ae8e 100644 --- a/src/main/java/com/phenoml/api/resources/agent/types/ChatMessageTemplate.java +++ b/src/main/java/com/phenoml/api/resources/agent/types/ChatMessageTemplate.java @@ -223,7 +223,6 @@ public static final class Builder { private Optional messageOrder = Optional.empty(); - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -403,6 +402,23 @@ public ChatMessageTemplate build() { additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("id") + || key.equalsIgnoreCase("session_id") + || key.equalsIgnoreCase("role") + || key.equalsIgnoreCase("content") + || key.equalsIgnoreCase("created") + || key.equalsIgnoreCase("updated") + || key.equalsIgnoreCase("function_name") + || key.equalsIgnoreCase("function_args") + || key.equalsIgnoreCase("function_result") + || key.equalsIgnoreCase("message_order")) { + return; + } + this.additionalProperties.put(key, value); + } + public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); return this; diff --git a/src/main/java/com/phenoml/api/resources/agent/types/ChatSessionTemplate.java b/src/main/java/com/phenoml/api/resources/agent/types/ChatSessionTemplate.java index 044ec545..62907181 100644 --- a/src/main/java/com/phenoml/api/resources/agent/types/ChatSessionTemplate.java +++ b/src/main/java/com/phenoml/api/resources/agent/types/ChatSessionTemplate.java @@ -201,7 +201,6 @@ public static final class Builder { private Optional updated = Optional.empty(); - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -359,6 +358,22 @@ public ChatSessionTemplate build() { additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("id") + || key.equalsIgnoreCase("session_id") + || key.equalsIgnoreCase("status") + || key.equalsIgnoreCase("system_prompt") + || key.equalsIgnoreCase("title") + || key.equalsIgnoreCase("agent_id") + || key.equalsIgnoreCase("created") + || key.equalsIgnoreCase("last_message_at") + || key.equalsIgnoreCase("updated")) { + return; + } + this.additionalProperties.put(key, value); + } + public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); return this; diff --git a/src/main/java/com/phenoml/api/resources/agent/types/DeleteResponse.java b/src/main/java/com/phenoml/api/resources/agent/types/DeleteResponse.java index aba6054f..6e6859fa 100644 --- a/src/main/java/com/phenoml/api/resources/agent/types/DeleteResponse.java +++ b/src/main/java/com/phenoml/api/resources/agent/types/DeleteResponse.java @@ -78,7 +78,6 @@ public static final class Builder { private Optional message = Optional.empty(); - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -115,6 +114,14 @@ public DeleteResponse build() { return new DeleteResponse(success, message, additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("success") || key.equalsIgnoreCase("message")) { + return; + } + this.additionalProperties.put(key, value); + } + public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); return this; diff --git a/src/main/java/com/phenoml/api/resources/agent/types/JsonPatchOperation.java b/src/main/java/com/phenoml/api/resources/agent/types/JsonPatchOperation.java index 1b6759d9..e0aa245b 100644 --- a/src/main/java/com/phenoml/api/resources/agent/types/JsonPatchOperation.java +++ b/src/main/java/com/phenoml/api/resources/agent/types/JsonPatchOperation.java @@ -153,7 +153,6 @@ public static final class Builder implements OpStage, PathStage, _FinalStage { private Optional value = Optional.empty(); - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -234,6 +233,17 @@ public JsonPatchOperation build() { return new JsonPatchOperation(op, path, value, from, additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("op") + || key.equalsIgnoreCase("path") + || key.equalsIgnoreCase("value") + || key.equalsIgnoreCase("from")) { + return; + } + this.additionalProperties.put(key, value); + } + @java.lang.Override public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); diff --git a/src/main/java/com/phenoml/api/resources/agent/types/ListResponse.java b/src/main/java/com/phenoml/api/resources/agent/types/ListResponse.java index 0a077cd3..cf209aa5 100644 --- a/src/main/java/com/phenoml/api/resources/agent/types/ListResponse.java +++ b/src/main/java/com/phenoml/api/resources/agent/types/ListResponse.java @@ -92,7 +92,6 @@ public static final class Builder { private Optional> agents = Optional.empty(); - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -141,6 +140,14 @@ public ListResponse build() { return new ListResponse(success, message, agents, additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("success") || key.equalsIgnoreCase("message") || key.equalsIgnoreCase("agents")) { + return; + } + this.additionalProperties.put(key, value); + } + public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); return this; diff --git a/src/main/java/com/phenoml/api/resources/agent/types/PromptTemplate.java b/src/main/java/com/phenoml/api/resources/agent/types/PromptTemplate.java index 2377ccbf..6e7e3a09 100644 --- a/src/main/java/com/phenoml/api/resources/agent/types/PromptTemplate.java +++ b/src/main/java/com/phenoml/api/resources/agent/types/PromptTemplate.java @@ -148,7 +148,6 @@ public static final class Builder { private Optional> tags = Optional.empty(); - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -251,6 +250,19 @@ public PromptTemplate build() { return new PromptTemplate(id, name, description, content, isDefault, tags, additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("id") + || key.equalsIgnoreCase("name") + || key.equalsIgnoreCase("description") + || key.equalsIgnoreCase("content") + || key.equalsIgnoreCase("is_default") + || key.equalsIgnoreCase("tags")) { + return; + } + this.additionalProperties.put(key, value); + } + public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); return this; diff --git a/src/main/java/com/phenoml/api/resources/agent/types/SuccessResponse.java b/src/main/java/com/phenoml/api/resources/agent/types/SuccessResponse.java index 4789f31c..1211d1f1 100644 --- a/src/main/java/com/phenoml/api/resources/agent/types/SuccessResponse.java +++ b/src/main/java/com/phenoml/api/resources/agent/types/SuccessResponse.java @@ -78,7 +78,6 @@ public static final class Builder { private Optional message = Optional.empty(); - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -115,6 +114,14 @@ public SuccessResponse build() { return new SuccessResponse(success, message, additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("success") || key.equalsIgnoreCase("message")) { + return; + } + this.additionalProperties.put(key, value); + } + public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); return this; diff --git a/src/main/java/com/phenoml/api/resources/authtoken/AsyncAuthtokenClient.java b/src/main/java/com/phenoml/api/resources/authtoken/AsyncAuthtokenClient.java index 3d0b948c..91380439 100644 --- a/src/main/java/com/phenoml/api/resources/authtoken/AsyncAuthtokenClient.java +++ b/src/main/java/com/phenoml/api/resources/authtoken/AsyncAuthtokenClient.java @@ -4,6 +4,7 @@ package com.phenoml.api.resources.authtoken; import com.phenoml.api.core.ClientOptions; +import com.phenoml.api.core.PhenomlClientHttpResponse; import com.phenoml.api.core.RequestOptions; import com.phenoml.api.resources.authtoken.requests.ClientCredentialsRequest; import com.phenoml.api.resources.authtoken.types.TokenResponse; @@ -33,7 +34,14 @@ public AsyncRawAuthtokenClient withRawResponse() { * returns an access token with token expiration information. */ public CompletableFuture getToken() { - return this.rawClient.getToken().thenApply(response -> response.body()); + CompletableFuture> rawFuture = this.rawClient.getToken(); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** @@ -43,7 +51,14 @@ public CompletableFuture getToken() { * returns an access token with token expiration information. */ public CompletableFuture getToken(RequestOptions requestOptions) { - return this.rawClient.getToken(requestOptions).thenApply(response -> response.body()); + CompletableFuture> rawFuture = this.rawClient.getToken(requestOptions); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** @@ -53,7 +68,14 @@ public CompletableFuture getToken(RequestOptions requestOptions) * returns an access token with token expiration information. */ public CompletableFuture getToken(ClientCredentialsRequest request) { - return this.rawClient.getToken(request).thenApply(response -> response.body()); + CompletableFuture> rawFuture = this.rawClient.getToken(request); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** @@ -63,6 +85,14 @@ public CompletableFuture getToken(ClientCredentialsRequest reques * returns an access token with token expiration information. */ public CompletableFuture getToken(ClientCredentialsRequest request, RequestOptions requestOptions) { - return this.rawClient.getToken(request, requestOptions).thenApply(response -> response.body()); + CompletableFuture> rawFuture = + this.rawClient.getToken(request, requestOptions); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } } diff --git a/src/main/java/com/phenoml/api/resources/authtoken/AsyncRawAuthtokenClient.java b/src/main/java/com/phenoml/api/resources/authtoken/AsyncRawAuthtokenClient.java index 8d254802..23ff0e48 100644 --- a/src/main/java/com/phenoml/api/resources/authtoken/AsyncRawAuthtokenClient.java +++ b/src/main/java/com/phenoml/api/resources/authtoken/AsyncRawAuthtokenClient.java @@ -4,6 +4,7 @@ package com.phenoml.api.resources.authtoken; import com.fasterxml.jackson.core.JsonProcessingException; +import com.phenoml.api.core.BodyProperties; import com.phenoml.api.core.ClientOptions; import com.phenoml.api.core.MediaTypes; import com.phenoml.api.core.ObjectMappers; @@ -86,7 +87,9 @@ public CompletableFuture> getToken( RequestBody body; try { body = RequestBody.create( - ObjectMappers.JSON_MAPPER.writeValueAsBytes(request), MediaTypes.APPLICATION_JSON); + ObjectMappers.JSON_MAPPER.writeValueAsBytes(BodyProperties.merge( + request, requestOptions != null ? requestOptions.getBodyProperties() : null)), + MediaTypes.APPLICATION_JSON); } catch (JsonProcessingException e) { throw new PhenomlClientException("Failed to serialize request", e); } @@ -111,7 +114,8 @@ public CompletableFuture> getToken( .build(); } CompletableFuture> future = new CompletableFuture<>(); - client.newCall(okhttpRequest).enqueue(new Callback() { + RetryInterceptor.AsyncCall okhttpCall = RetryInterceptor.newAsyncCall(client, okhttpRequest); + okhttpCall.enqueue(new Callback() { @Override public void onResponse(@NotNull Call call, @NotNull Response response) throws IOException { try (ResponseBody responseBody = response.body()) { @@ -160,6 +164,11 @@ public void onFailure(@NotNull Call call, @NotNull IOException e) { future.completeExceptionally(new PhenomlClientException("Network error executing HTTP request", e)); } }); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + okhttpCall.cancel(); + } + }); return future; } } diff --git a/src/main/java/com/phenoml/api/resources/authtoken/RawAuthtokenClient.java b/src/main/java/com/phenoml/api/resources/authtoken/RawAuthtokenClient.java index f14c6c67..dd6ec997 100644 --- a/src/main/java/com/phenoml/api/resources/authtoken/RawAuthtokenClient.java +++ b/src/main/java/com/phenoml/api/resources/authtoken/RawAuthtokenClient.java @@ -4,6 +4,7 @@ package com.phenoml.api.resources.authtoken; import com.fasterxml.jackson.core.JsonProcessingException; +import com.phenoml.api.core.BodyProperties; import com.phenoml.api.core.ClientOptions; import com.phenoml.api.core.MediaTypes; import com.phenoml.api.core.ObjectMappers; @@ -82,7 +83,9 @@ public PhenomlClientHttpResponse getToken( RequestBody body; try { body = RequestBody.create( - ObjectMappers.JSON_MAPPER.writeValueAsBytes(request), MediaTypes.APPLICATION_JSON); + ObjectMappers.JSON_MAPPER.writeValueAsBytes(BodyProperties.merge( + request, requestOptions != null ? requestOptions.getBodyProperties() : null)), + MediaTypes.APPLICATION_JSON); } catch (JsonProcessingException e) { throw new PhenomlClientException("Failed to serialize request", e); } diff --git a/src/main/java/com/phenoml/api/resources/authtoken/requests/ClientCredentialsRequest.java b/src/main/java/com/phenoml/api/resources/authtoken/requests/ClientCredentialsRequest.java index 99580c28..d43dd890 100644 --- a/src/main/java/com/phenoml/api/resources/authtoken/requests/ClientCredentialsRequest.java +++ b/src/main/java/com/phenoml/api/resources/authtoken/requests/ClientCredentialsRequest.java @@ -102,7 +102,6 @@ public static final class Builder { private Optional clientSecret = Optional.empty(); - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -160,6 +159,16 @@ public ClientCredentialsRequest build() { return new ClientCredentialsRequest(grantType, clientId, clientSecret, additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("grant_type") + || key.equalsIgnoreCase("client_id") + || key.equalsIgnoreCase("client_secret")) { + return; + } + this.additionalProperties.put(key, value); + } + public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); return this; diff --git a/src/main/java/com/phenoml/api/resources/authtoken/types/OAuthError.java b/src/main/java/com/phenoml/api/resources/authtoken/types/OAuthError.java index d1d41189..d57a749d 100644 --- a/src/main/java/com/phenoml/api/resources/authtoken/types/OAuthError.java +++ b/src/main/java/com/phenoml/api/resources/authtoken/types/OAuthError.java @@ -109,7 +109,6 @@ public static final class Builder implements ErrorStage, _FinalStage { private Optional errorDescription = Optional.empty(); - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -157,6 +156,14 @@ public OAuthError build() { return new OAuthError(error, errorDescription, additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("error") || key.equalsIgnoreCase("error_description")) { + return; + } + this.additionalProperties.put(key, value); + } + @java.lang.Override public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); diff --git a/src/main/java/com/phenoml/api/resources/authtoken/types/TokenResponse.java b/src/main/java/com/phenoml/api/resources/authtoken/types/TokenResponse.java index d909787b..451bef01 100644 --- a/src/main/java/com/phenoml/api/resources/authtoken/types/TokenResponse.java +++ b/src/main/java/com/phenoml/api/resources/authtoken/types/TokenResponse.java @@ -129,7 +129,6 @@ public static final class Builder implements AccessTokenStage, TokenTypeStage, E private int expiresIn; - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -180,6 +179,16 @@ public TokenResponse build() { return new TokenResponse(accessToken, tokenType, expiresIn, additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("access_token") + || key.equalsIgnoreCase("token_type") + || key.equalsIgnoreCase("expires_in")) { + return; + } + this.additionalProperties.put(key, value); + } + @java.lang.Override public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); diff --git a/src/main/java/com/phenoml/api/resources/cohort/AsyncCohortClient.java b/src/main/java/com/phenoml/api/resources/cohort/AsyncCohortClient.java index abda1b7b..903b7d86 100644 --- a/src/main/java/com/phenoml/api/resources/cohort/AsyncCohortClient.java +++ b/src/main/java/com/phenoml/api/resources/cohort/AsyncCohortClient.java @@ -4,6 +4,7 @@ package com.phenoml.api.resources.cohort; import com.phenoml.api.core.ClientOptions; +import com.phenoml.api.core.PhenomlClientHttpResponse; import com.phenoml.api.core.RequestOptions; import com.phenoml.api.resources.cohort.requests.CohortRequest; import com.phenoml.api.resources.cohort.types.CohortResponse; @@ -30,13 +31,28 @@ public AsyncRawCohortClient withRawResponse() { * Converts natural language text into structured FHIR search queries for patient cohort analysis */ public CompletableFuture analyze(CohortRequest request) { - return this.rawClient.analyze(request).thenApply(response -> response.body()); + CompletableFuture> rawFuture = this.rawClient.analyze(request); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** * Converts natural language text into structured FHIR search queries for patient cohort analysis */ public CompletableFuture analyze(CohortRequest request, RequestOptions requestOptions) { - return this.rawClient.analyze(request, requestOptions).thenApply(response -> response.body()); + CompletableFuture> rawFuture = + this.rawClient.analyze(request, requestOptions); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } } diff --git a/src/main/java/com/phenoml/api/resources/cohort/AsyncRawCohortClient.java b/src/main/java/com/phenoml/api/resources/cohort/AsyncRawCohortClient.java index 489594db..13e15fe0 100644 --- a/src/main/java/com/phenoml/api/resources/cohort/AsyncRawCohortClient.java +++ b/src/main/java/com/phenoml/api/resources/cohort/AsyncRawCohortClient.java @@ -4,6 +4,7 @@ package com.phenoml.api.resources.cohort; import com.fasterxml.jackson.core.JsonProcessingException; +import com.phenoml.api.core.BodyProperties; import com.phenoml.api.core.ClientOptions; import com.phenoml.api.core.MediaTypes; import com.phenoml.api.core.ObjectMappers; @@ -60,7 +61,9 @@ public CompletableFuture> analyze( RequestBody body; try { body = RequestBody.create( - ObjectMappers.JSON_MAPPER.writeValueAsBytes(request), MediaTypes.APPLICATION_JSON); + ObjectMappers.JSON_MAPPER.writeValueAsBytes(BodyProperties.merge( + request, requestOptions != null ? requestOptions.getBodyProperties() : null)), + MediaTypes.APPLICATION_JSON); } catch (JsonProcessingException e) { throw new PhenomlClientException("Failed to serialize request", e); } @@ -85,7 +88,8 @@ public CompletableFuture> analyze( .build(); } CompletableFuture> future = new CompletableFuture<>(); - client.newCall(okhttpRequest).enqueue(new Callback() { + RetryInterceptor.AsyncCall okhttpCall = RetryInterceptor.newAsyncCall(client, okhttpRequest); + okhttpCall.enqueue(new Callback() { @Override public void onResponse(@NotNull Call call, @NotNull Response response) throws IOException { try (ResponseBody responseBody = response.body()) { @@ -134,6 +138,11 @@ public void onFailure(@NotNull Call call, @NotNull IOException e) { future.completeExceptionally(new PhenomlClientException("Network error executing HTTP request", e)); } }); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + okhttpCall.cancel(); + } + }); return future; } } diff --git a/src/main/java/com/phenoml/api/resources/cohort/RawCohortClient.java b/src/main/java/com/phenoml/api/resources/cohort/RawCohortClient.java index 8136e8e5..a87c1e14 100644 --- a/src/main/java/com/phenoml/api/resources/cohort/RawCohortClient.java +++ b/src/main/java/com/phenoml/api/resources/cohort/RawCohortClient.java @@ -4,6 +4,7 @@ package com.phenoml.api.resources.cohort; import com.fasterxml.jackson.core.JsonProcessingException; +import com.phenoml.api.core.BodyProperties; import com.phenoml.api.core.ClientOptions; import com.phenoml.api.core.MediaTypes; import com.phenoml.api.core.ObjectMappers; @@ -55,7 +56,9 @@ public PhenomlClientHttpResponse analyze(CohortRequest request, RequestBody body; try { body = RequestBody.create( - ObjectMappers.JSON_MAPPER.writeValueAsBytes(request), MediaTypes.APPLICATION_JSON); + ObjectMappers.JSON_MAPPER.writeValueAsBytes(BodyProperties.merge( + request, requestOptions != null ? requestOptions.getBodyProperties() : null)), + MediaTypes.APPLICATION_JSON); } catch (JsonProcessingException e) { throw new PhenomlClientException("Failed to serialize request", e); } diff --git a/src/main/java/com/phenoml/api/resources/cohort/requests/CohortRequest.java b/src/main/java/com/phenoml/api/resources/cohort/requests/CohortRequest.java index f33c17cf..6a4223b4 100644 --- a/src/main/java/com/phenoml/api/resources/cohort/requests/CohortRequest.java +++ b/src/main/java/com/phenoml/api/resources/cohort/requests/CohortRequest.java @@ -86,7 +86,6 @@ public interface _FinalStage { public static final class Builder implements TextStage, _FinalStage { private String text; - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -113,6 +112,14 @@ public CohortRequest build() { return new CohortRequest(text, additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("text")) { + return; + } + this.additionalProperties.put(key, value); + } + @java.lang.Override public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); diff --git a/src/main/java/com/phenoml/api/resources/cohort/types/CohortResponse.java b/src/main/java/com/phenoml/api/resources/cohort/types/CohortResponse.java index 324a501c..15b79fdf 100644 --- a/src/main/java/com/phenoml/api/resources/cohort/types/CohortResponse.java +++ b/src/main/java/com/phenoml/api/resources/cohort/types/CohortResponse.java @@ -101,7 +101,6 @@ public static final class Builder { private Optional> queries = Optional.empty(); - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -159,6 +158,14 @@ public CohortResponse build() { return new CohortResponse(success, message, queries, additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("success") || key.equalsIgnoreCase("message") || key.equalsIgnoreCase("queries")) { + return; + } + this.additionalProperties.put(key, value); + } + public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); return this; diff --git a/src/main/java/com/phenoml/api/resources/cohort/types/SearchConcept.java b/src/main/java/com/phenoml/api/resources/cohort/types/SearchConcept.java index aeb50be8..cee3971b 100644 --- a/src/main/java/com/phenoml/api/resources/cohort/types/SearchConcept.java +++ b/src/main/java/com/phenoml/api/resources/cohort/types/SearchConcept.java @@ -117,7 +117,6 @@ public static final class Builder { private Optional exclude = Optional.empty(); - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -190,6 +189,17 @@ public SearchConcept build() { return new SearchConcept(resourceType, searchParams, concept, exclude, additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("resource_type") + || key.equalsIgnoreCase("search_params") + || key.equalsIgnoreCase("concept") + || key.equalsIgnoreCase("exclude")) { + return; + } + this.additionalProperties.put(key, value); + } + public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); return this; diff --git a/src/main/java/com/phenoml/api/resources/construe/AsyncConstrueClient.java b/src/main/java/com/phenoml/api/resources/construe/AsyncConstrueClient.java index 251027ff..73aaa7fb 100644 --- a/src/main/java/com/phenoml/api/resources/construe/AsyncConstrueClient.java +++ b/src/main/java/com/phenoml/api/resources/construe/AsyncConstrueClient.java @@ -4,6 +4,7 @@ package com.phenoml.api.resources.construe; import com.phenoml.api.core.ClientOptions; +import com.phenoml.api.core.PhenomlClientHttpResponse; import com.phenoml.api.core.RequestOptions; import com.phenoml.api.core.Suppliers; import com.phenoml.api.resources.construe.codes.AsyncCodesClient; @@ -41,7 +42,15 @@ public AsyncRawConstrueClient withRawResponse() { * Feedback includes the full extraction result received and the result the user expected. */ public CompletableFuture submitFeedback(FeedbackRequest request) { - return this.rawClient.submitFeedback(request).thenApply(response -> response.body()); + CompletableFuture> rawFuture = + this.rawClient.submitFeedback(request); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** @@ -49,7 +58,15 @@ public CompletableFuture submitFeedback(FeedbackRequest reques * Feedback includes the full extraction result received and the result the user expected. */ public CompletableFuture submitFeedback(FeedbackRequest request, RequestOptions requestOptions) { - return this.rawClient.submitFeedback(request, requestOptions).thenApply(response -> response.body()); + CompletableFuture> rawFuture = + this.rawClient.submitFeedback(request, requestOptions); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } public AsyncCodeSystemsClient codeSystems() { diff --git a/src/main/java/com/phenoml/api/resources/construe/AsyncRawConstrueClient.java b/src/main/java/com/phenoml/api/resources/construe/AsyncRawConstrueClient.java index 9c9285d0..4e36f995 100644 --- a/src/main/java/com/phenoml/api/resources/construe/AsyncRawConstrueClient.java +++ b/src/main/java/com/phenoml/api/resources/construe/AsyncRawConstrueClient.java @@ -4,6 +4,7 @@ package com.phenoml.api.resources.construe; import com.fasterxml.jackson.core.JsonProcessingException; +import com.phenoml.api.core.BodyProperties; import com.phenoml.api.core.ClientOptions; import com.phenoml.api.core.MediaTypes; import com.phenoml.api.core.ObjectMappers; @@ -63,7 +64,9 @@ public CompletableFuture> submitFeed RequestBody body; try { body = RequestBody.create( - ObjectMappers.JSON_MAPPER.writeValueAsBytes(request), MediaTypes.APPLICATION_JSON); + ObjectMappers.JSON_MAPPER.writeValueAsBytes(BodyProperties.merge( + request, requestOptions != null ? requestOptions.getBodyProperties() : null)), + MediaTypes.APPLICATION_JSON); } catch (JsonProcessingException e) { throw new PhenomlClientException("Failed to serialize request", e); } @@ -88,7 +91,8 @@ public CompletableFuture> submitFeed .build(); } CompletableFuture> future = new CompletableFuture<>(); - client.newCall(okhttpRequest).enqueue(new Callback() { + RetryInterceptor.AsyncCall okhttpCall = RetryInterceptor.newAsyncCall(client, okhttpRequest); + okhttpCall.enqueue(new Callback() { @Override public void onResponse(@NotNull Call call, @NotNull Response response) throws IOException { try (ResponseBody responseBody = response.body()) { @@ -142,6 +146,11 @@ public void onFailure(@NotNull Call call, @NotNull IOException e) { future.completeExceptionally(new PhenomlClientException("Network error executing HTTP request", e)); } }); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + okhttpCall.cancel(); + } + }); return future; } } diff --git a/src/main/java/com/phenoml/api/resources/construe/RawConstrueClient.java b/src/main/java/com/phenoml/api/resources/construe/RawConstrueClient.java index db93ee3d..fc8a2cd8 100644 --- a/src/main/java/com/phenoml/api/resources/construe/RawConstrueClient.java +++ b/src/main/java/com/phenoml/api/resources/construe/RawConstrueClient.java @@ -4,6 +4,7 @@ package com.phenoml.api.resources.construe; import com.fasterxml.jackson.core.JsonProcessingException; +import com.phenoml.api.core.BodyProperties; import com.phenoml.api.core.ClientOptions; import com.phenoml.api.core.MediaTypes; import com.phenoml.api.core.ObjectMappers; @@ -59,7 +60,9 @@ public PhenomlClientHttpResponse submitFeedback( RequestBody body; try { body = RequestBody.create( - ObjectMappers.JSON_MAPPER.writeValueAsBytes(request), MediaTypes.APPLICATION_JSON); + ObjectMappers.JSON_MAPPER.writeValueAsBytes(BodyProperties.merge( + request, requestOptions != null ? requestOptions.getBodyProperties() : null)), + MediaTypes.APPLICATION_JSON); } catch (JsonProcessingException e) { throw new PhenomlClientException("Failed to serialize request", e); } diff --git a/src/main/java/com/phenoml/api/resources/construe/codes/AsyncCodesClient.java b/src/main/java/com/phenoml/api/resources/construe/codes/AsyncCodesClient.java index 918eeb23..f9b20211 100644 --- a/src/main/java/com/phenoml/api/resources/construe/codes/AsyncCodesClient.java +++ b/src/main/java/com/phenoml/api/resources/construe/codes/AsyncCodesClient.java @@ -4,6 +4,7 @@ package com.phenoml.api.resources.construe.codes; import com.phenoml.api.core.ClientOptions; +import com.phenoml.api.core.PhenomlClientHttpResponse; import com.phenoml.api.core.RequestOptions; import com.phenoml.api.resources.construe.codes.requests.CodesListRequest; import com.phenoml.api.resources.construe.codes.requests.CrosswalkRequest; @@ -44,7 +45,14 @@ public AsyncRawCodesClient withRawResponse() { *

Usage of CPT is subject to AMA requirements: see PhenoML Terms of Service.

*/ public CompletableFuture extract(ExtractRequest request) { - return this.rawClient.extract(request).thenApply(response -> response.body()); + CompletableFuture> rawFuture = this.rawClient.extract(request); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** @@ -54,7 +62,15 @@ public CompletableFuture extract(ExtractRequest request) { *

Usage of CPT is subject to AMA requirements: see PhenoML Terms of Service.

*/ public CompletableFuture extract(ExtractRequest request, RequestOptions requestOptions) { - return this.rawClient.extract(request, requestOptions).thenApply(response -> response.body()); + CompletableFuture> rawFuture = + this.rawClient.extract(request, requestOptions); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** @@ -66,7 +82,14 @@ public CompletableFuture extract(ExtractRequest request, Req * code system name and version are both required.

*/ public CompletableFuture phenocr(PhenoCrRequest request) { - return this.rawClient.phenocr(request).thenApply(response -> response.body()); + CompletableFuture> rawFuture = this.rawClient.phenocr(request); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** @@ -78,7 +101,15 @@ public CompletableFuture phenocr(PhenoCrRequest request) { * code system name and version are both required.

*/ public CompletableFuture phenocr(PhenoCrRequest request, RequestOptions requestOptions) { - return this.rawClient.phenocr(request, requestOptions).thenApply(response -> response.body()); + CompletableFuture> rawFuture = + this.rawClient.phenocr(request, requestOptions); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** @@ -89,7 +120,14 @@ public CompletableFuture phenocr(PhenoCrRequest request, Req *

Usage of CPT is subject to AMA requirements: see PhenoML Terms of Service.

*/ public CompletableFuture crosswalk(CrosswalkRequest request) { - return this.rawClient.crosswalk(request).thenApply(response -> response.body()); + CompletableFuture> rawFuture = this.rawClient.crosswalk(request); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** @@ -100,7 +138,15 @@ public CompletableFuture crosswalk(CrosswalkRequest request) *

Usage of CPT is subject to AMA requirements: see PhenoML Terms of Service.

*/ public CompletableFuture crosswalk(CrosswalkRequest request, RequestOptions requestOptions) { - return this.rawClient.crosswalk(request, requestOptions).thenApply(response -> response.body()); + CompletableFuture> rawFuture = + this.rawClient.crosswalk(request, requestOptions); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** @@ -108,7 +154,14 @@ public CompletableFuture crosswalk(CrosswalkRequest request, *

Usage of CPT is subject to AMA requirements: see PhenoML Terms of Service.

*/ public CompletableFuture list(String codesystem) { - return this.rawClient.list(codesystem).thenApply(response -> response.body()); + CompletableFuture> rawFuture = this.rawClient.list(codesystem); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** @@ -116,7 +169,15 @@ public CompletableFuture list(String codesystem) { *

Usage of CPT is subject to AMA requirements: see PhenoML Terms of Service.

*/ public CompletableFuture list(String codesystem, RequestOptions requestOptions) { - return this.rawClient.list(codesystem, requestOptions).thenApply(response -> response.body()); + CompletableFuture> rawFuture = + this.rawClient.list(codesystem, requestOptions); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** @@ -124,7 +185,15 @@ public CompletableFuture list(String codesystem, RequestOptio *

Usage of CPT is subject to AMA requirements: see PhenoML Terms of Service.

*/ public CompletableFuture list(String codesystem, CodesListRequest request) { - return this.rawClient.list(codesystem, request).thenApply(response -> response.body()); + CompletableFuture> rawFuture = + this.rawClient.list(codesystem, request); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** @@ -133,7 +202,15 @@ public CompletableFuture list(String codesystem, CodesListReq */ public CompletableFuture list( String codesystem, CodesListRequest request, RequestOptions requestOptions) { - return this.rawClient.list(codesystem, request, requestOptions).thenApply(response -> response.body()); + CompletableFuture> rawFuture = + this.rawClient.list(codesystem, request, requestOptions); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** @@ -141,7 +218,15 @@ public CompletableFuture list( *

Usage of CPT is subject to AMA requirements: see PhenoML Terms of Service.

*/ public CompletableFuture lookup(String codesystem, String codeId) { - return this.rawClient.lookup(codesystem, codeId).thenApply(response -> response.body()); + CompletableFuture> rawFuture = + this.rawClient.lookup(codesystem, codeId); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** @@ -149,7 +234,15 @@ public CompletableFuture lookup(String codesystem, String codeI *

Usage of CPT is subject to AMA requirements: see PhenoML Terms of Service.

*/ public CompletableFuture lookup(String codesystem, String codeId, RequestOptions requestOptions) { - return this.rawClient.lookup(codesystem, codeId, requestOptions).thenApply(response -> response.body()); + CompletableFuture> rawFuture = + this.rawClient.lookup(codesystem, codeId, requestOptions); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** @@ -157,7 +250,15 @@ public CompletableFuture lookup(String codesystem, String codeI *

Usage of CPT is subject to AMA requirements: see PhenoML Terms of Service.

*/ public CompletableFuture lookup(String codesystem, String codeId, LookupRequest request) { - return this.rawClient.lookup(codesystem, codeId, request).thenApply(response -> response.body()); + CompletableFuture> rawFuture = + this.rawClient.lookup(codesystem, codeId, request); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** @@ -166,9 +267,15 @@ public CompletableFuture lookup(String codesystem, String codeI */ public CompletableFuture lookup( String codesystem, String codeId, LookupRequest request, RequestOptions requestOptions) { - return this.rawClient - .lookup(codesystem, codeId, request, requestOptions) - .thenApply(response -> response.body()); + CompletableFuture> rawFuture = + this.rawClient.lookup(codesystem, codeId, request, requestOptions); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** @@ -189,7 +296,15 @@ public CompletableFuture lookup( *

Usage of CPT is subject to AMA requirements: see PhenoML Terms of Service.

*/ public CompletableFuture searchSemantic(String codesystem, SearchSemanticRequest request) { - return this.rawClient.searchSemantic(codesystem, request).thenApply(response -> response.body()); + CompletableFuture> rawFuture = + this.rawClient.searchSemantic(codesystem, request); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** @@ -211,9 +326,15 @@ public CompletableFuture searchSemantic(String codesyste */ public CompletableFuture searchSemantic( String codesystem, SearchSemanticRequest request, RequestOptions requestOptions) { - return this.rawClient - .searchSemantic(codesystem, request, requestOptions) - .thenApply(response -> response.body()); + CompletableFuture> rawFuture = + this.rawClient.searchSemantic(codesystem, request, requestOptions); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** @@ -240,7 +361,15 @@ public CompletableFuture searchSemantic( *

Usage of CPT is subject to AMA requirements: see PhenoML Terms of Service.

*/ public CompletableFuture searchText(String codesystem, SearchTextRequest request) { - return this.rawClient.searchText(codesystem, request).thenApply(response -> response.body()); + CompletableFuture> rawFuture = + this.rawClient.searchText(codesystem, request); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** @@ -268,6 +397,14 @@ public CompletableFuture searchText(String codesystem, Searc */ public CompletableFuture searchText( String codesystem, SearchTextRequest request, RequestOptions requestOptions) { - return this.rawClient.searchText(codesystem, request, requestOptions).thenApply(response -> response.body()); + CompletableFuture> rawFuture = + this.rawClient.searchText(codesystem, request, requestOptions); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } } diff --git a/src/main/java/com/phenoml/api/resources/construe/codes/AsyncRawCodesClient.java b/src/main/java/com/phenoml/api/resources/construe/codes/AsyncRawCodesClient.java index aca4b143..341a2a28 100644 --- a/src/main/java/com/phenoml/api/resources/construe/codes/AsyncRawCodesClient.java +++ b/src/main/java/com/phenoml/api/resources/construe/codes/AsyncRawCodesClient.java @@ -4,9 +4,11 @@ package com.phenoml.api.resources.construe.codes; import com.fasterxml.jackson.core.JsonProcessingException; +import com.phenoml.api.core.BodyProperties; import com.phenoml.api.core.ClientOptions; import com.phenoml.api.core.MediaTypes; import com.phenoml.api.core.ObjectMappers; +import com.phenoml.api.core.PathSegments; import com.phenoml.api.core.PhenomlClientApiException; import com.phenoml.api.core.PhenomlClientException; import com.phenoml.api.core.PhenomlClientHttpResponse; @@ -82,7 +84,9 @@ public CompletableFuture> extract( RequestBody body; try { body = RequestBody.create( - ObjectMappers.JSON_MAPPER.writeValueAsBytes(request), MediaTypes.APPLICATION_JSON); + ObjectMappers.JSON_MAPPER.writeValueAsBytes(BodyProperties.merge( + request, requestOptions != null ? requestOptions.getBodyProperties() : null)), + MediaTypes.APPLICATION_JSON); } catch (JsonProcessingException e) { throw new PhenomlClientException("Failed to serialize request", e); } @@ -107,7 +111,8 @@ public CompletableFuture> extract( .build(); } CompletableFuture> future = new CompletableFuture<>(); - client.newCall(okhttpRequest).enqueue(new Callback() { + RetryInterceptor.AsyncCall okhttpCall = RetryInterceptor.newAsyncCall(client, okhttpRequest); + okhttpCall.enqueue(new Callback() { @Override public void onResponse(@NotNull Call call, @NotNull Response response) throws IOException { try (ResponseBody responseBody = response.body()) { @@ -171,6 +176,11 @@ public void onFailure(@NotNull Call call, @NotNull IOException e) { future.completeExceptionally(new PhenomlClientException("Network error executing HTTP request", e)); } }); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + okhttpCall.cancel(); + } + }); return future; } @@ -207,7 +217,9 @@ public CompletableFuture> phenocr( RequestBody body; try { body = RequestBody.create( - ObjectMappers.JSON_MAPPER.writeValueAsBytes(request), MediaTypes.APPLICATION_JSON); + ObjectMappers.JSON_MAPPER.writeValueAsBytes(BodyProperties.merge( + request, requestOptions != null ? requestOptions.getBodyProperties() : null)), + MediaTypes.APPLICATION_JSON); } catch (JsonProcessingException e) { throw new PhenomlClientException("Failed to serialize request", e); } @@ -232,7 +244,8 @@ public CompletableFuture> phenocr( .build(); } CompletableFuture> future = new CompletableFuture<>(); - client.newCall(okhttpRequest).enqueue(new Callback() { + RetryInterceptor.AsyncCall okhttpCall = RetryInterceptor.newAsyncCall(client, okhttpRequest); + okhttpCall.enqueue(new Callback() { @Override public void onResponse(@NotNull Call call, @NotNull Response response) throws IOException { try (ResponseBody responseBody = response.body()) { @@ -296,6 +309,11 @@ public void onFailure(@NotNull Call call, @NotNull IOException e) { future.completeExceptionally(new PhenomlClientException("Network error executing HTTP request", e)); } }); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + okhttpCall.cancel(); + } + }); return future; } @@ -330,7 +348,9 @@ public CompletableFuture> crosswalk RequestBody body; try { body = RequestBody.create( - ObjectMappers.JSON_MAPPER.writeValueAsBytes(request), MediaTypes.APPLICATION_JSON); + ObjectMappers.JSON_MAPPER.writeValueAsBytes(BodyProperties.merge( + request, requestOptions != null ? requestOptions.getBodyProperties() : null)), + MediaTypes.APPLICATION_JSON); } catch (JsonProcessingException e) { throw new PhenomlClientException("Failed to serialize request", e); } @@ -355,7 +375,8 @@ public CompletableFuture> crosswalk .build(); } CompletableFuture> future = new CompletableFuture<>(); - client.newCall(okhttpRequest).enqueue(new Callback() { + RetryInterceptor.AsyncCall okhttpCall = RetryInterceptor.newAsyncCall(client, okhttpRequest); + okhttpCall.enqueue(new Callback() { @Override public void onResponse(@NotNull Call call, @NotNull Response response) throws IOException { try (ResponseBody responseBody = response.body()) { @@ -404,6 +425,11 @@ public void onFailure(@NotNull Call call, @NotNull IOException e) { future.completeExceptionally(new PhenomlClientException("Network error executing HTTP request", e)); } }); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + okhttpCall.cancel(); + } + }); return future; } @@ -442,7 +468,7 @@ public CompletableFuture> list( HttpUrl.Builder httpUrl = HttpUrl.parse(this.clientOptions.environment().getUrl()) .newBuilder() .addPathSegments("construe/codes") - .addPathSegment(codesystem); + .addPathSegment(PathSegments.validate(codesystem)); if (request.getVersion().isPresent()) { QueryStringMapper.addQueryParameter( httpUrl, "version", request.getVersion().get(), false); @@ -480,7 +506,8 @@ public CompletableFuture> list( .build(); } CompletableFuture> future = new CompletableFuture<>(); - client.newCall(okhttpRequest).enqueue(new Callback() { + RetryInterceptor.AsyncCall okhttpCall = RetryInterceptor.newAsyncCall(client, okhttpRequest); + okhttpCall.enqueue(new Callback() { @Override public void onResponse(@NotNull Call call, @NotNull Response response) throws IOException { try (ResponseBody responseBody = response.body()) { @@ -534,6 +561,11 @@ public void onFailure(@NotNull Call call, @NotNull IOException e) { future.completeExceptionally(new PhenomlClientException("Network error executing HTTP request", e)); } }); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + okhttpCall.cancel(); + } + }); return future; } @@ -572,8 +604,8 @@ public CompletableFuture> lookup( HttpUrl.Builder httpUrl = HttpUrl.parse(this.clientOptions.environment().getUrl()) .newBuilder() .addPathSegments("construe/codes") - .addPathSegment(codesystem) - .addPathSegment(codeId); + .addPathSegment(PathSegments.validate(codesystem)) + .addPathSegment(PathSegments.validate(codeId)); if (request.getVersion().isPresent()) { QueryStringMapper.addQueryParameter( httpUrl, "version", request.getVersion().get(), false); @@ -603,7 +635,8 @@ public CompletableFuture> lookup( .build(); } CompletableFuture> future = new CompletableFuture<>(); - client.newCall(okhttpRequest).enqueue(new Callback() { + RetryInterceptor.AsyncCall okhttpCall = RetryInterceptor.newAsyncCall(client, okhttpRequest); + okhttpCall.enqueue(new Callback() { @Override public void onResponse(@NotNull Call call, @NotNull Response response) throws IOException { try (ResponseBody responseBody = response.body()) { @@ -657,6 +690,11 @@ public void onFailure(@NotNull Call call, @NotNull IOException e) { future.completeExceptionally(new PhenomlClientException("Network error executing HTTP request", e)); } }); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + okhttpCall.cancel(); + } + }); return future; } @@ -704,7 +742,7 @@ public CompletableFuture> sear HttpUrl.Builder httpUrl = HttpUrl.parse(this.clientOptions.environment().getUrl()) .newBuilder() .addPathSegments("construe/codes") - .addPathSegment(codesystem) + .addPathSegment(PathSegments.validate(codesystem)) .addPathSegments("search") .addPathSegments("semantic"); QueryStringMapper.addQueryParameter(httpUrl, "text", request.getText(), false); @@ -741,7 +779,8 @@ public CompletableFuture> sear .build(); } CompletableFuture> future = new CompletableFuture<>(); - client.newCall(okhttpRequest).enqueue(new Callback() { + RetryInterceptor.AsyncCall okhttpCall = RetryInterceptor.newAsyncCall(client, okhttpRequest); + okhttpCall.enqueue(new Callback() { @Override public void onResponse(@NotNull Call call, @NotNull Response response) throws IOException { try (ResponseBody responseBody = response.body()) { @@ -795,6 +834,11 @@ public void onFailure(@NotNull Call call, @NotNull IOException e) { future.completeExceptionally(new PhenomlClientException("Network error executing HTTP request", e)); } }); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + okhttpCall.cancel(); + } + }); return future; } @@ -854,7 +898,7 @@ public CompletableFuture> searchTe HttpUrl.Builder httpUrl = HttpUrl.parse(this.clientOptions.environment().getUrl()) .newBuilder() .addPathSegments("construe/codes") - .addPathSegment(codesystem) + .addPathSegment(PathSegments.validate(codesystem)) .addPathSegments("search") .addPathSegments("text"); QueryStringMapper.addQueryParameter(httpUrl, "q", request.getQ(), false); @@ -891,7 +935,8 @@ public CompletableFuture> searchTe .build(); } CompletableFuture> future = new CompletableFuture<>(); - client.newCall(okhttpRequest).enqueue(new Callback() { + RetryInterceptor.AsyncCall okhttpCall = RetryInterceptor.newAsyncCall(client, okhttpRequest); + okhttpCall.enqueue(new Callback() { @Override public void onResponse(@NotNull Call call, @NotNull Response response) throws IOException { try (ResponseBody responseBody = response.body()) { @@ -950,6 +995,11 @@ public void onFailure(@NotNull Call call, @NotNull IOException e) { future.completeExceptionally(new PhenomlClientException("Network error executing HTTP request", e)); } }); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + okhttpCall.cancel(); + } + }); return future; } } diff --git a/src/main/java/com/phenoml/api/resources/construe/codes/RawCodesClient.java b/src/main/java/com/phenoml/api/resources/construe/codes/RawCodesClient.java index 1f71943b..45ac7b99 100644 --- a/src/main/java/com/phenoml/api/resources/construe/codes/RawCodesClient.java +++ b/src/main/java/com/phenoml/api/resources/construe/codes/RawCodesClient.java @@ -4,9 +4,11 @@ package com.phenoml.api.resources.construe.codes; import com.fasterxml.jackson.core.JsonProcessingException; +import com.phenoml.api.core.BodyProperties; import com.phenoml.api.core.ClientOptions; import com.phenoml.api.core.MediaTypes; import com.phenoml.api.core.ObjectMappers; +import com.phenoml.api.core.PathSegments; import com.phenoml.api.core.PhenomlClientApiException; import com.phenoml.api.core.PhenomlClientException; import com.phenoml.api.core.PhenomlClientHttpResponse; @@ -78,7 +80,9 @@ public PhenomlClientHttpResponse extract( RequestBody body; try { body = RequestBody.create( - ObjectMappers.JSON_MAPPER.writeValueAsBytes(request), MediaTypes.APPLICATION_JSON); + ObjectMappers.JSON_MAPPER.writeValueAsBytes(BodyProperties.merge( + request, requestOptions != null ? requestOptions.getBodyProperties() : null)), + MediaTypes.APPLICATION_JSON); } catch (JsonProcessingException e) { throw new PhenomlClientException("Failed to serialize request", e); } @@ -176,7 +180,9 @@ public PhenomlClientHttpResponse phenocr( RequestBody body; try { body = RequestBody.create( - ObjectMappers.JSON_MAPPER.writeValueAsBytes(request), MediaTypes.APPLICATION_JSON); + ObjectMappers.JSON_MAPPER.writeValueAsBytes(BodyProperties.merge( + request, requestOptions != null ? requestOptions.getBodyProperties() : null)), + MediaTypes.APPLICATION_JSON); } catch (JsonProcessingException e) { throw new PhenomlClientException("Failed to serialize request", e); } @@ -272,7 +278,9 @@ public PhenomlClientHttpResponse crosswalk( RequestBody body; try { body = RequestBody.create( - ObjectMappers.JSON_MAPPER.writeValueAsBytes(request), MediaTypes.APPLICATION_JSON); + ObjectMappers.JSON_MAPPER.writeValueAsBytes(BodyProperties.merge( + request, requestOptions != null ? requestOptions.getBodyProperties() : null)), + MediaTypes.APPLICATION_JSON); } catch (JsonProcessingException e) { throw new PhenomlClientException("Failed to serialize request", e); } @@ -361,7 +369,7 @@ public PhenomlClientHttpResponse list( HttpUrl.Builder httpUrl = HttpUrl.parse(this.clientOptions.environment().getUrl()) .newBuilder() .addPathSegments("construe/codes") - .addPathSegment(codesystem); + .addPathSegment(PathSegments.validate(codesystem)); if (request.getVersion().isPresent()) { QueryStringMapper.addQueryParameter( httpUrl, "version", request.getVersion().get(), false); @@ -467,8 +475,8 @@ public PhenomlClientHttpResponse lookup( HttpUrl.Builder httpUrl = HttpUrl.parse(this.clientOptions.environment().getUrl()) .newBuilder() .addPathSegments("construe/codes") - .addPathSegment(codesystem) - .addPathSegment(codeId); + .addPathSegment(PathSegments.validate(codesystem)) + .addPathSegment(PathSegments.validate(codeId)); if (request.getVersion().isPresent()) { QueryStringMapper.addQueryParameter( httpUrl, "version", request.getVersion().get(), false); @@ -576,7 +584,7 @@ public PhenomlClientHttpResponse searchSemantic( HttpUrl.Builder httpUrl = HttpUrl.parse(this.clientOptions.environment().getUrl()) .newBuilder() .addPathSegments("construe/codes") - .addPathSegment(codesystem) + .addPathSegment(PathSegments.validate(codesystem)) .addPathSegments("search") .addPathSegments("semantic"); QueryStringMapper.addQueryParameter(httpUrl, "text", request.getText(), false); @@ -703,7 +711,7 @@ public PhenomlClientHttpResponse searchText( HttpUrl.Builder httpUrl = HttpUrl.parse(this.clientOptions.environment().getUrl()) .newBuilder() .addPathSegments("construe/codes") - .addPathSegment(codesystem) + .addPathSegment(PathSegments.validate(codesystem)) .addPathSegments("search") .addPathSegments("text"); QueryStringMapper.addQueryParameter(httpUrl, "q", request.getQ(), false); diff --git a/src/main/java/com/phenoml/api/resources/construe/codes/requests/CodesListRequest.java b/src/main/java/com/phenoml/api/resources/construe/codes/requests/CodesListRequest.java index 7c8ef013..f14b0abe 100644 --- a/src/main/java/com/phenoml/api/resources/construe/codes/requests/CodesListRequest.java +++ b/src/main/java/com/phenoml/api/resources/construe/codes/requests/CodesListRequest.java @@ -100,7 +100,6 @@ public static final class Builder { private Optional limit = Optional.empty(); - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -158,6 +157,14 @@ public CodesListRequest build() { return new CodesListRequest(version, cursor, limit, additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("version") || key.equalsIgnoreCase("cursor") || key.equalsIgnoreCase("limit")) { + return; + } + this.additionalProperties.put(key, value); + } + public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); return this; diff --git a/src/main/java/com/phenoml/api/resources/construe/codes/requests/CrosswalkRequest.java b/src/main/java/com/phenoml/api/resources/construe/codes/requests/CrosswalkRequest.java index 997993b1..ea379fcd 100644 --- a/src/main/java/com/phenoml/api/resources/construe/codes/requests/CrosswalkRequest.java +++ b/src/main/java/com/phenoml/api/resources/construe/codes/requests/CrosswalkRequest.java @@ -132,7 +132,6 @@ public static final class Builder implements SystemStage, CodeStage, _FinalStage private List targets = new ArrayList<>(); - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -207,6 +206,14 @@ public CrosswalkRequest build() { return new CrosswalkRequest(system, code, targets, additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("system") || key.equalsIgnoreCase("code") || key.equalsIgnoreCase("targets")) { + return; + } + this.additionalProperties.put(key, value); + } + @java.lang.Override public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); diff --git a/src/main/java/com/phenoml/api/resources/construe/codes/requests/ExtractRequest.java b/src/main/java/com/phenoml/api/resources/construe/codes/requests/ExtractRequest.java index 5563cd93..8b2a3de6 100644 --- a/src/main/java/com/phenoml/api/resources/construe/codes/requests/ExtractRequest.java +++ b/src/main/java/com/phenoml/api/resources/construe/codes/requests/ExtractRequest.java @@ -122,7 +122,6 @@ public static final class Builder implements TextStage, _FinalStage { private Optional system = Optional.empty(); - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -177,6 +176,14 @@ public ExtractRequest build() { return new ExtractRequest(text, system, config, additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("text") || key.equalsIgnoreCase("system") || key.equalsIgnoreCase("config")) { + return; + } + this.additionalProperties.put(key, value); + } + @java.lang.Override public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); diff --git a/src/main/java/com/phenoml/api/resources/construe/codes/requests/LookupRequest.java b/src/main/java/com/phenoml/api/resources/construe/codes/requests/LookupRequest.java index b85a65db..49c50a90 100644 --- a/src/main/java/com/phenoml/api/resources/construe/codes/requests/LookupRequest.java +++ b/src/main/java/com/phenoml/api/resources/construe/codes/requests/LookupRequest.java @@ -70,7 +70,6 @@ public static Builder builder() { public static final class Builder { private Optional version = Optional.empty(); - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -98,6 +97,14 @@ public LookupRequest build() { return new LookupRequest(version, additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("version")) { + return; + } + this.additionalProperties.put(key, value); + } + public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); return this; diff --git a/src/main/java/com/phenoml/api/resources/construe/codes/requests/PhenoCrRequest.java b/src/main/java/com/phenoml/api/resources/construe/codes/requests/PhenoCrRequest.java index 817d29f7..ba8bcbd0 100644 --- a/src/main/java/com/phenoml/api/resources/construe/codes/requests/PhenoCrRequest.java +++ b/src/main/java/com/phenoml/api/resources/construe/codes/requests/PhenoCrRequest.java @@ -101,7 +101,6 @@ public static final class Builder implements TextStage, SystemStage, _FinalStage private PhenocrExtractRequestSystem system; - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -136,6 +135,14 @@ public PhenoCrRequest build() { return new PhenoCrRequest(text, system, additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("text") || key.equalsIgnoreCase("system")) { + return; + } + this.additionalProperties.put(key, value); + } + @java.lang.Override public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); diff --git a/src/main/java/com/phenoml/api/resources/construe/codes/requests/SearchSemanticRequest.java b/src/main/java/com/phenoml/api/resources/construe/codes/requests/SearchSemanticRequest.java index 1a50ad18..0387abe8 100644 --- a/src/main/java/com/phenoml/api/resources/construe/codes/requests/SearchSemanticRequest.java +++ b/src/main/java/com/phenoml/api/resources/construe/codes/requests/SearchSemanticRequest.java @@ -129,7 +129,6 @@ public static final class Builder implements TextStage, _FinalStage { private Optional version = Optional.empty(); - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -198,6 +197,14 @@ public SearchSemanticRequest build() { return new SearchSemanticRequest(text, version, limit, additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("text") || key.equalsIgnoreCase("version") || key.equalsIgnoreCase("limit")) { + return; + } + this.additionalProperties.put(key, value); + } + @java.lang.Override public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); diff --git a/src/main/java/com/phenoml/api/resources/construe/codes/requests/SearchTextRequest.java b/src/main/java/com/phenoml/api/resources/construe/codes/requests/SearchTextRequest.java index 87527b9a..9eaf8f2c 100644 --- a/src/main/java/com/phenoml/api/resources/construe/codes/requests/SearchTextRequest.java +++ b/src/main/java/com/phenoml/api/resources/construe/codes/requests/SearchTextRequest.java @@ -129,7 +129,6 @@ public static final class Builder implements QStage, _FinalStage { private Optional version = Optional.empty(); - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -198,6 +197,14 @@ public SearchTextRequest build() { return new SearchTextRequest(q, version, limit, additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("q") || key.equalsIgnoreCase("version") || key.equalsIgnoreCase("limit")) { + return; + } + this.additionalProperties.put(key, value); + } + @java.lang.Override public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); diff --git a/src/main/java/com/phenoml/api/resources/construe/codesystems/AsyncCodeSystemsClient.java b/src/main/java/com/phenoml/api/resources/construe/codesystems/AsyncCodeSystemsClient.java index 3765f303..b96bfb09 100644 --- a/src/main/java/com/phenoml/api/resources/construe/codesystems/AsyncCodeSystemsClient.java +++ b/src/main/java/com/phenoml/api/resources/construe/codesystems/AsyncCodeSystemsClient.java @@ -4,6 +4,7 @@ package com.phenoml.api.resources.construe.codesystems; import com.phenoml.api.core.ClientOptions; +import com.phenoml.api.core.PhenomlClientHttpResponse; import com.phenoml.api.core.RequestOptions; import com.phenoml.api.resources.construe.codesystems.requests.DeleteRequest; import com.phenoml.api.resources.construe.codesystems.requests.ExportRequest; @@ -40,7 +41,14 @@ public AsyncRawCodeSystemsClient withRawResponse() { * transitions from "processing" to "ready" or "failed". */ public CompletableFuture upload(UploadRequest request) { - return this.rawClient.upload(request).thenApply(response -> response.body()); + CompletableFuture> rawFuture = this.rawClient.upload(request); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** @@ -50,42 +58,89 @@ public CompletableFuture upload(UploadRequest request) { * transitions from "processing" to "ready" or "failed". */ public CompletableFuture upload(UploadRequest request, RequestOptions requestOptions) { - return this.rawClient.upload(request, requestOptions).thenApply(response -> response.body()); + CompletableFuture> rawFuture = + this.rawClient.upload(request, requestOptions); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** * Returns the catalog of available code systems, including both built-in standard terminologies and custom uploaded systems. */ public CompletableFuture list() { - return this.rawClient.list().thenApply(response -> response.body()); + CompletableFuture> rawFuture = this.rawClient.list(); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** * Returns the catalog of available code systems, including both built-in standard terminologies and custom uploaded systems. */ public CompletableFuture list(RequestOptions requestOptions) { - return this.rawClient.list(requestOptions).thenApply(response -> response.body()); + CompletableFuture> rawFuture = + this.rawClient.list(requestOptions); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** * Returns full metadata for a single code system, including timestamps and builtin status. */ public CompletableFuture find(String codesystem) { - return this.rawClient.find(codesystem).thenApply(response -> response.body()); + CompletableFuture> rawFuture = + this.rawClient.find(codesystem); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** * Returns full metadata for a single code system, including timestamps and builtin status. */ public CompletableFuture find(String codesystem, RequestOptions requestOptions) { - return this.rawClient.find(codesystem, requestOptions).thenApply(response -> response.body()); + CompletableFuture> rawFuture = + this.rawClient.find(codesystem, requestOptions); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** * Returns full metadata for a single code system, including timestamps and builtin status. */ public CompletableFuture find(String codesystem, FindRequest request) { - return this.rawClient.find(codesystem, request).thenApply(response -> response.body()); + CompletableFuture> rawFuture = + this.rawClient.find(codesystem, request); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** @@ -93,7 +148,15 @@ public CompletableFuture find(String codesystem, Fi */ public CompletableFuture find( String codesystem, FindRequest request, RequestOptions requestOptions) { - return this.rawClient.find(codesystem, request, requestOptions).thenApply(response -> response.body()); + CompletableFuture> rawFuture = + this.rawClient.find(codesystem, request, requestOptions); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** @@ -101,7 +164,15 @@ public CompletableFuture find( * Only available on dedicated instances. Large systems may take up to a minute to delete. */ public CompletableFuture delete(String codesystem) { - return this.rawClient.delete(codesystem).thenApply(response -> response.body()); + CompletableFuture> rawFuture = + this.rawClient.delete(codesystem); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** @@ -109,7 +180,15 @@ public CompletableFuture delete(String codesystem) { * Only available on dedicated instances. Large systems may take up to a minute to delete. */ public CompletableFuture delete(String codesystem, RequestOptions requestOptions) { - return this.rawClient.delete(codesystem, requestOptions).thenApply(response -> response.body()); + CompletableFuture> rawFuture = + this.rawClient.delete(codesystem, requestOptions); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** @@ -117,7 +196,15 @@ public CompletableFuture delete(String codesystem, Req * Only available on dedicated instances. Large systems may take up to a minute to delete. */ public CompletableFuture delete(String codesystem, DeleteRequest request) { - return this.rawClient.delete(codesystem, request).thenApply(response -> response.body()); + CompletableFuture> rawFuture = + this.rawClient.delete(codesystem, request); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** @@ -126,7 +213,15 @@ public CompletableFuture delete(String codesystem, Del */ public CompletableFuture delete( String codesystem, DeleteRequest request, RequestOptions requestOptions) { - return this.rawClient.delete(codesystem, request, requestOptions).thenApply(response -> response.body()); + CompletableFuture> rawFuture = + this.rawClient.delete(codesystem, request, requestOptions); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** @@ -135,7 +230,15 @@ public CompletableFuture delete( * Only available on dedicated instances. Builtin systems cannot be exported. */ public CompletableFuture export(String codesystem) { - return this.rawClient.export(codesystem).thenApply(response -> response.body()); + CompletableFuture> rawFuture = + this.rawClient.export(codesystem); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** @@ -144,7 +247,15 @@ public CompletableFuture export(String codesystem) { * Only available on dedicated instances. Builtin systems cannot be exported. */ public CompletableFuture export(String codesystem, RequestOptions requestOptions) { - return this.rawClient.export(codesystem, requestOptions).thenApply(response -> response.body()); + CompletableFuture> rawFuture = + this.rawClient.export(codesystem, requestOptions); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** @@ -153,7 +264,15 @@ public CompletableFuture export(String codesystem, Req * Only available on dedicated instances. Builtin systems cannot be exported. */ public CompletableFuture export(String codesystem, ExportRequest request) { - return this.rawClient.export(codesystem, request).thenApply(response -> response.body()); + CompletableFuture> rawFuture = + this.rawClient.export(codesystem, request); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** @@ -163,6 +282,14 @@ public CompletableFuture export(String codesystem, Exp */ public CompletableFuture export( String codesystem, ExportRequest request, RequestOptions requestOptions) { - return this.rawClient.export(codesystem, request, requestOptions).thenApply(response -> response.body()); + CompletableFuture> rawFuture = + this.rawClient.export(codesystem, request, requestOptions); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } } diff --git a/src/main/java/com/phenoml/api/resources/construe/codesystems/AsyncRawCodeSystemsClient.java b/src/main/java/com/phenoml/api/resources/construe/codesystems/AsyncRawCodeSystemsClient.java index 743cb720..6cccf0c0 100644 --- a/src/main/java/com/phenoml/api/resources/construe/codesystems/AsyncRawCodeSystemsClient.java +++ b/src/main/java/com/phenoml/api/resources/construe/codesystems/AsyncRawCodeSystemsClient.java @@ -4,9 +4,11 @@ package com.phenoml.api.resources.construe.codesystems; import com.fasterxml.jackson.core.JsonProcessingException; +import com.phenoml.api.core.BodyProperties; import com.phenoml.api.core.ClientOptions; import com.phenoml.api.core.MediaTypes; import com.phenoml.api.core.ObjectMappers; +import com.phenoml.api.core.PathSegments; import com.phenoml.api.core.PhenomlClientApiException; import com.phenoml.api.core.PhenomlClientException; import com.phenoml.api.core.PhenomlClientHttpResponse; @@ -78,7 +80,9 @@ public CompletableFuture> upload( RequestBody body; try { body = RequestBody.create( - ObjectMappers.JSON_MAPPER.writeValueAsBytes(request), MediaTypes.APPLICATION_JSON); + ObjectMappers.JSON_MAPPER.writeValueAsBytes(BodyProperties.merge( + request, requestOptions != null ? requestOptions.getBodyProperties() : null)), + MediaTypes.APPLICATION_JSON); } catch (JsonProcessingException e) { throw new PhenomlClientException("Failed to serialize request", e); } @@ -103,7 +107,8 @@ public CompletableFuture> upload( .build(); } CompletableFuture> future = new CompletableFuture<>(); - client.newCall(okhttpRequest).enqueue(new Callback() { + RetryInterceptor.AsyncCall okhttpCall = RetryInterceptor.newAsyncCall(client, okhttpRequest); + okhttpCall.enqueue(new Callback() { @Override public void onResponse(@NotNull Call call, @NotNull Response response) throws IOException { try (ResponseBody responseBody = response.body()) { @@ -167,6 +172,11 @@ public void onFailure(@NotNull Call call, @NotNull IOException e) { future.completeExceptionally(new PhenomlClientException("Network error executing HTTP request", e)); } }); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + okhttpCall.cancel(); + } + }); return future; } @@ -209,7 +219,8 @@ public CompletableFuture> lis .build(); } CompletableFuture> future = new CompletableFuture<>(); - client.newCall(okhttpRequest).enqueue(new Callback() { + RetryInterceptor.AsyncCall okhttpCall = RetryInterceptor.newAsyncCall(client, okhttpRequest); + okhttpCall.enqueue(new Callback() { @Override public void onResponse(@NotNull Call call, @NotNull Response response) throws IOException { try (ResponseBody responseBody = response.body()) { @@ -253,6 +264,11 @@ public void onFailure(@NotNull Call call, @NotNull IOException e) { future.completeExceptionally(new PhenomlClientException("Network error executing HTTP request", e)); } }); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + okhttpCall.cancel(); + } + }); return future; } @@ -287,7 +303,7 @@ public CompletableFuture> HttpUrl.Builder httpUrl = HttpUrl.parse(this.clientOptions.environment().getUrl()) .newBuilder() .addPathSegments("construe/codes/systems") - .addPathSegment(codesystem); + .addPathSegment(PathSegments.validate(codesystem)); if (request.getVersion().isPresent()) { QueryStringMapper.addQueryParameter( httpUrl, "version", request.getVersion().get(), false); @@ -317,7 +333,8 @@ public CompletableFuture> .build(); } CompletableFuture> future = new CompletableFuture<>(); - client.newCall(okhttpRequest).enqueue(new Callback() { + RetryInterceptor.AsyncCall okhttpCall = RetryInterceptor.newAsyncCall(client, okhttpRequest); + okhttpCall.enqueue(new Callback() { @Override public void onResponse(@NotNull Call call, @NotNull Response response) throws IOException { try (ResponseBody responseBody = response.body()) { @@ -372,6 +389,11 @@ public void onFailure(@NotNull Call call, @NotNull IOException e) { future.completeExceptionally(new PhenomlClientException("Network error executing HTTP request", e)); } }); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + okhttpCall.cancel(); + } + }); return future; } @@ -410,7 +432,7 @@ public CompletableFuture> de HttpUrl.Builder httpUrl = HttpUrl.parse(this.clientOptions.environment().getUrl()) .newBuilder() .addPathSegments("construe/codes/systems") - .addPathSegment(codesystem); + .addPathSegment(PathSegments.validate(codesystem)); if (request.getVersion().isPresent()) { QueryStringMapper.addQueryParameter( httpUrl, "version", request.getVersion().get(), false); @@ -422,7 +444,10 @@ public CompletableFuture> de } Request.Builder _requestBuilder = new Request.Builder() .url(httpUrl.build()) - .method("DELETE", null) + .method( + "DELETE", + BodyProperties.toRequestBody( + requestOptions != null ? requestOptions.getBodyProperties() : null, null)) .headers(Headers.of(clientOptions.headers(requestOptions))) .addHeader("Accept", "application/json"); Request okhttpRequest = _requestBuilder.build(); @@ -440,7 +465,8 @@ public CompletableFuture> de .build(); } CompletableFuture> future = new CompletableFuture<>(); - client.newCall(okhttpRequest).enqueue(new Callback() { + RetryInterceptor.AsyncCall okhttpCall = RetryInterceptor.newAsyncCall(client, okhttpRequest); + okhttpCall.enqueue(new Callback() { @Override public void onResponse(@NotNull Call call, @NotNull Response response) throws IOException { try (ResponseBody responseBody = response.body()) { @@ -499,6 +525,11 @@ public void onFailure(@NotNull Call call, @NotNull IOException e) { future.completeExceptionally(new PhenomlClientException("Network error executing HTTP request", e)); } }); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + okhttpCall.cancel(); + } + }); return future; } @@ -541,7 +572,7 @@ public CompletableFuture> ex HttpUrl.Builder httpUrl = HttpUrl.parse(this.clientOptions.environment().getUrl()) .newBuilder() .addPathSegments("construe/codes/systems") - .addPathSegment(codesystem) + .addPathSegment(PathSegments.validate(codesystem)) .addPathSegments("export"); if (request.getVersion().isPresent()) { QueryStringMapper.addQueryParameter( @@ -572,7 +603,8 @@ public CompletableFuture> ex .build(); } CompletableFuture> future = new CompletableFuture<>(); - client.newCall(okhttpRequest).enqueue(new Callback() { + RetryInterceptor.AsyncCall okhttpCall = RetryInterceptor.newAsyncCall(client, okhttpRequest); + okhttpCall.enqueue(new Callback() { @Override public void onResponse(@NotNull Call call, @NotNull Response response) throws IOException { try (ResponseBody responseBody = response.body()) { @@ -641,6 +673,11 @@ public void onFailure(@NotNull Call call, @NotNull IOException e) { future.completeExceptionally(new PhenomlClientException("Network error executing HTTP request", e)); } }); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + okhttpCall.cancel(); + } + }); return future; } } diff --git a/src/main/java/com/phenoml/api/resources/construe/codesystems/RawCodeSystemsClient.java b/src/main/java/com/phenoml/api/resources/construe/codesystems/RawCodeSystemsClient.java index 39e8cedd..521ea1f6 100644 --- a/src/main/java/com/phenoml/api/resources/construe/codesystems/RawCodeSystemsClient.java +++ b/src/main/java/com/phenoml/api/resources/construe/codesystems/RawCodeSystemsClient.java @@ -4,9 +4,11 @@ package com.phenoml.api.resources.construe.codesystems; import com.fasterxml.jackson.core.JsonProcessingException; +import com.phenoml.api.core.BodyProperties; import com.phenoml.api.core.ClientOptions; import com.phenoml.api.core.MediaTypes; import com.phenoml.api.core.ObjectMappers; +import com.phenoml.api.core.PathSegments; import com.phenoml.api.core.PhenomlClientApiException; import com.phenoml.api.core.PhenomlClientException; import com.phenoml.api.core.PhenomlClientHttpResponse; @@ -73,7 +75,9 @@ public PhenomlClientHttpResponse upload(UploadRequest request, R RequestBody body; try { body = RequestBody.create( - ObjectMappers.JSON_MAPPER.writeValueAsBytes(request), MediaTypes.APPLICATION_JSON); + ObjectMappers.JSON_MAPPER.writeValueAsBytes(BodyProperties.merge( + request, requestOptions != null ? requestOptions.getBodyProperties() : null)), + MediaTypes.APPLICATION_JSON); } catch (JsonProcessingException e) { throw new PhenomlClientException("Failed to serialize request", e); } @@ -236,7 +240,7 @@ public PhenomlClientHttpResponse find( HttpUrl.Builder httpUrl = HttpUrl.parse(this.clientOptions.environment().getUrl()) .newBuilder() .addPathSegments("construe/codes/systems") - .addPathSegment(codesystem); + .addPathSegment(PathSegments.validate(codesystem)); if (request.getVersion().isPresent()) { QueryStringMapper.addQueryParameter( httpUrl, "version", request.getVersion().get(), false); @@ -335,7 +339,7 @@ public PhenomlClientHttpResponse delete( HttpUrl.Builder httpUrl = HttpUrl.parse(this.clientOptions.environment().getUrl()) .newBuilder() .addPathSegments("construe/codes/systems") - .addPathSegment(codesystem); + .addPathSegment(PathSegments.validate(codesystem)); if (request.getVersion().isPresent()) { QueryStringMapper.addQueryParameter( httpUrl, "version", request.getVersion().get(), false); @@ -347,7 +351,10 @@ public PhenomlClientHttpResponse delete( } Request.Builder _requestBuilder = new Request.Builder() .url(httpUrl.build()) - .method("DELETE", null) + .method( + "DELETE", + BodyProperties.toRequestBody( + requestOptions != null ? requestOptions.getBodyProperties() : null, null)) .headers(Headers.of(clientOptions.headers(requestOptions))) .addHeader("Accept", "application/json"); Request okhttpRequest = _requestBuilder.build(); @@ -441,7 +448,7 @@ public PhenomlClientHttpResponse export( HttpUrl.Builder httpUrl = HttpUrl.parse(this.clientOptions.environment().getUrl()) .newBuilder() .addPathSegments("construe/codes/systems") - .addPathSegment(codesystem) + .addPathSegment(PathSegments.validate(codesystem)) .addPathSegments("export"); if (request.getVersion().isPresent()) { QueryStringMapper.addQueryParameter( diff --git a/src/main/java/com/phenoml/api/resources/construe/codesystems/requests/DeleteRequest.java b/src/main/java/com/phenoml/api/resources/construe/codesystems/requests/DeleteRequest.java index c5bd53b4..10db92e9 100644 --- a/src/main/java/com/phenoml/api/resources/construe/codesystems/requests/DeleteRequest.java +++ b/src/main/java/com/phenoml/api/resources/construe/codesystems/requests/DeleteRequest.java @@ -70,7 +70,6 @@ public static Builder builder() { public static final class Builder { private Optional version = Optional.empty(); - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -98,6 +97,14 @@ public DeleteRequest build() { return new DeleteRequest(version, additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("version")) { + return; + } + this.additionalProperties.put(key, value); + } + public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); return this; diff --git a/src/main/java/com/phenoml/api/resources/construe/codesystems/requests/ExportRequest.java b/src/main/java/com/phenoml/api/resources/construe/codesystems/requests/ExportRequest.java index 1244f9e7..4607d247 100644 --- a/src/main/java/com/phenoml/api/resources/construe/codesystems/requests/ExportRequest.java +++ b/src/main/java/com/phenoml/api/resources/construe/codesystems/requests/ExportRequest.java @@ -70,7 +70,6 @@ public static Builder builder() { public static final class Builder { private Optional version = Optional.empty(); - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -98,6 +97,14 @@ public ExportRequest build() { return new ExportRequest(version, additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("version")) { + return; + } + this.additionalProperties.put(key, value); + } + public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); return this; diff --git a/src/main/java/com/phenoml/api/resources/construe/codesystems/requests/FindRequest.java b/src/main/java/com/phenoml/api/resources/construe/codesystems/requests/FindRequest.java index 3059340d..a800b325 100644 --- a/src/main/java/com/phenoml/api/resources/construe/codesystems/requests/FindRequest.java +++ b/src/main/java/com/phenoml/api/resources/construe/codesystems/requests/FindRequest.java @@ -70,7 +70,6 @@ public static Builder builder() { public static final class Builder { private Optional version = Optional.empty(); - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -98,6 +97,14 @@ public FindRequest build() { return new FindRequest(version, additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("version")) { + return; + } + this.additionalProperties.put(key, value); + } + public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); return this; diff --git a/src/main/java/com/phenoml/api/resources/construe/codesystems/requests/UploadRequest.java b/src/main/java/com/phenoml/api/resources/construe/codesystems/requests/UploadRequest.java index 09fa8749..414f2724 100644 --- a/src/main/java/com/phenoml/api/resources/construe/codesystems/requests/UploadRequest.java +++ b/src/main/java/com/phenoml/api/resources/construe/codesystems/requests/UploadRequest.java @@ -317,7 +317,6 @@ public static final class Builder implements NameStage, VersionStage, FormatStag private Optional revision = Optional.empty(); - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -540,6 +539,23 @@ public UploadRequest build() { additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("name") + || key.equalsIgnoreCase("version") + || key.equalsIgnoreCase("revision") + || key.equalsIgnoreCase("format") + || key.equalsIgnoreCase("file") + || key.equalsIgnoreCase("code_col") + || key.equalsIgnoreCase("desc_col") + || key.equalsIgnoreCase("defn_col") + || key.equalsIgnoreCase("codes") + || key.equalsIgnoreCase("replace")) { + return; + } + this.additionalProperties.put(key, value); + } + @java.lang.Override public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); diff --git a/src/main/java/com/phenoml/api/resources/construe/codesystems/types/UploadResponse.java b/src/main/java/com/phenoml/api/resources/construe/codesystems/types/UploadResponse.java index 7c70881b..7a81d89d 100644 --- a/src/main/java/com/phenoml/api/resources/construe/codesystems/types/UploadResponse.java +++ b/src/main/java/com/phenoml/api/resources/construe/codesystems/types/UploadResponse.java @@ -91,7 +91,6 @@ public static final class Builder { private Optional version = Optional.empty(); - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -140,6 +139,14 @@ public UploadResponse build() { return new UploadResponse(status, name, version, additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("status") || key.equalsIgnoreCase("name") || key.equalsIgnoreCase("version")) { + return; + } + this.additionalProperties.put(key, value); + } + public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); return this; diff --git a/src/main/java/com/phenoml/api/resources/construe/requests/FeedbackRequest.java b/src/main/java/com/phenoml/api/resources/construe/requests/FeedbackRequest.java index ed598aa3..5e5b9328 100644 --- a/src/main/java/com/phenoml/api/resources/construe/requests/FeedbackRequest.java +++ b/src/main/java/com/phenoml/api/resources/construe/requests/FeedbackRequest.java @@ -145,7 +145,6 @@ public static final class Builder implements TextStage, ReceivedResultStage, Exp private Optional detail = Optional.empty(); - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -209,6 +208,17 @@ public FeedbackRequest build() { return new FeedbackRequest(text, receivedResult, expectedResult, detail, additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("text") + || key.equalsIgnoreCase("received_result") + || key.equalsIgnoreCase("expected_result") + || key.equalsIgnoreCase("detail")) { + return; + } + this.additionalProperties.put(key, value); + } + @java.lang.Override public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); diff --git a/src/main/java/com/phenoml/api/resources/construe/types/Citation.java b/src/main/java/com/phenoml/api/resources/construe/types/Citation.java index 67b49d83..21ad1b06 100644 --- a/src/main/java/com/phenoml/api/resources/construe/types/Citation.java +++ b/src/main/java/com/phenoml/api/resources/construe/types/Citation.java @@ -126,7 +126,6 @@ public static final class Builder implements TextStage, BeginOffsetStage, EndOff private int endOffset; - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -177,6 +176,16 @@ public Citation build() { return new Citation(text, beginOffset, endOffset, additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("text") + || key.equalsIgnoreCase("begin_offset") + || key.equalsIgnoreCase("end_offset")) { + return; + } + this.additionalProperties.put(key, value); + } + @java.lang.Override public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); diff --git a/src/main/java/com/phenoml/api/resources/construe/types/CodeCategory.java b/src/main/java/com/phenoml/api/resources/construe/types/CodeCategory.java index b3e2b6b1..02fd387d 100644 --- a/src/main/java/com/phenoml/api/resources/construe/types/CodeCategory.java +++ b/src/main/java/com/phenoml/api/resources/construe/types/CodeCategory.java @@ -106,7 +106,6 @@ public static final class Builder implements UriStage, LabelStage, _FinalStage { private String label; - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -145,6 +144,14 @@ public CodeCategory build() { return new CodeCategory(uri, label, additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("uri") || key.equalsIgnoreCase("label")) { + return; + } + this.additionalProperties.put(key, value); + } + @java.lang.Override public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); diff --git a/src/main/java/com/phenoml/api/resources/construe/types/CodeResponse.java b/src/main/java/com/phenoml/api/resources/construe/types/CodeResponse.java index 63c3ab18..cf87fd3b 100644 --- a/src/main/java/com/phenoml/api/resources/construe/types/CodeResponse.java +++ b/src/main/java/com/phenoml/api/resources/construe/types/CodeResponse.java @@ -129,7 +129,6 @@ public static final class Builder implements CodeStage, DescriptionStage, _Final private Optional definition = Optional.empty(); - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -189,6 +188,16 @@ public CodeResponse build() { return new CodeResponse(code, description, definition, additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("code") + || key.equalsIgnoreCase("description") + || key.equalsIgnoreCase("definition")) { + return; + } + this.additionalProperties.put(key, value); + } + @java.lang.Override public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); diff --git a/src/main/java/com/phenoml/api/resources/construe/types/CodeSystemDetails.java b/src/main/java/com/phenoml/api/resources/construe/types/CodeSystemDetails.java index 0cad9471..bd40f157 100644 --- a/src/main/java/com/phenoml/api/resources/construe/types/CodeSystemDetails.java +++ b/src/main/java/com/phenoml/api/resources/construe/types/CodeSystemDetails.java @@ -150,7 +150,6 @@ public static final class Builder implements NameStage, VersionStage, CodeCountS private boolean builtin; - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -213,6 +212,17 @@ public CodeSystemDetails build() { return new CodeSystemDetails(name, version, codeCount, builtin, additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("name") + || key.equalsIgnoreCase("version") + || key.equalsIgnoreCase("code_count") + || key.equalsIgnoreCase("builtin")) { + return; + } + this.additionalProperties.put(key, value); + } + @java.lang.Override public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); diff --git a/src/main/java/com/phenoml/api/resources/construe/types/CodeSystemInfo.java b/src/main/java/com/phenoml/api/resources/construe/types/CodeSystemInfo.java index 9268b62d..9ca5d082 100644 --- a/src/main/java/com/phenoml/api/resources/construe/types/CodeSystemInfo.java +++ b/src/main/java/com/phenoml/api/resources/construe/types/CodeSystemInfo.java @@ -106,7 +106,6 @@ public static final class Builder implements NameStage, VersionStage, _FinalStag private String version; - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -145,6 +144,14 @@ public CodeSystemInfo build() { return new CodeSystemInfo(name, version, additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("name") || key.equalsIgnoreCase("version")) { + return; + } + this.additionalProperties.put(key, value); + } + @java.lang.Override public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); diff --git a/src/main/java/com/phenoml/api/resources/construe/types/CrosswalkMatch.java b/src/main/java/com/phenoml/api/resources/construe/types/CrosswalkMatch.java index 5d8689af..e892bf7b 100644 --- a/src/main/java/com/phenoml/api/resources/construe/types/CrosswalkMatch.java +++ b/src/main/java/com/phenoml/api/resources/construe/types/CrosswalkMatch.java @@ -156,7 +156,6 @@ public static final class Builder implements CodeStage, DisplayStage, CuiStage, private Optional suppress = Optional.empty(); - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -228,6 +227,17 @@ public CrosswalkMatch build() { return new CrosswalkMatch(code, display, cui, suppress, additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("code") + || key.equalsIgnoreCase("display") + || key.equalsIgnoreCase("cui") + || key.equalsIgnoreCase("suppress")) { + return; + } + this.additionalProperties.put(key, value); + } + @java.lang.Override public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); diff --git a/src/main/java/com/phenoml/api/resources/construe/types/CrosswalkResponse.java b/src/main/java/com/phenoml/api/resources/construe/types/CrosswalkResponse.java index 5e463538..b94d9c3b 100644 --- a/src/main/java/com/phenoml/api/resources/construe/types/CrosswalkResponse.java +++ b/src/main/java/com/phenoml/api/resources/construe/types/CrosswalkResponse.java @@ -182,7 +182,6 @@ public static final class Builder implements ResolvedUmlsReleaseStage, SystemSta private Optional reasonCode = Optional.empty(); - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -291,6 +290,18 @@ public CrosswalkResponse build() { return new CrosswalkResponse(resolvedUmlsRelease, system, code, reasonCode, targets, additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("resolved_umls_release") + || key.equalsIgnoreCase("system") + || key.equalsIgnoreCase("code") + || key.equalsIgnoreCase("reason_code") + || key.equalsIgnoreCase("targets")) { + return; + } + this.additionalProperties.put(key, value); + } + @java.lang.Override public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); diff --git a/src/main/java/com/phenoml/api/resources/construe/types/CrosswalkTarget.java b/src/main/java/com/phenoml/api/resources/construe/types/CrosswalkTarget.java index 54f7b7fa..38061892 100644 --- a/src/main/java/com/phenoml/api/resources/construe/types/CrosswalkTarget.java +++ b/src/main/java/com/phenoml/api/resources/construe/types/CrosswalkTarget.java @@ -136,7 +136,6 @@ public static final class Builder implements SystemStage, _FinalStage { private Optional reasonCode = Optional.empty(); - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -220,6 +219,16 @@ public CrosswalkTarget build() { return new CrosswalkTarget(system, reasonCode, matches, additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("system") + || key.equalsIgnoreCase("reason_code") + || key.equalsIgnoreCase("matches")) { + return; + } + this.additionalProperties.put(key, value); + } + @java.lang.Override public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); diff --git a/src/main/java/com/phenoml/api/resources/construe/types/DeleteCodeSystemResponse.java b/src/main/java/com/phenoml/api/resources/construe/types/DeleteCodeSystemResponse.java index 033513e4..4aa8807f 100644 --- a/src/main/java/com/phenoml/api/resources/construe/types/DeleteCodeSystemResponse.java +++ b/src/main/java/com/phenoml/api/resources/construe/types/DeleteCodeSystemResponse.java @@ -86,7 +86,6 @@ public interface _FinalStage { public static final class Builder implements MessageStage, _FinalStage { private String message; - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -113,6 +112,14 @@ public DeleteCodeSystemResponse build() { return new DeleteCodeSystemResponse(message, additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("message")) { + return; + } + this.additionalProperties.put(key, value); + } + @java.lang.Override public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); diff --git a/src/main/java/com/phenoml/api/resources/construe/types/ExportCodeSystemResponse.java b/src/main/java/com/phenoml/api/resources/construe/types/ExportCodeSystemResponse.java index 8e9b0bb4..8a1f37d1 100644 --- a/src/main/java/com/phenoml/api/resources/construe/types/ExportCodeSystemResponse.java +++ b/src/main/java/com/phenoml/api/resources/construe/types/ExportCodeSystemResponse.java @@ -140,7 +140,6 @@ public static final class Builder implements NameStage, VersionStage, _FinalStag private List codes = new ArrayList<>(); - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -215,6 +214,17 @@ public ExportCodeSystemResponse build() { return new ExportCodeSystemResponse(name, version, codes, additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("name") + || key.equalsIgnoreCase("version") + || key.equalsIgnoreCase("format") + || key.equalsIgnoreCase("codes")) { + return; + } + this.additionalProperties.put(key, value); + } + @java.lang.Override public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); diff --git a/src/main/java/com/phenoml/api/resources/construe/types/ExtractCodesResult.java b/src/main/java/com/phenoml/api/resources/construe/types/ExtractCodesResult.java index 73601d00..7184b01b 100644 --- a/src/main/java/com/phenoml/api/resources/construe/types/ExtractCodesResult.java +++ b/src/main/java/com/phenoml/api/resources/construe/types/ExtractCodesResult.java @@ -100,7 +100,6 @@ public static final class Builder implements SystemStage, _FinalStage { private List codes = new ArrayList<>(); - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -148,6 +147,14 @@ public ExtractCodesResult build() { return new ExtractCodesResult(system, codes, additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("system") || key.equalsIgnoreCase("codes")) { + return; + } + this.additionalProperties.put(key, value); + } + @java.lang.Override public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); diff --git a/src/main/java/com/phenoml/api/resources/construe/types/ExtractRequestConfig.java b/src/main/java/com/phenoml/api/resources/construe/types/ExtractRequestConfig.java index a0200d32..f3df7def 100644 --- a/src/main/java/com/phenoml/api/resources/construe/types/ExtractRequestConfig.java +++ b/src/main/java/com/phenoml/api/resources/construe/types/ExtractRequestConfig.java @@ -120,6 +120,7 @@ public Optional getCodeSimilarityFilter() { *
    *
  • none - No validation, returns all candidate codes
  • *
  • simple - LLM-based validation
  • + *
  • semantic_mapping - LLM validation requiring equivalent meaning for an entire local clinical term, preserving negation and specificity. Disables code-to-code similarity filtering so distinct equivalent candidates remain visible
  • *
  • medication_search - LLM-based validation tailored for medication concepts
  • *
  • chunk_code_jaccard_similarity - Token-level Jaccard similarity between source text chunk and code description
  • *
@@ -288,7 +289,6 @@ public static final class Builder { private Optional consistencyEffort = Optional.empty(); - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -372,6 +372,7 @@ public Builder codeSimilarityFilter(Float codeSimilarityFilter) { *
    *
  • none - No validation, returns all candidate codes
  • *
  • simple - LLM-based validation
  • + *
  • semantic_mapping - LLM validation requiring equivalent meaning for an entire local clinical term, preserving negation and specificity. Disables code-to-code similarity filtering so distinct equivalent candidates remain visible
  • *
  • medication_search - LLM-based validation tailored for medication concepts
  • *
  • chunk_code_jaccard_similarity - Token-level Jaccard similarity between source text chunk and code description
  • *
@@ -535,6 +536,25 @@ public ExtractRequestConfig build() { additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("chunking_method") + || key.equalsIgnoreCase("max_codes_per_chunk") + || key.equalsIgnoreCase("code_similarity_filter") + || key.equalsIgnoreCase("validation_method") + || key.equalsIgnoreCase("chunk_code_jaccard_similarity_filtering_threshold") + || key.equalsIgnoreCase("include_rationale") + || key.equalsIgnoreCase("include_ancestors") + || key.equalsIgnoreCase("include_invalid") + || key.equalsIgnoreCase("include_citations") + || key.equalsIgnoreCase("extraction_context") + || key.equalsIgnoreCase("min_context_relevance") + || key.equalsIgnoreCase("consistency_effort")) { + return; + } + this.additionalProperties.put(key, value); + } + public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); return this; diff --git a/src/main/java/com/phenoml/api/resources/construe/types/ExtractRequestConfigValidationMethod.java b/src/main/java/com/phenoml/api/resources/construe/types/ExtractRequestConfigValidationMethod.java index 1ef57e93..85fdb3fd 100644 --- a/src/main/java/com/phenoml/api/resources/construe/types/ExtractRequestConfigValidationMethod.java +++ b/src/main/java/com/phenoml/api/resources/construe/types/ExtractRequestConfigValidationMethod.java @@ -7,6 +7,9 @@ import com.fasterxml.jackson.annotation.JsonValue; public final class ExtractRequestConfigValidationMethod { + public static final ExtractRequestConfigValidationMethod SEMANTIC_MAPPING = + new ExtractRequestConfigValidationMethod(Value.SEMANTIC_MAPPING, "semantic_mapping"); + public static final ExtractRequestConfigValidationMethod SIMPLE = new ExtractRequestConfigValidationMethod(Value.SIMPLE, "simple"); @@ -53,6 +56,8 @@ public int hashCode() { public T visit(Visitor visitor) { switch (value) { + case SEMANTIC_MAPPING: + return visitor.visitSemanticMapping(); case SIMPLE: return visitor.visitSimple(); case MEDICATION_SEARCH: @@ -70,6 +75,8 @@ public T visit(Visitor visitor) { @JsonCreator(mode = JsonCreator.Mode.DELEGATING) public static ExtractRequestConfigValidationMethod valueOf(String value) { switch (value) { + case "semantic_mapping": + return SEMANTIC_MAPPING; case "simple": return SIMPLE; case "medication_search": @@ -88,6 +95,8 @@ public enum Value { SIMPLE, + SEMANTIC_MAPPING, + MEDICATION_SEARCH, CHUNK_CODE_JACCARD_SIMILARITY, @@ -100,6 +109,8 @@ public interface Visitor { T visitSimple(); + T visitSemanticMapping(); + T visitMedicationSearch(); T visitChunkCodeJaccardSimilarity(); diff --git a/src/main/java/com/phenoml/api/resources/construe/types/ExtractRequestSystem.java b/src/main/java/com/phenoml/api/resources/construe/types/ExtractRequestSystem.java index fbd331e6..ba4877a5 100644 --- a/src/main/java/com/phenoml/api/resources/construe/types/ExtractRequestSystem.java +++ b/src/main/java/com/phenoml/api/resources/construe/types/ExtractRequestSystem.java @@ -102,7 +102,6 @@ public static final class Builder { private Optional version = Optional.empty(); - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -163,6 +162,14 @@ public ExtractRequestSystem build() { return new ExtractRequestSystem(name, version, additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("name") || key.equalsIgnoreCase("version")) { + return; + } + this.additionalProperties.put(key, value); + } + public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); return this; diff --git a/src/main/java/com/phenoml/api/resources/construe/types/ExtractedCodeResult.java b/src/main/java/com/phenoml/api/resources/construe/types/ExtractedCodeResult.java index 42633f84..14819beb 100644 --- a/src/main/java/com/phenoml/api/resources/construe/types/ExtractedCodeResult.java +++ b/src/main/java/com/phenoml/api/resources/construe/types/ExtractedCodeResult.java @@ -232,7 +232,6 @@ public static final class Builder implements CodeStage, DescriptionStage, ValidS private Optional reason = Optional.empty(); - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -376,6 +375,20 @@ public ExtractedCodeResult build() { code, description, valid, reason, isAncestor, citations, categories, additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("code") + || key.equalsIgnoreCase("description") + || key.equalsIgnoreCase("valid") + || key.equalsIgnoreCase("reason") + || key.equalsIgnoreCase("is_ancestor") + || key.equalsIgnoreCase("citations") + || key.equalsIgnoreCase("categories")) { + return; + } + this.additionalProperties.put(key, value); + } + @java.lang.Override public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); diff --git a/src/main/java/com/phenoml/api/resources/construe/types/FeedbackResponse.java b/src/main/java/com/phenoml/api/resources/construe/types/FeedbackResponse.java index 640dc79a..49aae646 100644 --- a/src/main/java/com/phenoml/api/resources/construe/types/FeedbackResponse.java +++ b/src/main/java/com/phenoml/api/resources/construe/types/FeedbackResponse.java @@ -86,7 +86,6 @@ public interface _FinalStage { public static final class Builder implements IdStage, _FinalStage { private String id; - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -113,6 +112,14 @@ public FeedbackResponse build() { return new FeedbackResponse(id, additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("id")) { + return; + } + this.additionalProperties.put(key, value); + } + @java.lang.Override public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); diff --git a/src/main/java/com/phenoml/api/resources/construe/types/GetCodeResponse.java b/src/main/java/com/phenoml/api/resources/construe/types/GetCodeResponse.java index 2efc2488..b3c22ecd 100644 --- a/src/main/java/com/phenoml/api/resources/construe/types/GetCodeResponse.java +++ b/src/main/java/com/phenoml/api/resources/construe/types/GetCodeResponse.java @@ -150,7 +150,6 @@ public static final class Builder implements SystemStage, CodeStage, Description private Optional definition = Optional.empty(); - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -218,6 +217,17 @@ public GetCodeResponse build() { return new GetCodeResponse(system, code, description, definition, additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("system") + || key.equalsIgnoreCase("code") + || key.equalsIgnoreCase("description") + || key.equalsIgnoreCase("definition")) { + return; + } + this.additionalProperties.put(key, value); + } + @java.lang.Override public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); diff --git a/src/main/java/com/phenoml/api/resources/construe/types/GetCodeSystemDetailResponse.java b/src/main/java/com/phenoml/api/resources/construe/types/GetCodeSystemDetailResponse.java index 936ab665..b4d3faf9 100644 --- a/src/main/java/com/phenoml/api/resources/construe/types/GetCodeSystemDetailResponse.java +++ b/src/main/java/com/phenoml/api/resources/construe/types/GetCodeSystemDetailResponse.java @@ -240,7 +240,6 @@ public static final class Builder private OffsetDateTime updatedAt; - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -345,6 +344,20 @@ public GetCodeSystemDetailResponse build() { name, version, codeCount, builtin, status, createdAt, updatedAt, additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("name") + || key.equalsIgnoreCase("version") + || key.equalsIgnoreCase("code_count") + || key.equalsIgnoreCase("builtin") + || key.equalsIgnoreCase("status") + || key.equalsIgnoreCase("created_at") + || key.equalsIgnoreCase("updated_at")) { + return; + } + this.additionalProperties.put(key, value); + } + @java.lang.Override public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); diff --git a/src/main/java/com/phenoml/api/resources/construe/types/ListCodeSystemsResponse.java b/src/main/java/com/phenoml/api/resources/construe/types/ListCodeSystemsResponse.java index 85d7b093..652995c1 100644 --- a/src/main/java/com/phenoml/api/resources/construe/types/ListCodeSystemsResponse.java +++ b/src/main/java/com/phenoml/api/resources/construe/types/ListCodeSystemsResponse.java @@ -68,7 +68,6 @@ public static Builder builder() { public static final class Builder { private List systems = new ArrayList<>(); - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -103,6 +102,14 @@ public ListCodeSystemsResponse build() { return new ListCodeSystemsResponse(systems, additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("systems")) { + return; + } + this.additionalProperties.put(key, value); + } + public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); return this; diff --git a/src/main/java/com/phenoml/api/resources/construe/types/ListCodesResponse.java b/src/main/java/com/phenoml/api/resources/construe/types/ListCodesResponse.java index f63b0752..650c65b4 100644 --- a/src/main/java/com/phenoml/api/resources/construe/types/ListCodesResponse.java +++ b/src/main/java/com/phenoml/api/resources/construe/types/ListCodesResponse.java @@ -148,7 +148,6 @@ public static final class Builder implements SystemStage, HasMoreStage, _FinalSt private List codes = new ArrayList<>(); - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -229,6 +228,17 @@ public ListCodesResponse build() { return new ListCodesResponse(system, codes, nextCursor, hasMore, additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("system") + || key.equalsIgnoreCase("codes") + || key.equalsIgnoreCase("next_cursor") + || key.equalsIgnoreCase("has_more")) { + return; + } + this.additionalProperties.put(key, value); + } + @java.lang.Override public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); diff --git a/src/main/java/com/phenoml/api/resources/construe/types/PhenocrExtractRequestSystem.java b/src/main/java/com/phenoml/api/resources/construe/types/PhenocrExtractRequestSystem.java index f6c031ce..a92a35cc 100644 --- a/src/main/java/com/phenoml/api/resources/construe/types/PhenocrExtractRequestSystem.java +++ b/src/main/java/com/phenoml/api/resources/construe/types/PhenocrExtractRequestSystem.java @@ -106,7 +106,6 @@ public static final class Builder implements NameStage, VersionStage, _FinalStag private String version; - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -145,6 +144,14 @@ public PhenocrExtractRequestSystem build() { return new PhenocrExtractRequestSystem(name, version, additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("name") || key.equalsIgnoreCase("version")) { + return; + } + this.additionalProperties.put(key, value); + } + @java.lang.Override public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); diff --git a/src/main/java/com/phenoml/api/resources/construe/types/SemanticSearchResponse.java b/src/main/java/com/phenoml/api/resources/construe/types/SemanticSearchResponse.java index cdf097cf..6171e01f 100644 --- a/src/main/java/com/phenoml/api/resources/construe/types/SemanticSearchResponse.java +++ b/src/main/java/com/phenoml/api/resources/construe/types/SemanticSearchResponse.java @@ -106,7 +106,6 @@ public static final class Builder implements SystemStage, _FinalStage { private List results = new ArrayList<>(); - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -165,6 +164,14 @@ public SemanticSearchResponse build() { return new SemanticSearchResponse(system, results, additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("system") || key.equalsIgnoreCase("results")) { + return; + } + this.additionalProperties.put(key, value); + } + @java.lang.Override public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); diff --git a/src/main/java/com/phenoml/api/resources/construe/types/SemanticSearchResult.java b/src/main/java/com/phenoml/api/resources/construe/types/SemanticSearchResult.java index 7112dddb..5196033c 100644 --- a/src/main/java/com/phenoml/api/resources/construe/types/SemanticSearchResult.java +++ b/src/main/java/com/phenoml/api/resources/construe/types/SemanticSearchResult.java @@ -94,7 +94,6 @@ public static final class Builder implements CodeStage, DescriptionStage, _Final private String description; - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -125,6 +124,14 @@ public SemanticSearchResult build() { return new SemanticSearchResult(code, description, additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("code") || key.equalsIgnoreCase("description")) { + return; + } + this.additionalProperties.put(key, value); + } + @java.lang.Override public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); diff --git a/src/main/java/com/phenoml/api/resources/construe/types/TextSearchResponse.java b/src/main/java/com/phenoml/api/resources/construe/types/TextSearchResponse.java index 8cef1ebb..e463ac4e 100644 --- a/src/main/java/com/phenoml/api/resources/construe/types/TextSearchResponse.java +++ b/src/main/java/com/phenoml/api/resources/construe/types/TextSearchResponse.java @@ -129,7 +129,6 @@ public static final class Builder implements SystemStage, FoundStage, _FinalStag private List results = new ArrayList<>(); - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -200,6 +199,14 @@ public TextSearchResponse build() { return new TextSearchResponse(system, results, found, additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("system") || key.equalsIgnoreCase("results") || key.equalsIgnoreCase("found")) { + return; + } + this.additionalProperties.put(key, value); + } + @java.lang.Override public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); diff --git a/src/main/java/com/phenoml/api/resources/construe/types/TextSearchResult.java b/src/main/java/com/phenoml/api/resources/construe/types/TextSearchResult.java index 27c59338..85bf30aa 100644 --- a/src/main/java/com/phenoml/api/resources/construe/types/TextSearchResult.java +++ b/src/main/java/com/phenoml/api/resources/construe/types/TextSearchResult.java @@ -94,7 +94,6 @@ public static final class Builder implements CodeStage, DescriptionStage, _Final private String description; - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -125,6 +124,14 @@ public TextSearchResult build() { return new TextSearchResult(code, description, additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("code") || key.equalsIgnoreCase("description")) { + return; + } + this.additionalProperties.put(key, value); + } + @java.lang.Override public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); diff --git a/src/main/java/com/phenoml/api/resources/fhir/AsyncFhirClient.java b/src/main/java/com/phenoml/api/resources/fhir/AsyncFhirClient.java index 1bd7e4f2..04fa4df9 100644 --- a/src/main/java/com/phenoml/api/resources/fhir/AsyncFhirClient.java +++ b/src/main/java/com/phenoml/api/resources/fhir/AsyncFhirClient.java @@ -4,6 +4,7 @@ package com.phenoml.api.resources.fhir; import com.phenoml.api.core.ClientOptions; +import com.phenoml.api.core.PhenomlClientHttpResponse; import com.phenoml.api.core.RequestOptions; import com.phenoml.api.resources.fhir.requests.CreateRequest; import com.phenoml.api.resources.fhir.requests.DeleteRequest; @@ -50,7 +51,15 @@ public AsyncRawFhirClient withRawResponse() { *

The request is proxied to the configured FHIR server with appropriate authentication headers.

*/ public CompletableFuture search(String fhirProviderId, String fhirPath) { - return this.rawClient.search(fhirProviderId, fhirPath).thenApply(response -> response.body()); + CompletableFuture> rawFuture = + this.rawClient.search(fhirProviderId, fhirPath); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** @@ -71,7 +80,15 @@ public CompletableFuture search(String fhirProviderId, String fhirPath) *

The request is proxied to the configured FHIR server with appropriate authentication headers.

*/ public CompletableFuture search(String fhirProviderId, String fhirPath, RequestOptions requestOptions) { - return this.rawClient.search(fhirProviderId, fhirPath, requestOptions).thenApply(response -> response.body()); + CompletableFuture> rawFuture = + this.rawClient.search(fhirProviderId, fhirPath, requestOptions); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** @@ -92,7 +109,15 @@ public CompletableFuture search(String fhirProviderId, String fhirPath, *

The request is proxied to the configured FHIR server with appropriate authentication headers.

*/ public CompletableFuture search(String fhirProviderId, String fhirPath, SearchRequest request) { - return this.rawClient.search(fhirProviderId, fhirPath, request).thenApply(response -> response.body()); + CompletableFuture> rawFuture = + this.rawClient.search(fhirProviderId, fhirPath, request); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** @@ -114,9 +139,15 @@ public CompletableFuture search(String fhirProviderId, String fhirPath, */ public CompletableFuture search( String fhirProviderId, String fhirPath, SearchRequest request, RequestOptions requestOptions) { - return this.rawClient - .search(fhirProviderId, fhirPath, request, requestOptions) - .thenApply(response -> response.body()); + CompletableFuture> rawFuture = + this.rawClient.search(fhirProviderId, fhirPath, request, requestOptions); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** @@ -124,7 +155,15 @@ public CompletableFuture search( *

The request is proxied to the configured FHIR server with appropriate authentication headers.

*/ public CompletableFuture create(String fhirProviderId, String fhirPath, Object body) { - return this.rawClient.create(fhirProviderId, fhirPath, body).thenApply(response -> response.body()); + CompletableFuture> rawFuture = + this.rawClient.create(fhirProviderId, fhirPath, body); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** @@ -133,9 +172,15 @@ public CompletableFuture create(String fhirProviderId, String fhirPath, */ public CompletableFuture create( String fhirProviderId, String fhirPath, Object body, RequestOptions requestOptions) { - return this.rawClient - .create(fhirProviderId, fhirPath, body, requestOptions) - .thenApply(response -> response.body()); + CompletableFuture> rawFuture = + this.rawClient.create(fhirProviderId, fhirPath, body, requestOptions); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** @@ -143,7 +188,15 @@ public CompletableFuture create( *

The request is proxied to the configured FHIR server with appropriate authentication headers.

*/ public CompletableFuture create(String fhirProviderId, String fhirPath, CreateRequest request) { - return this.rawClient.create(fhirProviderId, fhirPath, request).thenApply(response -> response.body()); + CompletableFuture> rawFuture = + this.rawClient.create(fhirProviderId, fhirPath, request); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** @@ -152,9 +205,15 @@ public CompletableFuture create(String fhirProviderId, String fhirPath, */ public CompletableFuture create( String fhirProviderId, String fhirPath, CreateRequest request, RequestOptions requestOptions) { - return this.rawClient - .create(fhirProviderId, fhirPath, request, requestOptions) - .thenApply(response -> response.body()); + CompletableFuture> rawFuture = + this.rawClient.create(fhirProviderId, fhirPath, request, requestOptions); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** @@ -162,7 +221,15 @@ public CompletableFuture create( *

The request is proxied to the configured FHIR server with appropriate authentication headers.

*/ public CompletableFuture upsert(String fhirProviderId, String fhirPath, Object body) { - return this.rawClient.upsert(fhirProviderId, fhirPath, body).thenApply(response -> response.body()); + CompletableFuture> rawFuture = + this.rawClient.upsert(fhirProviderId, fhirPath, body); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** @@ -171,9 +238,15 @@ public CompletableFuture upsert(String fhirProviderId, String fhirPath, */ public CompletableFuture upsert( String fhirProviderId, String fhirPath, Object body, RequestOptions requestOptions) { - return this.rawClient - .upsert(fhirProviderId, fhirPath, body, requestOptions) - .thenApply(response -> response.body()); + CompletableFuture> rawFuture = + this.rawClient.upsert(fhirProviderId, fhirPath, body, requestOptions); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** @@ -181,7 +254,15 @@ public CompletableFuture upsert( *

The request is proxied to the configured FHIR server with appropriate authentication headers.

*/ public CompletableFuture upsert(String fhirProviderId, String fhirPath, UpsertRequest request) { - return this.rawClient.upsert(fhirProviderId, fhirPath, request).thenApply(response -> response.body()); + CompletableFuture> rawFuture = + this.rawClient.upsert(fhirProviderId, fhirPath, request); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** @@ -190,9 +271,15 @@ public CompletableFuture upsert(String fhirProviderId, String fhirPath, */ public CompletableFuture upsert( String fhirProviderId, String fhirPath, UpsertRequest request, RequestOptions requestOptions) { - return this.rawClient - .upsert(fhirProviderId, fhirPath, request, requestOptions) - .thenApply(response -> response.body()); + CompletableFuture> rawFuture = + this.rawClient.upsert(fhirProviderId, fhirPath, request, requestOptions); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** @@ -200,7 +287,15 @@ public CompletableFuture upsert( *

The request is proxied to the configured FHIR server with appropriate authentication headers.

*/ public CompletableFuture delete(String fhirProviderId, String fhirPath) { - return this.rawClient.delete(fhirProviderId, fhirPath).thenApply(response -> response.body()); + CompletableFuture> rawFuture = + this.rawClient.delete(fhirProviderId, fhirPath); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** @@ -208,7 +303,15 @@ public CompletableFuture delete(String fhirProviderId, String fhirPath) *

The request is proxied to the configured FHIR server with appropriate authentication headers.

*/ public CompletableFuture delete(String fhirProviderId, String fhirPath, RequestOptions requestOptions) { - return this.rawClient.delete(fhirProviderId, fhirPath, requestOptions).thenApply(response -> response.body()); + CompletableFuture> rawFuture = + this.rawClient.delete(fhirProviderId, fhirPath, requestOptions); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** @@ -216,7 +319,15 @@ public CompletableFuture delete(String fhirProviderId, String fhirPath, *

The request is proxied to the configured FHIR server with appropriate authentication headers.

*/ public CompletableFuture delete(String fhirProviderId, String fhirPath, DeleteRequest request) { - return this.rawClient.delete(fhirProviderId, fhirPath, request).thenApply(response -> response.body()); + CompletableFuture> rawFuture = + this.rawClient.delete(fhirProviderId, fhirPath, request); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** @@ -225,9 +336,15 @@ public CompletableFuture delete(String fhirProviderId, String fhirPath, */ public CompletableFuture delete( String fhirProviderId, String fhirPath, DeleteRequest request, RequestOptions requestOptions) { - return this.rawClient - .delete(fhirProviderId, fhirPath, request, requestOptions) - .thenApply(response -> response.body()); + CompletableFuture> rawFuture = + this.rawClient.delete(fhirProviderId, fhirPath, request, requestOptions); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** @@ -247,7 +364,15 @@ public CompletableFuture delete( *

The request is proxied to the configured FHIR server with appropriate authentication headers.

*/ public CompletableFuture patch(String fhirProviderId, String fhirPath, List body) { - return this.rawClient.patch(fhirProviderId, fhirPath, body).thenApply(response -> response.body()); + CompletableFuture> rawFuture = + this.rawClient.patch(fhirProviderId, fhirPath, body); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** @@ -268,9 +393,15 @@ public CompletableFuture patch(String fhirProviderId, String fhirPath, L */ public CompletableFuture patch( String fhirProviderId, String fhirPath, List body, RequestOptions requestOptions) { - return this.rawClient - .patch(fhirProviderId, fhirPath, body, requestOptions) - .thenApply(response -> response.body()); + CompletableFuture> rawFuture = + this.rawClient.patch(fhirProviderId, fhirPath, body, requestOptions); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** @@ -290,7 +421,15 @@ public CompletableFuture patch( *

The request is proxied to the configured FHIR server with appropriate authentication headers.

*/ public CompletableFuture patch(String fhirProviderId, String fhirPath, PatchRequest request) { - return this.rawClient.patch(fhirProviderId, fhirPath, request).thenApply(response -> response.body()); + CompletableFuture> rawFuture = + this.rawClient.patch(fhirProviderId, fhirPath, request); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** @@ -311,9 +450,15 @@ public CompletableFuture patch(String fhirProviderId, String fhirPath, P */ public CompletableFuture patch( String fhirProviderId, String fhirPath, PatchRequest request, RequestOptions requestOptions) { - return this.rawClient - .patch(fhirProviderId, fhirPath, request, requestOptions) - .thenApply(response -> response.body()); + CompletableFuture> rawFuture = + this.rawClient.patch(fhirProviderId, fhirPath, request, requestOptions); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** @@ -322,7 +467,15 @@ public CompletableFuture patch( *

The request is proxied to the configured FHIR server with appropriate authentication headers.

*/ public CompletableFuture executeBundle(String fhirProviderId, Object body) { - return this.rawClient.executeBundle(fhirProviderId, body).thenApply(response -> response.body()); + CompletableFuture> rawFuture = + this.rawClient.executeBundle(fhirProviderId, body); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** @@ -331,9 +484,15 @@ public CompletableFuture executeBundle(String fhirProviderId, Object bod *

The request is proxied to the configured FHIR server with appropriate authentication headers.

*/ public CompletableFuture executeBundle(String fhirProviderId, Object body, RequestOptions requestOptions) { - return this.rawClient - .executeBundle(fhirProviderId, body, requestOptions) - .thenApply(response -> response.body()); + CompletableFuture> rawFuture = + this.rawClient.executeBundle(fhirProviderId, body, requestOptions); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** @@ -342,7 +501,15 @@ public CompletableFuture executeBundle(String fhirProviderId, Object bod *

The request is proxied to the configured FHIR server with appropriate authentication headers.

*/ public CompletableFuture executeBundle(String fhirProviderId, ExecuteBundleRequest request) { - return this.rawClient.executeBundle(fhirProviderId, request).thenApply(response -> response.body()); + CompletableFuture> rawFuture = + this.rawClient.executeBundle(fhirProviderId, request); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** @@ -352,8 +519,14 @@ public CompletableFuture executeBundle(String fhirProviderId, ExecuteBun */ public CompletableFuture executeBundle( String fhirProviderId, ExecuteBundleRequest request, RequestOptions requestOptions) { - return this.rawClient - .executeBundle(fhirProviderId, request, requestOptions) - .thenApply(response -> response.body()); + CompletableFuture> rawFuture = + this.rawClient.executeBundle(fhirProviderId, request, requestOptions); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } } diff --git a/src/main/java/com/phenoml/api/resources/fhir/AsyncRawFhirClient.java b/src/main/java/com/phenoml/api/resources/fhir/AsyncRawFhirClient.java index 6ea22108..03e12a33 100644 --- a/src/main/java/com/phenoml/api/resources/fhir/AsyncRawFhirClient.java +++ b/src/main/java/com/phenoml/api/resources/fhir/AsyncRawFhirClient.java @@ -4,8 +4,10 @@ package com.phenoml.api.resources.fhir; import com.fasterxml.jackson.core.JsonProcessingException; +import com.phenoml.api.core.BodyProperties; import com.phenoml.api.core.ClientOptions; import com.phenoml.api.core.ObjectMappers; +import com.phenoml.api.core.PathSegments; import com.phenoml.api.core.PhenomlClientApiException; import com.phenoml.api.core.PhenomlClientException; import com.phenoml.api.core.PhenomlClientHttpResponse; @@ -127,9 +129,9 @@ public CompletableFuture> search( HttpUrl.Builder httpUrl = HttpUrl.parse(this.clientOptions.environment().getUrl()) .newBuilder() .addPathSegments("fhir-provider") - .addPathSegment(fhirProviderId) + .addPathSegment(PathSegments.validate(fhirProviderId)) .addPathSegments("fhir") - .addPathSegment(fhirPath); + .addPathSegment(PathSegments.validate(fhirPath)); if (requestOptions != null) { requestOptions.getQueryParameters().forEach((_key, _value) -> { httpUrl.addQueryParameter(_key, _value); @@ -163,7 +165,8 @@ public CompletableFuture> search( .build(); } CompletableFuture> future = new CompletableFuture<>(); - client.newCall(okhttpRequest).enqueue(new Callback() { + RetryInterceptor.AsyncCall okhttpCall = RetryInterceptor.newAsyncCall(client, okhttpRequest); + okhttpCall.enqueue(new Callback() { @Override public void onResponse(@NotNull Call call, @NotNull Response response) throws IOException { try (ResponseBody responseBody = response.body()) { @@ -190,6 +193,11 @@ public void onFailure(@NotNull Call call, @NotNull IOException e) { future.completeExceptionally(new PhenomlClientException("Network error executing HTTP request", e)); } }); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + okhttpCall.cancel(); + } + }); return future; } @@ -231,9 +239,9 @@ public CompletableFuture> create( HttpUrl.Builder httpUrl = HttpUrl.parse(this.clientOptions.environment().getUrl()) .newBuilder() .addPathSegments("fhir-provider") - .addPathSegment(fhirProviderId) + .addPathSegment(PathSegments.validate(fhirProviderId)) .addPathSegments("fhir") - .addPathSegment(fhirPath); + .addPathSegment(PathSegments.validate(fhirPath)); if (requestOptions != null) { requestOptions.getQueryParameters().forEach((_key, _value) -> { httpUrl.addQueryParameter(_key, _value); @@ -242,7 +250,8 @@ public CompletableFuture> create( RequestBody body; try { body = RequestBody.create( - ObjectMappers.JSON_MAPPER.writeValueAsBytes(request.getBody()), + ObjectMappers.JSON_MAPPER.writeValueAsBytes(BodyProperties.merge( + request.getBody(), requestOptions != null ? requestOptions.getBodyProperties() : null)), MediaType.parse("application/fhir+json")); } catch (Exception e) { throw new RuntimeException(e); @@ -276,7 +285,8 @@ public CompletableFuture> create( .build(); } CompletableFuture> future = new CompletableFuture<>(); - client.newCall(okhttpRequest).enqueue(new Callback() { + RetryInterceptor.AsyncCall okhttpCall = RetryInterceptor.newAsyncCall(client, okhttpRequest); + okhttpCall.enqueue(new Callback() { @Override public void onResponse(@NotNull Call call, @NotNull Response response) throws IOException { try (ResponseBody responseBody = response.body()) { @@ -303,6 +313,11 @@ public void onFailure(@NotNull Call call, @NotNull IOException e) { future.completeExceptionally(new PhenomlClientException("Network error executing HTTP request", e)); } }); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + okhttpCall.cancel(); + } + }); return future; } @@ -344,9 +359,9 @@ public CompletableFuture> upsert( HttpUrl.Builder httpUrl = HttpUrl.parse(this.clientOptions.environment().getUrl()) .newBuilder() .addPathSegments("fhir-provider") - .addPathSegment(fhirProviderId) + .addPathSegment(PathSegments.validate(fhirProviderId)) .addPathSegments("fhir") - .addPathSegment(fhirPath); + .addPathSegment(PathSegments.validate(fhirPath)); if (requestOptions != null) { requestOptions.getQueryParameters().forEach((_key, _value) -> { httpUrl.addQueryParameter(_key, _value); @@ -355,7 +370,8 @@ public CompletableFuture> upsert( RequestBody body; try { body = RequestBody.create( - ObjectMappers.JSON_MAPPER.writeValueAsBytes(request.getBody()), + ObjectMappers.JSON_MAPPER.writeValueAsBytes(BodyProperties.merge( + request.getBody(), requestOptions != null ? requestOptions.getBodyProperties() : null)), MediaType.parse("application/fhir+json")); } catch (Exception e) { throw new RuntimeException(e); @@ -389,7 +405,8 @@ public CompletableFuture> upsert( .build(); } CompletableFuture> future = new CompletableFuture<>(); - client.newCall(okhttpRequest).enqueue(new Callback() { + RetryInterceptor.AsyncCall okhttpCall = RetryInterceptor.newAsyncCall(client, okhttpRequest); + okhttpCall.enqueue(new Callback() { @Override public void onResponse(@NotNull Call call, @NotNull Response response) throws IOException { try (ResponseBody responseBody = response.body()) { @@ -416,6 +433,11 @@ public void onFailure(@NotNull Call call, @NotNull IOException e) { future.completeExceptionally(new PhenomlClientException("Network error executing HTTP request", e)); } }); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + okhttpCall.cancel(); + } + }); return future; } @@ -454,9 +476,9 @@ public CompletableFuture> delete( HttpUrl.Builder httpUrl = HttpUrl.parse(this.clientOptions.environment().getUrl()) .newBuilder() .addPathSegments("fhir-provider") - .addPathSegment(fhirProviderId) + .addPathSegment(PathSegments.validate(fhirProviderId)) .addPathSegments("fhir") - .addPathSegment(fhirPath); + .addPathSegment(PathSegments.validate(fhirPath)); if (requestOptions != null) { requestOptions.getQueryParameters().forEach((_key, _value) -> { httpUrl.addQueryParameter(_key, _value); @@ -464,7 +486,10 @@ public CompletableFuture> delete( } Request.Builder _requestBuilder = new Request.Builder() .url(httpUrl.build()) - .method("DELETE", null) + .method( + "DELETE", + BodyProperties.toRequestBody( + requestOptions != null ? requestOptions.getBodyProperties() : null, null)) .headers(Headers.of(clientOptions.headers(requestOptions))) .addHeader("Accept", "application/json"); if (request.getPhenomlOnBehalfOf().isPresent()) { @@ -490,7 +515,8 @@ public CompletableFuture> delete( .build(); } CompletableFuture> future = new CompletableFuture<>(); - client.newCall(okhttpRequest).enqueue(new Callback() { + RetryInterceptor.AsyncCall okhttpCall = RetryInterceptor.newAsyncCall(client, okhttpRequest); + okhttpCall.enqueue(new Callback() { @Override public void onResponse(@NotNull Call call, @NotNull Response response) throws IOException { try (ResponseBody responseBody = response.body()) { @@ -517,6 +543,11 @@ public void onFailure(@NotNull Call call, @NotNull IOException e) { future.completeExceptionally(new PhenomlClientException("Network error executing HTTP request", e)); } }); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + okhttpCall.cancel(); + } + }); return future; } @@ -604,9 +635,9 @@ public CompletableFuture> patch( HttpUrl.Builder httpUrl = HttpUrl.parse(this.clientOptions.environment().getUrl()) .newBuilder() .addPathSegments("fhir-provider") - .addPathSegment(fhirProviderId) + .addPathSegment(PathSegments.validate(fhirProviderId)) .addPathSegments("fhir") - .addPathSegment(fhirPath); + .addPathSegment(PathSegments.validate(fhirPath)); if (requestOptions != null) { requestOptions.getQueryParameters().forEach((_key, _value) -> { httpUrl.addQueryParameter(_key, _value); @@ -615,7 +646,8 @@ public CompletableFuture> patch( RequestBody body; try { body = RequestBody.create( - ObjectMappers.JSON_MAPPER.writeValueAsBytes(request.getBody()), + ObjectMappers.JSON_MAPPER.writeValueAsBytes(BodyProperties.merge( + request.getBody(), requestOptions != null ? requestOptions.getBodyProperties() : null)), MediaType.parse("application/json-patch+json")); } catch (Exception e) { throw new RuntimeException(e); @@ -649,7 +681,8 @@ public CompletableFuture> patch( .build(); } CompletableFuture> future = new CompletableFuture<>(); - client.newCall(okhttpRequest).enqueue(new Callback() { + RetryInterceptor.AsyncCall okhttpCall = RetryInterceptor.newAsyncCall(client, okhttpRequest); + okhttpCall.enqueue(new Callback() { @Override public void onResponse(@NotNull Call call, @NotNull Response response) throws IOException { try (ResponseBody responseBody = response.body()) { @@ -676,6 +709,11 @@ public void onFailure(@NotNull Call call, @NotNull IOException e) { future.completeExceptionally(new PhenomlClientException("Network error executing HTTP request", e)); } }); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + okhttpCall.cancel(); + } + }); return future; } @@ -720,7 +758,7 @@ public CompletableFuture> executeBundle( HttpUrl.Builder httpUrl = HttpUrl.parse(this.clientOptions.environment().getUrl()) .newBuilder() .addPathSegments("fhir-provider") - .addPathSegment(fhirProviderId) + .addPathSegment(PathSegments.validate(fhirProviderId)) .addPathSegments("fhir"); if (requestOptions != null) { requestOptions.getQueryParameters().forEach((_key, _value) -> { @@ -730,7 +768,8 @@ public CompletableFuture> executeBundle( RequestBody body; try { body = RequestBody.create( - ObjectMappers.JSON_MAPPER.writeValueAsBytes(request.getBody()), + ObjectMappers.JSON_MAPPER.writeValueAsBytes(BodyProperties.merge( + request.getBody(), requestOptions != null ? requestOptions.getBodyProperties() : null)), MediaType.parse("application/fhir+json")); } catch (Exception e) { throw new RuntimeException(e); @@ -764,7 +803,8 @@ public CompletableFuture> executeBundle( .build(); } CompletableFuture> future = new CompletableFuture<>(); - client.newCall(okhttpRequest).enqueue(new Callback() { + RetryInterceptor.AsyncCall okhttpCall = RetryInterceptor.newAsyncCall(client, okhttpRequest); + okhttpCall.enqueue(new Callback() { @Override public void onResponse(@NotNull Call call, @NotNull Response response) throws IOException { try (ResponseBody responseBody = response.body()) { @@ -791,6 +831,11 @@ public void onFailure(@NotNull Call call, @NotNull IOException e) { future.completeExceptionally(new PhenomlClientException("Network error executing HTTP request", e)); } }); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + okhttpCall.cancel(); + } + }); return future; } } diff --git a/src/main/java/com/phenoml/api/resources/fhir/RawFhirClient.java b/src/main/java/com/phenoml/api/resources/fhir/RawFhirClient.java index d1e63bb8..acc74acb 100644 --- a/src/main/java/com/phenoml/api/resources/fhir/RawFhirClient.java +++ b/src/main/java/com/phenoml/api/resources/fhir/RawFhirClient.java @@ -4,8 +4,10 @@ package com.phenoml.api.resources.fhir; import com.fasterxml.jackson.core.JsonProcessingException; +import com.phenoml.api.core.BodyProperties; import com.phenoml.api.core.ClientOptions; import com.phenoml.api.core.ObjectMappers; +import com.phenoml.api.core.PathSegments; import com.phenoml.api.core.PhenomlClientApiException; import com.phenoml.api.core.PhenomlClientException; import com.phenoml.api.core.PhenomlClientHttpResponse; @@ -122,9 +124,9 @@ public PhenomlClientHttpResponse search( HttpUrl.Builder httpUrl = HttpUrl.parse(this.clientOptions.environment().getUrl()) .newBuilder() .addPathSegments("fhir-provider") - .addPathSegment(fhirProviderId) + .addPathSegment(PathSegments.validate(fhirProviderId)) .addPathSegments("fhir") - .addPathSegment(fhirPath); + .addPathSegment(PathSegments.validate(fhirPath)); if (requestOptions != null) { requestOptions.getQueryParameters().forEach((_key, _value) -> { httpUrl.addQueryParameter(_key, _value); @@ -210,9 +212,9 @@ public PhenomlClientHttpResponse create( HttpUrl.Builder httpUrl = HttpUrl.parse(this.clientOptions.environment().getUrl()) .newBuilder() .addPathSegments("fhir-provider") - .addPathSegment(fhirProviderId) + .addPathSegment(PathSegments.validate(fhirProviderId)) .addPathSegments("fhir") - .addPathSegment(fhirPath); + .addPathSegment(PathSegments.validate(fhirPath)); if (requestOptions != null) { requestOptions.getQueryParameters().forEach((_key, _value) -> { httpUrl.addQueryParameter(_key, _value); @@ -221,7 +223,8 @@ public PhenomlClientHttpResponse create( RequestBody body; try { body = RequestBody.create( - ObjectMappers.JSON_MAPPER.writeValueAsBytes(request.getBody()), + ObjectMappers.JSON_MAPPER.writeValueAsBytes(BodyProperties.merge( + request.getBody(), requestOptions != null ? requestOptions.getBodyProperties() : null)), MediaType.parse("application/fhir+json")); } catch (Exception e) { throw new RuntimeException(e); @@ -307,9 +310,9 @@ public PhenomlClientHttpResponse upsert( HttpUrl.Builder httpUrl = HttpUrl.parse(this.clientOptions.environment().getUrl()) .newBuilder() .addPathSegments("fhir-provider") - .addPathSegment(fhirProviderId) + .addPathSegment(PathSegments.validate(fhirProviderId)) .addPathSegments("fhir") - .addPathSegment(fhirPath); + .addPathSegment(PathSegments.validate(fhirPath)); if (requestOptions != null) { requestOptions.getQueryParameters().forEach((_key, _value) -> { httpUrl.addQueryParameter(_key, _value); @@ -318,7 +321,8 @@ public PhenomlClientHttpResponse upsert( RequestBody body; try { body = RequestBody.create( - ObjectMappers.JSON_MAPPER.writeValueAsBytes(request.getBody()), + ObjectMappers.JSON_MAPPER.writeValueAsBytes(BodyProperties.merge( + request.getBody(), requestOptions != null ? requestOptions.getBodyProperties() : null)), MediaType.parse("application/fhir+json")); } catch (Exception e) { throw new RuntimeException(e); @@ -402,9 +406,9 @@ public PhenomlClientHttpResponse delete( HttpUrl.Builder httpUrl = HttpUrl.parse(this.clientOptions.environment().getUrl()) .newBuilder() .addPathSegments("fhir-provider") - .addPathSegment(fhirProviderId) + .addPathSegment(PathSegments.validate(fhirProviderId)) .addPathSegments("fhir") - .addPathSegment(fhirPath); + .addPathSegment(PathSegments.validate(fhirPath)); if (requestOptions != null) { requestOptions.getQueryParameters().forEach((_key, _value) -> { httpUrl.addQueryParameter(_key, _value); @@ -412,7 +416,10 @@ public PhenomlClientHttpResponse delete( } Request.Builder _requestBuilder = new Request.Builder() .url(httpUrl.build()) - .method("DELETE", null) + .method( + "DELETE", + BodyProperties.toRequestBody( + requestOptions != null ? requestOptions.getBodyProperties() : null, null)) .headers(Headers.of(clientOptions.headers(requestOptions))) .addHeader("Accept", "application/json"); if (request.getPhenomlOnBehalfOf().isPresent()) { @@ -537,9 +544,9 @@ public PhenomlClientHttpResponse patch( HttpUrl.Builder httpUrl = HttpUrl.parse(this.clientOptions.environment().getUrl()) .newBuilder() .addPathSegments("fhir-provider") - .addPathSegment(fhirProviderId) + .addPathSegment(PathSegments.validate(fhirProviderId)) .addPathSegments("fhir") - .addPathSegment(fhirPath); + .addPathSegment(PathSegments.validate(fhirPath)); if (requestOptions != null) { requestOptions.getQueryParameters().forEach((_key, _value) -> { httpUrl.addQueryParameter(_key, _value); @@ -548,7 +555,8 @@ public PhenomlClientHttpResponse patch( RequestBody body; try { body = RequestBody.create( - ObjectMappers.JSON_MAPPER.writeValueAsBytes(request.getBody()), + ObjectMappers.JSON_MAPPER.writeValueAsBytes(BodyProperties.merge( + request.getBody(), requestOptions != null ? requestOptions.getBodyProperties() : null)), MediaType.parse("application/json-patch+json")); } catch (Exception e) { throw new RuntimeException(e); @@ -638,7 +646,7 @@ public PhenomlClientHttpResponse executeBundle( HttpUrl.Builder httpUrl = HttpUrl.parse(this.clientOptions.environment().getUrl()) .newBuilder() .addPathSegments("fhir-provider") - .addPathSegment(fhirProviderId) + .addPathSegment(PathSegments.validate(fhirProviderId)) .addPathSegments("fhir"); if (requestOptions != null) { requestOptions.getQueryParameters().forEach((_key, _value) -> { @@ -648,7 +656,8 @@ public PhenomlClientHttpResponse executeBundle( RequestBody body; try { body = RequestBody.create( - ObjectMappers.JSON_MAPPER.writeValueAsBytes(request.getBody()), + ObjectMappers.JSON_MAPPER.writeValueAsBytes(BodyProperties.merge( + request.getBody(), requestOptions != null ? requestOptions.getBodyProperties() : null)), MediaType.parse("application/fhir+json")); } catch (Exception e) { throw new RuntimeException(e); diff --git a/src/main/java/com/phenoml/api/resources/fhir/requests/CreateRequest.java b/src/main/java/com/phenoml/api/resources/fhir/requests/CreateRequest.java index 4931ec74..2457d195 100644 --- a/src/main/java/com/phenoml/api/resources/fhir/requests/CreateRequest.java +++ b/src/main/java/com/phenoml/api/resources/fhir/requests/CreateRequest.java @@ -131,7 +131,6 @@ public static final class Builder implements BodyStage, _FinalStage { private Optional phenomlOnBehalfOf = Optional.empty(); - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -198,6 +197,14 @@ public CreateRequest build() { return new CreateRequest(phenomlOnBehalfOf, phenomlFhirProvider, body, additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("body")) { + return; + } + this.additionalProperties.put(key, value); + } + @java.lang.Override public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); diff --git a/src/main/java/com/phenoml/api/resources/fhir/requests/DeleteRequest.java b/src/main/java/com/phenoml/api/resources/fhir/requests/DeleteRequest.java index b65c6819..b3d88cfc 100644 --- a/src/main/java/com/phenoml/api/resources/fhir/requests/DeleteRequest.java +++ b/src/main/java/com/phenoml/api/resources/fhir/requests/DeleteRequest.java @@ -87,7 +87,6 @@ public static final class Builder { private Optional phenomlFhirProvider = Optional.empty(); - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -130,6 +129,11 @@ public DeleteRequest build() { return new DeleteRequest(phenomlOnBehalfOf, phenomlFhirProvider, additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + this.additionalProperties.put(key, value); + } + public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); return this; diff --git a/src/main/java/com/phenoml/api/resources/fhir/requests/ExecuteBundleRequest.java b/src/main/java/com/phenoml/api/resources/fhir/requests/ExecuteBundleRequest.java index 4f2e1fbe..226cc40e 100644 --- a/src/main/java/com/phenoml/api/resources/fhir/requests/ExecuteBundleRequest.java +++ b/src/main/java/com/phenoml/api/resources/fhir/requests/ExecuteBundleRequest.java @@ -131,7 +131,6 @@ public static final class Builder implements BodyStage, _FinalStage { private Optional phenomlOnBehalfOf = Optional.empty(); - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -198,6 +197,14 @@ public ExecuteBundleRequest build() { return new ExecuteBundleRequest(phenomlOnBehalfOf, phenomlFhirProvider, body, additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("body")) { + return; + } + this.additionalProperties.put(key, value); + } + @java.lang.Override public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); diff --git a/src/main/java/com/phenoml/api/resources/fhir/requests/PatchRequest.java b/src/main/java/com/phenoml/api/resources/fhir/requests/PatchRequest.java index c3b09b21..a3354da7 100644 --- a/src/main/java/com/phenoml/api/resources/fhir/requests/PatchRequest.java +++ b/src/main/java/com/phenoml/api/resources/fhir/requests/PatchRequest.java @@ -108,7 +108,6 @@ public static final class Builder { private List body = new ArrayList<>(); - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -176,6 +175,14 @@ public PatchRequest build() { return new PatchRequest(phenomlOnBehalfOf, phenomlFhirProvider, body, additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("body")) { + return; + } + this.additionalProperties.put(key, value); + } + public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); return this; diff --git a/src/main/java/com/phenoml/api/resources/fhir/requests/SearchRequest.java b/src/main/java/com/phenoml/api/resources/fhir/requests/SearchRequest.java index 2d7aa009..e57dd66e 100644 --- a/src/main/java/com/phenoml/api/resources/fhir/requests/SearchRequest.java +++ b/src/main/java/com/phenoml/api/resources/fhir/requests/SearchRequest.java @@ -87,7 +87,6 @@ public static final class Builder { private Optional phenomlFhirProvider = Optional.empty(); - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -130,6 +129,11 @@ public SearchRequest build() { return new SearchRequest(phenomlOnBehalfOf, phenomlFhirProvider, additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + this.additionalProperties.put(key, value); + } + public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); return this; diff --git a/src/main/java/com/phenoml/api/resources/fhir/requests/UpsertRequest.java b/src/main/java/com/phenoml/api/resources/fhir/requests/UpsertRequest.java index a0655965..c12799ec 100644 --- a/src/main/java/com/phenoml/api/resources/fhir/requests/UpsertRequest.java +++ b/src/main/java/com/phenoml/api/resources/fhir/requests/UpsertRequest.java @@ -131,7 +131,6 @@ public static final class Builder implements BodyStage, _FinalStage { private Optional phenomlOnBehalfOf = Optional.empty(); - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -198,6 +197,14 @@ public UpsertRequest build() { return new UpsertRequest(phenomlOnBehalfOf, phenomlFhirProvider, body, additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("body")) { + return; + } + this.additionalProperties.put(key, value); + } + @java.lang.Override public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); diff --git a/src/main/java/com/phenoml/api/resources/fhir/types/PatchRequestBodyItem.java b/src/main/java/com/phenoml/api/resources/fhir/types/PatchRequestBodyItem.java index 268eb796..c56215d2 100644 --- a/src/main/java/com/phenoml/api/resources/fhir/types/PatchRequestBodyItem.java +++ b/src/main/java/com/phenoml/api/resources/fhir/types/PatchRequestBodyItem.java @@ -153,7 +153,6 @@ public static final class Builder implements OpStage, PathStage, _FinalStage { private Optional value = Optional.empty(); - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -234,6 +233,17 @@ public PatchRequestBodyItem build() { return new PatchRequestBodyItem(op, path, value, from, additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("op") + || key.equalsIgnoreCase("path") + || key.equalsIgnoreCase("value") + || key.equalsIgnoreCase("from")) { + return; + } + this.additionalProperties.put(key, value); + } + @java.lang.Override public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); diff --git a/src/main/java/com/phenoml/api/resources/fhir2omop/AsyncFhir2OmopClient.java b/src/main/java/com/phenoml/api/resources/fhir2omop/AsyncFhir2OmopClient.java index 9a913c63..cf732e21 100644 --- a/src/main/java/com/phenoml/api/resources/fhir2omop/AsyncFhir2OmopClient.java +++ b/src/main/java/com/phenoml/api/resources/fhir2omop/AsyncFhir2OmopClient.java @@ -4,6 +4,7 @@ package com.phenoml.api.resources.fhir2omop; import com.phenoml.api.core.ClientOptions; +import com.phenoml.api.core.PhenomlClientHttpResponse; import com.phenoml.api.core.RequestOptions; import com.phenoml.api.resources.fhir2omop.requests.CreateOmopRequest; import com.phenoml.api.resources.fhir2omop.types.CreateOmopResponse; @@ -28,11 +29,42 @@ public AsyncRawFhir2OmopClient withRawResponse() { /** * Maps a FHIR R4 resource or Bundle into OMOP Common Data Model v5.4 rows, - * grouped by destination table in tables. + * grouped by destination table in tables. Uncertain-status and unresolved- + * primary-concept clinical rows stay in those tables and are identified by + * the optional rows_for_review index for consumer review and filtering. + * Status-based retention covers uncertain supplied Condition verification, + * unknown/missing/unreadable Encounter status, and explicit unknown status + * on MedicationStatement, Observation, and Procedure. Other status exclusions + * follow the resource-specific eligibility policy below. *

Set vocab_version to select the OMOP vocabulary release used for coded - * concept resolution. If omitted or empty, the API uses its default - * release. The response's vocab_version, when present, identifies the - * release used. Specify a release explicitly when reproducibility matters.

+ * and inferred concept resolution. If omitted or empty, the API uses its + * default release. The response's vocab_version, when present, identifies + * the release used. Specify a release explicitly when reproducibility matters.

+ *

Supported concept fields use semantic mapping for text-only inputs and + * unresolved source codings, with whole-term equivalence validation. + * CodeableConcept text takes precedence over coding display. A single + * equivalent suggestion must be validated before a real OMOP concept ID is + * returned. The suggested vocabulary/code must + * itself be an active standard concept valid for the destination; broader + * crosswalk targets are not accepted for inferred suggestions. + * Deterministic mappings take precedence; ambiguous or domain-mismatched + * deterministic results are not reinterpreted. Units, routes, visits, + * demographics, and collection sites use their field-specific resolution + * policies. Semantic mapping returns MAPPED with a real concept ID on + * success, or UNMAPPED with concept ID 0 when no standard concept is + * selected. A bare code is not treated as a description. No source coding + * is invented.

+ *

Contextual allergy finding decisions follow their dedicated policy and + * are not replaced by description-based mapping.

+ *

Every inferred mapping has + * mappings.resolution.semantic_mapping.status: MAPPED, with the OMOP + * vocabulary release. Filter linked rows using the mapping's table/row ID + * to exclude inferred results. + * Original source coding and clinical-status eligibility are preserved. + * For clinical rows covered by rows_for_review, successful inference + * does not remove uncertain-status rows from the index; unresolved primary + * concepts also remain indexed. + * All shaped rows appear once in tables.

*

Standards basis: FHIR R4 (v4.0.1) defines * the accepted source elements and OMOP CDM * v5.4 defines the @@ -55,46 +87,262 @@ public AsyncRawFhir2OmopClient withRawResponse() { * first address can produce location, and more than one supplied race * produces observation race rows (see Patient demographics below) *

  • observation_period -> one request-local derived row per person, - * spanning the populated dates of that person's visit, clinical, and - * death rows; this is not enrollment or capture-completeness evidence
  • + * spanning dates from that person's visit and clinical rows not listed + * in rows_for_review, plus Specimen and Death rows. Source end dates + * contribute even when the output table has no end column. No period + * row is returned for a person without qualifying dates; this is not + * enrollment or capture-completeness evidence *
  • Location -> location and care_site
  • *
  • Organization -> care_site; its first address can produce location
  • *
  • HealthcareService -> care_site
  • *
  • Practitioner and PractitionerRole -> provider
  • *
  • Encounter -> visit_occurrence
  • - *
  • Condition -> condition_occurrence
  • - *
  • Procedure -> procedure_occurrence
  • - *
  • MedicationRequest, MedicationStatement, and - * MedicationAdministration -> drug_exposure
  • - *
  • Immunization -> drug_exposure
  • - *
  • Observation -> measurement or observation. For coded - * Observations, the resolved OMOP concept domain selects the table; value - * form only breaks ties. For text-only Observations, numeric values route - * to measurement and nonnumeric values to observation.
  • - *
  • AllergyIntolerance -> observation
  • + *
  • Specimen -> specimen, with an explicit Patient subject; missing + * collection date or material retains a partial row with diagnostics
  • + *
  • DeviceUseStatement -> device_exposure, using referenced Device.type
  • + *
  • Condition -> domain-selected clinical table; default condition_occurrence
  • + *
  • Procedure -> domain-selected clinical table; default procedure_occurrence
  • + *
  • MedicationRequest, MedicationStatement, MedicationAdministration, and + * MedicationDispense -> domain-selected clinical table; default drug_exposure
  • + *
  • Immunization -> domain-selected clinical table; default drug_exposure
  • + *
  • Observation -> domain-selected clinical table. Numeric values prefer + * measurement and other values prefer observation when the code is + * valid for both tables. + * If no supported standard concept is selected, numeric values retain the + * measurement default and nonnumeric values retain observation.
  • + *
  • AllergyIntolerance -> observation for supported contextual + * allergy/intolerance mappings; other coded statements use domain-based + * placement excluding drug_exposure and device_exposure; default observation
  • * + *

    Clinical placement follows the informative Vulcan IG's vocabulary-driven + * domain assignment. Supported destinations are condition_occurrence, + * procedure_occurrence, drug_exposure, device_exposure, measurement, and observation. + * Resolved clinical records are returned in the table corresponding to the + * domain of their mapped OMOP standard concept. The source resource + * determines eligibility, field values, and the default table when no + * supported mapping is available. + * AllergyIntolerance records do not produce drug_exposure or + * device_exposure rows. Device placement requires eligible use evidence: + * prescriptions, uncertain diagnoses, and explicit negative Observation + * results (false, SNOMED Negative, Known absent, or No, or interpretation + * code NEG from + * http://terminology.hl7.org/CodeSystem/v3-ObservationInterpretation) + * do not accept Device placement, even when other supplied values are positive. + * A recorder, asserter, or information source is retained as context rather + * than treated as the device event's provider.

    + *

    Coded AllergyIntolerance.code statements are interpreted using their + * original codings, optional type, and category. Supported substances + * produce an Observation finding separately from the causative substance + * in value_as_concept_id. Drug-domain substances are valid values and do + * not imply drug exposure or medication allergy. The finding is selected + * by an explicit mapping policy, not a published substance-to-finding + * crosswalk. Contextual findings report MAPPED, + * resolution.via: CONTEXTUAL. observation_source_concept_id retains + * the known OMOP identity of the original coding written to + * observation_source_value, including when the finding is unresolved; + * it is 0 when no source identity is known. The configured finding is + * never substituted for the original source concept.

    + *

    This adapts the informative R5 Vulcan Value-as-Concept + * pattern + * to R4's substance/product codes, optional type, and repeating category. + * It selects a contextual finding and resolves the substance separately; + * published Maps to value decomposition of a composite finding is not performed.

    + *

    For type: allergy, medication (optionally with biologic) maps to + * Allergy to drug (439224); other category sets use Allergic disposition + * (43530807). For type: intolerance, medication (optionally biologic) + * uses Intolerance to drug (4240582), food alone uses Intolerance to food + * (4340252), and other sets use Intolerance to substance (36684107). + * All selected concepts must be active and standard in the requested + * vocabulary release. Limited reviewed substance mappings can derive an + * absent category: for example, peanut and egg extracts supply food context, and + * Penicillin G supplies medication context. No category is inferred solely + * from Drug-domain membership.

    + *

    With absent type, a supported substance maps to a mechanism-neutral + * propensity finding: medication (optionally with biologic) uses Propensity + * to adverse reactions to drug (4305025), food alone uses Propensity to + * adverse reactions to food (4303802), and other category sets or an + * unknown category use Propensity to adverse reaction (4172024). The + * original type remains absent; no allergy or intolerance mechanism is + * inferred. The substance value is resolved independently. Existing + * supported findings can supply their own mechanism and negative + * statements retain their meaning. Conflicting evidence leaves the + * affected concept unmapped. + * A single identified substance can still populate the value when only + * its category context conflicts. Distinct conflicting substances, + * positive/negative mixtures, finding/substance mixtures, and multiple + * standard substance targets do not select a substance value by input + * order. Equivalent supported translations may share one value; + * unresolved translations are disclosed and are not used as substances. + * Finding-only, value-only, and neither-resolved partial rows are retained.

    + *

    mappings.resolution.allergy retains supplied type/categories, coded + * clinical/verification statuses, the date-source element, per-coding + * interpretation, derived categories, and reported information loss. The + * finding and substance have separate mappings linked to the same row and + * fields. Broader findings retain source specificity in this metadata. + * Codes outside contextual policy coverage retain existing domain-based + * mapping, with the contextual coverage outcome reported separately. + * Text-only inputs retain their text-only behavior. Reaction-substance + * fallback and published Maps to value relationships are not applied. + * Null placeholders in repeating FHIR category primitives carry no category + * value and are omitted from category interpretation and audit values. + * An invalid supplied type/category drops only the affected AllergyIntolerance + * before concept resolution. Other resources are still processed. The + * dropped entry identifies the resource and reports + * allergy_type_unrecognized or allergy_category_unrecognized.

    + *

    For example, a Condition coded with ICD10CM Z79.4 can produce a mapped + * observation row. Z86.73 and ICD-O-3 8140/3 also map to concepts in + * the Observation domain. Use mappings.omop_table and mappings.omop_id + * to locate each result; the FHIR resource type alone does not determine + * the output table.

    + *

    Clinical rows include available person/visit/provider links, concept and + * source concept IDs, source value, source type provenance, and start + * timing. Dates follow the source resource's timing rules below, regardless + * of the output table. For example, a Condition follows its + * onset[x] / recordedDate timing rule even when returned as an + * observation. End dates are populated only in tables with end columns; + * for rows not listed in rows_for_review, they also contribute to the + * request-local observation period when the output table has no end column.

    + *

    Source-specific fields are populated only for the corresponding source + * and destination: Condition status in condition_occurrence, medication + * dosage/supply and Immunization lot details in drug_exposure, and + * Observation values/units/ranges in measurement or observation where + * those columns exist. Mapping notes explain the output table and which + * fields are populated. Coded medication/immunization routes on rows outside + * drug_exposure, and Observation values on rows outside measurement / + * observation, are retained in mappings linked to the output row, with + * omop_field omitted and selected: false. Coded routes are checked + * against the OMOP Route domain; those Observation values are preserved + * without concept resolution.

    + *

    Note destinations are unsupported. If no standard concept + * valid for a supported destination is found, the resource produces a row + * in its default table with concept ID 0 and UNMAPPED status. Its + * source concept ID is also 0, except that allergy rows retain a known + * original source identity from their coding evidence. A DOMAIN_MISMATCH + * outcome is identified in the mapping + * note. Text-only clinical inputs can also produce rows in another supported + * table when semantic mapping selects a standard concept valid for that destination; + * without a validated match, they use their default tables. Domain-routed + * clinical records produce one row each even when their code has multiple + * vocabulary mapping targets; DeviceUseStatement event granularity is + * described below. Vocabulary Maps to value mappings are not applied.

    *

    Medication is reference data for medication resources; it does not * create its own row because OMOP CDM has no Medication table. Administrative * linkages (provider, care site, and location) are best-effort and limited to * references supplied in the request. Patient.managingOrganization is a - * record custodian, not a care-delivery site. Provider specialty is not - * mapped. Recorded Practitioner.gender is distinct from Person + * record custodian, not a care-delivery site. Provider specialties resolve + * independently to standard Provider concepts. Recorded Practitioner.gender + * is distinct from Person * demographics: male and female resolve to validated OMOP Gender * concepts in provider.gender_concept_id; other, unknown, and absent * gender remain unmapped. Address.country is resolved to location.country_concept_id, * and CMS Place of Service codings in Location.type are resolved to * care_site.place_of_service_concept_id. + * Within one request, Practitioners with the same single distinct supplied + * NPI value merge into one provider row only when all supplied mapped identity + * attributes in the entire NPI group agree. The NPI system may be + * http://hl7.org/fhir/sid/us-npi (preferred in FHIR R4), + * urn:oid:2.16.840.1.113883.4.6, or bare 2.16.840.1.113883.4.6; + * these forms are equivalent for matching. OID forms are accepted for compatibility. + * The same matching rule applies to Practitioners contained in valid role + * references. NPI values are compared exactly as supplied, without format + * or checksum validation. Blank values and unknown markers UNK and + * UNKNOWN are treated as missing and do not populate npi or participate + * in matching. Marker detection ignores case and surrounding whitespace; + * all other values are preserved without trimming. Other identifier systems and names + * do not trigger a merge. Missing attributes can enrich a compatible merged row. + * Compatibility compares the name rendered from each Practitioner's first + * name entry, its gender, its first non-empty DEA identifier, and its parsed + * birth year. Differences in birth month/day within one year do not conflict. + * Rendered names are compared exactly: Jane Smith and Dr. Jane Smith + * conflict even when they share an NPI. + * If any of these attributes conflict, every resource in that NPI group stays + * separate, including resources missing the conflicting attribute. Source + * attributes and unambiguous structural reference links are preserved; no + * conflicting value is selected. Each affected row has CONFLICTING + * diagnostics at name, gender, identifier, or birthDate, linked by + * omop_table: provider and omop_id. All source gender mappings link to + * their retained or merged provider row. + * Repeated copies of one NPI on a resource are allowed. A resource with + * multiple distinct NPIs stays separate, leaves npi unset, preserves its + * source-resource identity in provider_source_value, and reports a + * CONFLICTING diagnostic at identifier linked by + * omop_table: provider and omop_id. + * Row IDs are per response. The API does not merge across requests. For a + * Practitioner with one NPI, provider_source_value is that NPI, even when + * its group conflicts. Consumers can use it to deduplicate compatible + * providers across requests, but must preserve rows flagged by identity + * conflict diagnostics and check compatibility across responses. Without + * one unambiguous NPI, source-resource identity is preserved without a + * person-level deduplication guarantee. * A PractitionerRole that identifies one supplied Practitioner aliases * that canonical provider: by a top-level structural reference, a - * parent-contained #id reference, or an exact identifier.system and - * identifier.value match against a top-level Practitioner. No remote + * parent-contained #id reference, or an identifier.system and + * identifier.value match against a top-level Practitioner. Identifier + * matching uses the equivalent NPI systems and missing-value rules above; + * all other systems and all supplied identifiers match exactly. No remote * identifier lookup is performed. When Reference.type is present it must - * be Practitioner; duplicate contained IDs and identifier matches are - * ambiguous. An explicit reference that is unresolved, ambiguous, or - * unsupported retains a role-fallback provider row and is returned in - * diagnostics. provider_role_contexts preserves role-specific + * be Practitioner; duplicate contained IDs and identifier matches to + * different canonical providers are ambiguous. An explicit reference that + * is unresolved, ambiguous, or unsupported retains a role-fallback provider + * row and is returned in diagnostics. provider_role_contexts preserves role-specific * specialty and care-site context that a canonical OMOP provider row cannot * represent together.

    + *

    A role whose practitioner reference has only a blank or unknown NPI + * identifier follows the missing-identifier behavior: its fallback row has + * an UNSUPPORTED diagnostic explaining that a usable identifier or literal + * reference is required. An unambiguous literal reference still links to + * its Practitioner when the NPI is missing or unknown. Original supplied + * unknown markers remain in the role's practitioner_identifier metadata.

    + *

    Each PractitionerRole.specialty entry resolves independently using its + * supplied codings and explicit text from that entry. NUCC codings resolve + * directly; other supported codings may use the UMLS bridge. Text fallback + * is permitted when every coding has a nonblank code and a well-formed, + * unsupported system identifier. Supported systems, mixed supported and + * unsupported systems, and incomplete or malformed codings block fallback. + * A supported coding remains authoritative even when the text differs. + * Within one specialty entry, complete codings from well-formed unsupported + * systems do not block supported codings that independently resolve to the + * same standard Provider concept. Unsupported companions remain unsupported + * in the coding audit and acquire no crosswalk. Missing or malformed + * companions, failures of recognized codings, and disagreements between + * supported codings still block selection. For example, NUCC 208000000X alongside a complete local + * PEDS coding can resolve to Pediatric Medicine, with or without text + * and in either coding order. This uses the supported coding's mapping + * and source concept, rather than text fallback. + * Coding displays do not supply text fallback evidence. + * Unsupported system identifiers must be absolute URIs without embedded + * whitespace, user information, or fragments. HTTP(S) identifiers require + * a host; URNs require a namespace and a nonempty namespace-specific value. + * Bare labels such as Epic do not qualify. + * Text uses deterministic vocabulary assertions in order: standard Provider + * concept name, English OMOP synonym, then an English UMLS string on a + * CUI with a NUCC atom, then a NUCC preferred-term label with its grouping + * prefix removed. The last layer retains the entire classification and + * specialization label, including qualifications. An ambiguous layer stops + * resolution. No semantic search or ML matching is used. + * Each role's input remains visible in mappings, including repeated + * identical inputs across one provider's roles. + * specialty_concept_id is populated only when every distinct supplied + * input resolves to the same standard Provider concept. With no inputs, + * or an unresolved, ambiguous, or conflicting input, it stays 0. + * A role without a specialty contributes no input. The endpoint applies + * no frequency or hierarchy selection. Each input's outcome and matching + * route (layer, matched string, and UMLS CUI/SAB when applicable) appear + * in mappings with omop_field: specialty_concept_id. An individually + * resolved input can coexist with a provider concept of 0 when another + * input fails or disagrees. Specialty mappings have selected: false: + * the source text remains in specialty_source_value when supplied source + * values agree, and every original value remains in role contexts and mappings. + * Specialty mapping evidence includes specialty_resolution with the text + * fallback decision and each coding's independent outcome. A successful + * text fallback reports MAPPED and source concept ID 0; its local + * codings remain unsupported in that audit. For example, complete local + * codings with explicit Pediatrics text can resolve to Pediatric Medicine + * through NUCC_LABEL. Adding a different or unresolved specialty still + * keeps the provider's singular concept at 0. + * Compound phrasings without a vocabulary assertion remain unresolved. + * For example, Pulmonology stays unresolved while Pulmonologist + * resolves to Pulmonary Disease. Results depend on vocab_version.

    *

    Patient demographics:

    *
      *
    • Sex at birth, race, and ethnicity are read from these US Core @@ -158,22 +406,138 @@ public AsyncRawFhir2OmopClient withRawResponse() { * 0; and Vulcan's suggested observation rows for multiple * ethnicities are not produced.
    • *
    - *

    DiagnosticReport, ServiceRequest, CarePlan, DocumentReference, - * Composition, Specimen, DeviceUseStatement, Coverage, Claim, and - * other unsupported resource types are accepted in a Bundle but ignored: they + *

    Specimen produces a specimen row only with an explicitly resolvable + * top-level Patient subject. Missing collection dates or material retain + * useful partial rows with linked diagnostics; those rows may require + * downstream completion before loading into a strict CDM instance. + * References support Patient/id and exact Bundle + * fullUrl matches (including URNs and absolute URLs); contained, logical, + * display-only, missing, non-Patient, and unresolved subjects never use the + * sole Patient fallback. Reference.type, when supplied, must identify Patient. + * Unsafe patient attribution and ineligible status drop only that Specimen + * with dropped and diagnostics; a missing Patient in the entire request + * remains a 400. Missing material is concept ID 0 with a type diagnostic + * and no terminology mapping/summary count. Supplied unmapped material is + * also 0, with its separate vocabulary mapping outcome and retained source code. + * Material (type) resolves against the Specimen domain, coded UCUM + * collection quantity units against Unit, and collection.bodySite against + * Spec Anatomic Site. Supplied unmapped optional concepts are 0; absent + * unit and site IDs are explicit null. Text-only body sites preserve their + * source text with site concept ID 0 and an UNMAPPED mapping; they do not + * produce a text-based concept suggestion. Material descriptions follow + * the semantic-mapping policy above. Original source codes and text-only + * descriptions are preserved. + * Numeric quantity is copied without + * unit conversion; invalid or comparator-bearing quantities are omitted + * with diagnostics without dropping the row. Missing optional data is allowed. + * Collection time uses collection.collectedDateTime or + * collection.collectedPeriod.start; end-only, partial, invalid, and absent + * dates leave specimen_date/specimen_datetime omitted with linked diagnostics. + * Both collected forms together are conflicting: retain the row without + * selecting a date. Period end and receivedTime never substitute for collection + * start. specimen_source_contexts.collection_timing preserves raw dateTime, + * Period boundaries, receivedTime, and the selected source path when a date + * is populated, including partial precision and original timezone offsets. + * specimen_collection_date_missing, specimen_collection_date_partial, and + * specimen_collection_date_unusable distinguish absent, partial, and invalid + * source dates; specimen_collection_multiple_forms reports conflicting choices. + * specimen_type_concept_id: 32817 is EHR provenance, independent of material + * and availability. Status absent/available/unavailable/unsatisfactory is + * supported; entered-in-error and unrecognized statuses drop the specimen. + * specimen_source_contexts.status preserves the supplied availability + * status for emitted rows; it is omitted when no status was supplied. + * Disease status is not inferred. Source identity precedence is resource id, + * then first identifier with a nonempty value in source array order, then + * accessionIdentifier.value, then Bundle fullUrl. No identity is fabricated; + * specimen_source_contexts preserves all valued identifier system/value + * pairs, accession identity, resource id/fullUrl, and the selected source path. + * Parent/derived specimens emit independently when each has its own Patient; + * missing date/material remains partial rather than being inherited. Parent + * and Measurement-to-Specimen links are not represented. Consuming CDM + * loaders own required-date completion, durable IDs, and dataset completeness. + * Source baseline is FHIR R4 4.0.1, with supported common fields reviewed against + * US Core Specimen 7.0.0; full profile conformance is not validated here.

    + *

    Device use and exposure

    + *

    DeviceUseStatement accepts active, completed, stopped, and on-hold use; + * intended, entered-in-error, missing, and unrecognized statuses are omitted + * with source-specific diagnostics. Its subject must explicitly resolve to + * an emitted Patient. Group, contained-Patient, identifier-only, external + * fetching, and sole-Patient attribution are unsupported for this path. + * Device references support exact Type/id and Bundle fullUrl/URN matches, + * plus unambiguous contained Devices scoped to the referring statement. + * Missing, wrong-type, unresolved, or ambiguous Device details retain a + * known-patient use row with device concept 0 and diagnostics. Missing type + * evidence has no vocabulary mapping entry; supplied unmapped type evidence + * has a mapping outcome. Inventory-only Device records emit no exposure.

    + *

    timingDateTime supplies the start only; timingPeriod preserves supplied + * boundaries independently. Each timingTiming.event produces a separate + * row, including a partial row for an unusable event, with its zero-based + * event_index in device_exposure_contexts.exposures. Repeat rules without events + * retain one partial row; events plus rules produce only the event rows. + * No recurrence, continuous min/max interval, status-derived end date, or + * recordedOn fallback is inferred. Year/month precision and malformed + * individual dates leave affected columns absent. Competing timing choices + * or a provably reversed Period leave both bounds absent. Supplied precision, + * offsets, schedules, and omission/conflict reasons remain in source context + * and diagnostics. Other clinical paths retain their own timing semantics.

    + *

    device_exposure_contexts contains one entry per source record that produced + * device exposures. Its exposures array links all rows derived from that + * record by device_exposure_id and, for Timing events, event_index. Shared + * timing, device evidence, and quantity_basis appear once per source record. + * Contexts are not combined by resource id or device identity; distinct + * records remain separate even when their identifiers match or are absent.

    + *

    device_source_value uses original type/clinical coding, never inventory + * or resource identity. An unambiguous Device.udiCarrier.deviceIdentifier + * supplies unique_device_id. Competing identifiers leave it absent and are + * preserved with issuer/jurisdiction/carrier context. production_id remains + * absent; carriers, lot, serial, manufacture, and expiry evidence remain in + * device_exposure_contexts.

    + *

    DeviceUseStatement rows use device_type_concept_id 32817 (EHR), with + * provider_id, visit_occurrence_id, and visit_detail_id unset. + * Routed rows retain their source classification, explicit visit links to + * emitted same-patient visits, and compatible performer links; + * Condition/Procedure recorder/asserter and + * MedicationStatement informationSource do not supply device provider_id. + * Supplied source, encounter, context, recorder, asserter, performer, and + * informationSource values on DeviceUseStatement are retained only in + * device_exposure_contexts.

    + *

    Device exposure quantity is reported as follows:

    + *
      + *
    • When the source has no quantity, dose, rate, or numeric Observation + * result field, quantity is 1 and device_exposure_contexts.quantity_basis + * is default_absent_count.
    • + *
    • When such a field is supplied, including a null or malformed value, + * quantity is omitted and quantity_basis is unsupported_supplied_evidence. + * The original values remain in device_exposure_contexts.evidence, and + * an UNSUPPORTED diagnostic identifies each supplied field.
    • + *
    + *

    For example, an Observation with valueQuantity.value of 2 returned as a + * device exposure has no quantity; the value 2 remains in its source context. + * timingTiming.repeat.count remains in source context and does not set + * quantity. unit_concept_id and unit_source_concept_id are always null.

    + *

    Distinct eligible statements and routed clinical records remain separate, + * including overlapping use and equal Timing events. derivedFrom is retained + * as supporting context, not duplicate identity. Row counts therefore need + * not count distinct uses or physical devices. IDs are request-local exposure + * identities. Before loading a CDM dataset, callers must assign durable IDs + * and populate any required fields omitted from partial rows.

    + *

    Unsupported resource types are accepted in a Bundle but ignored: they * create no row and no dropped entry. dropped is reserved for supported * row-producing resources that cannot safely produce a positive OMOP row: * required subject/patient, clinical code/text, or medication data may be * unusable, or the resource may explicitly negate or fail the documented * clinical-event eligibility policy. A single-Patient Bundle uses the sole - * Patient only when subject/patient is absent. An explicit subject/patient + * Patient only when subject/patient is absent, except DeviceUseStatement + * and Specimen, which require explicit Patient subjects. An explicit subject/patient * reference that is unresolved, ambiguous, or unsupported drops the clinical * resource in every request scope.

    *

    Eligibility distinguishes clinical validity, performed/taken evidence, and - * administrative workflow state. Checks run before terminology resolution; - * entered-in-error and explicitly non-performed events never produce a positive - * clinical-event row, and an unrecognized required status fails closed. Condition - * requires absent or confirmed canonical-HL7 verificationStatus; clinical course is not a + * administrative workflow state. Entered-in-error and explicitly + * non-performed events never produce a positive clinical-event row, and + * unrecognized required statuses are dropped except the retained Condition and + * Encounter cases below. Condition accepts absent or confirmed canonical-HL7 + * verificationStatus without a status retention reason; uncertain or unreadable + * supplied verification is retained and indexed. Clinical course is not a * diagnosis-role mapping. Procedures accept completed or stopped events. * Medication requests are prescription evidence only: doNotPerform, drafts, * cancellations, and non-authorizing intents are dropped. Medication statements @@ -182,69 +546,142 @@ public AsyncRawFhir2OmopClient withRawResponse() { * administration also needs an effectiveDateTime or effectivePeriod.start * that supplies start evidence; a valid partial date remains an undated row. * An on-hold administration is started evidence that is temporarily paused and - * expected to continue. Immunizations require completed - * status. Observations require final, + * expected to continue. MedicationDispense accepts completed records, + * including completed partial fills. Stopped records require a valid + * whenHandedOver, including partial dates, as evidence of actual handover. + * Preparation, in-progress (ready for pickup), on-hold, cancelled, declined, + * entered-in-error, unknown, missing, and unrecognized statuses are dropped. + * A completed dispense with missing or unusable handover timing remains a + * partial row; preparation time never substitutes for handover. + * Immunizations require completed + * status. Observations accept final, * amended, or corrected status; registered, - * preliminary, cancelled, entered-in-error, unknown, and missing statuses are + * preliminary, cancelled, entered-in-error, and missing statuses are * dropped. AllergyIntolerance accepts absent, unconfirmed, or confirmed canonical-HL7 * verificationStatus as a reported allergy, but drops refuted, - * entered-in-error, and unreadable supplied verification statuses. Encounter + * entered-in-error, and unreadable supplied verification statuses. Invalid + * supplied allergy type/category also drops that resource, for both coded + * and text-only statements. Encounter * accepts arrived, triaged, in-progress, onleave, or finished status, but drops - * planned, cancelled, entered-in-error, unknown, and missing statuses. An eligible - * encounter without Period.end remains a partial visit; no end date is invented.

    - *

    Coded Observation routing is selected from the resolved OMOP concept - * domain. Numeric and nonnumeric value[x] forms establish the preferred - * target only when the code is valid for both tables. A text-only - * Observation has no resolver target, so numeric values route to - * measurement and nonnumeric values to observation. Numeric values - * populate value_as_number in the selected row; other non-coded values + * planned, cancelled, and entered-in-error statuses. Unknown, missing, and unreadable + * Encounter statuses are retained and indexed without imputation. An eligible + * encounter without Period.end remains a partial visit; no end date is invented. + * MedicationStatement, Observation, and Procedure with explicit unknown + * status are also retained and indexed in rows_for_review when otherwise + * shapeable. Missing or unreadable statuses on these three resource types + * remain dropped. Retention preserves source status and does not establish + * that an event occurred or medication was taken. Device placement still + * requires eligible use evidence; unknown-status records cannot produce + * device_exposure rows.

    + *

    The mapped OMOP concept's domain determines the clinical output table. + * For Observations, numeric and nonnumeric value[x] forms determine the + * preferred table only when the code is valid for both measurement and + * observation. Unresolved Observations retain measurement rows for + * numeric values and observation rows for other values. In these two + * tables, numeric values populate value_as_number; other non-coded values * populate value_as_string for an observation or value_source_value * for a measurement. Coded valueCodeableConcept values are resolved - * against the selected row's value_as_concept_id and use the selected + * against value_as_concept_id in these tables and use the selected * bare code in value_source_value, leaving an observation's * value_as_string empty; unmapped or target-invalid coded values remain - * 0. + * 0. Active standard concepts from any OMOP domain are valid for + * value_as_concept_id per CDM 5.4. For example, a blood-culture Measurement + * can record Staphylococcus aureus as its coded result, even though the + * organism concept belongs to OMOP's Observation domain. + * For other clinical output tables, Observation values are retained only + * in mappings, without concept resolution or an omop_field, and with + * selected: false. * Other unsupported value[x] forms and Observation components do not * populate separate converted values. A * numeric comparator (<, <=, >, >=) is represented only by a - * measurement's operator_concept_id; units remain source text and have - * unit_concept_id of 0.

    + * measurement's operator_concept_id.

    + *

    For Observations returned as measurement or observation rows, + * Quantity units resolve independently against the table's + * unit_concept_id. + * Only coded UCUM (http://unitsofmeasure.org or its + * OID 2.16.840.1.113883.6.8, bare or urn:oid:) is supported. Codes are + * case-sensitive: no spelling aliases, text-only guesses, or numeric + * conversion are applied. Coded UCUM units map to valid standard OMOP + * Unit concepts using the release selected by vocab_version. + * Mappings are direct or follow an active OMOP Maps to relationship; + * UCUM units do not use a UMLS bridge. + * Unknown codes, + * vocabulary gaps, text-only units, missing coding, and unsupported + * systems keep the row and use 0; absent units use JSON null (the + * field is present). Explicit dimensionless code 1 is a supplied unit + * and resolves or remains 0; it is never treated as absent. + * unit_source_value contains Quantity.code when supplied, otherwise + * Quantity.unit. A separate unit mappings entry preserves the original + * Quantity.system/code in source_system/source_code and the exact + * human-readable Quantity.unit in source_name, with its own outcome. + * Quantity.unit provides display text and does not affect unit matching. + * Measurement also returns nullable unit_source_concept_id, identifying + * the OMOP source concept for Quantity.system/code when available, + * otherwise 0 for a supplied unit. + * Observation has no such column. Resolving a unit does not change + * numeric values, comparators, or reference ranges. + * Supported coded UCUM units include resolution in their mappings, + * whether the unit resolves or remains unmapped: + * its outcome and reason code distinguish vocabulary gaps, target-domain + * mismatches, and ambiguity. Unresolved or ambiguous units retain the row + * with concept ID 0. Resolved mappings retain the selected concept's + * direct or crosswalk provenance. Text-only units, units with missing + * coding, and unsupported unit systems omit resolution; their note + * explains the unmapped outcome.

    + *

    For Observations returned in other clinical tables, units are not resolved + * or included as separate mapping outcomes. Device exposure unit IDs remain + * null, with supplied quantity and unit evidence retained in + * device_exposure_contexts.

    + *

    Compatibility: absent unit_concept_id values previously returned 0 + * and now return null. Coded units use their code in unit_source_value + * rather than their human-readable text; the text remains in mappings. + * The Measurement unit_source_concept_id field is new.

    *

    A standard OMOP concept_id is selected for each eligible primary clinical coding * after considering all of the resource's supplied codings. An unambiguous - * coded medication route is resolved independently to - * drug_exposure.route_concept_id. Alongside the OMOP rows grouped by + * coded medication or immunization route is checked independently against + * the OMOP Route domain. For drug_exposure rows, route_concept_id + * contains the resolved concept ID, or 0 if unmapped; for other output + * tables the route is reported only in + * mappings, without omop_field and with selected: false. + * Alongside the OMOP rows grouped by * table (tables), the response carries mappings (an entry for every * supported source coding from a resource that shaped a row, linked back to * that row; some, such * as demographic null flavors, are reported without being resolved), + * rows_for_review (an index of uncertain-status or primary-concept-0 clinical + * rows in tables, with original statuses, reasons, and source/row identity), * provider_role_contexts (source role details linked to provider rows), * dropped (resources that could not be shaped into a row), * diagnostics (explicit references that could not safely create a link, - * and conflicting or unsupported Patient demographic extensions), + * conflicting Practitioner identity attributes, conflicting or unsupported + * Patient demographic extensions, and conflicting Encounter classifications), * vocab_version (the OMOP vocabulary release codes were resolved * against), and a small summary of the resolution outcomes.

    *

    A concept_id of 0 is reported, not omitted (OMOP "no matching - * concept" semantics): it covers both a coding with no standard match - * (UNMAPPED) and an unverified suggestion for a text-only resource - * (UNCHECKED). Visit and unit concept fields currently remain 0; - * Person demographics and Provider recorded gender follow the policies - * above. Coded Observation values may populate value_as_concept_id. - * Concepts set by a fixed convention rather than terminology resolution - * are measurement operator_concept_id, set from a value comparator (<, - * <=, >, >=), and the multiple-race concepts described above. Clinical - * *_source_value fields contain the selected FHIR code (or source text - * for text-only resources). + * concept" semantics): it covers a supplied concept without a validated + * standard match (UNMAPPED). + * Supported Encounter.class or Encounter.type values + * may populate visit_concept_id. Person demographics and Provider + * recorded gender follow the policies above. + * Coded Observation values and supported allergy substances may populate + * value_as_concept_id. + * Measurement operator_concept_id represents a value comparator (<, + * <=, >, >=); multiple-race concepts follow the conventions described + * above. Clinical *_source_value fields contain the selected FHIR code + * (or source text for text-only resources). * The corresponding selected mappings entry preserves the coding system * and full source-coding provenance. * Known OID-form coding systems are accepted as either FHIR OID URNs (for * example, urn:oid:2.16.840.1.113883.6.1 for LOINC) or bare OIDs, and - * are normalized to their canonical system URLs before terminology - * resolution. mappings[].source_system reports that canonical URL, so the - * OID and URL forms produce the same mapping. An unknown OID is not - * rewritten and may be UNMAPPED. + * produce the same mappings as their canonical system URLs. Coding + * mappings report that canonical URL in source_system; Quantity unit + * mappings preserve the original Quantity.system, including OID spelling. + * An unknown OID remains unchanged in source_system and may be + * UNMAPPED. * Other *_source_value fields preserve row-specific raw source values, * such as resource identifiers, names, units, or status codes. - * MedicationRequest uses 32838 (EHR prescription) for + * MedicationRequest uses 32838 (EHR prescription) and + * MedicationDispense uses 32825 (EHR dispensing record) for * drug_type_concept_id; other current resources use 32817 (EHR). This * is a coarse provenance policy: it does not infer patient-reported, * medication-history, or other more-specific type concepts from FHIR @@ -257,8 +694,8 @@ public AsyncRawFhir2OmopClient withRawResponse() { * (YYYY-MM-DDTHH:MM:SS, with fractional seconds to microseconds when * supplied): 2024-01-15T23:30:00-05:00 becomes 2024-01-15T23:30:00. * A datetime without a timezone is read as local time. - *

  • A partial date (2024 or 2024-03) leaves both columns unset. It - * still counts as that source's value, so later sources in the list + *
  • A partial date (2024 or 2024-03) leaves both columns unset. + * It still counts as that source's value, so later sources in the list * below are not used.
  • *
  • Free-text timing (such as onsetString), Age and Range forms, and * values that are not dates are ignored, so a later source in the list @@ -268,11 +705,18 @@ public AsyncRawFhir2OmopClient withRawResponse() { * completed, and a row is not dated from another resource such as its * Encounter.
  • *
  • Valid date values never cause a request to be rejected, and the response - * does not report which date elements were unusable. An on-hold + * reports missing, partial, malformed, or conflicting timing for Specimen, + * DeviceUseStatement, and MedicationDispense in diagnostics while retaining + * partial rows for downstream load completion. Other resources do not report which + * date elements were unusable. An on-hold * MedicationAdministration is an eligibility exception: it needs a supplied - * effective start as evidence that administration began.
  • + * effective start as evidence that administration began. + * A stopped MedicationDispense similarly requires supplied handover evidence. * - *

    Timing sources, in priority order where several are listed:

    + *

    Timing sources, in priority order where several are listed. These rules + * apply regardless of the clinical output table; end dates are populated + * only where the table has an end column, and verbatim_end_date is set + * only for medication resources returned as drug_exposure rows:

    *
      *
    • Patient: birthDate sets year_of_birth, month_of_birth, and * day_of_birth from the parts it supplies; birth_datetime is not @@ -284,6 +728,12 @@ public AsyncRawFhir2OmopClient withRawResponse() { * end from abatementDateTime or abatementPeriod.end.
    • *
    • Procedure: start from performedDateTime or * performedPeriod.start; end from performedPeriod.end only.
    • + *
    • DeviceUseStatement: timingDateTime sets the start only; + * timingPeriod.start and timingPeriod.end supply independent bounds; + * each timingTiming.event produces one start-only row. A schedule + * without explicit events produces one row without dates.
    • + *
    • Specimen: collection.collectedDateTime or collection.collectedPeriod.start; + * missing day precision retains an undated specimen with diagnostics.
    • *
    • Observation: effectiveDateTime, effectivePeriod.start, or * effectiveInstant.
    • *
    • AllergyIntolerance: recordedDate, then onsetDateTime or @@ -300,21 +750,212 @@ public AsyncRawFhir2OmopClient withRawResponse() { *
    • MedicationRequest: authoredOn, the order date, sets the start; it * is not evidence of administration. No end is set, and the validity * period is not used as an exposure duration.
    • + *
    • MedicationDispense: whenHandedOver sets the start. Missing, partial, + * or invalid handover leaves the start unset with a diagnostic. Comparable + * handover/preparation values proving handover before preparation also + * leave the start unset with a CONFLICTING diagnostic. whenPrepared, + * authorizing orders, and related administrations never supply timing. + * No exposure end or verbatim_end_date is inferred, including from + * daysSupply. Raw handover/preparation values retain precision and offsets + * in medication_dispense_source_contexts.source.
    • *
    • Differences from the Vulcan maps: birth_datetime is not set from * birthDate, Condition also reads onsetPeriod.start, and * AllergyIntolerance falls back to its onset when recordedDate is * missing.
    • *
    - *

    Medication details:

    + *

    Medication details for resources returned as drug_exposure rows:

    *
      *
    • For MedicationRequest, dispenseRequest.numberOfRepeatsAllowed sets * refills and a whole-day expectedSupplyDuration sets days_supply.
    • + *
    • For MedicationDispense, daysSupply sets days_supply only for a + * positive whole number of coded UCUM d days without a supplied comparator. + * The canonical UCUM URL, bare OID, and OID URN are accepted. Other units, + * fractions, text-only units, and malformed values leave it absent with a + * diagnostic; absent daysSupply is allowed. Refills are not inherited.
    • *
    • All non-empty dosage text is preserved in sig.
    • *
    • Coded dosage routes and Immunization.route are target-validated in * the OMOP Route domain. Conflicting routes are left unset; route * codings shared by every dosage instruction identify the same route.
    • *
    • Immunization.lotNumber is preserved in lot_number.
    • *
    + *

    MedicationDispense requires an explicit subject resolving to a Patient + * supplied in the request; Groups and missing subjects are unsupported. + * Source fields are based on FHIR R4 4.0.1 and reviewed against + * US Core MedicationDispense 7.0.0. + * Full profile conformance is not validated. Although US Core requires + * handover on completed records, this API retains such incomplete records + * with missing-handover diagnostics as partial conversion output. + * Supply either a usable medicationCodeableConcept (coded or text-only), + * or a medicationReference resolving to a Medication with usable code/text. + * Both choice forms together are conflicting and drop the dispense. + * Exact relative, Bundle fullUrl, and unambiguous contained Medication + * references are supported. Unresolved references are not rescued by display + * text; missing or ambiguous medication identity drops the dispense with a + * diagnostic. A usable but unmapped drug retains concept ID 0 and its + * mapping outcome in a row indexed for review. + * context links only to a supplied Encounter for the same patient. + * The first resolvable Practitioner/PractitionerRole in performer.actor + * supplies provider_id; other actor types do not supply a provider. + * Failed explicit links have diagnostics. Authorizing prescriptions are + * checked for request-local MedicationRequest identity and patient agreement; + * failures do not discard the dispense. They never supply medication, + * instructions, refills, timing, or prescription provenance to a dispense. + * Dispense instructions alone supply sig and route; conflicting routes + * remain unset with a diagnostic and original instructions in source context. + * Orders, dispenses, and administrations emit independently, with separate + * source identity and no deduplication merely because they concern the same drug. + * Every emitted dispensing drug row has a whenHandedOver/daysSupply + * diagnostic beginning medication_dispense_end_date_absent because the + * CDM-required exposure end remains absent. Missing/partial/unusable handover + * diagnostics begin medication_dispense_handover_missing, + * medication_dispense_handover_partial, or medication_dispense_handover_unusable.

    + *

    Encounter details:

    + *
      + *
    • + *

      Encounter classifications must supply both Coding.system and + * Coding.code to map to a standard OMOP Visit concept. For HL7 + * encounter class codes with system + * http://terminology.hl7.org/CodeSystem/v3-ActCode or + * http://hl7.org/fhir/v3/ActCode, the supported mappings are:

      + *
        + *
      • IMP (inpatient encounter) -> 9201 (Inpatient Visit)
      • + *
      • AMB (ambulatory encounter) -> 9202 (Outpatient Visit)
      • + *
      • EMER (emergency encounter) -> 9203 (Emergency Room Visit)
      • + *
      • VR (virtual encounter) -> 722455 (Telehealth)
      • + *
      • HH (home health encounter) -> 581476 (Home Visit)
      • + *
      + *

      OBSENC (observation encounter) has no direct class mapping. Other + * class or type codes can be used when they have a valid standard OMOP + * Visit mapping in the selected vocabulary release. Unsupported + * classifications retain visit_concept_id of 0 unless a supported + * type supplies the Visit as described below; they never default to + * outpatient.

      + *
    • + *
    • + *

      A mapped Encounter.class determines visit_concept_id. A mapped + * type that disagrees with it produces a CONFLICTING diagnostic with + * path class/type, while the class mapping remains selected. + * If class is missing or unmapped, type supplies the Visit only when + * all mapped type classifications agree on the same standard Visit + * concept. Codings within one type CodeableConcept are alternative + * encodings of that classification. When separate type classifications + * map to different Visit concepts, no type is selected, + * visit_concept_id remains 0, and a CONFLICTING diagnostic has + * path type.

      + *
    • + *
    • + *

      visit_source_value contains the selected class or type code; + * mappings preserves its coding system and identifies which + * classification was selected. visit_source_concept_id identifies + * the selected original source coding's OMOP concept when available, + * and is 0 otherwise, including for the HL7 encounter class codes + * listed above. Each eligible Encounter produces one Visit; + * classHistory, partOf, and admission/discharge concepts are not + * mapped.

      + *
    • + *
    • + *

      Direct MedicationRequest.dispenseRequest.quantity, MedicationDispense.quantity, + * MedicationAdministration.dosage.dose, and Immunization.doseQuantity + * can set quantity under the formulation policies below. Quantities + * must be positive finite numbers with a supported system/code pair + * and no supplied comparator. Ingredient amounts and injection volumes + * use http://unitsofmeasure.org (or its bare OID or + * urn:oid:2.16.840.1.113883.6.8) with case-sensitive codes matching or + * convertible to vocabulary-backed strength units. Exact unit matches, + * including [U] for insulin and heparin strengths, are accepted without + * a unit conversion. + * When codes differ, both source and strength units must belong to the + * same supported group: mass (ug, mg, g), volume (uL, mL, L), + * or milliequivalent representations (meq, 10*-3.eq). Other conversions, + * including kg, ML, Mg, [drp], mol, mmol, %, 1, and generic + * annotations such as {vial} or {syringe}, leave quantity unset with + * unit_mismatch. Codes are not case-corrected or inferred from display text. + * The original source system is preserved in the quantity audit. + * Fixed-form counts also accept the explicit codings below. Free-text, + * local, invalid, and cross-dimensional units are not interpreted.

      + *
    • + *
    + *

    Drug quantity policies follow CDM v5.4 DRUG_EXPOSURE conventions. + * The selected drug must be a Clinical Drug or Branded Drug, with one + * active dose form and valid vocabulary evidence for the chosen policy. + * Strength-based conversions also require one ingredient strength. Supported RxNorm dose forms:

    + *
      + *
    • fixed_dose_form_count: Oral Tablet (317541), Oral Capsule + * (316965), Extended Release Oral Tablet (316945), Extended + * Release Oral Capsule (316943), Delayed Release Oral Tablet (10312), + * or Delayed Release Oral Capsule (316995). A source ingredient amount is divided + * by the single positive per-unit ingredient amount after conversion to that + * strength unit when necessary. + * Concentration-based strengths cannot derive counts from ingredient amounts. The + * result is a count of the selected dose form, limited to whole units or + * multiples of one-half or one-third. Derived counts within a relative + * tolerance of 1e-12 of an allowed fraction are normalized to that count + * to remove arithmetic roundoff; other fractions remain unsupported. + * For example, 1,000 mg for a drug + * with 500 mg per tablet produces quantity: 2 tablets (DERIVED). A source + * of 1 g is converted to 1,000 mg and produces the same count. + * Explicit counts are copied (DIRECT) when their coding matches the + * selected tablet or capsule form, including the corresponding extended- + * and delayed-release forms: TAB / CAP under + * http://terminology.hl7.org/CodeSystem/v3-orderableDrugForm, or + * {tbl} / {Capsule} under http://unitsofmeasure.org. These exact + * case-sensitive count codings identify the supplied form; they are not + * dimensionally converted as UCUM units. Direct counts do not require + * ingredient strength: combination products and missing/invalid strengths + * do not change the supplied count. Ingredient-strength gaps, including + * missing ingredient or strength-unit references, do not prevent copying + * an explicit count. Drug or dose-form evidence gaps, unclassified or + * ambiguous forms, and gaps whose relevance cannot be established still + * prevent conversion. The whole/half/third rules still apply. + * For example, 20 TAB for amoxicillin/clavulanate is 20 tablets, and 30 TAB for + * a supported tablet produces quantity: 30; CAP for that tablet is + * omitted with unit_mismatch. Generic counts (1, {Each}), other + * annotations, packs, and free-text counts are not interpreted. + * See the FHIR R4 count example.
    • + *
    • clinical_injection_ingredient_amount: Injectable Solution (316949), + * Injectable Suspension (316950), or Injection (1649574). A source + * ingredient amount is converted to the vocabulary ingredient unit when + * necessary; an exact-unit amount is copied directly. Otherwise, a source + * amount convertible to the concentration's denominator unit is converted + * and multiplied by numerator value / the effective denominator reported in + * the audit evidence. Volume conversion requires a positive finite numerator, + * dimensionally distinct validated UCUM units, a null raw denominator value, and + * evidence.strength.denominator_basis with kind: PER_UNIT and + * value: 1. A null denominator alone is not interpreted as 1. Missing, unknown, + * non-applicable, quantified, or conflicting denominator evidence does + * not support volume conversion. For example, 80 mL with 2 mg/mL + * strength and a PER_UNIT basis produces quantity: 160 mg. A supplied + * 160 mg amount for the same drug produces quantity: 160 directly. A source + * of 0.08 L converts to 80 mL before producing the same 160 mg result. + * This output is already + * the ingredient amount: do not apply the drug-dose guide's + * dose = quantity × numerator formula to these injection rows, which + * would multiply by the concentration a second time. Use + * quantity_transformations.result_unit to identify the output unit.
    • + *
    + *

    Quantified products, packs, prefilled syringes, other liquids and dose + * forms, strength-based conversions of combination products, conflicting or incomplete required evidence, and + * unsupported source quantities leave quantity unset. Other supported + * fields and the clinical row are still returned. For example, a quantity + * supplied only as value: 2, unit: tablet is omitted with + * unsupported_unit, because a free-text unit cannot establish this policy. + * Rate-only administrations and MedicationStatement dosages do not establish + * a supported total quantity and are not used for quantity conversion.

    + *

    quantity_transformations audits each supplied quantity at one of the + * three direct paths above on a resource returned as a drug_exposure row, + * including an explicit null or malformed quantity. Each entry preserves + * the source, identifies the destination row, and reports DIRECT, DERIVED, or + * OMITTED. Successful entries identify their policy, evidence, result, + * and result unit; omitted entries give a stable reason. When a source code + * differs from the strength unit, unit_conversion records the intermediate + * converted amount and target unit. Converted results are DERIVED, even + * when no further concentration arithmetic is needed. A successful conversion + * can also be reported when a later policy check omits the final quantity. + * Dose-form and unit + * concept IDs refer to the response's vocab_version. Missing quantities + * produce no entry. Resources returned in other clinical tables have no + * quantity entry. Unsupported rates and statement dosages produce no entry, + * and resources in dropped have no quantity entry or destination row.

    *

    Medication codes are resolved whether they appear inline * (medicationCodeableConcept) or via a medicationReference to a contained, * relative (Type/id), or bundle-entry (urn:uuid) Medication resource. @@ -326,11 +967,12 @@ public AsyncRawFhir2OmopClient withRawResponse() { * subject/patient reference cannot be tied to a person; or an event excluded * by eligibility — are reported under dropped rather than emitted as blank * rows. The Bundle must contain at least one Patient resource.

    - *

    Structural references resolve only to top-level resources supplied in the - * request, by Type/id or an exactly matching Bundle fullUrl (including - * urn:uuid). Contained references are supported for medication code lookup - * and PractitionerRole.practitioner enrichment; the latter also supports - * exact request-local identifier matching without a remote lookup. Every + *

    References to top-level resources supplied in the request resolve by + * Type/id or an exactly matching Bundle fullUrl (including urn:uuid). + * Parent-scoped contained references are supported for medication code lookup, + * DeviceUseStatement.device details, and PractitionerRole.practitioner + * enrichment; the latter also supports + * request-local identifier matching with the NPI rules above, without a remote lookup. Every * nonzero structural foreign key targets a row in the same response. Missing * optional links remain unset without a diagnostic; an explicit optional * reference that is unresolved, ambiguous, conflicting with the row's @@ -347,16 +989,54 @@ public AsyncRawFhir2OmopClient withRawResponse() { * their own stable keys and remap every primary and foreign key together.

    */ public CompletableFuture create(CreateOmopRequest request) { - return this.rawClient.create(request).thenApply(response -> response.body()); + CompletableFuture> rawFuture = this.rawClient.create(request); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** * Maps a FHIR R4 resource or Bundle into OMOP Common Data Model v5.4 rows, - * grouped by destination table in tables. + * grouped by destination table in tables. Uncertain-status and unresolved- + * primary-concept clinical rows stay in those tables and are identified by + * the optional rows_for_review index for consumer review and filtering. + * Status-based retention covers uncertain supplied Condition verification, + * unknown/missing/unreadable Encounter status, and explicit unknown status + * on MedicationStatement, Observation, and Procedure. Other status exclusions + * follow the resource-specific eligibility policy below. *

    Set vocab_version to select the OMOP vocabulary release used for coded - * concept resolution. If omitted or empty, the API uses its default - * release. The response's vocab_version, when present, identifies the - * release used. Specify a release explicitly when reproducibility matters.

    + * and inferred concept resolution. If omitted or empty, the API uses its + * default release. The response's vocab_version, when present, identifies + * the release used. Specify a release explicitly when reproducibility matters.

    + *

    Supported concept fields use semantic mapping for text-only inputs and + * unresolved source codings, with whole-term equivalence validation. + * CodeableConcept text takes precedence over coding display. A single + * equivalent suggestion must be validated before a real OMOP concept ID is + * returned. The suggested vocabulary/code must + * itself be an active standard concept valid for the destination; broader + * crosswalk targets are not accepted for inferred suggestions. + * Deterministic mappings take precedence; ambiguous or domain-mismatched + * deterministic results are not reinterpreted. Units, routes, visits, + * demographics, and collection sites use their field-specific resolution + * policies. Semantic mapping returns MAPPED with a real concept ID on + * success, or UNMAPPED with concept ID 0 when no standard concept is + * selected. A bare code is not treated as a description. No source coding + * is invented.

    + *

    Contextual allergy finding decisions follow their dedicated policy and + * are not replaced by description-based mapping.

    + *

    Every inferred mapping has + * mappings.resolution.semantic_mapping.status: MAPPED, with the OMOP + * vocabulary release. Filter linked rows using the mapping's table/row ID + * to exclude inferred results. + * Original source coding and clinical-status eligibility are preserved. + * For clinical rows covered by rows_for_review, successful inference + * does not remove uncertain-status rows from the index; unresolved primary + * concepts also remain indexed. + * All shaped rows appear once in tables.

    *

    Standards basis: FHIR R4 (v4.0.1) defines * the accepted source elements and OMOP CDM * v5.4 defines the @@ -379,46 +1059,262 @@ public CompletableFuture create(CreateOmopRequest request) { * first address can produce location, and more than one supplied race * produces observation race rows (see Patient demographics below) *

  • observation_period -> one request-local derived row per person, - * spanning the populated dates of that person's visit, clinical, and - * death rows; this is not enrollment or capture-completeness evidence
  • + * spanning dates from that person's visit and clinical rows not listed + * in rows_for_review, plus Specimen and Death rows. Source end dates + * contribute even when the output table has no end column. No period + * row is returned for a person without qualifying dates; this is not + * enrollment or capture-completeness evidence *
  • Location -> location and care_site
  • *
  • Organization -> care_site; its first address can produce location
  • *
  • HealthcareService -> care_site
  • *
  • Practitioner and PractitionerRole -> provider
  • *
  • Encounter -> visit_occurrence
  • - *
  • Condition -> condition_occurrence
  • - *
  • Procedure -> procedure_occurrence
  • - *
  • MedicationRequest, MedicationStatement, and - * MedicationAdministration -> drug_exposure
  • - *
  • Immunization -> drug_exposure
  • - *
  • Observation -> measurement or observation. For coded - * Observations, the resolved OMOP concept domain selects the table; value - * form only breaks ties. For text-only Observations, numeric values route - * to measurement and nonnumeric values to observation.
  • - *
  • AllergyIntolerance -> observation
  • + *
  • Specimen -> specimen, with an explicit Patient subject; missing + * collection date or material retains a partial row with diagnostics
  • + *
  • DeviceUseStatement -> device_exposure, using referenced Device.type
  • + *
  • Condition -> domain-selected clinical table; default condition_occurrence
  • + *
  • Procedure -> domain-selected clinical table; default procedure_occurrence
  • + *
  • MedicationRequest, MedicationStatement, MedicationAdministration, and + * MedicationDispense -> domain-selected clinical table; default drug_exposure
  • + *
  • Immunization -> domain-selected clinical table; default drug_exposure
  • + *
  • Observation -> domain-selected clinical table. Numeric values prefer + * measurement and other values prefer observation when the code is + * valid for both tables. + * If no supported standard concept is selected, numeric values retain the + * measurement default and nonnumeric values retain observation.
  • + *
  • AllergyIntolerance -> observation for supported contextual + * allergy/intolerance mappings; other coded statements use domain-based + * placement excluding drug_exposure and device_exposure; default observation
  • * + *

    Clinical placement follows the informative Vulcan IG's vocabulary-driven + * domain assignment. Supported destinations are condition_occurrence, + * procedure_occurrence, drug_exposure, device_exposure, measurement, and observation. + * Resolved clinical records are returned in the table corresponding to the + * domain of their mapped OMOP standard concept. The source resource + * determines eligibility, field values, and the default table when no + * supported mapping is available. + * AllergyIntolerance records do not produce drug_exposure or + * device_exposure rows. Device placement requires eligible use evidence: + * prescriptions, uncertain diagnoses, and explicit negative Observation + * results (false, SNOMED Negative, Known absent, or No, or interpretation + * code NEG from + * http://terminology.hl7.org/CodeSystem/v3-ObservationInterpretation) + * do not accept Device placement, even when other supplied values are positive. + * A recorder, asserter, or information source is retained as context rather + * than treated as the device event's provider.

    + *

    Coded AllergyIntolerance.code statements are interpreted using their + * original codings, optional type, and category. Supported substances + * produce an Observation finding separately from the causative substance + * in value_as_concept_id. Drug-domain substances are valid values and do + * not imply drug exposure or medication allergy. The finding is selected + * by an explicit mapping policy, not a published substance-to-finding + * crosswalk. Contextual findings report MAPPED, + * resolution.via: CONTEXTUAL. observation_source_concept_id retains + * the known OMOP identity of the original coding written to + * observation_source_value, including when the finding is unresolved; + * it is 0 when no source identity is known. The configured finding is + * never substituted for the original source concept.

    + *

    This adapts the informative R5 Vulcan Value-as-Concept + * pattern + * to R4's substance/product codes, optional type, and repeating category. + * It selects a contextual finding and resolves the substance separately; + * published Maps to value decomposition of a composite finding is not performed.

    + *

    For type: allergy, medication (optionally with biologic) maps to + * Allergy to drug (439224); other category sets use Allergic disposition + * (43530807). For type: intolerance, medication (optionally biologic) + * uses Intolerance to drug (4240582), food alone uses Intolerance to food + * (4340252), and other sets use Intolerance to substance (36684107). + * All selected concepts must be active and standard in the requested + * vocabulary release. Limited reviewed substance mappings can derive an + * absent category: for example, peanut and egg extracts supply food context, and + * Penicillin G supplies medication context. No category is inferred solely + * from Drug-domain membership.

    + *

    With absent type, a supported substance maps to a mechanism-neutral + * propensity finding: medication (optionally with biologic) uses Propensity + * to adverse reactions to drug (4305025), food alone uses Propensity to + * adverse reactions to food (4303802), and other category sets or an + * unknown category use Propensity to adverse reaction (4172024). The + * original type remains absent; no allergy or intolerance mechanism is + * inferred. The substance value is resolved independently. Existing + * supported findings can supply their own mechanism and negative + * statements retain their meaning. Conflicting evidence leaves the + * affected concept unmapped. + * A single identified substance can still populate the value when only + * its category context conflicts. Distinct conflicting substances, + * positive/negative mixtures, finding/substance mixtures, and multiple + * standard substance targets do not select a substance value by input + * order. Equivalent supported translations may share one value; + * unresolved translations are disclosed and are not used as substances. + * Finding-only, value-only, and neither-resolved partial rows are retained.

    + *

    mappings.resolution.allergy retains supplied type/categories, coded + * clinical/verification statuses, the date-source element, per-coding + * interpretation, derived categories, and reported information loss. The + * finding and substance have separate mappings linked to the same row and + * fields. Broader findings retain source specificity in this metadata. + * Codes outside contextual policy coverage retain existing domain-based + * mapping, with the contextual coverage outcome reported separately. + * Text-only inputs retain their text-only behavior. Reaction-substance + * fallback and published Maps to value relationships are not applied. + * Null placeholders in repeating FHIR category primitives carry no category + * value and are omitted from category interpretation and audit values. + * An invalid supplied type/category drops only the affected AllergyIntolerance + * before concept resolution. Other resources are still processed. The + * dropped entry identifies the resource and reports + * allergy_type_unrecognized or allergy_category_unrecognized.

    + *

    For example, a Condition coded with ICD10CM Z79.4 can produce a mapped + * observation row. Z86.73 and ICD-O-3 8140/3 also map to concepts in + * the Observation domain. Use mappings.omop_table and mappings.omop_id + * to locate each result; the FHIR resource type alone does not determine + * the output table.

    + *

    Clinical rows include available person/visit/provider links, concept and + * source concept IDs, source value, source type provenance, and start + * timing. Dates follow the source resource's timing rules below, regardless + * of the output table. For example, a Condition follows its + * onset[x] / recordedDate timing rule even when returned as an + * observation. End dates are populated only in tables with end columns; + * for rows not listed in rows_for_review, they also contribute to the + * request-local observation period when the output table has no end column.

    + *

    Source-specific fields are populated only for the corresponding source + * and destination: Condition status in condition_occurrence, medication + * dosage/supply and Immunization lot details in drug_exposure, and + * Observation values/units/ranges in measurement or observation where + * those columns exist. Mapping notes explain the output table and which + * fields are populated. Coded medication/immunization routes on rows outside + * drug_exposure, and Observation values on rows outside measurement / + * observation, are retained in mappings linked to the output row, with + * omop_field omitted and selected: false. Coded routes are checked + * against the OMOP Route domain; those Observation values are preserved + * without concept resolution.

    + *

    Note destinations are unsupported. If no standard concept + * valid for a supported destination is found, the resource produces a row + * in its default table with concept ID 0 and UNMAPPED status. Its + * source concept ID is also 0, except that allergy rows retain a known + * original source identity from their coding evidence. A DOMAIN_MISMATCH + * outcome is identified in the mapping + * note. Text-only clinical inputs can also produce rows in another supported + * table when semantic mapping selects a standard concept valid for that destination; + * without a validated match, they use their default tables. Domain-routed + * clinical records produce one row each even when their code has multiple + * vocabulary mapping targets; DeviceUseStatement event granularity is + * described below. Vocabulary Maps to value mappings are not applied.

    *

    Medication is reference data for medication resources; it does not * create its own row because OMOP CDM has no Medication table. Administrative * linkages (provider, care site, and location) are best-effort and limited to * references supplied in the request. Patient.managingOrganization is a - * record custodian, not a care-delivery site. Provider specialty is not - * mapped. Recorded Practitioner.gender is distinct from Person + * record custodian, not a care-delivery site. Provider specialties resolve + * independently to standard Provider concepts. Recorded Practitioner.gender + * is distinct from Person * demographics: male and female resolve to validated OMOP Gender * concepts in provider.gender_concept_id; other, unknown, and absent * gender remain unmapped. Address.country is resolved to location.country_concept_id, * and CMS Place of Service codings in Location.type are resolved to * care_site.place_of_service_concept_id. + * Within one request, Practitioners with the same single distinct supplied + * NPI value merge into one provider row only when all supplied mapped identity + * attributes in the entire NPI group agree. The NPI system may be + * http://hl7.org/fhir/sid/us-npi (preferred in FHIR R4), + * urn:oid:2.16.840.1.113883.4.6, or bare 2.16.840.1.113883.4.6; + * these forms are equivalent for matching. OID forms are accepted for compatibility. + * The same matching rule applies to Practitioners contained in valid role + * references. NPI values are compared exactly as supplied, without format + * or checksum validation. Blank values and unknown markers UNK and + * UNKNOWN are treated as missing and do not populate npi or participate + * in matching. Marker detection ignores case and surrounding whitespace; + * all other values are preserved without trimming. Other identifier systems and names + * do not trigger a merge. Missing attributes can enrich a compatible merged row. + * Compatibility compares the name rendered from each Practitioner's first + * name entry, its gender, its first non-empty DEA identifier, and its parsed + * birth year. Differences in birth month/day within one year do not conflict. + * Rendered names are compared exactly: Jane Smith and Dr. Jane Smith + * conflict even when they share an NPI. + * If any of these attributes conflict, every resource in that NPI group stays + * separate, including resources missing the conflicting attribute. Source + * attributes and unambiguous structural reference links are preserved; no + * conflicting value is selected. Each affected row has CONFLICTING + * diagnostics at name, gender, identifier, or birthDate, linked by + * omop_table: provider and omop_id. All source gender mappings link to + * their retained or merged provider row. + * Repeated copies of one NPI on a resource are allowed. A resource with + * multiple distinct NPIs stays separate, leaves npi unset, preserves its + * source-resource identity in provider_source_value, and reports a + * CONFLICTING diagnostic at identifier linked by + * omop_table: provider and omop_id. + * Row IDs are per response. The API does not merge across requests. For a + * Practitioner with one NPI, provider_source_value is that NPI, even when + * its group conflicts. Consumers can use it to deduplicate compatible + * providers across requests, but must preserve rows flagged by identity + * conflict diagnostics and check compatibility across responses. Without + * one unambiguous NPI, source-resource identity is preserved without a + * person-level deduplication guarantee. * A PractitionerRole that identifies one supplied Practitioner aliases * that canonical provider: by a top-level structural reference, a - * parent-contained #id reference, or an exact identifier.system and - * identifier.value match against a top-level Practitioner. No remote + * parent-contained #id reference, or an identifier.system and + * identifier.value match against a top-level Practitioner. Identifier + * matching uses the equivalent NPI systems and missing-value rules above; + * all other systems and all supplied identifiers match exactly. No remote * identifier lookup is performed. When Reference.type is present it must - * be Practitioner; duplicate contained IDs and identifier matches are - * ambiguous. An explicit reference that is unresolved, ambiguous, or - * unsupported retains a role-fallback provider row and is returned in - * diagnostics. provider_role_contexts preserves role-specific + * be Practitioner; duplicate contained IDs and identifier matches to + * different canonical providers are ambiguous. An explicit reference that + * is unresolved, ambiguous, or unsupported retains a role-fallback provider + * row and is returned in diagnostics. provider_role_contexts preserves role-specific * specialty and care-site context that a canonical OMOP provider row cannot * represent together.

    + *

    A role whose practitioner reference has only a blank or unknown NPI + * identifier follows the missing-identifier behavior: its fallback row has + * an UNSUPPORTED diagnostic explaining that a usable identifier or literal + * reference is required. An unambiguous literal reference still links to + * its Practitioner when the NPI is missing or unknown. Original supplied + * unknown markers remain in the role's practitioner_identifier metadata.

    + *

    Each PractitionerRole.specialty entry resolves independently using its + * supplied codings and explicit text from that entry. NUCC codings resolve + * directly; other supported codings may use the UMLS bridge. Text fallback + * is permitted when every coding has a nonblank code and a well-formed, + * unsupported system identifier. Supported systems, mixed supported and + * unsupported systems, and incomplete or malformed codings block fallback. + * A supported coding remains authoritative even when the text differs. + * Within one specialty entry, complete codings from well-formed unsupported + * systems do not block supported codings that independently resolve to the + * same standard Provider concept. Unsupported companions remain unsupported + * in the coding audit and acquire no crosswalk. Missing or malformed + * companions, failures of recognized codings, and disagreements between + * supported codings still block selection. For example, NUCC 208000000X alongside a complete local + * PEDS coding can resolve to Pediatric Medicine, with or without text + * and in either coding order. This uses the supported coding's mapping + * and source concept, rather than text fallback. + * Coding displays do not supply text fallback evidence. + * Unsupported system identifiers must be absolute URIs without embedded + * whitespace, user information, or fragments. HTTP(S) identifiers require + * a host; URNs require a namespace and a nonempty namespace-specific value. + * Bare labels such as Epic do not qualify. + * Text uses deterministic vocabulary assertions in order: standard Provider + * concept name, English OMOP synonym, then an English UMLS string on a + * CUI with a NUCC atom, then a NUCC preferred-term label with its grouping + * prefix removed. The last layer retains the entire classification and + * specialization label, including qualifications. An ambiguous layer stops + * resolution. No semantic search or ML matching is used. + * Each role's input remains visible in mappings, including repeated + * identical inputs across one provider's roles. + * specialty_concept_id is populated only when every distinct supplied + * input resolves to the same standard Provider concept. With no inputs, + * or an unresolved, ambiguous, or conflicting input, it stays 0. + * A role without a specialty contributes no input. The endpoint applies + * no frequency or hierarchy selection. Each input's outcome and matching + * route (layer, matched string, and UMLS CUI/SAB when applicable) appear + * in mappings with omop_field: specialty_concept_id. An individually + * resolved input can coexist with a provider concept of 0 when another + * input fails or disagrees. Specialty mappings have selected: false: + * the source text remains in specialty_source_value when supplied source + * values agree, and every original value remains in role contexts and mappings. + * Specialty mapping evidence includes specialty_resolution with the text + * fallback decision and each coding's independent outcome. A successful + * text fallback reports MAPPED and source concept ID 0; its local + * codings remain unsupported in that audit. For example, complete local + * codings with explicit Pediatrics text can resolve to Pediatric Medicine + * through NUCC_LABEL. Adding a different or unresolved specialty still + * keeps the provider's singular concept at 0. + * Compound phrasings without a vocabulary assertion remain unresolved. + * For example, Pulmonology stays unresolved while Pulmonologist + * resolves to Pulmonary Disease. Results depend on vocab_version.

    *

    Patient demographics:

    *
      *
    • Sex at birth, race, and ethnicity are read from these US Core @@ -482,22 +1378,138 @@ public CompletableFuture create(CreateOmopRequest request) { * 0; and Vulcan's suggested observation rows for multiple * ethnicities are not produced.
    • *
    - *

    DiagnosticReport, ServiceRequest, CarePlan, DocumentReference, - * Composition, Specimen, DeviceUseStatement, Coverage, Claim, and - * other unsupported resource types are accepted in a Bundle but ignored: they + *

    Specimen produces a specimen row only with an explicitly resolvable + * top-level Patient subject. Missing collection dates or material retain + * useful partial rows with linked diagnostics; those rows may require + * downstream completion before loading into a strict CDM instance. + * References support Patient/id and exact Bundle + * fullUrl matches (including URNs and absolute URLs); contained, logical, + * display-only, missing, non-Patient, and unresolved subjects never use the + * sole Patient fallback. Reference.type, when supplied, must identify Patient. + * Unsafe patient attribution and ineligible status drop only that Specimen + * with dropped and diagnostics; a missing Patient in the entire request + * remains a 400. Missing material is concept ID 0 with a type diagnostic + * and no terminology mapping/summary count. Supplied unmapped material is + * also 0, with its separate vocabulary mapping outcome and retained source code. + * Material (type) resolves against the Specimen domain, coded UCUM + * collection quantity units against Unit, and collection.bodySite against + * Spec Anatomic Site. Supplied unmapped optional concepts are 0; absent + * unit and site IDs are explicit null. Text-only body sites preserve their + * source text with site concept ID 0 and an UNMAPPED mapping; they do not + * produce a text-based concept suggestion. Material descriptions follow + * the semantic-mapping policy above. Original source codes and text-only + * descriptions are preserved. + * Numeric quantity is copied without + * unit conversion; invalid or comparator-bearing quantities are omitted + * with diagnostics without dropping the row. Missing optional data is allowed. + * Collection time uses collection.collectedDateTime or + * collection.collectedPeriod.start; end-only, partial, invalid, and absent + * dates leave specimen_date/specimen_datetime omitted with linked diagnostics. + * Both collected forms together are conflicting: retain the row without + * selecting a date. Period end and receivedTime never substitute for collection + * start. specimen_source_contexts.collection_timing preserves raw dateTime, + * Period boundaries, receivedTime, and the selected source path when a date + * is populated, including partial precision and original timezone offsets. + * specimen_collection_date_missing, specimen_collection_date_partial, and + * specimen_collection_date_unusable distinguish absent, partial, and invalid + * source dates; specimen_collection_multiple_forms reports conflicting choices. + * specimen_type_concept_id: 32817 is EHR provenance, independent of material + * and availability. Status absent/available/unavailable/unsatisfactory is + * supported; entered-in-error and unrecognized statuses drop the specimen. + * specimen_source_contexts.status preserves the supplied availability + * status for emitted rows; it is omitted when no status was supplied. + * Disease status is not inferred. Source identity precedence is resource id, + * then first identifier with a nonempty value in source array order, then + * accessionIdentifier.value, then Bundle fullUrl. No identity is fabricated; + * specimen_source_contexts preserves all valued identifier system/value + * pairs, accession identity, resource id/fullUrl, and the selected source path. + * Parent/derived specimens emit independently when each has its own Patient; + * missing date/material remains partial rather than being inherited. Parent + * and Measurement-to-Specimen links are not represented. Consuming CDM + * loaders own required-date completion, durable IDs, and dataset completeness. + * Source baseline is FHIR R4 4.0.1, with supported common fields reviewed against + * US Core Specimen 7.0.0; full profile conformance is not validated here.

    + *

    Device use and exposure

    + *

    DeviceUseStatement accepts active, completed, stopped, and on-hold use; + * intended, entered-in-error, missing, and unrecognized statuses are omitted + * with source-specific diagnostics. Its subject must explicitly resolve to + * an emitted Patient. Group, contained-Patient, identifier-only, external + * fetching, and sole-Patient attribution are unsupported for this path. + * Device references support exact Type/id and Bundle fullUrl/URN matches, + * plus unambiguous contained Devices scoped to the referring statement. + * Missing, wrong-type, unresolved, or ambiguous Device details retain a + * known-patient use row with device concept 0 and diagnostics. Missing type + * evidence has no vocabulary mapping entry; supplied unmapped type evidence + * has a mapping outcome. Inventory-only Device records emit no exposure.

    + *

    timingDateTime supplies the start only; timingPeriod preserves supplied + * boundaries independently. Each timingTiming.event produces a separate + * row, including a partial row for an unusable event, with its zero-based + * event_index in device_exposure_contexts.exposures. Repeat rules without events + * retain one partial row; events plus rules produce only the event rows. + * No recurrence, continuous min/max interval, status-derived end date, or + * recordedOn fallback is inferred. Year/month precision and malformed + * individual dates leave affected columns absent. Competing timing choices + * or a provably reversed Period leave both bounds absent. Supplied precision, + * offsets, schedules, and omission/conflict reasons remain in source context + * and diagnostics. Other clinical paths retain their own timing semantics.

    + *

    device_exposure_contexts contains one entry per source record that produced + * device exposures. Its exposures array links all rows derived from that + * record by device_exposure_id and, for Timing events, event_index. Shared + * timing, device evidence, and quantity_basis appear once per source record. + * Contexts are not combined by resource id or device identity; distinct + * records remain separate even when their identifiers match or are absent.

    + *

    device_source_value uses original type/clinical coding, never inventory + * or resource identity. An unambiguous Device.udiCarrier.deviceIdentifier + * supplies unique_device_id. Competing identifiers leave it absent and are + * preserved with issuer/jurisdiction/carrier context. production_id remains + * absent; carriers, lot, serial, manufacture, and expiry evidence remain in + * device_exposure_contexts.

    + *

    DeviceUseStatement rows use device_type_concept_id 32817 (EHR), with + * provider_id, visit_occurrence_id, and visit_detail_id unset. + * Routed rows retain their source classification, explicit visit links to + * emitted same-patient visits, and compatible performer links; + * Condition/Procedure recorder/asserter and + * MedicationStatement informationSource do not supply device provider_id. + * Supplied source, encounter, context, recorder, asserter, performer, and + * informationSource values on DeviceUseStatement are retained only in + * device_exposure_contexts.

    + *

    Device exposure quantity is reported as follows:

    + *
      + *
    • When the source has no quantity, dose, rate, or numeric Observation + * result field, quantity is 1 and device_exposure_contexts.quantity_basis + * is default_absent_count.
    • + *
    • When such a field is supplied, including a null or malformed value, + * quantity is omitted and quantity_basis is unsupported_supplied_evidence. + * The original values remain in device_exposure_contexts.evidence, and + * an UNSUPPORTED diagnostic identifies each supplied field.
    • + *
    + *

    For example, an Observation with valueQuantity.value of 2 returned as a + * device exposure has no quantity; the value 2 remains in its source context. + * timingTiming.repeat.count remains in source context and does not set + * quantity. unit_concept_id and unit_source_concept_id are always null.

    + *

    Distinct eligible statements and routed clinical records remain separate, + * including overlapping use and equal Timing events. derivedFrom is retained + * as supporting context, not duplicate identity. Row counts therefore need + * not count distinct uses or physical devices. IDs are request-local exposure + * identities. Before loading a CDM dataset, callers must assign durable IDs + * and populate any required fields omitted from partial rows.

    + *

    Unsupported resource types are accepted in a Bundle but ignored: they * create no row and no dropped entry. dropped is reserved for supported * row-producing resources that cannot safely produce a positive OMOP row: * required subject/patient, clinical code/text, or medication data may be * unusable, or the resource may explicitly negate or fail the documented * clinical-event eligibility policy. A single-Patient Bundle uses the sole - * Patient only when subject/patient is absent. An explicit subject/patient + * Patient only when subject/patient is absent, except DeviceUseStatement + * and Specimen, which require explicit Patient subjects. An explicit subject/patient * reference that is unresolved, ambiguous, or unsupported drops the clinical * resource in every request scope.

    *

    Eligibility distinguishes clinical validity, performed/taken evidence, and - * administrative workflow state. Checks run before terminology resolution; - * entered-in-error and explicitly non-performed events never produce a positive - * clinical-event row, and an unrecognized required status fails closed. Condition - * requires absent or confirmed canonical-HL7 verificationStatus; clinical course is not a + * administrative workflow state. Entered-in-error and explicitly + * non-performed events never produce a positive clinical-event row, and + * unrecognized required statuses are dropped except the retained Condition and + * Encounter cases below. Condition accepts absent or confirmed canonical-HL7 + * verificationStatus without a status retention reason; uncertain or unreadable + * supplied verification is retained and indexed. Clinical course is not a * diagnosis-role mapping. Procedures accept completed or stopped events. * Medication requests are prescription evidence only: doNotPerform, drafts, * cancellations, and non-authorizing intents are dropped. Medication statements @@ -506,69 +1518,142 @@ public CompletableFuture create(CreateOmopRequest request) { * administration also needs an effectiveDateTime or effectivePeriod.start * that supplies start evidence; a valid partial date remains an undated row. * An on-hold administration is started evidence that is temporarily paused and - * expected to continue. Immunizations require completed - * status. Observations require final, + * expected to continue. MedicationDispense accepts completed records, + * including completed partial fills. Stopped records require a valid + * whenHandedOver, including partial dates, as evidence of actual handover. + * Preparation, in-progress (ready for pickup), on-hold, cancelled, declined, + * entered-in-error, unknown, missing, and unrecognized statuses are dropped. + * A completed dispense with missing or unusable handover timing remains a + * partial row; preparation time never substitutes for handover. + * Immunizations require completed + * status. Observations accept final, * amended, or corrected status; registered, - * preliminary, cancelled, entered-in-error, unknown, and missing statuses are + * preliminary, cancelled, entered-in-error, and missing statuses are * dropped. AllergyIntolerance accepts absent, unconfirmed, or confirmed canonical-HL7 * verificationStatus as a reported allergy, but drops refuted, - * entered-in-error, and unreadable supplied verification statuses. Encounter + * entered-in-error, and unreadable supplied verification statuses. Invalid + * supplied allergy type/category also drops that resource, for both coded + * and text-only statements. Encounter * accepts arrived, triaged, in-progress, onleave, or finished status, but drops - * planned, cancelled, entered-in-error, unknown, and missing statuses. An eligible - * encounter without Period.end remains a partial visit; no end date is invented.

    - *

    Coded Observation routing is selected from the resolved OMOP concept - * domain. Numeric and nonnumeric value[x] forms establish the preferred - * target only when the code is valid for both tables. A text-only - * Observation has no resolver target, so numeric values route to - * measurement and nonnumeric values to observation. Numeric values - * populate value_as_number in the selected row; other non-coded values + * planned, cancelled, and entered-in-error statuses. Unknown, missing, and unreadable + * Encounter statuses are retained and indexed without imputation. An eligible + * encounter without Period.end remains a partial visit; no end date is invented. + * MedicationStatement, Observation, and Procedure with explicit unknown + * status are also retained and indexed in rows_for_review when otherwise + * shapeable. Missing or unreadable statuses on these three resource types + * remain dropped. Retention preserves source status and does not establish + * that an event occurred or medication was taken. Device placement still + * requires eligible use evidence; unknown-status records cannot produce + * device_exposure rows.

    + *

    The mapped OMOP concept's domain determines the clinical output table. + * For Observations, numeric and nonnumeric value[x] forms determine the + * preferred table only when the code is valid for both measurement and + * observation. Unresolved Observations retain measurement rows for + * numeric values and observation rows for other values. In these two + * tables, numeric values populate value_as_number; other non-coded values * populate value_as_string for an observation or value_source_value * for a measurement. Coded valueCodeableConcept values are resolved - * against the selected row's value_as_concept_id and use the selected + * against value_as_concept_id in these tables and use the selected * bare code in value_source_value, leaving an observation's * value_as_string empty; unmapped or target-invalid coded values remain - * 0. + * 0. Active standard concepts from any OMOP domain are valid for + * value_as_concept_id per CDM 5.4. For example, a blood-culture Measurement + * can record Staphylococcus aureus as its coded result, even though the + * organism concept belongs to OMOP's Observation domain. + * For other clinical output tables, Observation values are retained only + * in mappings, without concept resolution or an omop_field, and with + * selected: false. * Other unsupported value[x] forms and Observation components do not * populate separate converted values. A * numeric comparator (<, <=, >, >=) is represented only by a - * measurement's operator_concept_id; units remain source text and have - * unit_concept_id of 0.

    + * measurement's operator_concept_id.

    + *

    For Observations returned as measurement or observation rows, + * Quantity units resolve independently against the table's + * unit_concept_id. + * Only coded UCUM (http://unitsofmeasure.org or its + * OID 2.16.840.1.113883.6.8, bare or urn:oid:) is supported. Codes are + * case-sensitive: no spelling aliases, text-only guesses, or numeric + * conversion are applied. Coded UCUM units map to valid standard OMOP + * Unit concepts using the release selected by vocab_version. + * Mappings are direct or follow an active OMOP Maps to relationship; + * UCUM units do not use a UMLS bridge. + * Unknown codes, + * vocabulary gaps, text-only units, missing coding, and unsupported + * systems keep the row and use 0; absent units use JSON null (the + * field is present). Explicit dimensionless code 1 is a supplied unit + * and resolves or remains 0; it is never treated as absent. + * unit_source_value contains Quantity.code when supplied, otherwise + * Quantity.unit. A separate unit mappings entry preserves the original + * Quantity.system/code in source_system/source_code and the exact + * human-readable Quantity.unit in source_name, with its own outcome. + * Quantity.unit provides display text and does not affect unit matching. + * Measurement also returns nullable unit_source_concept_id, identifying + * the OMOP source concept for Quantity.system/code when available, + * otherwise 0 for a supplied unit. + * Observation has no such column. Resolving a unit does not change + * numeric values, comparators, or reference ranges. + * Supported coded UCUM units include resolution in their mappings, + * whether the unit resolves or remains unmapped: + * its outcome and reason code distinguish vocabulary gaps, target-domain + * mismatches, and ambiguity. Unresolved or ambiguous units retain the row + * with concept ID 0. Resolved mappings retain the selected concept's + * direct or crosswalk provenance. Text-only units, units with missing + * coding, and unsupported unit systems omit resolution; their note + * explains the unmapped outcome.

    + *

    For Observations returned in other clinical tables, units are not resolved + * or included as separate mapping outcomes. Device exposure unit IDs remain + * null, with supplied quantity and unit evidence retained in + * device_exposure_contexts.

    + *

    Compatibility: absent unit_concept_id values previously returned 0 + * and now return null. Coded units use their code in unit_source_value + * rather than their human-readable text; the text remains in mappings. + * The Measurement unit_source_concept_id field is new.

    *

    A standard OMOP concept_id is selected for each eligible primary clinical coding * after considering all of the resource's supplied codings. An unambiguous - * coded medication route is resolved independently to - * drug_exposure.route_concept_id. Alongside the OMOP rows grouped by + * coded medication or immunization route is checked independently against + * the OMOP Route domain. For drug_exposure rows, route_concept_id + * contains the resolved concept ID, or 0 if unmapped; for other output + * tables the route is reported only in + * mappings, without omop_field and with selected: false. + * Alongside the OMOP rows grouped by * table (tables), the response carries mappings (an entry for every * supported source coding from a resource that shaped a row, linked back to * that row; some, such * as demographic null flavors, are reported without being resolved), + * rows_for_review (an index of uncertain-status or primary-concept-0 clinical + * rows in tables, with original statuses, reasons, and source/row identity), * provider_role_contexts (source role details linked to provider rows), * dropped (resources that could not be shaped into a row), * diagnostics (explicit references that could not safely create a link, - * and conflicting or unsupported Patient demographic extensions), + * conflicting Practitioner identity attributes, conflicting or unsupported + * Patient demographic extensions, and conflicting Encounter classifications), * vocab_version (the OMOP vocabulary release codes were resolved * against), and a small summary of the resolution outcomes.

    *

    A concept_id of 0 is reported, not omitted (OMOP "no matching - * concept" semantics): it covers both a coding with no standard match - * (UNMAPPED) and an unverified suggestion for a text-only resource - * (UNCHECKED). Visit and unit concept fields currently remain 0; - * Person demographics and Provider recorded gender follow the policies - * above. Coded Observation values may populate value_as_concept_id. - * Concepts set by a fixed convention rather than terminology resolution - * are measurement operator_concept_id, set from a value comparator (<, - * <=, >, >=), and the multiple-race concepts described above. Clinical - * *_source_value fields contain the selected FHIR code (or source text - * for text-only resources). + * concept" semantics): it covers a supplied concept without a validated + * standard match (UNMAPPED). + * Supported Encounter.class or Encounter.type values + * may populate visit_concept_id. Person demographics and Provider + * recorded gender follow the policies above. + * Coded Observation values and supported allergy substances may populate + * value_as_concept_id. + * Measurement operator_concept_id represents a value comparator (<, + * <=, >, >=); multiple-race concepts follow the conventions described + * above. Clinical *_source_value fields contain the selected FHIR code + * (or source text for text-only resources). * The corresponding selected mappings entry preserves the coding system * and full source-coding provenance. * Known OID-form coding systems are accepted as either FHIR OID URNs (for * example, urn:oid:2.16.840.1.113883.6.1 for LOINC) or bare OIDs, and - * are normalized to their canonical system URLs before terminology - * resolution. mappings[].source_system reports that canonical URL, so the - * OID and URL forms produce the same mapping. An unknown OID is not - * rewritten and may be UNMAPPED. + * produce the same mappings as their canonical system URLs. Coding + * mappings report that canonical URL in source_system; Quantity unit + * mappings preserve the original Quantity.system, including OID spelling. + * An unknown OID remains unchanged in source_system and may be + * UNMAPPED. * Other *_source_value fields preserve row-specific raw source values, * such as resource identifiers, names, units, or status codes. - * MedicationRequest uses 32838 (EHR prescription) for + * MedicationRequest uses 32838 (EHR prescription) and + * MedicationDispense uses 32825 (EHR dispensing record) for * drug_type_concept_id; other current resources use 32817 (EHR). This * is a coarse provenance policy: it does not infer patient-reported, * medication-history, or other more-specific type concepts from FHIR @@ -581,8 +1666,8 @@ public CompletableFuture create(CreateOmopRequest request) { * (YYYY-MM-DDTHH:MM:SS, with fractional seconds to microseconds when * supplied): 2024-01-15T23:30:00-05:00 becomes 2024-01-15T23:30:00. * A datetime without a timezone is read as local time. - *

  • A partial date (2024 or 2024-03) leaves both columns unset. It - * still counts as that source's value, so later sources in the list + *
  • A partial date (2024 or 2024-03) leaves both columns unset. + * It still counts as that source's value, so later sources in the list * below are not used.
  • *
  • Free-text timing (such as onsetString), Age and Range forms, and * values that are not dates are ignored, so a later source in the list @@ -592,11 +1677,18 @@ public CompletableFuture create(CreateOmopRequest request) { * completed, and a row is not dated from another resource such as its * Encounter.
  • *
  • Valid date values never cause a request to be rejected, and the response - * does not report which date elements were unusable. An on-hold + * reports missing, partial, malformed, or conflicting timing for Specimen, + * DeviceUseStatement, and MedicationDispense in diagnostics while retaining + * partial rows for downstream load completion. Other resources do not report which + * date elements were unusable. An on-hold * MedicationAdministration is an eligibility exception: it needs a supplied - * effective start as evidence that administration began.
  • + * effective start as evidence that administration began. + * A stopped MedicationDispense similarly requires supplied handover evidence. * - *

    Timing sources, in priority order where several are listed:

    + *

    Timing sources, in priority order where several are listed. These rules + * apply regardless of the clinical output table; end dates are populated + * only where the table has an end column, and verbatim_end_date is set + * only for medication resources returned as drug_exposure rows:

    *
      *
    • Patient: birthDate sets year_of_birth, month_of_birth, and * day_of_birth from the parts it supplies; birth_datetime is not @@ -608,6 +1700,12 @@ public CompletableFuture create(CreateOmopRequest request) { * end from abatementDateTime or abatementPeriod.end.
    • *
    • Procedure: start from performedDateTime or * performedPeriod.start; end from performedPeriod.end only.
    • + *
    • DeviceUseStatement: timingDateTime sets the start only; + * timingPeriod.start and timingPeriod.end supply independent bounds; + * each timingTiming.event produces one start-only row. A schedule + * without explicit events produces one row without dates.
    • + *
    • Specimen: collection.collectedDateTime or collection.collectedPeriod.start; + * missing day precision retains an undated specimen with diagnostics.
    • *
    • Observation: effectiveDateTime, effectivePeriod.start, or * effectiveInstant.
    • *
    • AllergyIntolerance: recordedDate, then onsetDateTime or @@ -624,21 +1722,212 @@ public CompletableFuture create(CreateOmopRequest request) { *
    • MedicationRequest: authoredOn, the order date, sets the start; it * is not evidence of administration. No end is set, and the validity * period is not used as an exposure duration.
    • + *
    • MedicationDispense: whenHandedOver sets the start. Missing, partial, + * or invalid handover leaves the start unset with a diagnostic. Comparable + * handover/preparation values proving handover before preparation also + * leave the start unset with a CONFLICTING diagnostic. whenPrepared, + * authorizing orders, and related administrations never supply timing. + * No exposure end or verbatim_end_date is inferred, including from + * daysSupply. Raw handover/preparation values retain precision and offsets + * in medication_dispense_source_contexts.source.
    • *
    • Differences from the Vulcan maps: birth_datetime is not set from * birthDate, Condition also reads onsetPeriod.start, and * AllergyIntolerance falls back to its onset when recordedDate is * missing.
    • *
    - *

    Medication details:

    + *

    Medication details for resources returned as drug_exposure rows:

    *
      *
    • For MedicationRequest, dispenseRequest.numberOfRepeatsAllowed sets * refills and a whole-day expectedSupplyDuration sets days_supply.
    • + *
    • For MedicationDispense, daysSupply sets days_supply only for a + * positive whole number of coded UCUM d days without a supplied comparator. + * The canonical UCUM URL, bare OID, and OID URN are accepted. Other units, + * fractions, text-only units, and malformed values leave it absent with a + * diagnostic; absent daysSupply is allowed. Refills are not inherited.
    • *
    • All non-empty dosage text is preserved in sig.
    • *
    • Coded dosage routes and Immunization.route are target-validated in * the OMOP Route domain. Conflicting routes are left unset; route * codings shared by every dosage instruction identify the same route.
    • *
    • Immunization.lotNumber is preserved in lot_number.
    • *
    + *

    MedicationDispense requires an explicit subject resolving to a Patient + * supplied in the request; Groups and missing subjects are unsupported. + * Source fields are based on FHIR R4 4.0.1 and reviewed against + * US Core MedicationDispense 7.0.0. + * Full profile conformance is not validated. Although US Core requires + * handover on completed records, this API retains such incomplete records + * with missing-handover diagnostics as partial conversion output. + * Supply either a usable medicationCodeableConcept (coded or text-only), + * or a medicationReference resolving to a Medication with usable code/text. + * Both choice forms together are conflicting and drop the dispense. + * Exact relative, Bundle fullUrl, and unambiguous contained Medication + * references are supported. Unresolved references are not rescued by display + * text; missing or ambiguous medication identity drops the dispense with a + * diagnostic. A usable but unmapped drug retains concept ID 0 and its + * mapping outcome in a row indexed for review. + * context links only to a supplied Encounter for the same patient. + * The first resolvable Practitioner/PractitionerRole in performer.actor + * supplies provider_id; other actor types do not supply a provider. + * Failed explicit links have diagnostics. Authorizing prescriptions are + * checked for request-local MedicationRequest identity and patient agreement; + * failures do not discard the dispense. They never supply medication, + * instructions, refills, timing, or prescription provenance to a dispense. + * Dispense instructions alone supply sig and route; conflicting routes + * remain unset with a diagnostic and original instructions in source context. + * Orders, dispenses, and administrations emit independently, with separate + * source identity and no deduplication merely because they concern the same drug. + * Every emitted dispensing drug row has a whenHandedOver/daysSupply + * diagnostic beginning medication_dispense_end_date_absent because the + * CDM-required exposure end remains absent. Missing/partial/unusable handover + * diagnostics begin medication_dispense_handover_missing, + * medication_dispense_handover_partial, or medication_dispense_handover_unusable.

    + *

    Encounter details:

    + *
      + *
    • + *

      Encounter classifications must supply both Coding.system and + * Coding.code to map to a standard OMOP Visit concept. For HL7 + * encounter class codes with system + * http://terminology.hl7.org/CodeSystem/v3-ActCode or + * http://hl7.org/fhir/v3/ActCode, the supported mappings are:

      + *
        + *
      • IMP (inpatient encounter) -> 9201 (Inpatient Visit)
      • + *
      • AMB (ambulatory encounter) -> 9202 (Outpatient Visit)
      • + *
      • EMER (emergency encounter) -> 9203 (Emergency Room Visit)
      • + *
      • VR (virtual encounter) -> 722455 (Telehealth)
      • + *
      • HH (home health encounter) -> 581476 (Home Visit)
      • + *
      + *

      OBSENC (observation encounter) has no direct class mapping. Other + * class or type codes can be used when they have a valid standard OMOP + * Visit mapping in the selected vocabulary release. Unsupported + * classifications retain visit_concept_id of 0 unless a supported + * type supplies the Visit as described below; they never default to + * outpatient.

      + *
    • + *
    • + *

      A mapped Encounter.class determines visit_concept_id. A mapped + * type that disagrees with it produces a CONFLICTING diagnostic with + * path class/type, while the class mapping remains selected. + * If class is missing or unmapped, type supplies the Visit only when + * all mapped type classifications agree on the same standard Visit + * concept. Codings within one type CodeableConcept are alternative + * encodings of that classification. When separate type classifications + * map to different Visit concepts, no type is selected, + * visit_concept_id remains 0, and a CONFLICTING diagnostic has + * path type.

      + *
    • + *
    • + *

      visit_source_value contains the selected class or type code; + * mappings preserves its coding system and identifies which + * classification was selected. visit_source_concept_id identifies + * the selected original source coding's OMOP concept when available, + * and is 0 otherwise, including for the HL7 encounter class codes + * listed above. Each eligible Encounter produces one Visit; + * classHistory, partOf, and admission/discharge concepts are not + * mapped.

      + *
    • + *
    • + *

      Direct MedicationRequest.dispenseRequest.quantity, MedicationDispense.quantity, + * MedicationAdministration.dosage.dose, and Immunization.doseQuantity + * can set quantity under the formulation policies below. Quantities + * must be positive finite numbers with a supported system/code pair + * and no supplied comparator. Ingredient amounts and injection volumes + * use http://unitsofmeasure.org (or its bare OID or + * urn:oid:2.16.840.1.113883.6.8) with case-sensitive codes matching or + * convertible to vocabulary-backed strength units. Exact unit matches, + * including [U] for insulin and heparin strengths, are accepted without + * a unit conversion. + * When codes differ, both source and strength units must belong to the + * same supported group: mass (ug, mg, g), volume (uL, mL, L), + * or milliequivalent representations (meq, 10*-3.eq). Other conversions, + * including kg, ML, Mg, [drp], mol, mmol, %, 1, and generic + * annotations such as {vial} or {syringe}, leave quantity unset with + * unit_mismatch. Codes are not case-corrected or inferred from display text. + * The original source system is preserved in the quantity audit. + * Fixed-form counts also accept the explicit codings below. Free-text, + * local, invalid, and cross-dimensional units are not interpreted.

      + *
    • + *
    + *

    Drug quantity policies follow CDM v5.4 DRUG_EXPOSURE conventions. + * The selected drug must be a Clinical Drug or Branded Drug, with one + * active dose form and valid vocabulary evidence for the chosen policy. + * Strength-based conversions also require one ingredient strength. Supported RxNorm dose forms:

    + *
      + *
    • fixed_dose_form_count: Oral Tablet (317541), Oral Capsule + * (316965), Extended Release Oral Tablet (316945), Extended + * Release Oral Capsule (316943), Delayed Release Oral Tablet (10312), + * or Delayed Release Oral Capsule (316995). A source ingredient amount is divided + * by the single positive per-unit ingredient amount after conversion to that + * strength unit when necessary. + * Concentration-based strengths cannot derive counts from ingredient amounts. The + * result is a count of the selected dose form, limited to whole units or + * multiples of one-half or one-third. Derived counts within a relative + * tolerance of 1e-12 of an allowed fraction are normalized to that count + * to remove arithmetic roundoff; other fractions remain unsupported. + * For example, 1,000 mg for a drug + * with 500 mg per tablet produces quantity: 2 tablets (DERIVED). A source + * of 1 g is converted to 1,000 mg and produces the same count. + * Explicit counts are copied (DIRECT) when their coding matches the + * selected tablet or capsule form, including the corresponding extended- + * and delayed-release forms: TAB / CAP under + * http://terminology.hl7.org/CodeSystem/v3-orderableDrugForm, or + * {tbl} / {Capsule} under http://unitsofmeasure.org. These exact + * case-sensitive count codings identify the supplied form; they are not + * dimensionally converted as UCUM units. Direct counts do not require + * ingredient strength: combination products and missing/invalid strengths + * do not change the supplied count. Ingredient-strength gaps, including + * missing ingredient or strength-unit references, do not prevent copying + * an explicit count. Drug or dose-form evidence gaps, unclassified or + * ambiguous forms, and gaps whose relevance cannot be established still + * prevent conversion. The whole/half/third rules still apply. + * For example, 20 TAB for amoxicillin/clavulanate is 20 tablets, and 30 TAB for + * a supported tablet produces quantity: 30; CAP for that tablet is + * omitted with unit_mismatch. Generic counts (1, {Each}), other + * annotations, packs, and free-text counts are not interpreted. + * See the FHIR R4 count example.
    • + *
    • clinical_injection_ingredient_amount: Injectable Solution (316949), + * Injectable Suspension (316950), or Injection (1649574). A source + * ingredient amount is converted to the vocabulary ingredient unit when + * necessary; an exact-unit amount is copied directly. Otherwise, a source + * amount convertible to the concentration's denominator unit is converted + * and multiplied by numerator value / the effective denominator reported in + * the audit evidence. Volume conversion requires a positive finite numerator, + * dimensionally distinct validated UCUM units, a null raw denominator value, and + * evidence.strength.denominator_basis with kind: PER_UNIT and + * value: 1. A null denominator alone is not interpreted as 1. Missing, unknown, + * non-applicable, quantified, or conflicting denominator evidence does + * not support volume conversion. For example, 80 mL with 2 mg/mL + * strength and a PER_UNIT basis produces quantity: 160 mg. A supplied + * 160 mg amount for the same drug produces quantity: 160 directly. A source + * of 0.08 L converts to 80 mL before producing the same 160 mg result. + * This output is already + * the ingredient amount: do not apply the drug-dose guide's + * dose = quantity × numerator formula to these injection rows, which + * would multiply by the concentration a second time. Use + * quantity_transformations.result_unit to identify the output unit.
    • + *
    + *

    Quantified products, packs, prefilled syringes, other liquids and dose + * forms, strength-based conversions of combination products, conflicting or incomplete required evidence, and + * unsupported source quantities leave quantity unset. Other supported + * fields and the clinical row are still returned. For example, a quantity + * supplied only as value: 2, unit: tablet is omitted with + * unsupported_unit, because a free-text unit cannot establish this policy. + * Rate-only administrations and MedicationStatement dosages do not establish + * a supported total quantity and are not used for quantity conversion.

    + *

    quantity_transformations audits each supplied quantity at one of the + * three direct paths above on a resource returned as a drug_exposure row, + * including an explicit null or malformed quantity. Each entry preserves + * the source, identifies the destination row, and reports DIRECT, DERIVED, or + * OMITTED. Successful entries identify their policy, evidence, result, + * and result unit; omitted entries give a stable reason. When a source code + * differs from the strength unit, unit_conversion records the intermediate + * converted amount and target unit. Converted results are DERIVED, even + * when no further concentration arithmetic is needed. A successful conversion + * can also be reported when a later policy check omits the final quantity. + * Dose-form and unit + * concept IDs refer to the response's vocab_version. Missing quantities + * produce no entry. Resources returned in other clinical tables have no + * quantity entry. Unsupported rates and statement dosages produce no entry, + * and resources in dropped have no quantity entry or destination row.

    *

    Medication codes are resolved whether they appear inline * (medicationCodeableConcept) or via a medicationReference to a contained, * relative (Type/id), or bundle-entry (urn:uuid) Medication resource. @@ -650,11 +1939,12 @@ public CompletableFuture create(CreateOmopRequest request) { * subject/patient reference cannot be tied to a person; or an event excluded * by eligibility — are reported under dropped rather than emitted as blank * rows. The Bundle must contain at least one Patient resource.

    - *

    Structural references resolve only to top-level resources supplied in the - * request, by Type/id or an exactly matching Bundle fullUrl (including - * urn:uuid). Contained references are supported for medication code lookup - * and PractitionerRole.practitioner enrichment; the latter also supports - * exact request-local identifier matching without a remote lookup. Every + *

    References to top-level resources supplied in the request resolve by + * Type/id or an exactly matching Bundle fullUrl (including urn:uuid). + * Parent-scoped contained references are supported for medication code lookup, + * DeviceUseStatement.device details, and PractitionerRole.practitioner + * enrichment; the latter also supports + * request-local identifier matching with the NPI rules above, without a remote lookup. Every * nonzero structural foreign key targets a row in the same response. Missing * optional links remain unset without a diagnostic; an explicit optional * reference that is unresolved, ambiguous, conflicting with the row's @@ -671,6 +1961,14 @@ public CompletableFuture create(CreateOmopRequest request) { * their own stable keys and remap every primary and foreign key together.

    */ public CompletableFuture create(CreateOmopRequest request, RequestOptions requestOptions) { - return this.rawClient.create(request, requestOptions).thenApply(response -> response.body()); + CompletableFuture> rawFuture = + this.rawClient.create(request, requestOptions); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } } diff --git a/src/main/java/com/phenoml/api/resources/fhir2omop/AsyncRawFhir2OmopClient.java b/src/main/java/com/phenoml/api/resources/fhir2omop/AsyncRawFhir2OmopClient.java index 758327ab..a91d85ff 100644 --- a/src/main/java/com/phenoml/api/resources/fhir2omop/AsyncRawFhir2OmopClient.java +++ b/src/main/java/com/phenoml/api/resources/fhir2omop/AsyncRawFhir2OmopClient.java @@ -4,6 +4,7 @@ package com.phenoml.api.resources.fhir2omop; import com.fasterxml.jackson.core.JsonProcessingException; +import com.phenoml.api.core.BodyProperties; import com.phenoml.api.core.ClientOptions; import com.phenoml.api.core.MediaTypes; import com.phenoml.api.core.ObjectMappers; @@ -39,11 +40,42 @@ public AsyncRawFhir2OmopClient(ClientOptions clientOptions) { /** * Maps a FHIR R4 resource or Bundle into OMOP Common Data Model v5.4 rows, - * grouped by destination table in tables. + * grouped by destination table in tables. Uncertain-status and unresolved- + * primary-concept clinical rows stay in those tables and are identified by + * the optional rows_for_review index for consumer review and filtering. + * Status-based retention covers uncertain supplied Condition verification, + * unknown/missing/unreadable Encounter status, and explicit unknown status + * on MedicationStatement, Observation, and Procedure. Other status exclusions + * follow the resource-specific eligibility policy below. *

    Set vocab_version to select the OMOP vocabulary release used for coded - * concept resolution. If omitted or empty, the API uses its default - * release. The response's vocab_version, when present, identifies the - * release used. Specify a release explicitly when reproducibility matters.

    + * and inferred concept resolution. If omitted or empty, the API uses its + * default release. The response's vocab_version, when present, identifies + * the release used. Specify a release explicitly when reproducibility matters.

    + *

    Supported concept fields use semantic mapping for text-only inputs and + * unresolved source codings, with whole-term equivalence validation. + * CodeableConcept text takes precedence over coding display. A single + * equivalent suggestion must be validated before a real OMOP concept ID is + * returned. The suggested vocabulary/code must + * itself be an active standard concept valid for the destination; broader + * crosswalk targets are not accepted for inferred suggestions. + * Deterministic mappings take precedence; ambiguous or domain-mismatched + * deterministic results are not reinterpreted. Units, routes, visits, + * demographics, and collection sites use their field-specific resolution + * policies. Semantic mapping returns MAPPED with a real concept ID on + * success, or UNMAPPED with concept ID 0 when no standard concept is + * selected. A bare code is not treated as a description. No source coding + * is invented.

    + *

    Contextual allergy finding decisions follow their dedicated policy and + * are not replaced by description-based mapping.

    + *

    Every inferred mapping has + * mappings.resolution.semantic_mapping.status: MAPPED, with the OMOP + * vocabulary release. Filter linked rows using the mapping's table/row ID + * to exclude inferred results. + * Original source coding and clinical-status eligibility are preserved. + * For clinical rows covered by rows_for_review, successful inference + * does not remove uncertain-status rows from the index; unresolved primary + * concepts also remain indexed. + * All shaped rows appear once in tables.

    *

    Standards basis: FHIR R4 (v4.0.1) defines * the accepted source elements and OMOP CDM * v5.4 defines the @@ -66,46 +98,262 @@ public AsyncRawFhir2OmopClient(ClientOptions clientOptions) { * first address can produce location, and more than one supplied race * produces observation race rows (see Patient demographics below) *

  • observation_period -> one request-local derived row per person, - * spanning the populated dates of that person's visit, clinical, and - * death rows; this is not enrollment or capture-completeness evidence
  • + * spanning dates from that person's visit and clinical rows not listed + * in rows_for_review, plus Specimen and Death rows. Source end dates + * contribute even when the output table has no end column. No period + * row is returned for a person without qualifying dates; this is not + * enrollment or capture-completeness evidence *
  • Location -> location and care_site
  • *
  • Organization -> care_site; its first address can produce location
  • *
  • HealthcareService -> care_site
  • *
  • Practitioner and PractitionerRole -> provider
  • *
  • Encounter -> visit_occurrence
  • - *
  • Condition -> condition_occurrence
  • - *
  • Procedure -> procedure_occurrence
  • - *
  • MedicationRequest, MedicationStatement, and - * MedicationAdministration -> drug_exposure
  • - *
  • Immunization -> drug_exposure
  • - *
  • Observation -> measurement or observation. For coded - * Observations, the resolved OMOP concept domain selects the table; value - * form only breaks ties. For text-only Observations, numeric values route - * to measurement and nonnumeric values to observation.
  • - *
  • AllergyIntolerance -> observation
  • + *
  • Specimen -> specimen, with an explicit Patient subject; missing + * collection date or material retains a partial row with diagnostics
  • + *
  • DeviceUseStatement -> device_exposure, using referenced Device.type
  • + *
  • Condition -> domain-selected clinical table; default condition_occurrence
  • + *
  • Procedure -> domain-selected clinical table; default procedure_occurrence
  • + *
  • MedicationRequest, MedicationStatement, MedicationAdministration, and + * MedicationDispense -> domain-selected clinical table; default drug_exposure
  • + *
  • Immunization -> domain-selected clinical table; default drug_exposure
  • + *
  • Observation -> domain-selected clinical table. Numeric values prefer + * measurement and other values prefer observation when the code is + * valid for both tables. + * If no supported standard concept is selected, numeric values retain the + * measurement default and nonnumeric values retain observation.
  • + *
  • AllergyIntolerance -> observation for supported contextual + * allergy/intolerance mappings; other coded statements use domain-based + * placement excluding drug_exposure and device_exposure; default observation
  • * + *

    Clinical placement follows the informative Vulcan IG's vocabulary-driven + * domain assignment. Supported destinations are condition_occurrence, + * procedure_occurrence, drug_exposure, device_exposure, measurement, and observation. + * Resolved clinical records are returned in the table corresponding to the + * domain of their mapped OMOP standard concept. The source resource + * determines eligibility, field values, and the default table when no + * supported mapping is available. + * AllergyIntolerance records do not produce drug_exposure or + * device_exposure rows. Device placement requires eligible use evidence: + * prescriptions, uncertain diagnoses, and explicit negative Observation + * results (false, SNOMED Negative, Known absent, or No, or interpretation + * code NEG from + * http://terminology.hl7.org/CodeSystem/v3-ObservationInterpretation) + * do not accept Device placement, even when other supplied values are positive. + * A recorder, asserter, or information source is retained as context rather + * than treated as the device event's provider.

    + *

    Coded AllergyIntolerance.code statements are interpreted using their + * original codings, optional type, and category. Supported substances + * produce an Observation finding separately from the causative substance + * in value_as_concept_id. Drug-domain substances are valid values and do + * not imply drug exposure or medication allergy. The finding is selected + * by an explicit mapping policy, not a published substance-to-finding + * crosswalk. Contextual findings report MAPPED, + * resolution.via: CONTEXTUAL. observation_source_concept_id retains + * the known OMOP identity of the original coding written to + * observation_source_value, including when the finding is unresolved; + * it is 0 when no source identity is known. The configured finding is + * never substituted for the original source concept.

    + *

    This adapts the informative R5 Vulcan Value-as-Concept + * pattern + * to R4's substance/product codes, optional type, and repeating category. + * It selects a contextual finding and resolves the substance separately; + * published Maps to value decomposition of a composite finding is not performed.

    + *

    For type: allergy, medication (optionally with biologic) maps to + * Allergy to drug (439224); other category sets use Allergic disposition + * (43530807). For type: intolerance, medication (optionally biologic) + * uses Intolerance to drug (4240582), food alone uses Intolerance to food + * (4340252), and other sets use Intolerance to substance (36684107). + * All selected concepts must be active and standard in the requested + * vocabulary release. Limited reviewed substance mappings can derive an + * absent category: for example, peanut and egg extracts supply food context, and + * Penicillin G supplies medication context. No category is inferred solely + * from Drug-domain membership.

    + *

    With absent type, a supported substance maps to a mechanism-neutral + * propensity finding: medication (optionally with biologic) uses Propensity + * to adverse reactions to drug (4305025), food alone uses Propensity to + * adverse reactions to food (4303802), and other category sets or an + * unknown category use Propensity to adverse reaction (4172024). The + * original type remains absent; no allergy or intolerance mechanism is + * inferred. The substance value is resolved independently. Existing + * supported findings can supply their own mechanism and negative + * statements retain their meaning. Conflicting evidence leaves the + * affected concept unmapped. + * A single identified substance can still populate the value when only + * its category context conflicts. Distinct conflicting substances, + * positive/negative mixtures, finding/substance mixtures, and multiple + * standard substance targets do not select a substance value by input + * order. Equivalent supported translations may share one value; + * unresolved translations are disclosed and are not used as substances. + * Finding-only, value-only, and neither-resolved partial rows are retained.

    + *

    mappings.resolution.allergy retains supplied type/categories, coded + * clinical/verification statuses, the date-source element, per-coding + * interpretation, derived categories, and reported information loss. The + * finding and substance have separate mappings linked to the same row and + * fields. Broader findings retain source specificity in this metadata. + * Codes outside contextual policy coverage retain existing domain-based + * mapping, with the contextual coverage outcome reported separately. + * Text-only inputs retain their text-only behavior. Reaction-substance + * fallback and published Maps to value relationships are not applied. + * Null placeholders in repeating FHIR category primitives carry no category + * value and are omitted from category interpretation and audit values. + * An invalid supplied type/category drops only the affected AllergyIntolerance + * before concept resolution. Other resources are still processed. The + * dropped entry identifies the resource and reports + * allergy_type_unrecognized or allergy_category_unrecognized.

    + *

    For example, a Condition coded with ICD10CM Z79.4 can produce a mapped + * observation row. Z86.73 and ICD-O-3 8140/3 also map to concepts in + * the Observation domain. Use mappings.omop_table and mappings.omop_id + * to locate each result; the FHIR resource type alone does not determine + * the output table.

    + *

    Clinical rows include available person/visit/provider links, concept and + * source concept IDs, source value, source type provenance, and start + * timing. Dates follow the source resource's timing rules below, regardless + * of the output table. For example, a Condition follows its + * onset[x] / recordedDate timing rule even when returned as an + * observation. End dates are populated only in tables with end columns; + * for rows not listed in rows_for_review, they also contribute to the + * request-local observation period when the output table has no end column.

    + *

    Source-specific fields are populated only for the corresponding source + * and destination: Condition status in condition_occurrence, medication + * dosage/supply and Immunization lot details in drug_exposure, and + * Observation values/units/ranges in measurement or observation where + * those columns exist. Mapping notes explain the output table and which + * fields are populated. Coded medication/immunization routes on rows outside + * drug_exposure, and Observation values on rows outside measurement / + * observation, are retained in mappings linked to the output row, with + * omop_field omitted and selected: false. Coded routes are checked + * against the OMOP Route domain; those Observation values are preserved + * without concept resolution.

    + *

    Note destinations are unsupported. If no standard concept + * valid for a supported destination is found, the resource produces a row + * in its default table with concept ID 0 and UNMAPPED status. Its + * source concept ID is also 0, except that allergy rows retain a known + * original source identity from their coding evidence. A DOMAIN_MISMATCH + * outcome is identified in the mapping + * note. Text-only clinical inputs can also produce rows in another supported + * table when semantic mapping selects a standard concept valid for that destination; + * without a validated match, they use their default tables. Domain-routed + * clinical records produce one row each even when their code has multiple + * vocabulary mapping targets; DeviceUseStatement event granularity is + * described below. Vocabulary Maps to value mappings are not applied.

    *

    Medication is reference data for medication resources; it does not * create its own row because OMOP CDM has no Medication table. Administrative * linkages (provider, care site, and location) are best-effort and limited to * references supplied in the request. Patient.managingOrganization is a - * record custodian, not a care-delivery site. Provider specialty is not - * mapped. Recorded Practitioner.gender is distinct from Person + * record custodian, not a care-delivery site. Provider specialties resolve + * independently to standard Provider concepts. Recorded Practitioner.gender + * is distinct from Person * demographics: male and female resolve to validated OMOP Gender * concepts in provider.gender_concept_id; other, unknown, and absent * gender remain unmapped. Address.country is resolved to location.country_concept_id, * and CMS Place of Service codings in Location.type are resolved to * care_site.place_of_service_concept_id. + * Within one request, Practitioners with the same single distinct supplied + * NPI value merge into one provider row only when all supplied mapped identity + * attributes in the entire NPI group agree. The NPI system may be + * http://hl7.org/fhir/sid/us-npi (preferred in FHIR R4), + * urn:oid:2.16.840.1.113883.4.6, or bare 2.16.840.1.113883.4.6; + * these forms are equivalent for matching. OID forms are accepted for compatibility. + * The same matching rule applies to Practitioners contained in valid role + * references. NPI values are compared exactly as supplied, without format + * or checksum validation. Blank values and unknown markers UNK and + * UNKNOWN are treated as missing and do not populate npi or participate + * in matching. Marker detection ignores case and surrounding whitespace; + * all other values are preserved without trimming. Other identifier systems and names + * do not trigger a merge. Missing attributes can enrich a compatible merged row. + * Compatibility compares the name rendered from each Practitioner's first + * name entry, its gender, its first non-empty DEA identifier, and its parsed + * birth year. Differences in birth month/day within one year do not conflict. + * Rendered names are compared exactly: Jane Smith and Dr. Jane Smith + * conflict even when they share an NPI. + * If any of these attributes conflict, every resource in that NPI group stays + * separate, including resources missing the conflicting attribute. Source + * attributes and unambiguous structural reference links are preserved; no + * conflicting value is selected. Each affected row has CONFLICTING + * diagnostics at name, gender, identifier, or birthDate, linked by + * omop_table: provider and omop_id. All source gender mappings link to + * their retained or merged provider row. + * Repeated copies of one NPI on a resource are allowed. A resource with + * multiple distinct NPIs stays separate, leaves npi unset, preserves its + * source-resource identity in provider_source_value, and reports a + * CONFLICTING diagnostic at identifier linked by + * omop_table: provider and omop_id. + * Row IDs are per response. The API does not merge across requests. For a + * Practitioner with one NPI, provider_source_value is that NPI, even when + * its group conflicts. Consumers can use it to deduplicate compatible + * providers across requests, but must preserve rows flagged by identity + * conflict diagnostics and check compatibility across responses. Without + * one unambiguous NPI, source-resource identity is preserved without a + * person-level deduplication guarantee. * A PractitionerRole that identifies one supplied Practitioner aliases * that canonical provider: by a top-level structural reference, a - * parent-contained #id reference, or an exact identifier.system and - * identifier.value match against a top-level Practitioner. No remote + * parent-contained #id reference, or an identifier.system and + * identifier.value match against a top-level Practitioner. Identifier + * matching uses the equivalent NPI systems and missing-value rules above; + * all other systems and all supplied identifiers match exactly. No remote * identifier lookup is performed. When Reference.type is present it must - * be Practitioner; duplicate contained IDs and identifier matches are - * ambiguous. An explicit reference that is unresolved, ambiguous, or - * unsupported retains a role-fallback provider row and is returned in - * diagnostics. provider_role_contexts preserves role-specific + * be Practitioner; duplicate contained IDs and identifier matches to + * different canonical providers are ambiguous. An explicit reference that + * is unresolved, ambiguous, or unsupported retains a role-fallback provider + * row and is returned in diagnostics. provider_role_contexts preserves role-specific * specialty and care-site context that a canonical OMOP provider row cannot * represent together.

    + *

    A role whose practitioner reference has only a blank or unknown NPI + * identifier follows the missing-identifier behavior: its fallback row has + * an UNSUPPORTED diagnostic explaining that a usable identifier or literal + * reference is required. An unambiguous literal reference still links to + * its Practitioner when the NPI is missing or unknown. Original supplied + * unknown markers remain in the role's practitioner_identifier metadata.

    + *

    Each PractitionerRole.specialty entry resolves independently using its + * supplied codings and explicit text from that entry. NUCC codings resolve + * directly; other supported codings may use the UMLS bridge. Text fallback + * is permitted when every coding has a nonblank code and a well-formed, + * unsupported system identifier. Supported systems, mixed supported and + * unsupported systems, and incomplete or malformed codings block fallback. + * A supported coding remains authoritative even when the text differs. + * Within one specialty entry, complete codings from well-formed unsupported + * systems do not block supported codings that independently resolve to the + * same standard Provider concept. Unsupported companions remain unsupported + * in the coding audit and acquire no crosswalk. Missing or malformed + * companions, failures of recognized codings, and disagreements between + * supported codings still block selection. For example, NUCC 208000000X alongside a complete local + * PEDS coding can resolve to Pediatric Medicine, with or without text + * and in either coding order. This uses the supported coding's mapping + * and source concept, rather than text fallback. + * Coding displays do not supply text fallback evidence. + * Unsupported system identifiers must be absolute URIs without embedded + * whitespace, user information, or fragments. HTTP(S) identifiers require + * a host; URNs require a namespace and a nonempty namespace-specific value. + * Bare labels such as Epic do not qualify. + * Text uses deterministic vocabulary assertions in order: standard Provider + * concept name, English OMOP synonym, then an English UMLS string on a + * CUI with a NUCC atom, then a NUCC preferred-term label with its grouping + * prefix removed. The last layer retains the entire classification and + * specialization label, including qualifications. An ambiguous layer stops + * resolution. No semantic search or ML matching is used. + * Each role's input remains visible in mappings, including repeated + * identical inputs across one provider's roles. + * specialty_concept_id is populated only when every distinct supplied + * input resolves to the same standard Provider concept. With no inputs, + * or an unresolved, ambiguous, or conflicting input, it stays 0. + * A role without a specialty contributes no input. The endpoint applies + * no frequency or hierarchy selection. Each input's outcome and matching + * route (layer, matched string, and UMLS CUI/SAB when applicable) appear + * in mappings with omop_field: specialty_concept_id. An individually + * resolved input can coexist with a provider concept of 0 when another + * input fails or disagrees. Specialty mappings have selected: false: + * the source text remains in specialty_source_value when supplied source + * values agree, and every original value remains in role contexts and mappings. + * Specialty mapping evidence includes specialty_resolution with the text + * fallback decision and each coding's independent outcome. A successful + * text fallback reports MAPPED and source concept ID 0; its local + * codings remain unsupported in that audit. For example, complete local + * codings with explicit Pediatrics text can resolve to Pediatric Medicine + * through NUCC_LABEL. Adding a different or unresolved specialty still + * keeps the provider's singular concept at 0. + * Compound phrasings without a vocabulary assertion remain unresolved. + * For example, Pulmonology stays unresolved while Pulmonologist + * resolves to Pulmonary Disease. Results depend on vocab_version.

    *

    Patient demographics:

    *
      *
    • Sex at birth, race, and ethnicity are read from these US Core @@ -169,22 +417,138 @@ public AsyncRawFhir2OmopClient(ClientOptions clientOptions) { * 0; and Vulcan's suggested observation rows for multiple * ethnicities are not produced.
    • *
    - *

    DiagnosticReport, ServiceRequest, CarePlan, DocumentReference, - * Composition, Specimen, DeviceUseStatement, Coverage, Claim, and - * other unsupported resource types are accepted in a Bundle but ignored: they + *

    Specimen produces a specimen row only with an explicitly resolvable + * top-level Patient subject. Missing collection dates or material retain + * useful partial rows with linked diagnostics; those rows may require + * downstream completion before loading into a strict CDM instance. + * References support Patient/id and exact Bundle + * fullUrl matches (including URNs and absolute URLs); contained, logical, + * display-only, missing, non-Patient, and unresolved subjects never use the + * sole Patient fallback. Reference.type, when supplied, must identify Patient. + * Unsafe patient attribution and ineligible status drop only that Specimen + * with dropped and diagnostics; a missing Patient in the entire request + * remains a 400. Missing material is concept ID 0 with a type diagnostic + * and no terminology mapping/summary count. Supplied unmapped material is + * also 0, with its separate vocabulary mapping outcome and retained source code. + * Material (type) resolves against the Specimen domain, coded UCUM + * collection quantity units against Unit, and collection.bodySite against + * Spec Anatomic Site. Supplied unmapped optional concepts are 0; absent + * unit and site IDs are explicit null. Text-only body sites preserve their + * source text with site concept ID 0 and an UNMAPPED mapping; they do not + * produce a text-based concept suggestion. Material descriptions follow + * the semantic-mapping policy above. Original source codes and text-only + * descriptions are preserved. + * Numeric quantity is copied without + * unit conversion; invalid or comparator-bearing quantities are omitted + * with diagnostics without dropping the row. Missing optional data is allowed. + * Collection time uses collection.collectedDateTime or + * collection.collectedPeriod.start; end-only, partial, invalid, and absent + * dates leave specimen_date/specimen_datetime omitted with linked diagnostics. + * Both collected forms together are conflicting: retain the row without + * selecting a date. Period end and receivedTime never substitute for collection + * start. specimen_source_contexts.collection_timing preserves raw dateTime, + * Period boundaries, receivedTime, and the selected source path when a date + * is populated, including partial precision and original timezone offsets. + * specimen_collection_date_missing, specimen_collection_date_partial, and + * specimen_collection_date_unusable distinguish absent, partial, and invalid + * source dates; specimen_collection_multiple_forms reports conflicting choices. + * specimen_type_concept_id: 32817 is EHR provenance, independent of material + * and availability. Status absent/available/unavailable/unsatisfactory is + * supported; entered-in-error and unrecognized statuses drop the specimen. + * specimen_source_contexts.status preserves the supplied availability + * status for emitted rows; it is omitted when no status was supplied. + * Disease status is not inferred. Source identity precedence is resource id, + * then first identifier with a nonempty value in source array order, then + * accessionIdentifier.value, then Bundle fullUrl. No identity is fabricated; + * specimen_source_contexts preserves all valued identifier system/value + * pairs, accession identity, resource id/fullUrl, and the selected source path. + * Parent/derived specimens emit independently when each has its own Patient; + * missing date/material remains partial rather than being inherited. Parent + * and Measurement-to-Specimen links are not represented. Consuming CDM + * loaders own required-date completion, durable IDs, and dataset completeness. + * Source baseline is FHIR R4 4.0.1, with supported common fields reviewed against + * US Core Specimen 7.0.0; full profile conformance is not validated here.

    + *

    Device use and exposure

    + *

    DeviceUseStatement accepts active, completed, stopped, and on-hold use; + * intended, entered-in-error, missing, and unrecognized statuses are omitted + * with source-specific diagnostics. Its subject must explicitly resolve to + * an emitted Patient. Group, contained-Patient, identifier-only, external + * fetching, and sole-Patient attribution are unsupported for this path. + * Device references support exact Type/id and Bundle fullUrl/URN matches, + * plus unambiguous contained Devices scoped to the referring statement. + * Missing, wrong-type, unresolved, or ambiguous Device details retain a + * known-patient use row with device concept 0 and diagnostics. Missing type + * evidence has no vocabulary mapping entry; supplied unmapped type evidence + * has a mapping outcome. Inventory-only Device records emit no exposure.

    + *

    timingDateTime supplies the start only; timingPeriod preserves supplied + * boundaries independently. Each timingTiming.event produces a separate + * row, including a partial row for an unusable event, with its zero-based + * event_index in device_exposure_contexts.exposures. Repeat rules without events + * retain one partial row; events plus rules produce only the event rows. + * No recurrence, continuous min/max interval, status-derived end date, or + * recordedOn fallback is inferred. Year/month precision and malformed + * individual dates leave affected columns absent. Competing timing choices + * or a provably reversed Period leave both bounds absent. Supplied precision, + * offsets, schedules, and omission/conflict reasons remain in source context + * and diagnostics. Other clinical paths retain their own timing semantics.

    + *

    device_exposure_contexts contains one entry per source record that produced + * device exposures. Its exposures array links all rows derived from that + * record by device_exposure_id and, for Timing events, event_index. Shared + * timing, device evidence, and quantity_basis appear once per source record. + * Contexts are not combined by resource id or device identity; distinct + * records remain separate even when their identifiers match or are absent.

    + *

    device_source_value uses original type/clinical coding, never inventory + * or resource identity. An unambiguous Device.udiCarrier.deviceIdentifier + * supplies unique_device_id. Competing identifiers leave it absent and are + * preserved with issuer/jurisdiction/carrier context. production_id remains + * absent; carriers, lot, serial, manufacture, and expiry evidence remain in + * device_exposure_contexts.

    + *

    DeviceUseStatement rows use device_type_concept_id 32817 (EHR), with + * provider_id, visit_occurrence_id, and visit_detail_id unset. + * Routed rows retain their source classification, explicit visit links to + * emitted same-patient visits, and compatible performer links; + * Condition/Procedure recorder/asserter and + * MedicationStatement informationSource do not supply device provider_id. + * Supplied source, encounter, context, recorder, asserter, performer, and + * informationSource values on DeviceUseStatement are retained only in + * device_exposure_contexts.

    + *

    Device exposure quantity is reported as follows:

    + *
      + *
    • When the source has no quantity, dose, rate, or numeric Observation + * result field, quantity is 1 and device_exposure_contexts.quantity_basis + * is default_absent_count.
    • + *
    • When such a field is supplied, including a null or malformed value, + * quantity is omitted and quantity_basis is unsupported_supplied_evidence. + * The original values remain in device_exposure_contexts.evidence, and + * an UNSUPPORTED diagnostic identifies each supplied field.
    • + *
    + *

    For example, an Observation with valueQuantity.value of 2 returned as a + * device exposure has no quantity; the value 2 remains in its source context. + * timingTiming.repeat.count remains in source context and does not set + * quantity. unit_concept_id and unit_source_concept_id are always null.

    + *

    Distinct eligible statements and routed clinical records remain separate, + * including overlapping use and equal Timing events. derivedFrom is retained + * as supporting context, not duplicate identity. Row counts therefore need + * not count distinct uses or physical devices. IDs are request-local exposure + * identities. Before loading a CDM dataset, callers must assign durable IDs + * and populate any required fields omitted from partial rows.

    + *

    Unsupported resource types are accepted in a Bundle but ignored: they * create no row and no dropped entry. dropped is reserved for supported * row-producing resources that cannot safely produce a positive OMOP row: * required subject/patient, clinical code/text, or medication data may be * unusable, or the resource may explicitly negate or fail the documented * clinical-event eligibility policy. A single-Patient Bundle uses the sole - * Patient only when subject/patient is absent. An explicit subject/patient + * Patient only when subject/patient is absent, except DeviceUseStatement + * and Specimen, which require explicit Patient subjects. An explicit subject/patient * reference that is unresolved, ambiguous, or unsupported drops the clinical * resource in every request scope.

    *

    Eligibility distinguishes clinical validity, performed/taken evidence, and - * administrative workflow state. Checks run before terminology resolution; - * entered-in-error and explicitly non-performed events never produce a positive - * clinical-event row, and an unrecognized required status fails closed. Condition - * requires absent or confirmed canonical-HL7 verificationStatus; clinical course is not a + * administrative workflow state. Entered-in-error and explicitly + * non-performed events never produce a positive clinical-event row, and + * unrecognized required statuses are dropped except the retained Condition and + * Encounter cases below. Condition accepts absent or confirmed canonical-HL7 + * verificationStatus without a status retention reason; uncertain or unreadable + * supplied verification is retained and indexed. Clinical course is not a * diagnosis-role mapping. Procedures accept completed or stopped events. * Medication requests are prescription evidence only: doNotPerform, drafts, * cancellations, and non-authorizing intents are dropped. Medication statements @@ -193,69 +557,142 @@ public AsyncRawFhir2OmopClient(ClientOptions clientOptions) { * administration also needs an effectiveDateTime or effectivePeriod.start * that supplies start evidence; a valid partial date remains an undated row. * An on-hold administration is started evidence that is temporarily paused and - * expected to continue. Immunizations require completed - * status. Observations require final, + * expected to continue. MedicationDispense accepts completed records, + * including completed partial fills. Stopped records require a valid + * whenHandedOver, including partial dates, as evidence of actual handover. + * Preparation, in-progress (ready for pickup), on-hold, cancelled, declined, + * entered-in-error, unknown, missing, and unrecognized statuses are dropped. + * A completed dispense with missing or unusable handover timing remains a + * partial row; preparation time never substitutes for handover. + * Immunizations require completed + * status. Observations accept final, * amended, or corrected status; registered, - * preliminary, cancelled, entered-in-error, unknown, and missing statuses are + * preliminary, cancelled, entered-in-error, and missing statuses are * dropped. AllergyIntolerance accepts absent, unconfirmed, or confirmed canonical-HL7 * verificationStatus as a reported allergy, but drops refuted, - * entered-in-error, and unreadable supplied verification statuses. Encounter + * entered-in-error, and unreadable supplied verification statuses. Invalid + * supplied allergy type/category also drops that resource, for both coded + * and text-only statements. Encounter * accepts arrived, triaged, in-progress, onleave, or finished status, but drops - * planned, cancelled, entered-in-error, unknown, and missing statuses. An eligible - * encounter without Period.end remains a partial visit; no end date is invented.

    - *

    Coded Observation routing is selected from the resolved OMOP concept - * domain. Numeric and nonnumeric value[x] forms establish the preferred - * target only when the code is valid for both tables. A text-only - * Observation has no resolver target, so numeric values route to - * measurement and nonnumeric values to observation. Numeric values - * populate value_as_number in the selected row; other non-coded values + * planned, cancelled, and entered-in-error statuses. Unknown, missing, and unreadable + * Encounter statuses are retained and indexed without imputation. An eligible + * encounter without Period.end remains a partial visit; no end date is invented. + * MedicationStatement, Observation, and Procedure with explicit unknown + * status are also retained and indexed in rows_for_review when otherwise + * shapeable. Missing or unreadable statuses on these three resource types + * remain dropped. Retention preserves source status and does not establish + * that an event occurred or medication was taken. Device placement still + * requires eligible use evidence; unknown-status records cannot produce + * device_exposure rows.

    + *

    The mapped OMOP concept's domain determines the clinical output table. + * For Observations, numeric and nonnumeric value[x] forms determine the + * preferred table only when the code is valid for both measurement and + * observation. Unresolved Observations retain measurement rows for + * numeric values and observation rows for other values. In these two + * tables, numeric values populate value_as_number; other non-coded values * populate value_as_string for an observation or value_source_value * for a measurement. Coded valueCodeableConcept values are resolved - * against the selected row's value_as_concept_id and use the selected + * against value_as_concept_id in these tables and use the selected * bare code in value_source_value, leaving an observation's * value_as_string empty; unmapped or target-invalid coded values remain - * 0. + * 0. Active standard concepts from any OMOP domain are valid for + * value_as_concept_id per CDM 5.4. For example, a blood-culture Measurement + * can record Staphylococcus aureus as its coded result, even though the + * organism concept belongs to OMOP's Observation domain. + * For other clinical output tables, Observation values are retained only + * in mappings, without concept resolution or an omop_field, and with + * selected: false. * Other unsupported value[x] forms and Observation components do not * populate separate converted values. A * numeric comparator (<, <=, >, >=) is represented only by a - * measurement's operator_concept_id; units remain source text and have - * unit_concept_id of 0.

    + * measurement's operator_concept_id.

    + *

    For Observations returned as measurement or observation rows, + * Quantity units resolve independently against the table's + * unit_concept_id. + * Only coded UCUM (http://unitsofmeasure.org or its + * OID 2.16.840.1.113883.6.8, bare or urn:oid:) is supported. Codes are + * case-sensitive: no spelling aliases, text-only guesses, or numeric + * conversion are applied. Coded UCUM units map to valid standard OMOP + * Unit concepts using the release selected by vocab_version. + * Mappings are direct or follow an active OMOP Maps to relationship; + * UCUM units do not use a UMLS bridge. + * Unknown codes, + * vocabulary gaps, text-only units, missing coding, and unsupported + * systems keep the row and use 0; absent units use JSON null (the + * field is present). Explicit dimensionless code 1 is a supplied unit + * and resolves or remains 0; it is never treated as absent. + * unit_source_value contains Quantity.code when supplied, otherwise + * Quantity.unit. A separate unit mappings entry preserves the original + * Quantity.system/code in source_system/source_code and the exact + * human-readable Quantity.unit in source_name, with its own outcome. + * Quantity.unit provides display text and does not affect unit matching. + * Measurement also returns nullable unit_source_concept_id, identifying + * the OMOP source concept for Quantity.system/code when available, + * otherwise 0 for a supplied unit. + * Observation has no such column. Resolving a unit does not change + * numeric values, comparators, or reference ranges. + * Supported coded UCUM units include resolution in their mappings, + * whether the unit resolves or remains unmapped: + * its outcome and reason code distinguish vocabulary gaps, target-domain + * mismatches, and ambiguity. Unresolved or ambiguous units retain the row + * with concept ID 0. Resolved mappings retain the selected concept's + * direct or crosswalk provenance. Text-only units, units with missing + * coding, and unsupported unit systems omit resolution; their note + * explains the unmapped outcome.

    + *

    For Observations returned in other clinical tables, units are not resolved + * or included as separate mapping outcomes. Device exposure unit IDs remain + * null, with supplied quantity and unit evidence retained in + * device_exposure_contexts.

    + *

    Compatibility: absent unit_concept_id values previously returned 0 + * and now return null. Coded units use their code in unit_source_value + * rather than their human-readable text; the text remains in mappings. + * The Measurement unit_source_concept_id field is new.

    *

    A standard OMOP concept_id is selected for each eligible primary clinical coding * after considering all of the resource's supplied codings. An unambiguous - * coded medication route is resolved independently to - * drug_exposure.route_concept_id. Alongside the OMOP rows grouped by + * coded medication or immunization route is checked independently against + * the OMOP Route domain. For drug_exposure rows, route_concept_id + * contains the resolved concept ID, or 0 if unmapped; for other output + * tables the route is reported only in + * mappings, without omop_field and with selected: false. + * Alongside the OMOP rows grouped by * table (tables), the response carries mappings (an entry for every * supported source coding from a resource that shaped a row, linked back to * that row; some, such * as demographic null flavors, are reported without being resolved), + * rows_for_review (an index of uncertain-status or primary-concept-0 clinical + * rows in tables, with original statuses, reasons, and source/row identity), * provider_role_contexts (source role details linked to provider rows), * dropped (resources that could not be shaped into a row), * diagnostics (explicit references that could not safely create a link, - * and conflicting or unsupported Patient demographic extensions), + * conflicting Practitioner identity attributes, conflicting or unsupported + * Patient demographic extensions, and conflicting Encounter classifications), * vocab_version (the OMOP vocabulary release codes were resolved * against), and a small summary of the resolution outcomes.

    *

    A concept_id of 0 is reported, not omitted (OMOP "no matching - * concept" semantics): it covers both a coding with no standard match - * (UNMAPPED) and an unverified suggestion for a text-only resource - * (UNCHECKED). Visit and unit concept fields currently remain 0; - * Person demographics and Provider recorded gender follow the policies - * above. Coded Observation values may populate value_as_concept_id. - * Concepts set by a fixed convention rather than terminology resolution - * are measurement operator_concept_id, set from a value comparator (<, - * <=, >, >=), and the multiple-race concepts described above. Clinical - * *_source_value fields contain the selected FHIR code (or source text - * for text-only resources). + * concept" semantics): it covers a supplied concept without a validated + * standard match (UNMAPPED). + * Supported Encounter.class or Encounter.type values + * may populate visit_concept_id. Person demographics and Provider + * recorded gender follow the policies above. + * Coded Observation values and supported allergy substances may populate + * value_as_concept_id. + * Measurement operator_concept_id represents a value comparator (<, + * <=, >, >=); multiple-race concepts follow the conventions described + * above. Clinical *_source_value fields contain the selected FHIR code + * (or source text for text-only resources). * The corresponding selected mappings entry preserves the coding system * and full source-coding provenance. * Known OID-form coding systems are accepted as either FHIR OID URNs (for * example, urn:oid:2.16.840.1.113883.6.1 for LOINC) or bare OIDs, and - * are normalized to their canonical system URLs before terminology - * resolution. mappings[].source_system reports that canonical URL, so the - * OID and URL forms produce the same mapping. An unknown OID is not - * rewritten and may be UNMAPPED. + * produce the same mappings as their canonical system URLs. Coding + * mappings report that canonical URL in source_system; Quantity unit + * mappings preserve the original Quantity.system, including OID spelling. + * An unknown OID remains unchanged in source_system and may be + * UNMAPPED. * Other *_source_value fields preserve row-specific raw source values, * such as resource identifiers, names, units, or status codes. - * MedicationRequest uses 32838 (EHR prescription) for + * MedicationRequest uses 32838 (EHR prescription) and + * MedicationDispense uses 32825 (EHR dispensing record) for * drug_type_concept_id; other current resources use 32817 (EHR). This * is a coarse provenance policy: it does not infer patient-reported, * medication-history, or other more-specific type concepts from FHIR @@ -268,8 +705,8 @@ public AsyncRawFhir2OmopClient(ClientOptions clientOptions) { * (YYYY-MM-DDTHH:MM:SS, with fractional seconds to microseconds when * supplied): 2024-01-15T23:30:00-05:00 becomes 2024-01-15T23:30:00. * A datetime without a timezone is read as local time. - *

  • A partial date (2024 or 2024-03) leaves both columns unset. It - * still counts as that source's value, so later sources in the list + *
  • A partial date (2024 or 2024-03) leaves both columns unset. + * It still counts as that source's value, so later sources in the list * below are not used.
  • *
  • Free-text timing (such as onsetString), Age and Range forms, and * values that are not dates are ignored, so a later source in the list @@ -279,11 +716,18 @@ public AsyncRawFhir2OmopClient(ClientOptions clientOptions) { * completed, and a row is not dated from another resource such as its * Encounter.
  • *
  • Valid date values never cause a request to be rejected, and the response - * does not report which date elements were unusable. An on-hold + * reports missing, partial, malformed, or conflicting timing for Specimen, + * DeviceUseStatement, and MedicationDispense in diagnostics while retaining + * partial rows for downstream load completion. Other resources do not report which + * date elements were unusable. An on-hold * MedicationAdministration is an eligibility exception: it needs a supplied - * effective start as evidence that administration began.
  • + * effective start as evidence that administration began. + * A stopped MedicationDispense similarly requires supplied handover evidence. * - *

    Timing sources, in priority order where several are listed:

    + *

    Timing sources, in priority order where several are listed. These rules + * apply regardless of the clinical output table; end dates are populated + * only where the table has an end column, and verbatim_end_date is set + * only for medication resources returned as drug_exposure rows:

    *
      *
    • Patient: birthDate sets year_of_birth, month_of_birth, and * day_of_birth from the parts it supplies; birth_datetime is not @@ -295,6 +739,12 @@ public AsyncRawFhir2OmopClient(ClientOptions clientOptions) { * end from abatementDateTime or abatementPeriod.end.
    • *
    • Procedure: start from performedDateTime or * performedPeriod.start; end from performedPeriod.end only.
    • + *
    • DeviceUseStatement: timingDateTime sets the start only; + * timingPeriod.start and timingPeriod.end supply independent bounds; + * each timingTiming.event produces one start-only row. A schedule + * without explicit events produces one row without dates.
    • + *
    • Specimen: collection.collectedDateTime or collection.collectedPeriod.start; + * missing day precision retains an undated specimen with diagnostics.
    • *
    • Observation: effectiveDateTime, effectivePeriod.start, or * effectiveInstant.
    • *
    • AllergyIntolerance: recordedDate, then onsetDateTime or @@ -311,21 +761,212 @@ public AsyncRawFhir2OmopClient(ClientOptions clientOptions) { *
    • MedicationRequest: authoredOn, the order date, sets the start; it * is not evidence of administration. No end is set, and the validity * period is not used as an exposure duration.
    • + *
    • MedicationDispense: whenHandedOver sets the start. Missing, partial, + * or invalid handover leaves the start unset with a diagnostic. Comparable + * handover/preparation values proving handover before preparation also + * leave the start unset with a CONFLICTING diagnostic. whenPrepared, + * authorizing orders, and related administrations never supply timing. + * No exposure end or verbatim_end_date is inferred, including from + * daysSupply. Raw handover/preparation values retain precision and offsets + * in medication_dispense_source_contexts.source.
    • *
    • Differences from the Vulcan maps: birth_datetime is not set from * birthDate, Condition also reads onsetPeriod.start, and * AllergyIntolerance falls back to its onset when recordedDate is * missing.
    • *
    - *

    Medication details:

    + *

    Medication details for resources returned as drug_exposure rows:

    *
      *
    • For MedicationRequest, dispenseRequest.numberOfRepeatsAllowed sets * refills and a whole-day expectedSupplyDuration sets days_supply.
    • + *
    • For MedicationDispense, daysSupply sets days_supply only for a + * positive whole number of coded UCUM d days without a supplied comparator. + * The canonical UCUM URL, bare OID, and OID URN are accepted. Other units, + * fractions, text-only units, and malformed values leave it absent with a + * diagnostic; absent daysSupply is allowed. Refills are not inherited.
    • *
    • All non-empty dosage text is preserved in sig.
    • *
    • Coded dosage routes and Immunization.route are target-validated in * the OMOP Route domain. Conflicting routes are left unset; route * codings shared by every dosage instruction identify the same route.
    • *
    • Immunization.lotNumber is preserved in lot_number.
    • *
    + *

    MedicationDispense requires an explicit subject resolving to a Patient + * supplied in the request; Groups and missing subjects are unsupported. + * Source fields are based on FHIR R4 4.0.1 and reviewed against + * US Core MedicationDispense 7.0.0. + * Full profile conformance is not validated. Although US Core requires + * handover on completed records, this API retains such incomplete records + * with missing-handover diagnostics as partial conversion output. + * Supply either a usable medicationCodeableConcept (coded or text-only), + * or a medicationReference resolving to a Medication with usable code/text. + * Both choice forms together are conflicting and drop the dispense. + * Exact relative, Bundle fullUrl, and unambiguous contained Medication + * references are supported. Unresolved references are not rescued by display + * text; missing or ambiguous medication identity drops the dispense with a + * diagnostic. A usable but unmapped drug retains concept ID 0 and its + * mapping outcome in a row indexed for review. + * context links only to a supplied Encounter for the same patient. + * The first resolvable Practitioner/PractitionerRole in performer.actor + * supplies provider_id; other actor types do not supply a provider. + * Failed explicit links have diagnostics. Authorizing prescriptions are + * checked for request-local MedicationRequest identity and patient agreement; + * failures do not discard the dispense. They never supply medication, + * instructions, refills, timing, or prescription provenance to a dispense. + * Dispense instructions alone supply sig and route; conflicting routes + * remain unset with a diagnostic and original instructions in source context. + * Orders, dispenses, and administrations emit independently, with separate + * source identity and no deduplication merely because they concern the same drug. + * Every emitted dispensing drug row has a whenHandedOver/daysSupply + * diagnostic beginning medication_dispense_end_date_absent because the + * CDM-required exposure end remains absent. Missing/partial/unusable handover + * diagnostics begin medication_dispense_handover_missing, + * medication_dispense_handover_partial, or medication_dispense_handover_unusable.

    + *

    Encounter details:

    + *
      + *
    • + *

      Encounter classifications must supply both Coding.system and + * Coding.code to map to a standard OMOP Visit concept. For HL7 + * encounter class codes with system + * http://terminology.hl7.org/CodeSystem/v3-ActCode or + * http://hl7.org/fhir/v3/ActCode, the supported mappings are:

      + *
        + *
      • IMP (inpatient encounter) -> 9201 (Inpatient Visit)
      • + *
      • AMB (ambulatory encounter) -> 9202 (Outpatient Visit)
      • + *
      • EMER (emergency encounter) -> 9203 (Emergency Room Visit)
      • + *
      • VR (virtual encounter) -> 722455 (Telehealth)
      • + *
      • HH (home health encounter) -> 581476 (Home Visit)
      • + *
      + *

      OBSENC (observation encounter) has no direct class mapping. Other + * class or type codes can be used when they have a valid standard OMOP + * Visit mapping in the selected vocabulary release. Unsupported + * classifications retain visit_concept_id of 0 unless a supported + * type supplies the Visit as described below; they never default to + * outpatient.

      + *
    • + *
    • + *

      A mapped Encounter.class determines visit_concept_id. A mapped + * type that disagrees with it produces a CONFLICTING diagnostic with + * path class/type, while the class mapping remains selected. + * If class is missing or unmapped, type supplies the Visit only when + * all mapped type classifications agree on the same standard Visit + * concept. Codings within one type CodeableConcept are alternative + * encodings of that classification. When separate type classifications + * map to different Visit concepts, no type is selected, + * visit_concept_id remains 0, and a CONFLICTING diagnostic has + * path type.

      + *
    • + *
    • + *

      visit_source_value contains the selected class or type code; + * mappings preserves its coding system and identifies which + * classification was selected. visit_source_concept_id identifies + * the selected original source coding's OMOP concept when available, + * and is 0 otherwise, including for the HL7 encounter class codes + * listed above. Each eligible Encounter produces one Visit; + * classHistory, partOf, and admission/discharge concepts are not + * mapped.

      + *
    • + *
    • + *

      Direct MedicationRequest.dispenseRequest.quantity, MedicationDispense.quantity, + * MedicationAdministration.dosage.dose, and Immunization.doseQuantity + * can set quantity under the formulation policies below. Quantities + * must be positive finite numbers with a supported system/code pair + * and no supplied comparator. Ingredient amounts and injection volumes + * use http://unitsofmeasure.org (or its bare OID or + * urn:oid:2.16.840.1.113883.6.8) with case-sensitive codes matching or + * convertible to vocabulary-backed strength units. Exact unit matches, + * including [U] for insulin and heparin strengths, are accepted without + * a unit conversion. + * When codes differ, both source and strength units must belong to the + * same supported group: mass (ug, mg, g), volume (uL, mL, L), + * or milliequivalent representations (meq, 10*-3.eq). Other conversions, + * including kg, ML, Mg, [drp], mol, mmol, %, 1, and generic + * annotations such as {vial} or {syringe}, leave quantity unset with + * unit_mismatch. Codes are not case-corrected or inferred from display text. + * The original source system is preserved in the quantity audit. + * Fixed-form counts also accept the explicit codings below. Free-text, + * local, invalid, and cross-dimensional units are not interpreted.

      + *
    • + *
    + *

    Drug quantity policies follow CDM v5.4 DRUG_EXPOSURE conventions. + * The selected drug must be a Clinical Drug or Branded Drug, with one + * active dose form and valid vocabulary evidence for the chosen policy. + * Strength-based conversions also require one ingredient strength. Supported RxNorm dose forms:

    + *
      + *
    • fixed_dose_form_count: Oral Tablet (317541), Oral Capsule + * (316965), Extended Release Oral Tablet (316945), Extended + * Release Oral Capsule (316943), Delayed Release Oral Tablet (10312), + * or Delayed Release Oral Capsule (316995). A source ingredient amount is divided + * by the single positive per-unit ingredient amount after conversion to that + * strength unit when necessary. + * Concentration-based strengths cannot derive counts from ingredient amounts. The + * result is a count of the selected dose form, limited to whole units or + * multiples of one-half or one-third. Derived counts within a relative + * tolerance of 1e-12 of an allowed fraction are normalized to that count + * to remove arithmetic roundoff; other fractions remain unsupported. + * For example, 1,000 mg for a drug + * with 500 mg per tablet produces quantity: 2 tablets (DERIVED). A source + * of 1 g is converted to 1,000 mg and produces the same count. + * Explicit counts are copied (DIRECT) when their coding matches the + * selected tablet or capsule form, including the corresponding extended- + * and delayed-release forms: TAB / CAP under + * http://terminology.hl7.org/CodeSystem/v3-orderableDrugForm, or + * {tbl} / {Capsule} under http://unitsofmeasure.org. These exact + * case-sensitive count codings identify the supplied form; they are not + * dimensionally converted as UCUM units. Direct counts do not require + * ingredient strength: combination products and missing/invalid strengths + * do not change the supplied count. Ingredient-strength gaps, including + * missing ingredient or strength-unit references, do not prevent copying + * an explicit count. Drug or dose-form evidence gaps, unclassified or + * ambiguous forms, and gaps whose relevance cannot be established still + * prevent conversion. The whole/half/third rules still apply. + * For example, 20 TAB for amoxicillin/clavulanate is 20 tablets, and 30 TAB for + * a supported tablet produces quantity: 30; CAP for that tablet is + * omitted with unit_mismatch. Generic counts (1, {Each}), other + * annotations, packs, and free-text counts are not interpreted. + * See the FHIR R4 count example.
    • + *
    • clinical_injection_ingredient_amount: Injectable Solution (316949), + * Injectable Suspension (316950), or Injection (1649574). A source + * ingredient amount is converted to the vocabulary ingredient unit when + * necessary; an exact-unit amount is copied directly. Otherwise, a source + * amount convertible to the concentration's denominator unit is converted + * and multiplied by numerator value / the effective denominator reported in + * the audit evidence. Volume conversion requires a positive finite numerator, + * dimensionally distinct validated UCUM units, a null raw denominator value, and + * evidence.strength.denominator_basis with kind: PER_UNIT and + * value: 1. A null denominator alone is not interpreted as 1. Missing, unknown, + * non-applicable, quantified, or conflicting denominator evidence does + * not support volume conversion. For example, 80 mL with 2 mg/mL + * strength and a PER_UNIT basis produces quantity: 160 mg. A supplied + * 160 mg amount for the same drug produces quantity: 160 directly. A source + * of 0.08 L converts to 80 mL before producing the same 160 mg result. + * This output is already + * the ingredient amount: do not apply the drug-dose guide's + * dose = quantity × numerator formula to these injection rows, which + * would multiply by the concentration a second time. Use + * quantity_transformations.result_unit to identify the output unit.
    • + *
    + *

    Quantified products, packs, prefilled syringes, other liquids and dose + * forms, strength-based conversions of combination products, conflicting or incomplete required evidence, and + * unsupported source quantities leave quantity unset. Other supported + * fields and the clinical row are still returned. For example, a quantity + * supplied only as value: 2, unit: tablet is omitted with + * unsupported_unit, because a free-text unit cannot establish this policy. + * Rate-only administrations and MedicationStatement dosages do not establish + * a supported total quantity and are not used for quantity conversion.

    + *

    quantity_transformations audits each supplied quantity at one of the + * three direct paths above on a resource returned as a drug_exposure row, + * including an explicit null or malformed quantity. Each entry preserves + * the source, identifies the destination row, and reports DIRECT, DERIVED, or + * OMITTED. Successful entries identify their policy, evidence, result, + * and result unit; omitted entries give a stable reason. When a source code + * differs from the strength unit, unit_conversion records the intermediate + * converted amount and target unit. Converted results are DERIVED, even + * when no further concentration arithmetic is needed. A successful conversion + * can also be reported when a later policy check omits the final quantity. + * Dose-form and unit + * concept IDs refer to the response's vocab_version. Missing quantities + * produce no entry. Resources returned in other clinical tables have no + * quantity entry. Unsupported rates and statement dosages produce no entry, + * and resources in dropped have no quantity entry or destination row.

    *

    Medication codes are resolved whether they appear inline * (medicationCodeableConcept) or via a medicationReference to a contained, * relative (Type/id), or bundle-entry (urn:uuid) Medication resource. @@ -337,11 +978,12 @@ public AsyncRawFhir2OmopClient(ClientOptions clientOptions) { * subject/patient reference cannot be tied to a person; or an event excluded * by eligibility — are reported under dropped rather than emitted as blank * rows. The Bundle must contain at least one Patient resource.

    - *

    Structural references resolve only to top-level resources supplied in the - * request, by Type/id or an exactly matching Bundle fullUrl (including - * urn:uuid). Contained references are supported for medication code lookup - * and PractitionerRole.practitioner enrichment; the latter also supports - * exact request-local identifier matching without a remote lookup. Every + *

    References to top-level resources supplied in the request resolve by + * Type/id or an exactly matching Bundle fullUrl (including urn:uuid). + * Parent-scoped contained references are supported for medication code lookup, + * DeviceUseStatement.device details, and PractitionerRole.practitioner + * enrichment; the latter also supports + * request-local identifier matching with the NPI rules above, without a remote lookup. Every * nonzero structural foreign key targets a row in the same response. Missing * optional links remain unset without a diagnostic; an explicit optional * reference that is unresolved, ambiguous, conflicting with the row's @@ -363,11 +1005,42 @@ public CompletableFuture> create(C /** * Maps a FHIR R4 resource or Bundle into OMOP Common Data Model v5.4 rows, - * grouped by destination table in tables. + * grouped by destination table in tables. Uncertain-status and unresolved- + * primary-concept clinical rows stay in those tables and are identified by + * the optional rows_for_review index for consumer review and filtering. + * Status-based retention covers uncertain supplied Condition verification, + * unknown/missing/unreadable Encounter status, and explicit unknown status + * on MedicationStatement, Observation, and Procedure. Other status exclusions + * follow the resource-specific eligibility policy below. *

    Set vocab_version to select the OMOP vocabulary release used for coded - * concept resolution. If omitted or empty, the API uses its default - * release. The response's vocab_version, when present, identifies the - * release used. Specify a release explicitly when reproducibility matters.

    + * and inferred concept resolution. If omitted or empty, the API uses its + * default release. The response's vocab_version, when present, identifies + * the release used. Specify a release explicitly when reproducibility matters.

    + *

    Supported concept fields use semantic mapping for text-only inputs and + * unresolved source codings, with whole-term equivalence validation. + * CodeableConcept text takes precedence over coding display. A single + * equivalent suggestion must be validated before a real OMOP concept ID is + * returned. The suggested vocabulary/code must + * itself be an active standard concept valid for the destination; broader + * crosswalk targets are not accepted for inferred suggestions. + * Deterministic mappings take precedence; ambiguous or domain-mismatched + * deterministic results are not reinterpreted. Units, routes, visits, + * demographics, and collection sites use their field-specific resolution + * policies. Semantic mapping returns MAPPED with a real concept ID on + * success, or UNMAPPED with concept ID 0 when no standard concept is + * selected. A bare code is not treated as a description. No source coding + * is invented.

    + *

    Contextual allergy finding decisions follow their dedicated policy and + * are not replaced by description-based mapping.

    + *

    Every inferred mapping has + * mappings.resolution.semantic_mapping.status: MAPPED, with the OMOP + * vocabulary release. Filter linked rows using the mapping's table/row ID + * to exclude inferred results. + * Original source coding and clinical-status eligibility are preserved. + * For clinical rows covered by rows_for_review, successful inference + * does not remove uncertain-status rows from the index; unresolved primary + * concepts also remain indexed. + * All shaped rows appear once in tables.

    *

    Standards basis: FHIR R4 (v4.0.1) defines * the accepted source elements and OMOP CDM * v5.4 defines the @@ -390,46 +1063,262 @@ public CompletableFuture> create(C * first address can produce location, and more than one supplied race * produces observation race rows (see Patient demographics below) *

  • observation_period -> one request-local derived row per person, - * spanning the populated dates of that person's visit, clinical, and - * death rows; this is not enrollment or capture-completeness evidence
  • + * spanning dates from that person's visit and clinical rows not listed + * in rows_for_review, plus Specimen and Death rows. Source end dates + * contribute even when the output table has no end column. No period + * row is returned for a person without qualifying dates; this is not + * enrollment or capture-completeness evidence *
  • Location -> location and care_site
  • *
  • Organization -> care_site; its first address can produce location
  • *
  • HealthcareService -> care_site
  • *
  • Practitioner and PractitionerRole -> provider
  • *
  • Encounter -> visit_occurrence
  • - *
  • Condition -> condition_occurrence
  • - *
  • Procedure -> procedure_occurrence
  • - *
  • MedicationRequest, MedicationStatement, and - * MedicationAdministration -> drug_exposure
  • - *
  • Immunization -> drug_exposure
  • - *
  • Observation -> measurement or observation. For coded - * Observations, the resolved OMOP concept domain selects the table; value - * form only breaks ties. For text-only Observations, numeric values route - * to measurement and nonnumeric values to observation.
  • - *
  • AllergyIntolerance -> observation
  • + *
  • Specimen -> specimen, with an explicit Patient subject; missing + * collection date or material retains a partial row with diagnostics
  • + *
  • DeviceUseStatement -> device_exposure, using referenced Device.type
  • + *
  • Condition -> domain-selected clinical table; default condition_occurrence
  • + *
  • Procedure -> domain-selected clinical table; default procedure_occurrence
  • + *
  • MedicationRequest, MedicationStatement, MedicationAdministration, and + * MedicationDispense -> domain-selected clinical table; default drug_exposure
  • + *
  • Immunization -> domain-selected clinical table; default drug_exposure
  • + *
  • Observation -> domain-selected clinical table. Numeric values prefer + * measurement and other values prefer observation when the code is + * valid for both tables. + * If no supported standard concept is selected, numeric values retain the + * measurement default and nonnumeric values retain observation.
  • + *
  • AllergyIntolerance -> observation for supported contextual + * allergy/intolerance mappings; other coded statements use domain-based + * placement excluding drug_exposure and device_exposure; default observation
  • * + *

    Clinical placement follows the informative Vulcan IG's vocabulary-driven + * domain assignment. Supported destinations are condition_occurrence, + * procedure_occurrence, drug_exposure, device_exposure, measurement, and observation. + * Resolved clinical records are returned in the table corresponding to the + * domain of their mapped OMOP standard concept. The source resource + * determines eligibility, field values, and the default table when no + * supported mapping is available. + * AllergyIntolerance records do not produce drug_exposure or + * device_exposure rows. Device placement requires eligible use evidence: + * prescriptions, uncertain diagnoses, and explicit negative Observation + * results (false, SNOMED Negative, Known absent, or No, or interpretation + * code NEG from + * http://terminology.hl7.org/CodeSystem/v3-ObservationInterpretation) + * do not accept Device placement, even when other supplied values are positive. + * A recorder, asserter, or information source is retained as context rather + * than treated as the device event's provider.

    + *

    Coded AllergyIntolerance.code statements are interpreted using their + * original codings, optional type, and category. Supported substances + * produce an Observation finding separately from the causative substance + * in value_as_concept_id. Drug-domain substances are valid values and do + * not imply drug exposure or medication allergy. The finding is selected + * by an explicit mapping policy, not a published substance-to-finding + * crosswalk. Contextual findings report MAPPED, + * resolution.via: CONTEXTUAL. observation_source_concept_id retains + * the known OMOP identity of the original coding written to + * observation_source_value, including when the finding is unresolved; + * it is 0 when no source identity is known. The configured finding is + * never substituted for the original source concept.

    + *

    This adapts the informative R5 Vulcan Value-as-Concept + * pattern + * to R4's substance/product codes, optional type, and repeating category. + * It selects a contextual finding and resolves the substance separately; + * published Maps to value decomposition of a composite finding is not performed.

    + *

    For type: allergy, medication (optionally with biologic) maps to + * Allergy to drug (439224); other category sets use Allergic disposition + * (43530807). For type: intolerance, medication (optionally biologic) + * uses Intolerance to drug (4240582), food alone uses Intolerance to food + * (4340252), and other sets use Intolerance to substance (36684107). + * All selected concepts must be active and standard in the requested + * vocabulary release. Limited reviewed substance mappings can derive an + * absent category: for example, peanut and egg extracts supply food context, and + * Penicillin G supplies medication context. No category is inferred solely + * from Drug-domain membership.

    + *

    With absent type, a supported substance maps to a mechanism-neutral + * propensity finding: medication (optionally with biologic) uses Propensity + * to adverse reactions to drug (4305025), food alone uses Propensity to + * adverse reactions to food (4303802), and other category sets or an + * unknown category use Propensity to adverse reaction (4172024). The + * original type remains absent; no allergy or intolerance mechanism is + * inferred. The substance value is resolved independently. Existing + * supported findings can supply their own mechanism and negative + * statements retain their meaning. Conflicting evidence leaves the + * affected concept unmapped. + * A single identified substance can still populate the value when only + * its category context conflicts. Distinct conflicting substances, + * positive/negative mixtures, finding/substance mixtures, and multiple + * standard substance targets do not select a substance value by input + * order. Equivalent supported translations may share one value; + * unresolved translations are disclosed and are not used as substances. + * Finding-only, value-only, and neither-resolved partial rows are retained.

    + *

    mappings.resolution.allergy retains supplied type/categories, coded + * clinical/verification statuses, the date-source element, per-coding + * interpretation, derived categories, and reported information loss. The + * finding and substance have separate mappings linked to the same row and + * fields. Broader findings retain source specificity in this metadata. + * Codes outside contextual policy coverage retain existing domain-based + * mapping, with the contextual coverage outcome reported separately. + * Text-only inputs retain their text-only behavior. Reaction-substance + * fallback and published Maps to value relationships are not applied. + * Null placeholders in repeating FHIR category primitives carry no category + * value and are omitted from category interpretation and audit values. + * An invalid supplied type/category drops only the affected AllergyIntolerance + * before concept resolution. Other resources are still processed. The + * dropped entry identifies the resource and reports + * allergy_type_unrecognized or allergy_category_unrecognized.

    + *

    For example, a Condition coded with ICD10CM Z79.4 can produce a mapped + * observation row. Z86.73 and ICD-O-3 8140/3 also map to concepts in + * the Observation domain. Use mappings.omop_table and mappings.omop_id + * to locate each result; the FHIR resource type alone does not determine + * the output table.

    + *

    Clinical rows include available person/visit/provider links, concept and + * source concept IDs, source value, source type provenance, and start + * timing. Dates follow the source resource's timing rules below, regardless + * of the output table. For example, a Condition follows its + * onset[x] / recordedDate timing rule even when returned as an + * observation. End dates are populated only in tables with end columns; + * for rows not listed in rows_for_review, they also contribute to the + * request-local observation period when the output table has no end column.

    + *

    Source-specific fields are populated only for the corresponding source + * and destination: Condition status in condition_occurrence, medication + * dosage/supply and Immunization lot details in drug_exposure, and + * Observation values/units/ranges in measurement or observation where + * those columns exist. Mapping notes explain the output table and which + * fields are populated. Coded medication/immunization routes on rows outside + * drug_exposure, and Observation values on rows outside measurement / + * observation, are retained in mappings linked to the output row, with + * omop_field omitted and selected: false. Coded routes are checked + * against the OMOP Route domain; those Observation values are preserved + * without concept resolution.

    + *

    Note destinations are unsupported. If no standard concept + * valid for a supported destination is found, the resource produces a row + * in its default table with concept ID 0 and UNMAPPED status. Its + * source concept ID is also 0, except that allergy rows retain a known + * original source identity from their coding evidence. A DOMAIN_MISMATCH + * outcome is identified in the mapping + * note. Text-only clinical inputs can also produce rows in another supported + * table when semantic mapping selects a standard concept valid for that destination; + * without a validated match, they use their default tables. Domain-routed + * clinical records produce one row each even when their code has multiple + * vocabulary mapping targets; DeviceUseStatement event granularity is + * described below. Vocabulary Maps to value mappings are not applied.

    *

    Medication is reference data for medication resources; it does not * create its own row because OMOP CDM has no Medication table. Administrative * linkages (provider, care site, and location) are best-effort and limited to * references supplied in the request. Patient.managingOrganization is a - * record custodian, not a care-delivery site. Provider specialty is not - * mapped. Recorded Practitioner.gender is distinct from Person + * record custodian, not a care-delivery site. Provider specialties resolve + * independently to standard Provider concepts. Recorded Practitioner.gender + * is distinct from Person * demographics: male and female resolve to validated OMOP Gender * concepts in provider.gender_concept_id; other, unknown, and absent * gender remain unmapped. Address.country is resolved to location.country_concept_id, * and CMS Place of Service codings in Location.type are resolved to * care_site.place_of_service_concept_id. + * Within one request, Practitioners with the same single distinct supplied + * NPI value merge into one provider row only when all supplied mapped identity + * attributes in the entire NPI group agree. The NPI system may be + * http://hl7.org/fhir/sid/us-npi (preferred in FHIR R4), + * urn:oid:2.16.840.1.113883.4.6, or bare 2.16.840.1.113883.4.6; + * these forms are equivalent for matching. OID forms are accepted for compatibility. + * The same matching rule applies to Practitioners contained in valid role + * references. NPI values are compared exactly as supplied, without format + * or checksum validation. Blank values and unknown markers UNK and + * UNKNOWN are treated as missing and do not populate npi or participate + * in matching. Marker detection ignores case and surrounding whitespace; + * all other values are preserved without trimming. Other identifier systems and names + * do not trigger a merge. Missing attributes can enrich a compatible merged row. + * Compatibility compares the name rendered from each Practitioner's first + * name entry, its gender, its first non-empty DEA identifier, and its parsed + * birth year. Differences in birth month/day within one year do not conflict. + * Rendered names are compared exactly: Jane Smith and Dr. Jane Smith + * conflict even when they share an NPI. + * If any of these attributes conflict, every resource in that NPI group stays + * separate, including resources missing the conflicting attribute. Source + * attributes and unambiguous structural reference links are preserved; no + * conflicting value is selected. Each affected row has CONFLICTING + * diagnostics at name, gender, identifier, or birthDate, linked by + * omop_table: provider and omop_id. All source gender mappings link to + * their retained or merged provider row. + * Repeated copies of one NPI on a resource are allowed. A resource with + * multiple distinct NPIs stays separate, leaves npi unset, preserves its + * source-resource identity in provider_source_value, and reports a + * CONFLICTING diagnostic at identifier linked by + * omop_table: provider and omop_id. + * Row IDs are per response. The API does not merge across requests. For a + * Practitioner with one NPI, provider_source_value is that NPI, even when + * its group conflicts. Consumers can use it to deduplicate compatible + * providers across requests, but must preserve rows flagged by identity + * conflict diagnostics and check compatibility across responses. Without + * one unambiguous NPI, source-resource identity is preserved without a + * person-level deduplication guarantee. * A PractitionerRole that identifies one supplied Practitioner aliases * that canonical provider: by a top-level structural reference, a - * parent-contained #id reference, or an exact identifier.system and - * identifier.value match against a top-level Practitioner. No remote + * parent-contained #id reference, or an identifier.system and + * identifier.value match against a top-level Practitioner. Identifier + * matching uses the equivalent NPI systems and missing-value rules above; + * all other systems and all supplied identifiers match exactly. No remote * identifier lookup is performed. When Reference.type is present it must - * be Practitioner; duplicate contained IDs and identifier matches are - * ambiguous. An explicit reference that is unresolved, ambiguous, or - * unsupported retains a role-fallback provider row and is returned in - * diagnostics. provider_role_contexts preserves role-specific + * be Practitioner; duplicate contained IDs and identifier matches to + * different canonical providers are ambiguous. An explicit reference that + * is unresolved, ambiguous, or unsupported retains a role-fallback provider + * row and is returned in diagnostics. provider_role_contexts preserves role-specific * specialty and care-site context that a canonical OMOP provider row cannot * represent together.

    + *

    A role whose practitioner reference has only a blank or unknown NPI + * identifier follows the missing-identifier behavior: its fallback row has + * an UNSUPPORTED diagnostic explaining that a usable identifier or literal + * reference is required. An unambiguous literal reference still links to + * its Practitioner when the NPI is missing or unknown. Original supplied + * unknown markers remain in the role's practitioner_identifier metadata.

    + *

    Each PractitionerRole.specialty entry resolves independently using its + * supplied codings and explicit text from that entry. NUCC codings resolve + * directly; other supported codings may use the UMLS bridge. Text fallback + * is permitted when every coding has a nonblank code and a well-formed, + * unsupported system identifier. Supported systems, mixed supported and + * unsupported systems, and incomplete or malformed codings block fallback. + * A supported coding remains authoritative even when the text differs. + * Within one specialty entry, complete codings from well-formed unsupported + * systems do not block supported codings that independently resolve to the + * same standard Provider concept. Unsupported companions remain unsupported + * in the coding audit and acquire no crosswalk. Missing or malformed + * companions, failures of recognized codings, and disagreements between + * supported codings still block selection. For example, NUCC 208000000X alongside a complete local + * PEDS coding can resolve to Pediatric Medicine, with or without text + * and in either coding order. This uses the supported coding's mapping + * and source concept, rather than text fallback. + * Coding displays do not supply text fallback evidence. + * Unsupported system identifiers must be absolute URIs without embedded + * whitespace, user information, or fragments. HTTP(S) identifiers require + * a host; URNs require a namespace and a nonempty namespace-specific value. + * Bare labels such as Epic do not qualify. + * Text uses deterministic vocabulary assertions in order: standard Provider + * concept name, English OMOP synonym, then an English UMLS string on a + * CUI with a NUCC atom, then a NUCC preferred-term label with its grouping + * prefix removed. The last layer retains the entire classification and + * specialization label, including qualifications. An ambiguous layer stops + * resolution. No semantic search or ML matching is used. + * Each role's input remains visible in mappings, including repeated + * identical inputs across one provider's roles. + * specialty_concept_id is populated only when every distinct supplied + * input resolves to the same standard Provider concept. With no inputs, + * or an unresolved, ambiguous, or conflicting input, it stays 0. + * A role without a specialty contributes no input. The endpoint applies + * no frequency or hierarchy selection. Each input's outcome and matching + * route (layer, matched string, and UMLS CUI/SAB when applicable) appear + * in mappings with omop_field: specialty_concept_id. An individually + * resolved input can coexist with a provider concept of 0 when another + * input fails or disagrees. Specialty mappings have selected: false: + * the source text remains in specialty_source_value when supplied source + * values agree, and every original value remains in role contexts and mappings. + * Specialty mapping evidence includes specialty_resolution with the text + * fallback decision and each coding's independent outcome. A successful + * text fallback reports MAPPED and source concept ID 0; its local + * codings remain unsupported in that audit. For example, complete local + * codings with explicit Pediatrics text can resolve to Pediatric Medicine + * through NUCC_LABEL. Adding a different or unresolved specialty still + * keeps the provider's singular concept at 0. + * Compound phrasings without a vocabulary assertion remain unresolved. + * For example, Pulmonology stays unresolved while Pulmonologist + * resolves to Pulmonary Disease. Results depend on vocab_version.

    *

    Patient demographics:

    *
      *
    • Sex at birth, race, and ethnicity are read from these US Core @@ -493,22 +1382,138 @@ public CompletableFuture> create(C * 0; and Vulcan's suggested observation rows for multiple * ethnicities are not produced.
    • *
    - *

    DiagnosticReport, ServiceRequest, CarePlan, DocumentReference, - * Composition, Specimen, DeviceUseStatement, Coverage, Claim, and - * other unsupported resource types are accepted in a Bundle but ignored: they + *

    Specimen produces a specimen row only with an explicitly resolvable + * top-level Patient subject. Missing collection dates or material retain + * useful partial rows with linked diagnostics; those rows may require + * downstream completion before loading into a strict CDM instance. + * References support Patient/id and exact Bundle + * fullUrl matches (including URNs and absolute URLs); contained, logical, + * display-only, missing, non-Patient, and unresolved subjects never use the + * sole Patient fallback. Reference.type, when supplied, must identify Patient. + * Unsafe patient attribution and ineligible status drop only that Specimen + * with dropped and diagnostics; a missing Patient in the entire request + * remains a 400. Missing material is concept ID 0 with a type diagnostic + * and no terminology mapping/summary count. Supplied unmapped material is + * also 0, with its separate vocabulary mapping outcome and retained source code. + * Material (type) resolves against the Specimen domain, coded UCUM + * collection quantity units against Unit, and collection.bodySite against + * Spec Anatomic Site. Supplied unmapped optional concepts are 0; absent + * unit and site IDs are explicit null. Text-only body sites preserve their + * source text with site concept ID 0 and an UNMAPPED mapping; they do not + * produce a text-based concept suggestion. Material descriptions follow + * the semantic-mapping policy above. Original source codes and text-only + * descriptions are preserved. + * Numeric quantity is copied without + * unit conversion; invalid or comparator-bearing quantities are omitted + * with diagnostics without dropping the row. Missing optional data is allowed. + * Collection time uses collection.collectedDateTime or + * collection.collectedPeriod.start; end-only, partial, invalid, and absent + * dates leave specimen_date/specimen_datetime omitted with linked diagnostics. + * Both collected forms together are conflicting: retain the row without + * selecting a date. Period end and receivedTime never substitute for collection + * start. specimen_source_contexts.collection_timing preserves raw dateTime, + * Period boundaries, receivedTime, and the selected source path when a date + * is populated, including partial precision and original timezone offsets. + * specimen_collection_date_missing, specimen_collection_date_partial, and + * specimen_collection_date_unusable distinguish absent, partial, and invalid + * source dates; specimen_collection_multiple_forms reports conflicting choices. + * specimen_type_concept_id: 32817 is EHR provenance, independent of material + * and availability. Status absent/available/unavailable/unsatisfactory is + * supported; entered-in-error and unrecognized statuses drop the specimen. + * specimen_source_contexts.status preserves the supplied availability + * status for emitted rows; it is omitted when no status was supplied. + * Disease status is not inferred. Source identity precedence is resource id, + * then first identifier with a nonempty value in source array order, then + * accessionIdentifier.value, then Bundle fullUrl. No identity is fabricated; + * specimen_source_contexts preserves all valued identifier system/value + * pairs, accession identity, resource id/fullUrl, and the selected source path. + * Parent/derived specimens emit independently when each has its own Patient; + * missing date/material remains partial rather than being inherited. Parent + * and Measurement-to-Specimen links are not represented. Consuming CDM + * loaders own required-date completion, durable IDs, and dataset completeness. + * Source baseline is FHIR R4 4.0.1, with supported common fields reviewed against + * US Core Specimen 7.0.0; full profile conformance is not validated here.

    + *

    Device use and exposure

    + *

    DeviceUseStatement accepts active, completed, stopped, and on-hold use; + * intended, entered-in-error, missing, and unrecognized statuses are omitted + * with source-specific diagnostics. Its subject must explicitly resolve to + * an emitted Patient. Group, contained-Patient, identifier-only, external + * fetching, and sole-Patient attribution are unsupported for this path. + * Device references support exact Type/id and Bundle fullUrl/URN matches, + * plus unambiguous contained Devices scoped to the referring statement. + * Missing, wrong-type, unresolved, or ambiguous Device details retain a + * known-patient use row with device concept 0 and diagnostics. Missing type + * evidence has no vocabulary mapping entry; supplied unmapped type evidence + * has a mapping outcome. Inventory-only Device records emit no exposure.

    + *

    timingDateTime supplies the start only; timingPeriod preserves supplied + * boundaries independently. Each timingTiming.event produces a separate + * row, including a partial row for an unusable event, with its zero-based + * event_index in device_exposure_contexts.exposures. Repeat rules without events + * retain one partial row; events plus rules produce only the event rows. + * No recurrence, continuous min/max interval, status-derived end date, or + * recordedOn fallback is inferred. Year/month precision and malformed + * individual dates leave affected columns absent. Competing timing choices + * or a provably reversed Period leave both bounds absent. Supplied precision, + * offsets, schedules, and omission/conflict reasons remain in source context + * and diagnostics. Other clinical paths retain their own timing semantics.

    + *

    device_exposure_contexts contains one entry per source record that produced + * device exposures. Its exposures array links all rows derived from that + * record by device_exposure_id and, for Timing events, event_index. Shared + * timing, device evidence, and quantity_basis appear once per source record. + * Contexts are not combined by resource id or device identity; distinct + * records remain separate even when their identifiers match or are absent.

    + *

    device_source_value uses original type/clinical coding, never inventory + * or resource identity. An unambiguous Device.udiCarrier.deviceIdentifier + * supplies unique_device_id. Competing identifiers leave it absent and are + * preserved with issuer/jurisdiction/carrier context. production_id remains + * absent; carriers, lot, serial, manufacture, and expiry evidence remain in + * device_exposure_contexts.

    + *

    DeviceUseStatement rows use device_type_concept_id 32817 (EHR), with + * provider_id, visit_occurrence_id, and visit_detail_id unset. + * Routed rows retain their source classification, explicit visit links to + * emitted same-patient visits, and compatible performer links; + * Condition/Procedure recorder/asserter and + * MedicationStatement informationSource do not supply device provider_id. + * Supplied source, encounter, context, recorder, asserter, performer, and + * informationSource values on DeviceUseStatement are retained only in + * device_exposure_contexts.

    + *

    Device exposure quantity is reported as follows:

    + *
      + *
    • When the source has no quantity, dose, rate, or numeric Observation + * result field, quantity is 1 and device_exposure_contexts.quantity_basis + * is default_absent_count.
    • + *
    • When such a field is supplied, including a null or malformed value, + * quantity is omitted and quantity_basis is unsupported_supplied_evidence. + * The original values remain in device_exposure_contexts.evidence, and + * an UNSUPPORTED diagnostic identifies each supplied field.
    • + *
    + *

    For example, an Observation with valueQuantity.value of 2 returned as a + * device exposure has no quantity; the value 2 remains in its source context. + * timingTiming.repeat.count remains in source context and does not set + * quantity. unit_concept_id and unit_source_concept_id are always null.

    + *

    Distinct eligible statements and routed clinical records remain separate, + * including overlapping use and equal Timing events. derivedFrom is retained + * as supporting context, not duplicate identity. Row counts therefore need + * not count distinct uses or physical devices. IDs are request-local exposure + * identities. Before loading a CDM dataset, callers must assign durable IDs + * and populate any required fields omitted from partial rows.

    + *

    Unsupported resource types are accepted in a Bundle but ignored: they * create no row and no dropped entry. dropped is reserved for supported * row-producing resources that cannot safely produce a positive OMOP row: * required subject/patient, clinical code/text, or medication data may be * unusable, or the resource may explicitly negate or fail the documented * clinical-event eligibility policy. A single-Patient Bundle uses the sole - * Patient only when subject/patient is absent. An explicit subject/patient + * Patient only when subject/patient is absent, except DeviceUseStatement + * and Specimen, which require explicit Patient subjects. An explicit subject/patient * reference that is unresolved, ambiguous, or unsupported drops the clinical * resource in every request scope.

    *

    Eligibility distinguishes clinical validity, performed/taken evidence, and - * administrative workflow state. Checks run before terminology resolution; - * entered-in-error and explicitly non-performed events never produce a positive - * clinical-event row, and an unrecognized required status fails closed. Condition - * requires absent or confirmed canonical-HL7 verificationStatus; clinical course is not a + * administrative workflow state. Entered-in-error and explicitly + * non-performed events never produce a positive clinical-event row, and + * unrecognized required statuses are dropped except the retained Condition and + * Encounter cases below. Condition accepts absent or confirmed canonical-HL7 + * verificationStatus without a status retention reason; uncertain or unreadable + * supplied verification is retained and indexed. Clinical course is not a * diagnosis-role mapping. Procedures accept completed or stopped events. * Medication requests are prescription evidence only: doNotPerform, drafts, * cancellations, and non-authorizing intents are dropped. Medication statements @@ -517,69 +1522,142 @@ public CompletableFuture> create(C * administration also needs an effectiveDateTime or effectivePeriod.start * that supplies start evidence; a valid partial date remains an undated row. * An on-hold administration is started evidence that is temporarily paused and - * expected to continue. Immunizations require completed - * status. Observations require final, + * expected to continue. MedicationDispense accepts completed records, + * including completed partial fills. Stopped records require a valid + * whenHandedOver, including partial dates, as evidence of actual handover. + * Preparation, in-progress (ready for pickup), on-hold, cancelled, declined, + * entered-in-error, unknown, missing, and unrecognized statuses are dropped. + * A completed dispense with missing or unusable handover timing remains a + * partial row; preparation time never substitutes for handover. + * Immunizations require completed + * status. Observations accept final, * amended, or corrected status; registered, - * preliminary, cancelled, entered-in-error, unknown, and missing statuses are + * preliminary, cancelled, entered-in-error, and missing statuses are * dropped. AllergyIntolerance accepts absent, unconfirmed, or confirmed canonical-HL7 * verificationStatus as a reported allergy, but drops refuted, - * entered-in-error, and unreadable supplied verification statuses. Encounter + * entered-in-error, and unreadable supplied verification statuses. Invalid + * supplied allergy type/category also drops that resource, for both coded + * and text-only statements. Encounter * accepts arrived, triaged, in-progress, onleave, or finished status, but drops - * planned, cancelled, entered-in-error, unknown, and missing statuses. An eligible - * encounter without Period.end remains a partial visit; no end date is invented.

    - *

    Coded Observation routing is selected from the resolved OMOP concept - * domain. Numeric and nonnumeric value[x] forms establish the preferred - * target only when the code is valid for both tables. A text-only - * Observation has no resolver target, so numeric values route to - * measurement and nonnumeric values to observation. Numeric values - * populate value_as_number in the selected row; other non-coded values + * planned, cancelled, and entered-in-error statuses. Unknown, missing, and unreadable + * Encounter statuses are retained and indexed without imputation. An eligible + * encounter without Period.end remains a partial visit; no end date is invented. + * MedicationStatement, Observation, and Procedure with explicit unknown + * status are also retained and indexed in rows_for_review when otherwise + * shapeable. Missing or unreadable statuses on these three resource types + * remain dropped. Retention preserves source status and does not establish + * that an event occurred or medication was taken. Device placement still + * requires eligible use evidence; unknown-status records cannot produce + * device_exposure rows.

    + *

    The mapped OMOP concept's domain determines the clinical output table. + * For Observations, numeric and nonnumeric value[x] forms determine the + * preferred table only when the code is valid for both measurement and + * observation. Unresolved Observations retain measurement rows for + * numeric values and observation rows for other values. In these two + * tables, numeric values populate value_as_number; other non-coded values * populate value_as_string for an observation or value_source_value * for a measurement. Coded valueCodeableConcept values are resolved - * against the selected row's value_as_concept_id and use the selected + * against value_as_concept_id in these tables and use the selected * bare code in value_source_value, leaving an observation's * value_as_string empty; unmapped or target-invalid coded values remain - * 0. + * 0. Active standard concepts from any OMOP domain are valid for + * value_as_concept_id per CDM 5.4. For example, a blood-culture Measurement + * can record Staphylococcus aureus as its coded result, even though the + * organism concept belongs to OMOP's Observation domain. + * For other clinical output tables, Observation values are retained only + * in mappings, without concept resolution or an omop_field, and with + * selected: false. * Other unsupported value[x] forms and Observation components do not * populate separate converted values. A * numeric comparator (<, <=, >, >=) is represented only by a - * measurement's operator_concept_id; units remain source text and have - * unit_concept_id of 0.

    + * measurement's operator_concept_id.

    + *

    For Observations returned as measurement or observation rows, + * Quantity units resolve independently against the table's + * unit_concept_id. + * Only coded UCUM (http://unitsofmeasure.org or its + * OID 2.16.840.1.113883.6.8, bare or urn:oid:) is supported. Codes are + * case-sensitive: no spelling aliases, text-only guesses, or numeric + * conversion are applied. Coded UCUM units map to valid standard OMOP + * Unit concepts using the release selected by vocab_version. + * Mappings are direct or follow an active OMOP Maps to relationship; + * UCUM units do not use a UMLS bridge. + * Unknown codes, + * vocabulary gaps, text-only units, missing coding, and unsupported + * systems keep the row and use 0; absent units use JSON null (the + * field is present). Explicit dimensionless code 1 is a supplied unit + * and resolves or remains 0; it is never treated as absent. + * unit_source_value contains Quantity.code when supplied, otherwise + * Quantity.unit. A separate unit mappings entry preserves the original + * Quantity.system/code in source_system/source_code and the exact + * human-readable Quantity.unit in source_name, with its own outcome. + * Quantity.unit provides display text and does not affect unit matching. + * Measurement also returns nullable unit_source_concept_id, identifying + * the OMOP source concept for Quantity.system/code when available, + * otherwise 0 for a supplied unit. + * Observation has no such column. Resolving a unit does not change + * numeric values, comparators, or reference ranges. + * Supported coded UCUM units include resolution in their mappings, + * whether the unit resolves or remains unmapped: + * its outcome and reason code distinguish vocabulary gaps, target-domain + * mismatches, and ambiguity. Unresolved or ambiguous units retain the row + * with concept ID 0. Resolved mappings retain the selected concept's + * direct or crosswalk provenance. Text-only units, units with missing + * coding, and unsupported unit systems omit resolution; their note + * explains the unmapped outcome.

    + *

    For Observations returned in other clinical tables, units are not resolved + * or included as separate mapping outcomes. Device exposure unit IDs remain + * null, with supplied quantity and unit evidence retained in + * device_exposure_contexts.

    + *

    Compatibility: absent unit_concept_id values previously returned 0 + * and now return null. Coded units use their code in unit_source_value + * rather than their human-readable text; the text remains in mappings. + * The Measurement unit_source_concept_id field is new.

    *

    A standard OMOP concept_id is selected for each eligible primary clinical coding * after considering all of the resource's supplied codings. An unambiguous - * coded medication route is resolved independently to - * drug_exposure.route_concept_id. Alongside the OMOP rows grouped by + * coded medication or immunization route is checked independently against + * the OMOP Route domain. For drug_exposure rows, route_concept_id + * contains the resolved concept ID, or 0 if unmapped; for other output + * tables the route is reported only in + * mappings, without omop_field and with selected: false. + * Alongside the OMOP rows grouped by * table (tables), the response carries mappings (an entry for every * supported source coding from a resource that shaped a row, linked back to * that row; some, such * as demographic null flavors, are reported without being resolved), + * rows_for_review (an index of uncertain-status or primary-concept-0 clinical + * rows in tables, with original statuses, reasons, and source/row identity), * provider_role_contexts (source role details linked to provider rows), * dropped (resources that could not be shaped into a row), * diagnostics (explicit references that could not safely create a link, - * and conflicting or unsupported Patient demographic extensions), + * conflicting Practitioner identity attributes, conflicting or unsupported + * Patient demographic extensions, and conflicting Encounter classifications), * vocab_version (the OMOP vocabulary release codes were resolved * against), and a small summary of the resolution outcomes.

    *

    A concept_id of 0 is reported, not omitted (OMOP "no matching - * concept" semantics): it covers both a coding with no standard match - * (UNMAPPED) and an unverified suggestion for a text-only resource - * (UNCHECKED). Visit and unit concept fields currently remain 0; - * Person demographics and Provider recorded gender follow the policies - * above. Coded Observation values may populate value_as_concept_id. - * Concepts set by a fixed convention rather than terminology resolution - * are measurement operator_concept_id, set from a value comparator (<, - * <=, >, >=), and the multiple-race concepts described above. Clinical - * *_source_value fields contain the selected FHIR code (or source text - * for text-only resources). + * concept" semantics): it covers a supplied concept without a validated + * standard match (UNMAPPED). + * Supported Encounter.class or Encounter.type values + * may populate visit_concept_id. Person demographics and Provider + * recorded gender follow the policies above. + * Coded Observation values and supported allergy substances may populate + * value_as_concept_id. + * Measurement operator_concept_id represents a value comparator (<, + * <=, >, >=); multiple-race concepts follow the conventions described + * above. Clinical *_source_value fields contain the selected FHIR code + * (or source text for text-only resources). * The corresponding selected mappings entry preserves the coding system * and full source-coding provenance. * Known OID-form coding systems are accepted as either FHIR OID URNs (for * example, urn:oid:2.16.840.1.113883.6.1 for LOINC) or bare OIDs, and - * are normalized to their canonical system URLs before terminology - * resolution. mappings[].source_system reports that canonical URL, so the - * OID and URL forms produce the same mapping. An unknown OID is not - * rewritten and may be UNMAPPED. + * produce the same mappings as their canonical system URLs. Coding + * mappings report that canonical URL in source_system; Quantity unit + * mappings preserve the original Quantity.system, including OID spelling. + * An unknown OID remains unchanged in source_system and may be + * UNMAPPED. * Other *_source_value fields preserve row-specific raw source values, * such as resource identifiers, names, units, or status codes. - * MedicationRequest uses 32838 (EHR prescription) for + * MedicationRequest uses 32838 (EHR prescription) and + * MedicationDispense uses 32825 (EHR dispensing record) for * drug_type_concept_id; other current resources use 32817 (EHR). This * is a coarse provenance policy: it does not infer patient-reported, * medication-history, or other more-specific type concepts from FHIR @@ -592,8 +1670,8 @@ public CompletableFuture> create(C * (YYYY-MM-DDTHH:MM:SS, with fractional seconds to microseconds when * supplied): 2024-01-15T23:30:00-05:00 becomes 2024-01-15T23:30:00. * A datetime without a timezone is read as local time. - *

  • A partial date (2024 or 2024-03) leaves both columns unset. It - * still counts as that source's value, so later sources in the list + *
  • A partial date (2024 or 2024-03) leaves both columns unset. + * It still counts as that source's value, so later sources in the list * below are not used.
  • *
  • Free-text timing (such as onsetString), Age and Range forms, and * values that are not dates are ignored, so a later source in the list @@ -603,11 +1681,18 @@ public CompletableFuture> create(C * completed, and a row is not dated from another resource such as its * Encounter.
  • *
  • Valid date values never cause a request to be rejected, and the response - * does not report which date elements were unusable. An on-hold + * reports missing, partial, malformed, or conflicting timing for Specimen, + * DeviceUseStatement, and MedicationDispense in diagnostics while retaining + * partial rows for downstream load completion. Other resources do not report which + * date elements were unusable. An on-hold * MedicationAdministration is an eligibility exception: it needs a supplied - * effective start as evidence that administration began.
  • + * effective start as evidence that administration began. + * A stopped MedicationDispense similarly requires supplied handover evidence. * - *

    Timing sources, in priority order where several are listed:

    + *

    Timing sources, in priority order where several are listed. These rules + * apply regardless of the clinical output table; end dates are populated + * only where the table has an end column, and verbatim_end_date is set + * only for medication resources returned as drug_exposure rows:

    *
      *
    • Patient: birthDate sets year_of_birth, month_of_birth, and * day_of_birth from the parts it supplies; birth_datetime is not @@ -619,6 +1704,12 @@ public CompletableFuture> create(C * end from abatementDateTime or abatementPeriod.end.
    • *
    • Procedure: start from performedDateTime or * performedPeriod.start; end from performedPeriod.end only.
    • + *
    • DeviceUseStatement: timingDateTime sets the start only; + * timingPeriod.start and timingPeriod.end supply independent bounds; + * each timingTiming.event produces one start-only row. A schedule + * without explicit events produces one row without dates.
    • + *
    • Specimen: collection.collectedDateTime or collection.collectedPeriod.start; + * missing day precision retains an undated specimen with diagnostics.
    • *
    • Observation: effectiveDateTime, effectivePeriod.start, or * effectiveInstant.
    • *
    • AllergyIntolerance: recordedDate, then onsetDateTime or @@ -635,21 +1726,212 @@ public CompletableFuture> create(C *
    • MedicationRequest: authoredOn, the order date, sets the start; it * is not evidence of administration. No end is set, and the validity * period is not used as an exposure duration.
    • + *
    • MedicationDispense: whenHandedOver sets the start. Missing, partial, + * or invalid handover leaves the start unset with a diagnostic. Comparable + * handover/preparation values proving handover before preparation also + * leave the start unset with a CONFLICTING diagnostic. whenPrepared, + * authorizing orders, and related administrations never supply timing. + * No exposure end or verbatim_end_date is inferred, including from + * daysSupply. Raw handover/preparation values retain precision and offsets + * in medication_dispense_source_contexts.source.
    • *
    • Differences from the Vulcan maps: birth_datetime is not set from * birthDate, Condition also reads onsetPeriod.start, and * AllergyIntolerance falls back to its onset when recordedDate is * missing.
    • *
    - *

    Medication details:

    + *

    Medication details for resources returned as drug_exposure rows:

    *
      *
    • For MedicationRequest, dispenseRequest.numberOfRepeatsAllowed sets * refills and a whole-day expectedSupplyDuration sets days_supply.
    • + *
    • For MedicationDispense, daysSupply sets days_supply only for a + * positive whole number of coded UCUM d days without a supplied comparator. + * The canonical UCUM URL, bare OID, and OID URN are accepted. Other units, + * fractions, text-only units, and malformed values leave it absent with a + * diagnostic; absent daysSupply is allowed. Refills are not inherited.
    • *
    • All non-empty dosage text is preserved in sig.
    • *
    • Coded dosage routes and Immunization.route are target-validated in * the OMOP Route domain. Conflicting routes are left unset; route * codings shared by every dosage instruction identify the same route.
    • *
    • Immunization.lotNumber is preserved in lot_number.
    • *
    + *

    MedicationDispense requires an explicit subject resolving to a Patient + * supplied in the request; Groups and missing subjects are unsupported. + * Source fields are based on FHIR R4 4.0.1 and reviewed against + * US Core MedicationDispense 7.0.0. + * Full profile conformance is not validated. Although US Core requires + * handover on completed records, this API retains such incomplete records + * with missing-handover diagnostics as partial conversion output. + * Supply either a usable medicationCodeableConcept (coded or text-only), + * or a medicationReference resolving to a Medication with usable code/text. + * Both choice forms together are conflicting and drop the dispense. + * Exact relative, Bundle fullUrl, and unambiguous contained Medication + * references are supported. Unresolved references are not rescued by display + * text; missing or ambiguous medication identity drops the dispense with a + * diagnostic. A usable but unmapped drug retains concept ID 0 and its + * mapping outcome in a row indexed for review. + * context links only to a supplied Encounter for the same patient. + * The first resolvable Practitioner/PractitionerRole in performer.actor + * supplies provider_id; other actor types do not supply a provider. + * Failed explicit links have diagnostics. Authorizing prescriptions are + * checked for request-local MedicationRequest identity and patient agreement; + * failures do not discard the dispense. They never supply medication, + * instructions, refills, timing, or prescription provenance to a dispense. + * Dispense instructions alone supply sig and route; conflicting routes + * remain unset with a diagnostic and original instructions in source context. + * Orders, dispenses, and administrations emit independently, with separate + * source identity and no deduplication merely because they concern the same drug. + * Every emitted dispensing drug row has a whenHandedOver/daysSupply + * diagnostic beginning medication_dispense_end_date_absent because the + * CDM-required exposure end remains absent. Missing/partial/unusable handover + * diagnostics begin medication_dispense_handover_missing, + * medication_dispense_handover_partial, or medication_dispense_handover_unusable.

    + *

    Encounter details:

    + *
      + *
    • + *

      Encounter classifications must supply both Coding.system and + * Coding.code to map to a standard OMOP Visit concept. For HL7 + * encounter class codes with system + * http://terminology.hl7.org/CodeSystem/v3-ActCode or + * http://hl7.org/fhir/v3/ActCode, the supported mappings are:

      + *
        + *
      • IMP (inpatient encounter) -> 9201 (Inpatient Visit)
      • + *
      • AMB (ambulatory encounter) -> 9202 (Outpatient Visit)
      • + *
      • EMER (emergency encounter) -> 9203 (Emergency Room Visit)
      • + *
      • VR (virtual encounter) -> 722455 (Telehealth)
      • + *
      • HH (home health encounter) -> 581476 (Home Visit)
      • + *
      + *

      OBSENC (observation encounter) has no direct class mapping. Other + * class or type codes can be used when they have a valid standard OMOP + * Visit mapping in the selected vocabulary release. Unsupported + * classifications retain visit_concept_id of 0 unless a supported + * type supplies the Visit as described below; they never default to + * outpatient.

      + *
    • + *
    • + *

      A mapped Encounter.class determines visit_concept_id. A mapped + * type that disagrees with it produces a CONFLICTING diagnostic with + * path class/type, while the class mapping remains selected. + * If class is missing or unmapped, type supplies the Visit only when + * all mapped type classifications agree on the same standard Visit + * concept. Codings within one type CodeableConcept are alternative + * encodings of that classification. When separate type classifications + * map to different Visit concepts, no type is selected, + * visit_concept_id remains 0, and a CONFLICTING diagnostic has + * path type.

      + *
    • + *
    • + *

      visit_source_value contains the selected class or type code; + * mappings preserves its coding system and identifies which + * classification was selected. visit_source_concept_id identifies + * the selected original source coding's OMOP concept when available, + * and is 0 otherwise, including for the HL7 encounter class codes + * listed above. Each eligible Encounter produces one Visit; + * classHistory, partOf, and admission/discharge concepts are not + * mapped.

      + *
    • + *
    • + *

      Direct MedicationRequest.dispenseRequest.quantity, MedicationDispense.quantity, + * MedicationAdministration.dosage.dose, and Immunization.doseQuantity + * can set quantity under the formulation policies below. Quantities + * must be positive finite numbers with a supported system/code pair + * and no supplied comparator. Ingredient amounts and injection volumes + * use http://unitsofmeasure.org (or its bare OID or + * urn:oid:2.16.840.1.113883.6.8) with case-sensitive codes matching or + * convertible to vocabulary-backed strength units. Exact unit matches, + * including [U] for insulin and heparin strengths, are accepted without + * a unit conversion. + * When codes differ, both source and strength units must belong to the + * same supported group: mass (ug, mg, g), volume (uL, mL, L), + * or milliequivalent representations (meq, 10*-3.eq). Other conversions, + * including kg, ML, Mg, [drp], mol, mmol, %, 1, and generic + * annotations such as {vial} or {syringe}, leave quantity unset with + * unit_mismatch. Codes are not case-corrected or inferred from display text. + * The original source system is preserved in the quantity audit. + * Fixed-form counts also accept the explicit codings below. Free-text, + * local, invalid, and cross-dimensional units are not interpreted.

      + *
    • + *
    + *

    Drug quantity policies follow CDM v5.4 DRUG_EXPOSURE conventions. + * The selected drug must be a Clinical Drug or Branded Drug, with one + * active dose form and valid vocabulary evidence for the chosen policy. + * Strength-based conversions also require one ingredient strength. Supported RxNorm dose forms:

    + *
      + *
    • fixed_dose_form_count: Oral Tablet (317541), Oral Capsule + * (316965), Extended Release Oral Tablet (316945), Extended + * Release Oral Capsule (316943), Delayed Release Oral Tablet (10312), + * or Delayed Release Oral Capsule (316995). A source ingredient amount is divided + * by the single positive per-unit ingredient amount after conversion to that + * strength unit when necessary. + * Concentration-based strengths cannot derive counts from ingredient amounts. The + * result is a count of the selected dose form, limited to whole units or + * multiples of one-half or one-third. Derived counts within a relative + * tolerance of 1e-12 of an allowed fraction are normalized to that count + * to remove arithmetic roundoff; other fractions remain unsupported. + * For example, 1,000 mg for a drug + * with 500 mg per tablet produces quantity: 2 tablets (DERIVED). A source + * of 1 g is converted to 1,000 mg and produces the same count. + * Explicit counts are copied (DIRECT) when their coding matches the + * selected tablet or capsule form, including the corresponding extended- + * and delayed-release forms: TAB / CAP under + * http://terminology.hl7.org/CodeSystem/v3-orderableDrugForm, or + * {tbl} / {Capsule} under http://unitsofmeasure.org. These exact + * case-sensitive count codings identify the supplied form; they are not + * dimensionally converted as UCUM units. Direct counts do not require + * ingredient strength: combination products and missing/invalid strengths + * do not change the supplied count. Ingredient-strength gaps, including + * missing ingredient or strength-unit references, do not prevent copying + * an explicit count. Drug or dose-form evidence gaps, unclassified or + * ambiguous forms, and gaps whose relevance cannot be established still + * prevent conversion. The whole/half/third rules still apply. + * For example, 20 TAB for amoxicillin/clavulanate is 20 tablets, and 30 TAB for + * a supported tablet produces quantity: 30; CAP for that tablet is + * omitted with unit_mismatch. Generic counts (1, {Each}), other + * annotations, packs, and free-text counts are not interpreted. + * See the FHIR R4 count example.
    • + *
    • clinical_injection_ingredient_amount: Injectable Solution (316949), + * Injectable Suspension (316950), or Injection (1649574). A source + * ingredient amount is converted to the vocabulary ingredient unit when + * necessary; an exact-unit amount is copied directly. Otherwise, a source + * amount convertible to the concentration's denominator unit is converted + * and multiplied by numerator value / the effective denominator reported in + * the audit evidence. Volume conversion requires a positive finite numerator, + * dimensionally distinct validated UCUM units, a null raw denominator value, and + * evidence.strength.denominator_basis with kind: PER_UNIT and + * value: 1. A null denominator alone is not interpreted as 1. Missing, unknown, + * non-applicable, quantified, or conflicting denominator evidence does + * not support volume conversion. For example, 80 mL with 2 mg/mL + * strength and a PER_UNIT basis produces quantity: 160 mg. A supplied + * 160 mg amount for the same drug produces quantity: 160 directly. A source + * of 0.08 L converts to 80 mL before producing the same 160 mg result. + * This output is already + * the ingredient amount: do not apply the drug-dose guide's + * dose = quantity × numerator formula to these injection rows, which + * would multiply by the concentration a second time. Use + * quantity_transformations.result_unit to identify the output unit.
    • + *
    + *

    Quantified products, packs, prefilled syringes, other liquids and dose + * forms, strength-based conversions of combination products, conflicting or incomplete required evidence, and + * unsupported source quantities leave quantity unset. Other supported + * fields and the clinical row are still returned. For example, a quantity + * supplied only as value: 2, unit: tablet is omitted with + * unsupported_unit, because a free-text unit cannot establish this policy. + * Rate-only administrations and MedicationStatement dosages do not establish + * a supported total quantity and are not used for quantity conversion.

    + *

    quantity_transformations audits each supplied quantity at one of the + * three direct paths above on a resource returned as a drug_exposure row, + * including an explicit null or malformed quantity. Each entry preserves + * the source, identifies the destination row, and reports DIRECT, DERIVED, or + * OMITTED. Successful entries identify their policy, evidence, result, + * and result unit; omitted entries give a stable reason. When a source code + * differs from the strength unit, unit_conversion records the intermediate + * converted amount and target unit. Converted results are DERIVED, even + * when no further concentration arithmetic is needed. A successful conversion + * can also be reported when a later policy check omits the final quantity. + * Dose-form and unit + * concept IDs refer to the response's vocab_version. Missing quantities + * produce no entry. Resources returned in other clinical tables have no + * quantity entry. Unsupported rates and statement dosages produce no entry, + * and resources in dropped have no quantity entry or destination row.

    *

    Medication codes are resolved whether they appear inline * (medicationCodeableConcept) or via a medicationReference to a contained, * relative (Type/id), or bundle-entry (urn:uuid) Medication resource. @@ -661,11 +1943,12 @@ public CompletableFuture> create(C * subject/patient reference cannot be tied to a person; or an event excluded * by eligibility — are reported under dropped rather than emitted as blank * rows. The Bundle must contain at least one Patient resource.

    - *

    Structural references resolve only to top-level resources supplied in the - * request, by Type/id or an exactly matching Bundle fullUrl (including - * urn:uuid). Contained references are supported for medication code lookup - * and PractitionerRole.practitioner enrichment; the latter also supports - * exact request-local identifier matching without a remote lookup. Every + *

    References to top-level resources supplied in the request resolve by + * Type/id or an exactly matching Bundle fullUrl (including urn:uuid). + * Parent-scoped contained references are supported for medication code lookup, + * DeviceUseStatement.device details, and PractitionerRole.practitioner + * enrichment; the latter also supports + * request-local identifier matching with the NPI rules above, without a remote lookup. Every * nonzero structural foreign key targets a row in the same response. Missing * optional links remain unset without a diagnostic; an explicit optional * reference that is unresolved, ambiguous, conflicting with the row's @@ -694,7 +1977,9 @@ public CompletableFuture> create( RequestBody body; try { body = RequestBody.create( - ObjectMappers.JSON_MAPPER.writeValueAsBytes(request), MediaTypes.APPLICATION_JSON); + ObjectMappers.JSON_MAPPER.writeValueAsBytes(BodyProperties.merge( + request, requestOptions != null ? requestOptions.getBodyProperties() : null)), + MediaTypes.APPLICATION_JSON); } catch (JsonProcessingException e) { throw new PhenomlClientException("Failed to serialize request", e); } @@ -719,7 +2004,8 @@ public CompletableFuture> create( .build(); } CompletableFuture> future = new CompletableFuture<>(); - client.newCall(okhttpRequest).enqueue(new Callback() { + RetryInterceptor.AsyncCall okhttpCall = RetryInterceptor.newAsyncCall(client, okhttpRequest); + okhttpCall.enqueue(new Callback() { @Override public void onResponse(@NotNull Call call, @NotNull Response response) throws IOException { try (ResponseBody responseBody = response.body()) { @@ -768,6 +2054,11 @@ public void onFailure(@NotNull Call call, @NotNull IOException e) { future.completeExceptionally(new PhenomlClientException("Network error executing HTTP request", e)); } }); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + okhttpCall.cancel(); + } + }); return future; } } diff --git a/src/main/java/com/phenoml/api/resources/fhir2omop/Fhir2OmopClient.java b/src/main/java/com/phenoml/api/resources/fhir2omop/Fhir2OmopClient.java index 524ce2a6..64413c00 100644 --- a/src/main/java/com/phenoml/api/resources/fhir2omop/Fhir2OmopClient.java +++ b/src/main/java/com/phenoml/api/resources/fhir2omop/Fhir2OmopClient.java @@ -27,11 +27,42 @@ public RawFhir2OmopClient withRawResponse() { /** * Maps a FHIR R4 resource or Bundle into OMOP Common Data Model v5.4 rows, - * grouped by destination table in tables. + * grouped by destination table in tables. Uncertain-status and unresolved- + * primary-concept clinical rows stay in those tables and are identified by + * the optional rows_for_review index for consumer review and filtering. + * Status-based retention covers uncertain supplied Condition verification, + * unknown/missing/unreadable Encounter status, and explicit unknown status + * on MedicationStatement, Observation, and Procedure. Other status exclusions + * follow the resource-specific eligibility policy below. *

    Set vocab_version to select the OMOP vocabulary release used for coded - * concept resolution. If omitted or empty, the API uses its default - * release. The response's vocab_version, when present, identifies the - * release used. Specify a release explicitly when reproducibility matters.

    + * and inferred concept resolution. If omitted or empty, the API uses its + * default release. The response's vocab_version, when present, identifies + * the release used. Specify a release explicitly when reproducibility matters.

    + *

    Supported concept fields use semantic mapping for text-only inputs and + * unresolved source codings, with whole-term equivalence validation. + * CodeableConcept text takes precedence over coding display. A single + * equivalent suggestion must be validated before a real OMOP concept ID is + * returned. The suggested vocabulary/code must + * itself be an active standard concept valid for the destination; broader + * crosswalk targets are not accepted for inferred suggestions. + * Deterministic mappings take precedence; ambiguous or domain-mismatched + * deterministic results are not reinterpreted. Units, routes, visits, + * demographics, and collection sites use their field-specific resolution + * policies. Semantic mapping returns MAPPED with a real concept ID on + * success, or UNMAPPED with concept ID 0 when no standard concept is + * selected. A bare code is not treated as a description. No source coding + * is invented.

    + *

    Contextual allergy finding decisions follow their dedicated policy and + * are not replaced by description-based mapping.

    + *

    Every inferred mapping has + * mappings.resolution.semantic_mapping.status: MAPPED, with the OMOP + * vocabulary release. Filter linked rows using the mapping's table/row ID + * to exclude inferred results. + * Original source coding and clinical-status eligibility are preserved. + * For clinical rows covered by rows_for_review, successful inference + * does not remove uncertain-status rows from the index; unresolved primary + * concepts also remain indexed. + * All shaped rows appear once in tables.

    *

    Standards basis: FHIR R4 (v4.0.1) defines * the accepted source elements and OMOP CDM * v5.4 defines the @@ -54,46 +85,262 @@ public RawFhir2OmopClient withRawResponse() { * first address can produce location, and more than one supplied race * produces observation race rows (see Patient demographics below) *

  • observation_period -> one request-local derived row per person, - * spanning the populated dates of that person's visit, clinical, and - * death rows; this is not enrollment or capture-completeness evidence
  • + * spanning dates from that person's visit and clinical rows not listed + * in rows_for_review, plus Specimen and Death rows. Source end dates + * contribute even when the output table has no end column. No period + * row is returned for a person without qualifying dates; this is not + * enrollment or capture-completeness evidence *
  • Location -> location and care_site
  • *
  • Organization -> care_site; its first address can produce location
  • *
  • HealthcareService -> care_site
  • *
  • Practitioner and PractitionerRole -> provider
  • *
  • Encounter -> visit_occurrence
  • - *
  • Condition -> condition_occurrence
  • - *
  • Procedure -> procedure_occurrence
  • - *
  • MedicationRequest, MedicationStatement, and - * MedicationAdministration -> drug_exposure
  • - *
  • Immunization -> drug_exposure
  • - *
  • Observation -> measurement or observation. For coded - * Observations, the resolved OMOP concept domain selects the table; value - * form only breaks ties. For text-only Observations, numeric values route - * to measurement and nonnumeric values to observation.
  • - *
  • AllergyIntolerance -> observation
  • + *
  • Specimen -> specimen, with an explicit Patient subject; missing + * collection date or material retains a partial row with diagnostics
  • + *
  • DeviceUseStatement -> device_exposure, using referenced Device.type
  • + *
  • Condition -> domain-selected clinical table; default condition_occurrence
  • + *
  • Procedure -> domain-selected clinical table; default procedure_occurrence
  • + *
  • MedicationRequest, MedicationStatement, MedicationAdministration, and + * MedicationDispense -> domain-selected clinical table; default drug_exposure
  • + *
  • Immunization -> domain-selected clinical table; default drug_exposure
  • + *
  • Observation -> domain-selected clinical table. Numeric values prefer + * measurement and other values prefer observation when the code is + * valid for both tables. + * If no supported standard concept is selected, numeric values retain the + * measurement default and nonnumeric values retain observation.
  • + *
  • AllergyIntolerance -> observation for supported contextual + * allergy/intolerance mappings; other coded statements use domain-based + * placement excluding drug_exposure and device_exposure; default observation
  • * + *

    Clinical placement follows the informative Vulcan IG's vocabulary-driven + * domain assignment. Supported destinations are condition_occurrence, + * procedure_occurrence, drug_exposure, device_exposure, measurement, and observation. + * Resolved clinical records are returned in the table corresponding to the + * domain of their mapped OMOP standard concept. The source resource + * determines eligibility, field values, and the default table when no + * supported mapping is available. + * AllergyIntolerance records do not produce drug_exposure or + * device_exposure rows. Device placement requires eligible use evidence: + * prescriptions, uncertain diagnoses, and explicit negative Observation + * results (false, SNOMED Negative, Known absent, or No, or interpretation + * code NEG from + * http://terminology.hl7.org/CodeSystem/v3-ObservationInterpretation) + * do not accept Device placement, even when other supplied values are positive. + * A recorder, asserter, or information source is retained as context rather + * than treated as the device event's provider.

    + *

    Coded AllergyIntolerance.code statements are interpreted using their + * original codings, optional type, and category. Supported substances + * produce an Observation finding separately from the causative substance + * in value_as_concept_id. Drug-domain substances are valid values and do + * not imply drug exposure or medication allergy. The finding is selected + * by an explicit mapping policy, not a published substance-to-finding + * crosswalk. Contextual findings report MAPPED, + * resolution.via: CONTEXTUAL. observation_source_concept_id retains + * the known OMOP identity of the original coding written to + * observation_source_value, including when the finding is unresolved; + * it is 0 when no source identity is known. The configured finding is + * never substituted for the original source concept.

    + *

    This adapts the informative R5 Vulcan Value-as-Concept + * pattern + * to R4's substance/product codes, optional type, and repeating category. + * It selects a contextual finding and resolves the substance separately; + * published Maps to value decomposition of a composite finding is not performed.

    + *

    For type: allergy, medication (optionally with biologic) maps to + * Allergy to drug (439224); other category sets use Allergic disposition + * (43530807). For type: intolerance, medication (optionally biologic) + * uses Intolerance to drug (4240582), food alone uses Intolerance to food + * (4340252), and other sets use Intolerance to substance (36684107). + * All selected concepts must be active and standard in the requested + * vocabulary release. Limited reviewed substance mappings can derive an + * absent category: for example, peanut and egg extracts supply food context, and + * Penicillin G supplies medication context. No category is inferred solely + * from Drug-domain membership.

    + *

    With absent type, a supported substance maps to a mechanism-neutral + * propensity finding: medication (optionally with biologic) uses Propensity + * to adverse reactions to drug (4305025), food alone uses Propensity to + * adverse reactions to food (4303802), and other category sets or an + * unknown category use Propensity to adverse reaction (4172024). The + * original type remains absent; no allergy or intolerance mechanism is + * inferred. The substance value is resolved independently. Existing + * supported findings can supply their own mechanism and negative + * statements retain their meaning. Conflicting evidence leaves the + * affected concept unmapped. + * A single identified substance can still populate the value when only + * its category context conflicts. Distinct conflicting substances, + * positive/negative mixtures, finding/substance mixtures, and multiple + * standard substance targets do not select a substance value by input + * order. Equivalent supported translations may share one value; + * unresolved translations are disclosed and are not used as substances. + * Finding-only, value-only, and neither-resolved partial rows are retained.

    + *

    mappings.resolution.allergy retains supplied type/categories, coded + * clinical/verification statuses, the date-source element, per-coding + * interpretation, derived categories, and reported information loss. The + * finding and substance have separate mappings linked to the same row and + * fields. Broader findings retain source specificity in this metadata. + * Codes outside contextual policy coverage retain existing domain-based + * mapping, with the contextual coverage outcome reported separately. + * Text-only inputs retain their text-only behavior. Reaction-substance + * fallback and published Maps to value relationships are not applied. + * Null placeholders in repeating FHIR category primitives carry no category + * value and are omitted from category interpretation and audit values. + * An invalid supplied type/category drops only the affected AllergyIntolerance + * before concept resolution. Other resources are still processed. The + * dropped entry identifies the resource and reports + * allergy_type_unrecognized or allergy_category_unrecognized.

    + *

    For example, a Condition coded with ICD10CM Z79.4 can produce a mapped + * observation row. Z86.73 and ICD-O-3 8140/3 also map to concepts in + * the Observation domain. Use mappings.omop_table and mappings.omop_id + * to locate each result; the FHIR resource type alone does not determine + * the output table.

    + *

    Clinical rows include available person/visit/provider links, concept and + * source concept IDs, source value, source type provenance, and start + * timing. Dates follow the source resource's timing rules below, regardless + * of the output table. For example, a Condition follows its + * onset[x] / recordedDate timing rule even when returned as an + * observation. End dates are populated only in tables with end columns; + * for rows not listed in rows_for_review, they also contribute to the + * request-local observation period when the output table has no end column.

    + *

    Source-specific fields are populated only for the corresponding source + * and destination: Condition status in condition_occurrence, medication + * dosage/supply and Immunization lot details in drug_exposure, and + * Observation values/units/ranges in measurement or observation where + * those columns exist. Mapping notes explain the output table and which + * fields are populated. Coded medication/immunization routes on rows outside + * drug_exposure, and Observation values on rows outside measurement / + * observation, are retained in mappings linked to the output row, with + * omop_field omitted and selected: false. Coded routes are checked + * against the OMOP Route domain; those Observation values are preserved + * without concept resolution.

    + *

    Note destinations are unsupported. If no standard concept + * valid for a supported destination is found, the resource produces a row + * in its default table with concept ID 0 and UNMAPPED status. Its + * source concept ID is also 0, except that allergy rows retain a known + * original source identity from their coding evidence. A DOMAIN_MISMATCH + * outcome is identified in the mapping + * note. Text-only clinical inputs can also produce rows in another supported + * table when semantic mapping selects a standard concept valid for that destination; + * without a validated match, they use their default tables. Domain-routed + * clinical records produce one row each even when their code has multiple + * vocabulary mapping targets; DeviceUseStatement event granularity is + * described below. Vocabulary Maps to value mappings are not applied.

    *

    Medication is reference data for medication resources; it does not * create its own row because OMOP CDM has no Medication table. Administrative * linkages (provider, care site, and location) are best-effort and limited to * references supplied in the request. Patient.managingOrganization is a - * record custodian, not a care-delivery site. Provider specialty is not - * mapped. Recorded Practitioner.gender is distinct from Person + * record custodian, not a care-delivery site. Provider specialties resolve + * independently to standard Provider concepts. Recorded Practitioner.gender + * is distinct from Person * demographics: male and female resolve to validated OMOP Gender * concepts in provider.gender_concept_id; other, unknown, and absent * gender remain unmapped. Address.country is resolved to location.country_concept_id, * and CMS Place of Service codings in Location.type are resolved to * care_site.place_of_service_concept_id. + * Within one request, Practitioners with the same single distinct supplied + * NPI value merge into one provider row only when all supplied mapped identity + * attributes in the entire NPI group agree. The NPI system may be + * http://hl7.org/fhir/sid/us-npi (preferred in FHIR R4), + * urn:oid:2.16.840.1.113883.4.6, or bare 2.16.840.1.113883.4.6; + * these forms are equivalent for matching. OID forms are accepted for compatibility. + * The same matching rule applies to Practitioners contained in valid role + * references. NPI values are compared exactly as supplied, without format + * or checksum validation. Blank values and unknown markers UNK and + * UNKNOWN are treated as missing and do not populate npi or participate + * in matching. Marker detection ignores case and surrounding whitespace; + * all other values are preserved without trimming. Other identifier systems and names + * do not trigger a merge. Missing attributes can enrich a compatible merged row. + * Compatibility compares the name rendered from each Practitioner's first + * name entry, its gender, its first non-empty DEA identifier, and its parsed + * birth year. Differences in birth month/day within one year do not conflict. + * Rendered names are compared exactly: Jane Smith and Dr. Jane Smith + * conflict even when they share an NPI. + * If any of these attributes conflict, every resource in that NPI group stays + * separate, including resources missing the conflicting attribute. Source + * attributes and unambiguous structural reference links are preserved; no + * conflicting value is selected. Each affected row has CONFLICTING + * diagnostics at name, gender, identifier, or birthDate, linked by + * omop_table: provider and omop_id. All source gender mappings link to + * their retained or merged provider row. + * Repeated copies of one NPI on a resource are allowed. A resource with + * multiple distinct NPIs stays separate, leaves npi unset, preserves its + * source-resource identity in provider_source_value, and reports a + * CONFLICTING diagnostic at identifier linked by + * omop_table: provider and omop_id. + * Row IDs are per response. The API does not merge across requests. For a + * Practitioner with one NPI, provider_source_value is that NPI, even when + * its group conflicts. Consumers can use it to deduplicate compatible + * providers across requests, but must preserve rows flagged by identity + * conflict diagnostics and check compatibility across responses. Without + * one unambiguous NPI, source-resource identity is preserved without a + * person-level deduplication guarantee. * A PractitionerRole that identifies one supplied Practitioner aliases * that canonical provider: by a top-level structural reference, a - * parent-contained #id reference, or an exact identifier.system and - * identifier.value match against a top-level Practitioner. No remote + * parent-contained #id reference, or an identifier.system and + * identifier.value match against a top-level Practitioner. Identifier + * matching uses the equivalent NPI systems and missing-value rules above; + * all other systems and all supplied identifiers match exactly. No remote * identifier lookup is performed. When Reference.type is present it must - * be Practitioner; duplicate contained IDs and identifier matches are - * ambiguous. An explicit reference that is unresolved, ambiguous, or - * unsupported retains a role-fallback provider row and is returned in - * diagnostics. provider_role_contexts preserves role-specific + * be Practitioner; duplicate contained IDs and identifier matches to + * different canonical providers are ambiguous. An explicit reference that + * is unresolved, ambiguous, or unsupported retains a role-fallback provider + * row and is returned in diagnostics. provider_role_contexts preserves role-specific * specialty and care-site context that a canonical OMOP provider row cannot * represent together.

    + *

    A role whose practitioner reference has only a blank or unknown NPI + * identifier follows the missing-identifier behavior: its fallback row has + * an UNSUPPORTED diagnostic explaining that a usable identifier or literal + * reference is required. An unambiguous literal reference still links to + * its Practitioner when the NPI is missing or unknown. Original supplied + * unknown markers remain in the role's practitioner_identifier metadata.

    + *

    Each PractitionerRole.specialty entry resolves independently using its + * supplied codings and explicit text from that entry. NUCC codings resolve + * directly; other supported codings may use the UMLS bridge. Text fallback + * is permitted when every coding has a nonblank code and a well-formed, + * unsupported system identifier. Supported systems, mixed supported and + * unsupported systems, and incomplete or malformed codings block fallback. + * A supported coding remains authoritative even when the text differs. + * Within one specialty entry, complete codings from well-formed unsupported + * systems do not block supported codings that independently resolve to the + * same standard Provider concept. Unsupported companions remain unsupported + * in the coding audit and acquire no crosswalk. Missing or malformed + * companions, failures of recognized codings, and disagreements between + * supported codings still block selection. For example, NUCC 208000000X alongside a complete local + * PEDS coding can resolve to Pediatric Medicine, with or without text + * and in either coding order. This uses the supported coding's mapping + * and source concept, rather than text fallback. + * Coding displays do not supply text fallback evidence. + * Unsupported system identifiers must be absolute URIs without embedded + * whitespace, user information, or fragments. HTTP(S) identifiers require + * a host; URNs require a namespace and a nonempty namespace-specific value. + * Bare labels such as Epic do not qualify. + * Text uses deterministic vocabulary assertions in order: standard Provider + * concept name, English OMOP synonym, then an English UMLS string on a + * CUI with a NUCC atom, then a NUCC preferred-term label with its grouping + * prefix removed. The last layer retains the entire classification and + * specialization label, including qualifications. An ambiguous layer stops + * resolution. No semantic search or ML matching is used. + * Each role's input remains visible in mappings, including repeated + * identical inputs across one provider's roles. + * specialty_concept_id is populated only when every distinct supplied + * input resolves to the same standard Provider concept. With no inputs, + * or an unresolved, ambiguous, or conflicting input, it stays 0. + * A role without a specialty contributes no input. The endpoint applies + * no frequency or hierarchy selection. Each input's outcome and matching + * route (layer, matched string, and UMLS CUI/SAB when applicable) appear + * in mappings with omop_field: specialty_concept_id. An individually + * resolved input can coexist with a provider concept of 0 when another + * input fails or disagrees. Specialty mappings have selected: false: + * the source text remains in specialty_source_value when supplied source + * values agree, and every original value remains in role contexts and mappings. + * Specialty mapping evidence includes specialty_resolution with the text + * fallback decision and each coding's independent outcome. A successful + * text fallback reports MAPPED and source concept ID 0; its local + * codings remain unsupported in that audit. For example, complete local + * codings with explicit Pediatrics text can resolve to Pediatric Medicine + * through NUCC_LABEL. Adding a different or unresolved specialty still + * keeps the provider's singular concept at 0. + * Compound phrasings without a vocabulary assertion remain unresolved. + * For example, Pulmonology stays unresolved while Pulmonologist + * resolves to Pulmonary Disease. Results depend on vocab_version.

    *

    Patient demographics:

    *
      *
    • Sex at birth, race, and ethnicity are read from these US Core @@ -157,22 +404,138 @@ public RawFhir2OmopClient withRawResponse() { * 0; and Vulcan's suggested observation rows for multiple * ethnicities are not produced.
    • *
    - *

    DiagnosticReport, ServiceRequest, CarePlan, DocumentReference, - * Composition, Specimen, DeviceUseStatement, Coverage, Claim, and - * other unsupported resource types are accepted in a Bundle but ignored: they + *

    Specimen produces a specimen row only with an explicitly resolvable + * top-level Patient subject. Missing collection dates or material retain + * useful partial rows with linked diagnostics; those rows may require + * downstream completion before loading into a strict CDM instance. + * References support Patient/id and exact Bundle + * fullUrl matches (including URNs and absolute URLs); contained, logical, + * display-only, missing, non-Patient, and unresolved subjects never use the + * sole Patient fallback. Reference.type, when supplied, must identify Patient. + * Unsafe patient attribution and ineligible status drop only that Specimen + * with dropped and diagnostics; a missing Patient in the entire request + * remains a 400. Missing material is concept ID 0 with a type diagnostic + * and no terminology mapping/summary count. Supplied unmapped material is + * also 0, with its separate vocabulary mapping outcome and retained source code. + * Material (type) resolves against the Specimen domain, coded UCUM + * collection quantity units against Unit, and collection.bodySite against + * Spec Anatomic Site. Supplied unmapped optional concepts are 0; absent + * unit and site IDs are explicit null. Text-only body sites preserve their + * source text with site concept ID 0 and an UNMAPPED mapping; they do not + * produce a text-based concept suggestion. Material descriptions follow + * the semantic-mapping policy above. Original source codes and text-only + * descriptions are preserved. + * Numeric quantity is copied without + * unit conversion; invalid or comparator-bearing quantities are omitted + * with diagnostics without dropping the row. Missing optional data is allowed. + * Collection time uses collection.collectedDateTime or + * collection.collectedPeriod.start; end-only, partial, invalid, and absent + * dates leave specimen_date/specimen_datetime omitted with linked diagnostics. + * Both collected forms together are conflicting: retain the row without + * selecting a date. Period end and receivedTime never substitute for collection + * start. specimen_source_contexts.collection_timing preserves raw dateTime, + * Period boundaries, receivedTime, and the selected source path when a date + * is populated, including partial precision and original timezone offsets. + * specimen_collection_date_missing, specimen_collection_date_partial, and + * specimen_collection_date_unusable distinguish absent, partial, and invalid + * source dates; specimen_collection_multiple_forms reports conflicting choices. + * specimen_type_concept_id: 32817 is EHR provenance, independent of material + * and availability. Status absent/available/unavailable/unsatisfactory is + * supported; entered-in-error and unrecognized statuses drop the specimen. + * specimen_source_contexts.status preserves the supplied availability + * status for emitted rows; it is omitted when no status was supplied. + * Disease status is not inferred. Source identity precedence is resource id, + * then first identifier with a nonempty value in source array order, then + * accessionIdentifier.value, then Bundle fullUrl. No identity is fabricated; + * specimen_source_contexts preserves all valued identifier system/value + * pairs, accession identity, resource id/fullUrl, and the selected source path. + * Parent/derived specimens emit independently when each has its own Patient; + * missing date/material remains partial rather than being inherited. Parent + * and Measurement-to-Specimen links are not represented. Consuming CDM + * loaders own required-date completion, durable IDs, and dataset completeness. + * Source baseline is FHIR R4 4.0.1, with supported common fields reviewed against + * US Core Specimen 7.0.0; full profile conformance is not validated here.

    + *

    Device use and exposure

    + *

    DeviceUseStatement accepts active, completed, stopped, and on-hold use; + * intended, entered-in-error, missing, and unrecognized statuses are omitted + * with source-specific diagnostics. Its subject must explicitly resolve to + * an emitted Patient. Group, contained-Patient, identifier-only, external + * fetching, and sole-Patient attribution are unsupported for this path. + * Device references support exact Type/id and Bundle fullUrl/URN matches, + * plus unambiguous contained Devices scoped to the referring statement. + * Missing, wrong-type, unresolved, or ambiguous Device details retain a + * known-patient use row with device concept 0 and diagnostics. Missing type + * evidence has no vocabulary mapping entry; supplied unmapped type evidence + * has a mapping outcome. Inventory-only Device records emit no exposure.

    + *

    timingDateTime supplies the start only; timingPeriod preserves supplied + * boundaries independently. Each timingTiming.event produces a separate + * row, including a partial row for an unusable event, with its zero-based + * event_index in device_exposure_contexts.exposures. Repeat rules without events + * retain one partial row; events plus rules produce only the event rows. + * No recurrence, continuous min/max interval, status-derived end date, or + * recordedOn fallback is inferred. Year/month precision and malformed + * individual dates leave affected columns absent. Competing timing choices + * or a provably reversed Period leave both bounds absent. Supplied precision, + * offsets, schedules, and omission/conflict reasons remain in source context + * and diagnostics. Other clinical paths retain their own timing semantics.

    + *

    device_exposure_contexts contains one entry per source record that produced + * device exposures. Its exposures array links all rows derived from that + * record by device_exposure_id and, for Timing events, event_index. Shared + * timing, device evidence, and quantity_basis appear once per source record. + * Contexts are not combined by resource id or device identity; distinct + * records remain separate even when their identifiers match or are absent.

    + *

    device_source_value uses original type/clinical coding, never inventory + * or resource identity. An unambiguous Device.udiCarrier.deviceIdentifier + * supplies unique_device_id. Competing identifiers leave it absent and are + * preserved with issuer/jurisdiction/carrier context. production_id remains + * absent; carriers, lot, serial, manufacture, and expiry evidence remain in + * device_exposure_contexts.

    + *

    DeviceUseStatement rows use device_type_concept_id 32817 (EHR), with + * provider_id, visit_occurrence_id, and visit_detail_id unset. + * Routed rows retain their source classification, explicit visit links to + * emitted same-patient visits, and compatible performer links; + * Condition/Procedure recorder/asserter and + * MedicationStatement informationSource do not supply device provider_id. + * Supplied source, encounter, context, recorder, asserter, performer, and + * informationSource values on DeviceUseStatement are retained only in + * device_exposure_contexts.

    + *

    Device exposure quantity is reported as follows:

    + *
      + *
    • When the source has no quantity, dose, rate, or numeric Observation + * result field, quantity is 1 and device_exposure_contexts.quantity_basis + * is default_absent_count.
    • + *
    • When such a field is supplied, including a null or malformed value, + * quantity is omitted and quantity_basis is unsupported_supplied_evidence. + * The original values remain in device_exposure_contexts.evidence, and + * an UNSUPPORTED diagnostic identifies each supplied field.
    • + *
    + *

    For example, an Observation with valueQuantity.value of 2 returned as a + * device exposure has no quantity; the value 2 remains in its source context. + * timingTiming.repeat.count remains in source context and does not set + * quantity. unit_concept_id and unit_source_concept_id are always null.

    + *

    Distinct eligible statements and routed clinical records remain separate, + * including overlapping use and equal Timing events. derivedFrom is retained + * as supporting context, not duplicate identity. Row counts therefore need + * not count distinct uses or physical devices. IDs are request-local exposure + * identities. Before loading a CDM dataset, callers must assign durable IDs + * and populate any required fields omitted from partial rows.

    + *

    Unsupported resource types are accepted in a Bundle but ignored: they * create no row and no dropped entry. dropped is reserved for supported * row-producing resources that cannot safely produce a positive OMOP row: * required subject/patient, clinical code/text, or medication data may be * unusable, or the resource may explicitly negate or fail the documented * clinical-event eligibility policy. A single-Patient Bundle uses the sole - * Patient only when subject/patient is absent. An explicit subject/patient + * Patient only when subject/patient is absent, except DeviceUseStatement + * and Specimen, which require explicit Patient subjects. An explicit subject/patient * reference that is unresolved, ambiguous, or unsupported drops the clinical * resource in every request scope.

    *

    Eligibility distinguishes clinical validity, performed/taken evidence, and - * administrative workflow state. Checks run before terminology resolution; - * entered-in-error and explicitly non-performed events never produce a positive - * clinical-event row, and an unrecognized required status fails closed. Condition - * requires absent or confirmed canonical-HL7 verificationStatus; clinical course is not a + * administrative workflow state. Entered-in-error and explicitly + * non-performed events never produce a positive clinical-event row, and + * unrecognized required statuses are dropped except the retained Condition and + * Encounter cases below. Condition accepts absent or confirmed canonical-HL7 + * verificationStatus without a status retention reason; uncertain or unreadable + * supplied verification is retained and indexed. Clinical course is not a * diagnosis-role mapping. Procedures accept completed or stopped events. * Medication requests are prescription evidence only: doNotPerform, drafts, * cancellations, and non-authorizing intents are dropped. Medication statements @@ -181,69 +544,142 @@ public RawFhir2OmopClient withRawResponse() { * administration also needs an effectiveDateTime or effectivePeriod.start * that supplies start evidence; a valid partial date remains an undated row. * An on-hold administration is started evidence that is temporarily paused and - * expected to continue. Immunizations require completed - * status. Observations require final, + * expected to continue. MedicationDispense accepts completed records, + * including completed partial fills. Stopped records require a valid + * whenHandedOver, including partial dates, as evidence of actual handover. + * Preparation, in-progress (ready for pickup), on-hold, cancelled, declined, + * entered-in-error, unknown, missing, and unrecognized statuses are dropped. + * A completed dispense with missing or unusable handover timing remains a + * partial row; preparation time never substitutes for handover. + * Immunizations require completed + * status. Observations accept final, * amended, or corrected status; registered, - * preliminary, cancelled, entered-in-error, unknown, and missing statuses are + * preliminary, cancelled, entered-in-error, and missing statuses are * dropped. AllergyIntolerance accepts absent, unconfirmed, or confirmed canonical-HL7 * verificationStatus as a reported allergy, but drops refuted, - * entered-in-error, and unreadable supplied verification statuses. Encounter + * entered-in-error, and unreadable supplied verification statuses. Invalid + * supplied allergy type/category also drops that resource, for both coded + * and text-only statements. Encounter * accepts arrived, triaged, in-progress, onleave, or finished status, but drops - * planned, cancelled, entered-in-error, unknown, and missing statuses. An eligible - * encounter without Period.end remains a partial visit; no end date is invented.

    - *

    Coded Observation routing is selected from the resolved OMOP concept - * domain. Numeric and nonnumeric value[x] forms establish the preferred - * target only when the code is valid for both tables. A text-only - * Observation has no resolver target, so numeric values route to - * measurement and nonnumeric values to observation. Numeric values - * populate value_as_number in the selected row; other non-coded values + * planned, cancelled, and entered-in-error statuses. Unknown, missing, and unreadable + * Encounter statuses are retained and indexed without imputation. An eligible + * encounter without Period.end remains a partial visit; no end date is invented. + * MedicationStatement, Observation, and Procedure with explicit unknown + * status are also retained and indexed in rows_for_review when otherwise + * shapeable. Missing or unreadable statuses on these three resource types + * remain dropped. Retention preserves source status and does not establish + * that an event occurred or medication was taken. Device placement still + * requires eligible use evidence; unknown-status records cannot produce + * device_exposure rows.

    + *

    The mapped OMOP concept's domain determines the clinical output table. + * For Observations, numeric and nonnumeric value[x] forms determine the + * preferred table only when the code is valid for both measurement and + * observation. Unresolved Observations retain measurement rows for + * numeric values and observation rows for other values. In these two + * tables, numeric values populate value_as_number; other non-coded values * populate value_as_string for an observation or value_source_value * for a measurement. Coded valueCodeableConcept values are resolved - * against the selected row's value_as_concept_id and use the selected + * against value_as_concept_id in these tables and use the selected * bare code in value_source_value, leaving an observation's * value_as_string empty; unmapped or target-invalid coded values remain - * 0. + * 0. Active standard concepts from any OMOP domain are valid for + * value_as_concept_id per CDM 5.4. For example, a blood-culture Measurement + * can record Staphylococcus aureus as its coded result, even though the + * organism concept belongs to OMOP's Observation domain. + * For other clinical output tables, Observation values are retained only + * in mappings, without concept resolution or an omop_field, and with + * selected: false. * Other unsupported value[x] forms and Observation components do not * populate separate converted values. A * numeric comparator (<, <=, >, >=) is represented only by a - * measurement's operator_concept_id; units remain source text and have - * unit_concept_id of 0.

    + * measurement's operator_concept_id.

    + *

    For Observations returned as measurement or observation rows, + * Quantity units resolve independently against the table's + * unit_concept_id. + * Only coded UCUM (http://unitsofmeasure.org or its + * OID 2.16.840.1.113883.6.8, bare or urn:oid:) is supported. Codes are + * case-sensitive: no spelling aliases, text-only guesses, or numeric + * conversion are applied. Coded UCUM units map to valid standard OMOP + * Unit concepts using the release selected by vocab_version. + * Mappings are direct or follow an active OMOP Maps to relationship; + * UCUM units do not use a UMLS bridge. + * Unknown codes, + * vocabulary gaps, text-only units, missing coding, and unsupported + * systems keep the row and use 0; absent units use JSON null (the + * field is present). Explicit dimensionless code 1 is a supplied unit + * and resolves or remains 0; it is never treated as absent. + * unit_source_value contains Quantity.code when supplied, otherwise + * Quantity.unit. A separate unit mappings entry preserves the original + * Quantity.system/code in source_system/source_code and the exact + * human-readable Quantity.unit in source_name, with its own outcome. + * Quantity.unit provides display text and does not affect unit matching. + * Measurement also returns nullable unit_source_concept_id, identifying + * the OMOP source concept for Quantity.system/code when available, + * otherwise 0 for a supplied unit. + * Observation has no such column. Resolving a unit does not change + * numeric values, comparators, or reference ranges. + * Supported coded UCUM units include resolution in their mappings, + * whether the unit resolves or remains unmapped: + * its outcome and reason code distinguish vocabulary gaps, target-domain + * mismatches, and ambiguity. Unresolved or ambiguous units retain the row + * with concept ID 0. Resolved mappings retain the selected concept's + * direct or crosswalk provenance. Text-only units, units with missing + * coding, and unsupported unit systems omit resolution; their note + * explains the unmapped outcome.

    + *

    For Observations returned in other clinical tables, units are not resolved + * or included as separate mapping outcomes. Device exposure unit IDs remain + * null, with supplied quantity and unit evidence retained in + * device_exposure_contexts.

    + *

    Compatibility: absent unit_concept_id values previously returned 0 + * and now return null. Coded units use their code in unit_source_value + * rather than their human-readable text; the text remains in mappings. + * The Measurement unit_source_concept_id field is new.

    *

    A standard OMOP concept_id is selected for each eligible primary clinical coding * after considering all of the resource's supplied codings. An unambiguous - * coded medication route is resolved independently to - * drug_exposure.route_concept_id. Alongside the OMOP rows grouped by + * coded medication or immunization route is checked independently against + * the OMOP Route domain. For drug_exposure rows, route_concept_id + * contains the resolved concept ID, or 0 if unmapped; for other output + * tables the route is reported only in + * mappings, without omop_field and with selected: false. + * Alongside the OMOP rows grouped by * table (tables), the response carries mappings (an entry for every * supported source coding from a resource that shaped a row, linked back to * that row; some, such * as demographic null flavors, are reported without being resolved), + * rows_for_review (an index of uncertain-status or primary-concept-0 clinical + * rows in tables, with original statuses, reasons, and source/row identity), * provider_role_contexts (source role details linked to provider rows), * dropped (resources that could not be shaped into a row), * diagnostics (explicit references that could not safely create a link, - * and conflicting or unsupported Patient demographic extensions), + * conflicting Practitioner identity attributes, conflicting or unsupported + * Patient demographic extensions, and conflicting Encounter classifications), * vocab_version (the OMOP vocabulary release codes were resolved * against), and a small summary of the resolution outcomes.

    *

    A concept_id of 0 is reported, not omitted (OMOP "no matching - * concept" semantics): it covers both a coding with no standard match - * (UNMAPPED) and an unverified suggestion for a text-only resource - * (UNCHECKED). Visit and unit concept fields currently remain 0; - * Person demographics and Provider recorded gender follow the policies - * above. Coded Observation values may populate value_as_concept_id. - * Concepts set by a fixed convention rather than terminology resolution - * are measurement operator_concept_id, set from a value comparator (<, - * <=, >, >=), and the multiple-race concepts described above. Clinical - * *_source_value fields contain the selected FHIR code (or source text - * for text-only resources). + * concept" semantics): it covers a supplied concept without a validated + * standard match (UNMAPPED). + * Supported Encounter.class or Encounter.type values + * may populate visit_concept_id. Person demographics and Provider + * recorded gender follow the policies above. + * Coded Observation values and supported allergy substances may populate + * value_as_concept_id. + * Measurement operator_concept_id represents a value comparator (<, + * <=, >, >=); multiple-race concepts follow the conventions described + * above. Clinical *_source_value fields contain the selected FHIR code + * (or source text for text-only resources). * The corresponding selected mappings entry preserves the coding system * and full source-coding provenance. * Known OID-form coding systems are accepted as either FHIR OID URNs (for * example, urn:oid:2.16.840.1.113883.6.1 for LOINC) or bare OIDs, and - * are normalized to their canonical system URLs before terminology - * resolution. mappings[].source_system reports that canonical URL, so the - * OID and URL forms produce the same mapping. An unknown OID is not - * rewritten and may be UNMAPPED. + * produce the same mappings as their canonical system URLs. Coding + * mappings report that canonical URL in source_system; Quantity unit + * mappings preserve the original Quantity.system, including OID spelling. + * An unknown OID remains unchanged in source_system and may be + * UNMAPPED. * Other *_source_value fields preserve row-specific raw source values, * such as resource identifiers, names, units, or status codes. - * MedicationRequest uses 32838 (EHR prescription) for + * MedicationRequest uses 32838 (EHR prescription) and + * MedicationDispense uses 32825 (EHR dispensing record) for * drug_type_concept_id; other current resources use 32817 (EHR). This * is a coarse provenance policy: it does not infer patient-reported, * medication-history, or other more-specific type concepts from FHIR @@ -256,8 +692,8 @@ public RawFhir2OmopClient withRawResponse() { * (YYYY-MM-DDTHH:MM:SS, with fractional seconds to microseconds when * supplied): 2024-01-15T23:30:00-05:00 becomes 2024-01-15T23:30:00. * A datetime without a timezone is read as local time. - *

  • A partial date (2024 or 2024-03) leaves both columns unset. It - * still counts as that source's value, so later sources in the list + *
  • A partial date (2024 or 2024-03) leaves both columns unset. + * It still counts as that source's value, so later sources in the list * below are not used.
  • *
  • Free-text timing (such as onsetString), Age and Range forms, and * values that are not dates are ignored, so a later source in the list @@ -267,11 +703,18 @@ public RawFhir2OmopClient withRawResponse() { * completed, and a row is not dated from another resource such as its * Encounter.
  • *
  • Valid date values never cause a request to be rejected, and the response - * does not report which date elements were unusable. An on-hold + * reports missing, partial, malformed, or conflicting timing for Specimen, + * DeviceUseStatement, and MedicationDispense in diagnostics while retaining + * partial rows for downstream load completion. Other resources do not report which + * date elements were unusable. An on-hold * MedicationAdministration is an eligibility exception: it needs a supplied - * effective start as evidence that administration began.
  • + * effective start as evidence that administration began. + * A stopped MedicationDispense similarly requires supplied handover evidence. * - *

    Timing sources, in priority order where several are listed:

    + *

    Timing sources, in priority order where several are listed. These rules + * apply regardless of the clinical output table; end dates are populated + * only where the table has an end column, and verbatim_end_date is set + * only for medication resources returned as drug_exposure rows:

    *
      *
    • Patient: birthDate sets year_of_birth, month_of_birth, and * day_of_birth from the parts it supplies; birth_datetime is not @@ -283,6 +726,12 @@ public RawFhir2OmopClient withRawResponse() { * end from abatementDateTime or abatementPeriod.end.
    • *
    • Procedure: start from performedDateTime or * performedPeriod.start; end from performedPeriod.end only.
    • + *
    • DeviceUseStatement: timingDateTime sets the start only; + * timingPeriod.start and timingPeriod.end supply independent bounds; + * each timingTiming.event produces one start-only row. A schedule + * without explicit events produces one row without dates.
    • + *
    • Specimen: collection.collectedDateTime or collection.collectedPeriod.start; + * missing day precision retains an undated specimen with diagnostics.
    • *
    • Observation: effectiveDateTime, effectivePeriod.start, or * effectiveInstant.
    • *
    • AllergyIntolerance: recordedDate, then onsetDateTime or @@ -299,21 +748,212 @@ public RawFhir2OmopClient withRawResponse() { *
    • MedicationRequest: authoredOn, the order date, sets the start; it * is not evidence of administration. No end is set, and the validity * period is not used as an exposure duration.
    • + *
    • MedicationDispense: whenHandedOver sets the start. Missing, partial, + * or invalid handover leaves the start unset with a diagnostic. Comparable + * handover/preparation values proving handover before preparation also + * leave the start unset with a CONFLICTING diagnostic. whenPrepared, + * authorizing orders, and related administrations never supply timing. + * No exposure end or verbatim_end_date is inferred, including from + * daysSupply. Raw handover/preparation values retain precision and offsets + * in medication_dispense_source_contexts.source.
    • *
    • Differences from the Vulcan maps: birth_datetime is not set from * birthDate, Condition also reads onsetPeriod.start, and * AllergyIntolerance falls back to its onset when recordedDate is * missing.
    • *
    - *

    Medication details:

    + *

    Medication details for resources returned as drug_exposure rows:

    *
      *
    • For MedicationRequest, dispenseRequest.numberOfRepeatsAllowed sets * refills and a whole-day expectedSupplyDuration sets days_supply.
    • + *
    • For MedicationDispense, daysSupply sets days_supply only for a + * positive whole number of coded UCUM d days without a supplied comparator. + * The canonical UCUM URL, bare OID, and OID URN are accepted. Other units, + * fractions, text-only units, and malformed values leave it absent with a + * diagnostic; absent daysSupply is allowed. Refills are not inherited.
    • *
    • All non-empty dosage text is preserved in sig.
    • *
    • Coded dosage routes and Immunization.route are target-validated in * the OMOP Route domain. Conflicting routes are left unset; route * codings shared by every dosage instruction identify the same route.
    • *
    • Immunization.lotNumber is preserved in lot_number.
    • *
    + *

    MedicationDispense requires an explicit subject resolving to a Patient + * supplied in the request; Groups and missing subjects are unsupported. + * Source fields are based on FHIR R4 4.0.1 and reviewed against + * US Core MedicationDispense 7.0.0. + * Full profile conformance is not validated. Although US Core requires + * handover on completed records, this API retains such incomplete records + * with missing-handover diagnostics as partial conversion output. + * Supply either a usable medicationCodeableConcept (coded or text-only), + * or a medicationReference resolving to a Medication with usable code/text. + * Both choice forms together are conflicting and drop the dispense. + * Exact relative, Bundle fullUrl, and unambiguous contained Medication + * references are supported. Unresolved references are not rescued by display + * text; missing or ambiguous medication identity drops the dispense with a + * diagnostic. A usable but unmapped drug retains concept ID 0 and its + * mapping outcome in a row indexed for review. + * context links only to a supplied Encounter for the same patient. + * The first resolvable Practitioner/PractitionerRole in performer.actor + * supplies provider_id; other actor types do not supply a provider. + * Failed explicit links have diagnostics. Authorizing prescriptions are + * checked for request-local MedicationRequest identity and patient agreement; + * failures do not discard the dispense. They never supply medication, + * instructions, refills, timing, or prescription provenance to a dispense. + * Dispense instructions alone supply sig and route; conflicting routes + * remain unset with a diagnostic and original instructions in source context. + * Orders, dispenses, and administrations emit independently, with separate + * source identity and no deduplication merely because they concern the same drug. + * Every emitted dispensing drug row has a whenHandedOver/daysSupply + * diagnostic beginning medication_dispense_end_date_absent because the + * CDM-required exposure end remains absent. Missing/partial/unusable handover + * diagnostics begin medication_dispense_handover_missing, + * medication_dispense_handover_partial, or medication_dispense_handover_unusable.

    + *

    Encounter details:

    + *
      + *
    • + *

      Encounter classifications must supply both Coding.system and + * Coding.code to map to a standard OMOP Visit concept. For HL7 + * encounter class codes with system + * http://terminology.hl7.org/CodeSystem/v3-ActCode or + * http://hl7.org/fhir/v3/ActCode, the supported mappings are:

      + *
        + *
      • IMP (inpatient encounter) -> 9201 (Inpatient Visit)
      • + *
      • AMB (ambulatory encounter) -> 9202 (Outpatient Visit)
      • + *
      • EMER (emergency encounter) -> 9203 (Emergency Room Visit)
      • + *
      • VR (virtual encounter) -> 722455 (Telehealth)
      • + *
      • HH (home health encounter) -> 581476 (Home Visit)
      • + *
      + *

      OBSENC (observation encounter) has no direct class mapping. Other + * class or type codes can be used when they have a valid standard OMOP + * Visit mapping in the selected vocabulary release. Unsupported + * classifications retain visit_concept_id of 0 unless a supported + * type supplies the Visit as described below; they never default to + * outpatient.

      + *
    • + *
    • + *

      A mapped Encounter.class determines visit_concept_id. A mapped + * type that disagrees with it produces a CONFLICTING diagnostic with + * path class/type, while the class mapping remains selected. + * If class is missing or unmapped, type supplies the Visit only when + * all mapped type classifications agree on the same standard Visit + * concept. Codings within one type CodeableConcept are alternative + * encodings of that classification. When separate type classifications + * map to different Visit concepts, no type is selected, + * visit_concept_id remains 0, and a CONFLICTING diagnostic has + * path type.

      + *
    • + *
    • + *

      visit_source_value contains the selected class or type code; + * mappings preserves its coding system and identifies which + * classification was selected. visit_source_concept_id identifies + * the selected original source coding's OMOP concept when available, + * and is 0 otherwise, including for the HL7 encounter class codes + * listed above. Each eligible Encounter produces one Visit; + * classHistory, partOf, and admission/discharge concepts are not + * mapped.

      + *
    • + *
    • + *

      Direct MedicationRequest.dispenseRequest.quantity, MedicationDispense.quantity, + * MedicationAdministration.dosage.dose, and Immunization.doseQuantity + * can set quantity under the formulation policies below. Quantities + * must be positive finite numbers with a supported system/code pair + * and no supplied comparator. Ingredient amounts and injection volumes + * use http://unitsofmeasure.org (or its bare OID or + * urn:oid:2.16.840.1.113883.6.8) with case-sensitive codes matching or + * convertible to vocabulary-backed strength units. Exact unit matches, + * including [U] for insulin and heparin strengths, are accepted without + * a unit conversion. + * When codes differ, both source and strength units must belong to the + * same supported group: mass (ug, mg, g), volume (uL, mL, L), + * or milliequivalent representations (meq, 10*-3.eq). Other conversions, + * including kg, ML, Mg, [drp], mol, mmol, %, 1, and generic + * annotations such as {vial} or {syringe}, leave quantity unset with + * unit_mismatch. Codes are not case-corrected or inferred from display text. + * The original source system is preserved in the quantity audit. + * Fixed-form counts also accept the explicit codings below. Free-text, + * local, invalid, and cross-dimensional units are not interpreted.

      + *
    • + *
    + *

    Drug quantity policies follow CDM v5.4 DRUG_EXPOSURE conventions. + * The selected drug must be a Clinical Drug or Branded Drug, with one + * active dose form and valid vocabulary evidence for the chosen policy. + * Strength-based conversions also require one ingredient strength. Supported RxNorm dose forms:

    + *
      + *
    • fixed_dose_form_count: Oral Tablet (317541), Oral Capsule + * (316965), Extended Release Oral Tablet (316945), Extended + * Release Oral Capsule (316943), Delayed Release Oral Tablet (10312), + * or Delayed Release Oral Capsule (316995). A source ingredient amount is divided + * by the single positive per-unit ingredient amount after conversion to that + * strength unit when necessary. + * Concentration-based strengths cannot derive counts from ingredient amounts. The + * result is a count of the selected dose form, limited to whole units or + * multiples of one-half or one-third. Derived counts within a relative + * tolerance of 1e-12 of an allowed fraction are normalized to that count + * to remove arithmetic roundoff; other fractions remain unsupported. + * For example, 1,000 mg for a drug + * with 500 mg per tablet produces quantity: 2 tablets (DERIVED). A source + * of 1 g is converted to 1,000 mg and produces the same count. + * Explicit counts are copied (DIRECT) when their coding matches the + * selected tablet or capsule form, including the corresponding extended- + * and delayed-release forms: TAB / CAP under + * http://terminology.hl7.org/CodeSystem/v3-orderableDrugForm, or + * {tbl} / {Capsule} under http://unitsofmeasure.org. These exact + * case-sensitive count codings identify the supplied form; they are not + * dimensionally converted as UCUM units. Direct counts do not require + * ingredient strength: combination products and missing/invalid strengths + * do not change the supplied count. Ingredient-strength gaps, including + * missing ingredient or strength-unit references, do not prevent copying + * an explicit count. Drug or dose-form evidence gaps, unclassified or + * ambiguous forms, and gaps whose relevance cannot be established still + * prevent conversion. The whole/half/third rules still apply. + * For example, 20 TAB for amoxicillin/clavulanate is 20 tablets, and 30 TAB for + * a supported tablet produces quantity: 30; CAP for that tablet is + * omitted with unit_mismatch. Generic counts (1, {Each}), other + * annotations, packs, and free-text counts are not interpreted. + * See the FHIR R4 count example.
    • + *
    • clinical_injection_ingredient_amount: Injectable Solution (316949), + * Injectable Suspension (316950), or Injection (1649574). A source + * ingredient amount is converted to the vocabulary ingredient unit when + * necessary; an exact-unit amount is copied directly. Otherwise, a source + * amount convertible to the concentration's denominator unit is converted + * and multiplied by numerator value / the effective denominator reported in + * the audit evidence. Volume conversion requires a positive finite numerator, + * dimensionally distinct validated UCUM units, a null raw denominator value, and + * evidence.strength.denominator_basis with kind: PER_UNIT and + * value: 1. A null denominator alone is not interpreted as 1. Missing, unknown, + * non-applicable, quantified, or conflicting denominator evidence does + * not support volume conversion. For example, 80 mL with 2 mg/mL + * strength and a PER_UNIT basis produces quantity: 160 mg. A supplied + * 160 mg amount for the same drug produces quantity: 160 directly. A source + * of 0.08 L converts to 80 mL before producing the same 160 mg result. + * This output is already + * the ingredient amount: do not apply the drug-dose guide's + * dose = quantity × numerator formula to these injection rows, which + * would multiply by the concentration a second time. Use + * quantity_transformations.result_unit to identify the output unit.
    • + *
    + *

    Quantified products, packs, prefilled syringes, other liquids and dose + * forms, strength-based conversions of combination products, conflicting or incomplete required evidence, and + * unsupported source quantities leave quantity unset. Other supported + * fields and the clinical row are still returned. For example, a quantity + * supplied only as value: 2, unit: tablet is omitted with + * unsupported_unit, because a free-text unit cannot establish this policy. + * Rate-only administrations and MedicationStatement dosages do not establish + * a supported total quantity and are not used for quantity conversion.

    + *

    quantity_transformations audits each supplied quantity at one of the + * three direct paths above on a resource returned as a drug_exposure row, + * including an explicit null or malformed quantity. Each entry preserves + * the source, identifies the destination row, and reports DIRECT, DERIVED, or + * OMITTED. Successful entries identify their policy, evidence, result, + * and result unit; omitted entries give a stable reason. When a source code + * differs from the strength unit, unit_conversion records the intermediate + * converted amount and target unit. Converted results are DERIVED, even + * when no further concentration arithmetic is needed. A successful conversion + * can also be reported when a later policy check omits the final quantity. + * Dose-form and unit + * concept IDs refer to the response's vocab_version. Missing quantities + * produce no entry. Resources returned in other clinical tables have no + * quantity entry. Unsupported rates and statement dosages produce no entry, + * and resources in dropped have no quantity entry or destination row.

    *

    Medication codes are resolved whether they appear inline * (medicationCodeableConcept) or via a medicationReference to a contained, * relative (Type/id), or bundle-entry (urn:uuid) Medication resource. @@ -325,11 +965,12 @@ public RawFhir2OmopClient withRawResponse() { * subject/patient reference cannot be tied to a person; or an event excluded * by eligibility — are reported under dropped rather than emitted as blank * rows. The Bundle must contain at least one Patient resource.

    - *

    Structural references resolve only to top-level resources supplied in the - * request, by Type/id or an exactly matching Bundle fullUrl (including - * urn:uuid). Contained references are supported for medication code lookup - * and PractitionerRole.practitioner enrichment; the latter also supports - * exact request-local identifier matching without a remote lookup. Every + *

    References to top-level resources supplied in the request resolve by + * Type/id or an exactly matching Bundle fullUrl (including urn:uuid). + * Parent-scoped contained references are supported for medication code lookup, + * DeviceUseStatement.device details, and PractitionerRole.practitioner + * enrichment; the latter also supports + * request-local identifier matching with the NPI rules above, without a remote lookup. Every * nonzero structural foreign key targets a row in the same response. Missing * optional links remain unset without a diagnostic; an explicit optional * reference that is unresolved, ambiguous, conflicting with the row's @@ -351,11 +992,42 @@ public CreateOmopResponse create(CreateOmopRequest request) { /** * Maps a FHIR R4 resource or Bundle into OMOP Common Data Model v5.4 rows, - * grouped by destination table in tables. + * grouped by destination table in tables. Uncertain-status and unresolved- + * primary-concept clinical rows stay in those tables and are identified by + * the optional rows_for_review index for consumer review and filtering. + * Status-based retention covers uncertain supplied Condition verification, + * unknown/missing/unreadable Encounter status, and explicit unknown status + * on MedicationStatement, Observation, and Procedure. Other status exclusions + * follow the resource-specific eligibility policy below. *

    Set vocab_version to select the OMOP vocabulary release used for coded - * concept resolution. If omitted or empty, the API uses its default - * release. The response's vocab_version, when present, identifies the - * release used. Specify a release explicitly when reproducibility matters.

    + * and inferred concept resolution. If omitted or empty, the API uses its + * default release. The response's vocab_version, when present, identifies + * the release used. Specify a release explicitly when reproducibility matters.

    + *

    Supported concept fields use semantic mapping for text-only inputs and + * unresolved source codings, with whole-term equivalence validation. + * CodeableConcept text takes precedence over coding display. A single + * equivalent suggestion must be validated before a real OMOP concept ID is + * returned. The suggested vocabulary/code must + * itself be an active standard concept valid for the destination; broader + * crosswalk targets are not accepted for inferred suggestions. + * Deterministic mappings take precedence; ambiguous or domain-mismatched + * deterministic results are not reinterpreted. Units, routes, visits, + * demographics, and collection sites use their field-specific resolution + * policies. Semantic mapping returns MAPPED with a real concept ID on + * success, or UNMAPPED with concept ID 0 when no standard concept is + * selected. A bare code is not treated as a description. No source coding + * is invented.

    + *

    Contextual allergy finding decisions follow their dedicated policy and + * are not replaced by description-based mapping.

    + *

    Every inferred mapping has + * mappings.resolution.semantic_mapping.status: MAPPED, with the OMOP + * vocabulary release. Filter linked rows using the mapping's table/row ID + * to exclude inferred results. + * Original source coding and clinical-status eligibility are preserved. + * For clinical rows covered by rows_for_review, successful inference + * does not remove uncertain-status rows from the index; unresolved primary + * concepts also remain indexed. + * All shaped rows appear once in tables.

    *

    Standards basis: FHIR R4 (v4.0.1) defines * the accepted source elements and OMOP CDM * v5.4 defines the @@ -378,46 +1050,262 @@ public CreateOmopResponse create(CreateOmopRequest request) { * first address can produce location, and more than one supplied race * produces observation race rows (see Patient demographics below) *

  • observation_period -> one request-local derived row per person, - * spanning the populated dates of that person's visit, clinical, and - * death rows; this is not enrollment or capture-completeness evidence
  • + * spanning dates from that person's visit and clinical rows not listed + * in rows_for_review, plus Specimen and Death rows. Source end dates + * contribute even when the output table has no end column. No period + * row is returned for a person without qualifying dates; this is not + * enrollment or capture-completeness evidence *
  • Location -> location and care_site
  • *
  • Organization -> care_site; its first address can produce location
  • *
  • HealthcareService -> care_site
  • *
  • Practitioner and PractitionerRole -> provider
  • *
  • Encounter -> visit_occurrence
  • - *
  • Condition -> condition_occurrence
  • - *
  • Procedure -> procedure_occurrence
  • - *
  • MedicationRequest, MedicationStatement, and - * MedicationAdministration -> drug_exposure
  • - *
  • Immunization -> drug_exposure
  • - *
  • Observation -> measurement or observation. For coded - * Observations, the resolved OMOP concept domain selects the table; value - * form only breaks ties. For text-only Observations, numeric values route - * to measurement and nonnumeric values to observation.
  • - *
  • AllergyIntolerance -> observation
  • + *
  • Specimen -> specimen, with an explicit Patient subject; missing + * collection date or material retains a partial row with diagnostics
  • + *
  • DeviceUseStatement -> device_exposure, using referenced Device.type
  • + *
  • Condition -> domain-selected clinical table; default condition_occurrence
  • + *
  • Procedure -> domain-selected clinical table; default procedure_occurrence
  • + *
  • MedicationRequest, MedicationStatement, MedicationAdministration, and + * MedicationDispense -> domain-selected clinical table; default drug_exposure
  • + *
  • Immunization -> domain-selected clinical table; default drug_exposure
  • + *
  • Observation -> domain-selected clinical table. Numeric values prefer + * measurement and other values prefer observation when the code is + * valid for both tables. + * If no supported standard concept is selected, numeric values retain the + * measurement default and nonnumeric values retain observation.
  • + *
  • AllergyIntolerance -> observation for supported contextual + * allergy/intolerance mappings; other coded statements use domain-based + * placement excluding drug_exposure and device_exposure; default observation
  • * + *

    Clinical placement follows the informative Vulcan IG's vocabulary-driven + * domain assignment. Supported destinations are condition_occurrence, + * procedure_occurrence, drug_exposure, device_exposure, measurement, and observation. + * Resolved clinical records are returned in the table corresponding to the + * domain of their mapped OMOP standard concept. The source resource + * determines eligibility, field values, and the default table when no + * supported mapping is available. + * AllergyIntolerance records do not produce drug_exposure or + * device_exposure rows. Device placement requires eligible use evidence: + * prescriptions, uncertain diagnoses, and explicit negative Observation + * results (false, SNOMED Negative, Known absent, or No, or interpretation + * code NEG from + * http://terminology.hl7.org/CodeSystem/v3-ObservationInterpretation) + * do not accept Device placement, even when other supplied values are positive. + * A recorder, asserter, or information source is retained as context rather + * than treated as the device event's provider.

    + *

    Coded AllergyIntolerance.code statements are interpreted using their + * original codings, optional type, and category. Supported substances + * produce an Observation finding separately from the causative substance + * in value_as_concept_id. Drug-domain substances are valid values and do + * not imply drug exposure or medication allergy. The finding is selected + * by an explicit mapping policy, not a published substance-to-finding + * crosswalk. Contextual findings report MAPPED, + * resolution.via: CONTEXTUAL. observation_source_concept_id retains + * the known OMOP identity of the original coding written to + * observation_source_value, including when the finding is unresolved; + * it is 0 when no source identity is known. The configured finding is + * never substituted for the original source concept.

    + *

    This adapts the informative R5 Vulcan Value-as-Concept + * pattern + * to R4's substance/product codes, optional type, and repeating category. + * It selects a contextual finding and resolves the substance separately; + * published Maps to value decomposition of a composite finding is not performed.

    + *

    For type: allergy, medication (optionally with biologic) maps to + * Allergy to drug (439224); other category sets use Allergic disposition + * (43530807). For type: intolerance, medication (optionally biologic) + * uses Intolerance to drug (4240582), food alone uses Intolerance to food + * (4340252), and other sets use Intolerance to substance (36684107). + * All selected concepts must be active and standard in the requested + * vocabulary release. Limited reviewed substance mappings can derive an + * absent category: for example, peanut and egg extracts supply food context, and + * Penicillin G supplies medication context. No category is inferred solely + * from Drug-domain membership.

    + *

    With absent type, a supported substance maps to a mechanism-neutral + * propensity finding: medication (optionally with biologic) uses Propensity + * to adverse reactions to drug (4305025), food alone uses Propensity to + * adverse reactions to food (4303802), and other category sets or an + * unknown category use Propensity to adverse reaction (4172024). The + * original type remains absent; no allergy or intolerance mechanism is + * inferred. The substance value is resolved independently. Existing + * supported findings can supply their own mechanism and negative + * statements retain their meaning. Conflicting evidence leaves the + * affected concept unmapped. + * A single identified substance can still populate the value when only + * its category context conflicts. Distinct conflicting substances, + * positive/negative mixtures, finding/substance mixtures, and multiple + * standard substance targets do not select a substance value by input + * order. Equivalent supported translations may share one value; + * unresolved translations are disclosed and are not used as substances. + * Finding-only, value-only, and neither-resolved partial rows are retained.

    + *

    mappings.resolution.allergy retains supplied type/categories, coded + * clinical/verification statuses, the date-source element, per-coding + * interpretation, derived categories, and reported information loss. The + * finding and substance have separate mappings linked to the same row and + * fields. Broader findings retain source specificity in this metadata. + * Codes outside contextual policy coverage retain existing domain-based + * mapping, with the contextual coverage outcome reported separately. + * Text-only inputs retain their text-only behavior. Reaction-substance + * fallback and published Maps to value relationships are not applied. + * Null placeholders in repeating FHIR category primitives carry no category + * value and are omitted from category interpretation and audit values. + * An invalid supplied type/category drops only the affected AllergyIntolerance + * before concept resolution. Other resources are still processed. The + * dropped entry identifies the resource and reports + * allergy_type_unrecognized or allergy_category_unrecognized.

    + *

    For example, a Condition coded with ICD10CM Z79.4 can produce a mapped + * observation row. Z86.73 and ICD-O-3 8140/3 also map to concepts in + * the Observation domain. Use mappings.omop_table and mappings.omop_id + * to locate each result; the FHIR resource type alone does not determine + * the output table.

    + *

    Clinical rows include available person/visit/provider links, concept and + * source concept IDs, source value, source type provenance, and start + * timing. Dates follow the source resource's timing rules below, regardless + * of the output table. For example, a Condition follows its + * onset[x] / recordedDate timing rule even when returned as an + * observation. End dates are populated only in tables with end columns; + * for rows not listed in rows_for_review, they also contribute to the + * request-local observation period when the output table has no end column.

    + *

    Source-specific fields are populated only for the corresponding source + * and destination: Condition status in condition_occurrence, medication + * dosage/supply and Immunization lot details in drug_exposure, and + * Observation values/units/ranges in measurement or observation where + * those columns exist. Mapping notes explain the output table and which + * fields are populated. Coded medication/immunization routes on rows outside + * drug_exposure, and Observation values on rows outside measurement / + * observation, are retained in mappings linked to the output row, with + * omop_field omitted and selected: false. Coded routes are checked + * against the OMOP Route domain; those Observation values are preserved + * without concept resolution.

    + *

    Note destinations are unsupported. If no standard concept + * valid for a supported destination is found, the resource produces a row + * in its default table with concept ID 0 and UNMAPPED status. Its + * source concept ID is also 0, except that allergy rows retain a known + * original source identity from their coding evidence. A DOMAIN_MISMATCH + * outcome is identified in the mapping + * note. Text-only clinical inputs can also produce rows in another supported + * table when semantic mapping selects a standard concept valid for that destination; + * without a validated match, they use their default tables. Domain-routed + * clinical records produce one row each even when their code has multiple + * vocabulary mapping targets; DeviceUseStatement event granularity is + * described below. Vocabulary Maps to value mappings are not applied.

    *

    Medication is reference data for medication resources; it does not * create its own row because OMOP CDM has no Medication table. Administrative * linkages (provider, care site, and location) are best-effort and limited to * references supplied in the request. Patient.managingOrganization is a - * record custodian, not a care-delivery site. Provider specialty is not - * mapped. Recorded Practitioner.gender is distinct from Person + * record custodian, not a care-delivery site. Provider specialties resolve + * independently to standard Provider concepts. Recorded Practitioner.gender + * is distinct from Person * demographics: male and female resolve to validated OMOP Gender * concepts in provider.gender_concept_id; other, unknown, and absent * gender remain unmapped. Address.country is resolved to location.country_concept_id, * and CMS Place of Service codings in Location.type are resolved to * care_site.place_of_service_concept_id. + * Within one request, Practitioners with the same single distinct supplied + * NPI value merge into one provider row only when all supplied mapped identity + * attributes in the entire NPI group agree. The NPI system may be + * http://hl7.org/fhir/sid/us-npi (preferred in FHIR R4), + * urn:oid:2.16.840.1.113883.4.6, or bare 2.16.840.1.113883.4.6; + * these forms are equivalent for matching. OID forms are accepted for compatibility. + * The same matching rule applies to Practitioners contained in valid role + * references. NPI values are compared exactly as supplied, without format + * or checksum validation. Blank values and unknown markers UNK and + * UNKNOWN are treated as missing and do not populate npi or participate + * in matching. Marker detection ignores case and surrounding whitespace; + * all other values are preserved without trimming. Other identifier systems and names + * do not trigger a merge. Missing attributes can enrich a compatible merged row. + * Compatibility compares the name rendered from each Practitioner's first + * name entry, its gender, its first non-empty DEA identifier, and its parsed + * birth year. Differences in birth month/day within one year do not conflict. + * Rendered names are compared exactly: Jane Smith and Dr. Jane Smith + * conflict even when they share an NPI. + * If any of these attributes conflict, every resource in that NPI group stays + * separate, including resources missing the conflicting attribute. Source + * attributes and unambiguous structural reference links are preserved; no + * conflicting value is selected. Each affected row has CONFLICTING + * diagnostics at name, gender, identifier, or birthDate, linked by + * omop_table: provider and omop_id. All source gender mappings link to + * their retained or merged provider row. + * Repeated copies of one NPI on a resource are allowed. A resource with + * multiple distinct NPIs stays separate, leaves npi unset, preserves its + * source-resource identity in provider_source_value, and reports a + * CONFLICTING diagnostic at identifier linked by + * omop_table: provider and omop_id. + * Row IDs are per response. The API does not merge across requests. For a + * Practitioner with one NPI, provider_source_value is that NPI, even when + * its group conflicts. Consumers can use it to deduplicate compatible + * providers across requests, but must preserve rows flagged by identity + * conflict diagnostics and check compatibility across responses. Without + * one unambiguous NPI, source-resource identity is preserved without a + * person-level deduplication guarantee. * A PractitionerRole that identifies one supplied Practitioner aliases * that canonical provider: by a top-level structural reference, a - * parent-contained #id reference, or an exact identifier.system and - * identifier.value match against a top-level Practitioner. No remote + * parent-contained #id reference, or an identifier.system and + * identifier.value match against a top-level Practitioner. Identifier + * matching uses the equivalent NPI systems and missing-value rules above; + * all other systems and all supplied identifiers match exactly. No remote * identifier lookup is performed. When Reference.type is present it must - * be Practitioner; duplicate contained IDs and identifier matches are - * ambiguous. An explicit reference that is unresolved, ambiguous, or - * unsupported retains a role-fallback provider row and is returned in - * diagnostics. provider_role_contexts preserves role-specific + * be Practitioner; duplicate contained IDs and identifier matches to + * different canonical providers are ambiguous. An explicit reference that + * is unresolved, ambiguous, or unsupported retains a role-fallback provider + * row and is returned in diagnostics. provider_role_contexts preserves role-specific * specialty and care-site context that a canonical OMOP provider row cannot * represent together.

    + *

    A role whose practitioner reference has only a blank or unknown NPI + * identifier follows the missing-identifier behavior: its fallback row has + * an UNSUPPORTED diagnostic explaining that a usable identifier or literal + * reference is required. An unambiguous literal reference still links to + * its Practitioner when the NPI is missing or unknown. Original supplied + * unknown markers remain in the role's practitioner_identifier metadata.

    + *

    Each PractitionerRole.specialty entry resolves independently using its + * supplied codings and explicit text from that entry. NUCC codings resolve + * directly; other supported codings may use the UMLS bridge. Text fallback + * is permitted when every coding has a nonblank code and a well-formed, + * unsupported system identifier. Supported systems, mixed supported and + * unsupported systems, and incomplete or malformed codings block fallback. + * A supported coding remains authoritative even when the text differs. + * Within one specialty entry, complete codings from well-formed unsupported + * systems do not block supported codings that independently resolve to the + * same standard Provider concept. Unsupported companions remain unsupported + * in the coding audit and acquire no crosswalk. Missing or malformed + * companions, failures of recognized codings, and disagreements between + * supported codings still block selection. For example, NUCC 208000000X alongside a complete local + * PEDS coding can resolve to Pediatric Medicine, with or without text + * and in either coding order. This uses the supported coding's mapping + * and source concept, rather than text fallback. + * Coding displays do not supply text fallback evidence. + * Unsupported system identifiers must be absolute URIs without embedded + * whitespace, user information, or fragments. HTTP(S) identifiers require + * a host; URNs require a namespace and a nonempty namespace-specific value. + * Bare labels such as Epic do not qualify. + * Text uses deterministic vocabulary assertions in order: standard Provider + * concept name, English OMOP synonym, then an English UMLS string on a + * CUI with a NUCC atom, then a NUCC preferred-term label with its grouping + * prefix removed. The last layer retains the entire classification and + * specialization label, including qualifications. An ambiguous layer stops + * resolution. No semantic search or ML matching is used. + * Each role's input remains visible in mappings, including repeated + * identical inputs across one provider's roles. + * specialty_concept_id is populated only when every distinct supplied + * input resolves to the same standard Provider concept. With no inputs, + * or an unresolved, ambiguous, or conflicting input, it stays 0. + * A role without a specialty contributes no input. The endpoint applies + * no frequency or hierarchy selection. Each input's outcome and matching + * route (layer, matched string, and UMLS CUI/SAB when applicable) appear + * in mappings with omop_field: specialty_concept_id. An individually + * resolved input can coexist with a provider concept of 0 when another + * input fails or disagrees. Specialty mappings have selected: false: + * the source text remains in specialty_source_value when supplied source + * values agree, and every original value remains in role contexts and mappings. + * Specialty mapping evidence includes specialty_resolution with the text + * fallback decision and each coding's independent outcome. A successful + * text fallback reports MAPPED and source concept ID 0; its local + * codings remain unsupported in that audit. For example, complete local + * codings with explicit Pediatrics text can resolve to Pediatric Medicine + * through NUCC_LABEL. Adding a different or unresolved specialty still + * keeps the provider's singular concept at 0. + * Compound phrasings without a vocabulary assertion remain unresolved. + * For example, Pulmonology stays unresolved while Pulmonologist + * resolves to Pulmonary Disease. Results depend on vocab_version.

    *

    Patient demographics:

    *
      *
    • Sex at birth, race, and ethnicity are read from these US Core @@ -481,22 +1369,138 @@ public CreateOmopResponse create(CreateOmopRequest request) { * 0; and Vulcan's suggested observation rows for multiple * ethnicities are not produced.
    • *
    - *

    DiagnosticReport, ServiceRequest, CarePlan, DocumentReference, - * Composition, Specimen, DeviceUseStatement, Coverage, Claim, and - * other unsupported resource types are accepted in a Bundle but ignored: they + *

    Specimen produces a specimen row only with an explicitly resolvable + * top-level Patient subject. Missing collection dates or material retain + * useful partial rows with linked diagnostics; those rows may require + * downstream completion before loading into a strict CDM instance. + * References support Patient/id and exact Bundle + * fullUrl matches (including URNs and absolute URLs); contained, logical, + * display-only, missing, non-Patient, and unresolved subjects never use the + * sole Patient fallback. Reference.type, when supplied, must identify Patient. + * Unsafe patient attribution and ineligible status drop only that Specimen + * with dropped and diagnostics; a missing Patient in the entire request + * remains a 400. Missing material is concept ID 0 with a type diagnostic + * and no terminology mapping/summary count. Supplied unmapped material is + * also 0, with its separate vocabulary mapping outcome and retained source code. + * Material (type) resolves against the Specimen domain, coded UCUM + * collection quantity units against Unit, and collection.bodySite against + * Spec Anatomic Site. Supplied unmapped optional concepts are 0; absent + * unit and site IDs are explicit null. Text-only body sites preserve their + * source text with site concept ID 0 and an UNMAPPED mapping; they do not + * produce a text-based concept suggestion. Material descriptions follow + * the semantic-mapping policy above. Original source codes and text-only + * descriptions are preserved. + * Numeric quantity is copied without + * unit conversion; invalid or comparator-bearing quantities are omitted + * with diagnostics without dropping the row. Missing optional data is allowed. + * Collection time uses collection.collectedDateTime or + * collection.collectedPeriod.start; end-only, partial, invalid, and absent + * dates leave specimen_date/specimen_datetime omitted with linked diagnostics. + * Both collected forms together are conflicting: retain the row without + * selecting a date. Period end and receivedTime never substitute for collection + * start. specimen_source_contexts.collection_timing preserves raw dateTime, + * Period boundaries, receivedTime, and the selected source path when a date + * is populated, including partial precision and original timezone offsets. + * specimen_collection_date_missing, specimen_collection_date_partial, and + * specimen_collection_date_unusable distinguish absent, partial, and invalid + * source dates; specimen_collection_multiple_forms reports conflicting choices. + * specimen_type_concept_id: 32817 is EHR provenance, independent of material + * and availability. Status absent/available/unavailable/unsatisfactory is + * supported; entered-in-error and unrecognized statuses drop the specimen. + * specimen_source_contexts.status preserves the supplied availability + * status for emitted rows; it is omitted when no status was supplied. + * Disease status is not inferred. Source identity precedence is resource id, + * then first identifier with a nonempty value in source array order, then + * accessionIdentifier.value, then Bundle fullUrl. No identity is fabricated; + * specimen_source_contexts preserves all valued identifier system/value + * pairs, accession identity, resource id/fullUrl, and the selected source path. + * Parent/derived specimens emit independently when each has its own Patient; + * missing date/material remains partial rather than being inherited. Parent + * and Measurement-to-Specimen links are not represented. Consuming CDM + * loaders own required-date completion, durable IDs, and dataset completeness. + * Source baseline is FHIR R4 4.0.1, with supported common fields reviewed against + * US Core Specimen 7.0.0; full profile conformance is not validated here.

    + *

    Device use and exposure

    + *

    DeviceUseStatement accepts active, completed, stopped, and on-hold use; + * intended, entered-in-error, missing, and unrecognized statuses are omitted + * with source-specific diagnostics. Its subject must explicitly resolve to + * an emitted Patient. Group, contained-Patient, identifier-only, external + * fetching, and sole-Patient attribution are unsupported for this path. + * Device references support exact Type/id and Bundle fullUrl/URN matches, + * plus unambiguous contained Devices scoped to the referring statement. + * Missing, wrong-type, unresolved, or ambiguous Device details retain a + * known-patient use row with device concept 0 and diagnostics. Missing type + * evidence has no vocabulary mapping entry; supplied unmapped type evidence + * has a mapping outcome. Inventory-only Device records emit no exposure.

    + *

    timingDateTime supplies the start only; timingPeriod preserves supplied + * boundaries independently. Each timingTiming.event produces a separate + * row, including a partial row for an unusable event, with its zero-based + * event_index in device_exposure_contexts.exposures. Repeat rules without events + * retain one partial row; events plus rules produce only the event rows. + * No recurrence, continuous min/max interval, status-derived end date, or + * recordedOn fallback is inferred. Year/month precision and malformed + * individual dates leave affected columns absent. Competing timing choices + * or a provably reversed Period leave both bounds absent. Supplied precision, + * offsets, schedules, and omission/conflict reasons remain in source context + * and diagnostics. Other clinical paths retain their own timing semantics.

    + *

    device_exposure_contexts contains one entry per source record that produced + * device exposures. Its exposures array links all rows derived from that + * record by device_exposure_id and, for Timing events, event_index. Shared + * timing, device evidence, and quantity_basis appear once per source record. + * Contexts are not combined by resource id or device identity; distinct + * records remain separate even when their identifiers match or are absent.

    + *

    device_source_value uses original type/clinical coding, never inventory + * or resource identity. An unambiguous Device.udiCarrier.deviceIdentifier + * supplies unique_device_id. Competing identifiers leave it absent and are + * preserved with issuer/jurisdiction/carrier context. production_id remains + * absent; carriers, lot, serial, manufacture, and expiry evidence remain in + * device_exposure_contexts.

    + *

    DeviceUseStatement rows use device_type_concept_id 32817 (EHR), with + * provider_id, visit_occurrence_id, and visit_detail_id unset. + * Routed rows retain their source classification, explicit visit links to + * emitted same-patient visits, and compatible performer links; + * Condition/Procedure recorder/asserter and + * MedicationStatement informationSource do not supply device provider_id. + * Supplied source, encounter, context, recorder, asserter, performer, and + * informationSource values on DeviceUseStatement are retained only in + * device_exposure_contexts.

    + *

    Device exposure quantity is reported as follows:

    + *
      + *
    • When the source has no quantity, dose, rate, or numeric Observation + * result field, quantity is 1 and device_exposure_contexts.quantity_basis + * is default_absent_count.
    • + *
    • When such a field is supplied, including a null or malformed value, + * quantity is omitted and quantity_basis is unsupported_supplied_evidence. + * The original values remain in device_exposure_contexts.evidence, and + * an UNSUPPORTED diagnostic identifies each supplied field.
    • + *
    + *

    For example, an Observation with valueQuantity.value of 2 returned as a + * device exposure has no quantity; the value 2 remains in its source context. + * timingTiming.repeat.count remains in source context and does not set + * quantity. unit_concept_id and unit_source_concept_id are always null.

    + *

    Distinct eligible statements and routed clinical records remain separate, + * including overlapping use and equal Timing events. derivedFrom is retained + * as supporting context, not duplicate identity. Row counts therefore need + * not count distinct uses or physical devices. IDs are request-local exposure + * identities. Before loading a CDM dataset, callers must assign durable IDs + * and populate any required fields omitted from partial rows.

    + *

    Unsupported resource types are accepted in a Bundle but ignored: they * create no row and no dropped entry. dropped is reserved for supported * row-producing resources that cannot safely produce a positive OMOP row: * required subject/patient, clinical code/text, or medication data may be * unusable, or the resource may explicitly negate or fail the documented * clinical-event eligibility policy. A single-Patient Bundle uses the sole - * Patient only when subject/patient is absent. An explicit subject/patient + * Patient only when subject/patient is absent, except DeviceUseStatement + * and Specimen, which require explicit Patient subjects. An explicit subject/patient * reference that is unresolved, ambiguous, or unsupported drops the clinical * resource in every request scope.

    *

    Eligibility distinguishes clinical validity, performed/taken evidence, and - * administrative workflow state. Checks run before terminology resolution; - * entered-in-error and explicitly non-performed events never produce a positive - * clinical-event row, and an unrecognized required status fails closed. Condition - * requires absent or confirmed canonical-HL7 verificationStatus; clinical course is not a + * administrative workflow state. Entered-in-error and explicitly + * non-performed events never produce a positive clinical-event row, and + * unrecognized required statuses are dropped except the retained Condition and + * Encounter cases below. Condition accepts absent or confirmed canonical-HL7 + * verificationStatus without a status retention reason; uncertain or unreadable + * supplied verification is retained and indexed. Clinical course is not a * diagnosis-role mapping. Procedures accept completed or stopped events. * Medication requests are prescription evidence only: doNotPerform, drafts, * cancellations, and non-authorizing intents are dropped. Medication statements @@ -505,69 +1509,142 @@ public CreateOmopResponse create(CreateOmopRequest request) { * administration also needs an effectiveDateTime or effectivePeriod.start * that supplies start evidence; a valid partial date remains an undated row. * An on-hold administration is started evidence that is temporarily paused and - * expected to continue. Immunizations require completed - * status. Observations require final, + * expected to continue. MedicationDispense accepts completed records, + * including completed partial fills. Stopped records require a valid + * whenHandedOver, including partial dates, as evidence of actual handover. + * Preparation, in-progress (ready for pickup), on-hold, cancelled, declined, + * entered-in-error, unknown, missing, and unrecognized statuses are dropped. + * A completed dispense with missing or unusable handover timing remains a + * partial row; preparation time never substitutes for handover. + * Immunizations require completed + * status. Observations accept final, * amended, or corrected status; registered, - * preliminary, cancelled, entered-in-error, unknown, and missing statuses are + * preliminary, cancelled, entered-in-error, and missing statuses are * dropped. AllergyIntolerance accepts absent, unconfirmed, or confirmed canonical-HL7 * verificationStatus as a reported allergy, but drops refuted, - * entered-in-error, and unreadable supplied verification statuses. Encounter + * entered-in-error, and unreadable supplied verification statuses. Invalid + * supplied allergy type/category also drops that resource, for both coded + * and text-only statements. Encounter * accepts arrived, triaged, in-progress, onleave, or finished status, but drops - * planned, cancelled, entered-in-error, unknown, and missing statuses. An eligible - * encounter without Period.end remains a partial visit; no end date is invented.

    - *

    Coded Observation routing is selected from the resolved OMOP concept - * domain. Numeric and nonnumeric value[x] forms establish the preferred - * target only when the code is valid for both tables. A text-only - * Observation has no resolver target, so numeric values route to - * measurement and nonnumeric values to observation. Numeric values - * populate value_as_number in the selected row; other non-coded values + * planned, cancelled, and entered-in-error statuses. Unknown, missing, and unreadable + * Encounter statuses are retained and indexed without imputation. An eligible + * encounter without Period.end remains a partial visit; no end date is invented. + * MedicationStatement, Observation, and Procedure with explicit unknown + * status are also retained and indexed in rows_for_review when otherwise + * shapeable. Missing or unreadable statuses on these three resource types + * remain dropped. Retention preserves source status and does not establish + * that an event occurred or medication was taken. Device placement still + * requires eligible use evidence; unknown-status records cannot produce + * device_exposure rows.

    + *

    The mapped OMOP concept's domain determines the clinical output table. + * For Observations, numeric and nonnumeric value[x] forms determine the + * preferred table only when the code is valid for both measurement and + * observation. Unresolved Observations retain measurement rows for + * numeric values and observation rows for other values. In these two + * tables, numeric values populate value_as_number; other non-coded values * populate value_as_string for an observation or value_source_value * for a measurement. Coded valueCodeableConcept values are resolved - * against the selected row's value_as_concept_id and use the selected + * against value_as_concept_id in these tables and use the selected * bare code in value_source_value, leaving an observation's * value_as_string empty; unmapped or target-invalid coded values remain - * 0. + * 0. Active standard concepts from any OMOP domain are valid for + * value_as_concept_id per CDM 5.4. For example, a blood-culture Measurement + * can record Staphylococcus aureus as its coded result, even though the + * organism concept belongs to OMOP's Observation domain. + * For other clinical output tables, Observation values are retained only + * in mappings, without concept resolution or an omop_field, and with + * selected: false. * Other unsupported value[x] forms and Observation components do not * populate separate converted values. A * numeric comparator (<, <=, >, >=) is represented only by a - * measurement's operator_concept_id; units remain source text and have - * unit_concept_id of 0.

    + * measurement's operator_concept_id.

    + *

    For Observations returned as measurement or observation rows, + * Quantity units resolve independently against the table's + * unit_concept_id. + * Only coded UCUM (http://unitsofmeasure.org or its + * OID 2.16.840.1.113883.6.8, bare or urn:oid:) is supported. Codes are + * case-sensitive: no spelling aliases, text-only guesses, or numeric + * conversion are applied. Coded UCUM units map to valid standard OMOP + * Unit concepts using the release selected by vocab_version. + * Mappings are direct or follow an active OMOP Maps to relationship; + * UCUM units do not use a UMLS bridge. + * Unknown codes, + * vocabulary gaps, text-only units, missing coding, and unsupported + * systems keep the row and use 0; absent units use JSON null (the + * field is present). Explicit dimensionless code 1 is a supplied unit + * and resolves or remains 0; it is never treated as absent. + * unit_source_value contains Quantity.code when supplied, otherwise + * Quantity.unit. A separate unit mappings entry preserves the original + * Quantity.system/code in source_system/source_code and the exact + * human-readable Quantity.unit in source_name, with its own outcome. + * Quantity.unit provides display text and does not affect unit matching. + * Measurement also returns nullable unit_source_concept_id, identifying + * the OMOP source concept for Quantity.system/code when available, + * otherwise 0 for a supplied unit. + * Observation has no such column. Resolving a unit does not change + * numeric values, comparators, or reference ranges. + * Supported coded UCUM units include resolution in their mappings, + * whether the unit resolves or remains unmapped: + * its outcome and reason code distinguish vocabulary gaps, target-domain + * mismatches, and ambiguity. Unresolved or ambiguous units retain the row + * with concept ID 0. Resolved mappings retain the selected concept's + * direct or crosswalk provenance. Text-only units, units with missing + * coding, and unsupported unit systems omit resolution; their note + * explains the unmapped outcome.

    + *

    For Observations returned in other clinical tables, units are not resolved + * or included as separate mapping outcomes. Device exposure unit IDs remain + * null, with supplied quantity and unit evidence retained in + * device_exposure_contexts.

    + *

    Compatibility: absent unit_concept_id values previously returned 0 + * and now return null. Coded units use their code in unit_source_value + * rather than their human-readable text; the text remains in mappings. + * The Measurement unit_source_concept_id field is new.

    *

    A standard OMOP concept_id is selected for each eligible primary clinical coding * after considering all of the resource's supplied codings. An unambiguous - * coded medication route is resolved independently to - * drug_exposure.route_concept_id. Alongside the OMOP rows grouped by + * coded medication or immunization route is checked independently against + * the OMOP Route domain. For drug_exposure rows, route_concept_id + * contains the resolved concept ID, or 0 if unmapped; for other output + * tables the route is reported only in + * mappings, without omop_field and with selected: false. + * Alongside the OMOP rows grouped by * table (tables), the response carries mappings (an entry for every * supported source coding from a resource that shaped a row, linked back to * that row; some, such * as demographic null flavors, are reported without being resolved), + * rows_for_review (an index of uncertain-status or primary-concept-0 clinical + * rows in tables, with original statuses, reasons, and source/row identity), * provider_role_contexts (source role details linked to provider rows), * dropped (resources that could not be shaped into a row), * diagnostics (explicit references that could not safely create a link, - * and conflicting or unsupported Patient demographic extensions), + * conflicting Practitioner identity attributes, conflicting or unsupported + * Patient demographic extensions, and conflicting Encounter classifications), * vocab_version (the OMOP vocabulary release codes were resolved * against), and a small summary of the resolution outcomes.

    *

    A concept_id of 0 is reported, not omitted (OMOP "no matching - * concept" semantics): it covers both a coding with no standard match - * (UNMAPPED) and an unverified suggestion for a text-only resource - * (UNCHECKED). Visit and unit concept fields currently remain 0; - * Person demographics and Provider recorded gender follow the policies - * above. Coded Observation values may populate value_as_concept_id. - * Concepts set by a fixed convention rather than terminology resolution - * are measurement operator_concept_id, set from a value comparator (<, - * <=, >, >=), and the multiple-race concepts described above. Clinical - * *_source_value fields contain the selected FHIR code (or source text - * for text-only resources). + * concept" semantics): it covers a supplied concept without a validated + * standard match (UNMAPPED). + * Supported Encounter.class or Encounter.type values + * may populate visit_concept_id. Person demographics and Provider + * recorded gender follow the policies above. + * Coded Observation values and supported allergy substances may populate + * value_as_concept_id. + * Measurement operator_concept_id represents a value comparator (<, + * <=, >, >=); multiple-race concepts follow the conventions described + * above. Clinical *_source_value fields contain the selected FHIR code + * (or source text for text-only resources). * The corresponding selected mappings entry preserves the coding system * and full source-coding provenance. * Known OID-form coding systems are accepted as either FHIR OID URNs (for * example, urn:oid:2.16.840.1.113883.6.1 for LOINC) or bare OIDs, and - * are normalized to their canonical system URLs before terminology - * resolution. mappings[].source_system reports that canonical URL, so the - * OID and URL forms produce the same mapping. An unknown OID is not - * rewritten and may be UNMAPPED. + * produce the same mappings as their canonical system URLs. Coding + * mappings report that canonical URL in source_system; Quantity unit + * mappings preserve the original Quantity.system, including OID spelling. + * An unknown OID remains unchanged in source_system and may be + * UNMAPPED. * Other *_source_value fields preserve row-specific raw source values, * such as resource identifiers, names, units, or status codes. - * MedicationRequest uses 32838 (EHR prescription) for + * MedicationRequest uses 32838 (EHR prescription) and + * MedicationDispense uses 32825 (EHR dispensing record) for * drug_type_concept_id; other current resources use 32817 (EHR). This * is a coarse provenance policy: it does not infer patient-reported, * medication-history, or other more-specific type concepts from FHIR @@ -580,8 +1657,8 @@ public CreateOmopResponse create(CreateOmopRequest request) { * (YYYY-MM-DDTHH:MM:SS, with fractional seconds to microseconds when * supplied): 2024-01-15T23:30:00-05:00 becomes 2024-01-15T23:30:00. * A datetime without a timezone is read as local time. - *

  • A partial date (2024 or 2024-03) leaves both columns unset. It - * still counts as that source's value, so later sources in the list + *
  • A partial date (2024 or 2024-03) leaves both columns unset. + * It still counts as that source's value, so later sources in the list * below are not used.
  • *
  • Free-text timing (such as onsetString), Age and Range forms, and * values that are not dates are ignored, so a later source in the list @@ -591,11 +1668,18 @@ public CreateOmopResponse create(CreateOmopRequest request) { * completed, and a row is not dated from another resource such as its * Encounter.
  • *
  • Valid date values never cause a request to be rejected, and the response - * does not report which date elements were unusable. An on-hold + * reports missing, partial, malformed, or conflicting timing for Specimen, + * DeviceUseStatement, and MedicationDispense in diagnostics while retaining + * partial rows for downstream load completion. Other resources do not report which + * date elements were unusable. An on-hold * MedicationAdministration is an eligibility exception: it needs a supplied - * effective start as evidence that administration began.
  • + * effective start as evidence that administration began. + * A stopped MedicationDispense similarly requires supplied handover evidence. * - *

    Timing sources, in priority order where several are listed:

    + *

    Timing sources, in priority order where several are listed. These rules + * apply regardless of the clinical output table; end dates are populated + * only where the table has an end column, and verbatim_end_date is set + * only for medication resources returned as drug_exposure rows:

    *
      *
    • Patient: birthDate sets year_of_birth, month_of_birth, and * day_of_birth from the parts it supplies; birth_datetime is not @@ -607,6 +1691,12 @@ public CreateOmopResponse create(CreateOmopRequest request) { * end from abatementDateTime or abatementPeriod.end.
    • *
    • Procedure: start from performedDateTime or * performedPeriod.start; end from performedPeriod.end only.
    • + *
    • DeviceUseStatement: timingDateTime sets the start only; + * timingPeriod.start and timingPeriod.end supply independent bounds; + * each timingTiming.event produces one start-only row. A schedule + * without explicit events produces one row without dates.
    • + *
    • Specimen: collection.collectedDateTime or collection.collectedPeriod.start; + * missing day precision retains an undated specimen with diagnostics.
    • *
    • Observation: effectiveDateTime, effectivePeriod.start, or * effectiveInstant.
    • *
    • AllergyIntolerance: recordedDate, then onsetDateTime or @@ -623,21 +1713,212 @@ public CreateOmopResponse create(CreateOmopRequest request) { *
    • MedicationRequest: authoredOn, the order date, sets the start; it * is not evidence of administration. No end is set, and the validity * period is not used as an exposure duration.
    • + *
    • MedicationDispense: whenHandedOver sets the start. Missing, partial, + * or invalid handover leaves the start unset with a diagnostic. Comparable + * handover/preparation values proving handover before preparation also + * leave the start unset with a CONFLICTING diagnostic. whenPrepared, + * authorizing orders, and related administrations never supply timing. + * No exposure end or verbatim_end_date is inferred, including from + * daysSupply. Raw handover/preparation values retain precision and offsets + * in medication_dispense_source_contexts.source.
    • *
    • Differences from the Vulcan maps: birth_datetime is not set from * birthDate, Condition also reads onsetPeriod.start, and * AllergyIntolerance falls back to its onset when recordedDate is * missing.
    • *
    - *

    Medication details:

    + *

    Medication details for resources returned as drug_exposure rows:

    *
      *
    • For MedicationRequest, dispenseRequest.numberOfRepeatsAllowed sets * refills and a whole-day expectedSupplyDuration sets days_supply.
    • + *
    • For MedicationDispense, daysSupply sets days_supply only for a + * positive whole number of coded UCUM d days without a supplied comparator. + * The canonical UCUM URL, bare OID, and OID URN are accepted. Other units, + * fractions, text-only units, and malformed values leave it absent with a + * diagnostic; absent daysSupply is allowed. Refills are not inherited.
    • *
    • All non-empty dosage text is preserved in sig.
    • *
    • Coded dosage routes and Immunization.route are target-validated in * the OMOP Route domain. Conflicting routes are left unset; route * codings shared by every dosage instruction identify the same route.
    • *
    • Immunization.lotNumber is preserved in lot_number.
    • *
    + *

    MedicationDispense requires an explicit subject resolving to a Patient + * supplied in the request; Groups and missing subjects are unsupported. + * Source fields are based on FHIR R4 4.0.1 and reviewed against + * US Core MedicationDispense 7.0.0. + * Full profile conformance is not validated. Although US Core requires + * handover on completed records, this API retains such incomplete records + * with missing-handover diagnostics as partial conversion output. + * Supply either a usable medicationCodeableConcept (coded or text-only), + * or a medicationReference resolving to a Medication with usable code/text. + * Both choice forms together are conflicting and drop the dispense. + * Exact relative, Bundle fullUrl, and unambiguous contained Medication + * references are supported. Unresolved references are not rescued by display + * text; missing or ambiguous medication identity drops the dispense with a + * diagnostic. A usable but unmapped drug retains concept ID 0 and its + * mapping outcome in a row indexed for review. + * context links only to a supplied Encounter for the same patient. + * The first resolvable Practitioner/PractitionerRole in performer.actor + * supplies provider_id; other actor types do not supply a provider. + * Failed explicit links have diagnostics. Authorizing prescriptions are + * checked for request-local MedicationRequest identity and patient agreement; + * failures do not discard the dispense. They never supply medication, + * instructions, refills, timing, or prescription provenance to a dispense. + * Dispense instructions alone supply sig and route; conflicting routes + * remain unset with a diagnostic and original instructions in source context. + * Orders, dispenses, and administrations emit independently, with separate + * source identity and no deduplication merely because they concern the same drug. + * Every emitted dispensing drug row has a whenHandedOver/daysSupply + * diagnostic beginning medication_dispense_end_date_absent because the + * CDM-required exposure end remains absent. Missing/partial/unusable handover + * diagnostics begin medication_dispense_handover_missing, + * medication_dispense_handover_partial, or medication_dispense_handover_unusable.

    + *

    Encounter details:

    + *
      + *
    • + *

      Encounter classifications must supply both Coding.system and + * Coding.code to map to a standard OMOP Visit concept. For HL7 + * encounter class codes with system + * http://terminology.hl7.org/CodeSystem/v3-ActCode or + * http://hl7.org/fhir/v3/ActCode, the supported mappings are:

      + *
        + *
      • IMP (inpatient encounter) -> 9201 (Inpatient Visit)
      • + *
      • AMB (ambulatory encounter) -> 9202 (Outpatient Visit)
      • + *
      • EMER (emergency encounter) -> 9203 (Emergency Room Visit)
      • + *
      • VR (virtual encounter) -> 722455 (Telehealth)
      • + *
      • HH (home health encounter) -> 581476 (Home Visit)
      • + *
      + *

      OBSENC (observation encounter) has no direct class mapping. Other + * class or type codes can be used when they have a valid standard OMOP + * Visit mapping in the selected vocabulary release. Unsupported + * classifications retain visit_concept_id of 0 unless a supported + * type supplies the Visit as described below; they never default to + * outpatient.

      + *
    • + *
    • + *

      A mapped Encounter.class determines visit_concept_id. A mapped + * type that disagrees with it produces a CONFLICTING diagnostic with + * path class/type, while the class mapping remains selected. + * If class is missing or unmapped, type supplies the Visit only when + * all mapped type classifications agree on the same standard Visit + * concept. Codings within one type CodeableConcept are alternative + * encodings of that classification. When separate type classifications + * map to different Visit concepts, no type is selected, + * visit_concept_id remains 0, and a CONFLICTING diagnostic has + * path type.

      + *
    • + *
    • + *

      visit_source_value contains the selected class or type code; + * mappings preserves its coding system and identifies which + * classification was selected. visit_source_concept_id identifies + * the selected original source coding's OMOP concept when available, + * and is 0 otherwise, including for the HL7 encounter class codes + * listed above. Each eligible Encounter produces one Visit; + * classHistory, partOf, and admission/discharge concepts are not + * mapped.

      + *
    • + *
    • + *

      Direct MedicationRequest.dispenseRequest.quantity, MedicationDispense.quantity, + * MedicationAdministration.dosage.dose, and Immunization.doseQuantity + * can set quantity under the formulation policies below. Quantities + * must be positive finite numbers with a supported system/code pair + * and no supplied comparator. Ingredient amounts and injection volumes + * use http://unitsofmeasure.org (or its bare OID or + * urn:oid:2.16.840.1.113883.6.8) with case-sensitive codes matching or + * convertible to vocabulary-backed strength units. Exact unit matches, + * including [U] for insulin and heparin strengths, are accepted without + * a unit conversion. + * When codes differ, both source and strength units must belong to the + * same supported group: mass (ug, mg, g), volume (uL, mL, L), + * or milliequivalent representations (meq, 10*-3.eq). Other conversions, + * including kg, ML, Mg, [drp], mol, mmol, %, 1, and generic + * annotations such as {vial} or {syringe}, leave quantity unset with + * unit_mismatch. Codes are not case-corrected or inferred from display text. + * The original source system is preserved in the quantity audit. + * Fixed-form counts also accept the explicit codings below. Free-text, + * local, invalid, and cross-dimensional units are not interpreted.

      + *
    • + *
    + *

    Drug quantity policies follow CDM v5.4 DRUG_EXPOSURE conventions. + * The selected drug must be a Clinical Drug or Branded Drug, with one + * active dose form and valid vocabulary evidence for the chosen policy. + * Strength-based conversions also require one ingredient strength. Supported RxNorm dose forms:

    + *
      + *
    • fixed_dose_form_count: Oral Tablet (317541), Oral Capsule + * (316965), Extended Release Oral Tablet (316945), Extended + * Release Oral Capsule (316943), Delayed Release Oral Tablet (10312), + * or Delayed Release Oral Capsule (316995). A source ingredient amount is divided + * by the single positive per-unit ingredient amount after conversion to that + * strength unit when necessary. + * Concentration-based strengths cannot derive counts from ingredient amounts. The + * result is a count of the selected dose form, limited to whole units or + * multiples of one-half or one-third. Derived counts within a relative + * tolerance of 1e-12 of an allowed fraction are normalized to that count + * to remove arithmetic roundoff; other fractions remain unsupported. + * For example, 1,000 mg for a drug + * with 500 mg per tablet produces quantity: 2 tablets (DERIVED). A source + * of 1 g is converted to 1,000 mg and produces the same count. + * Explicit counts are copied (DIRECT) when their coding matches the + * selected tablet or capsule form, including the corresponding extended- + * and delayed-release forms: TAB / CAP under + * http://terminology.hl7.org/CodeSystem/v3-orderableDrugForm, or + * {tbl} / {Capsule} under http://unitsofmeasure.org. These exact + * case-sensitive count codings identify the supplied form; they are not + * dimensionally converted as UCUM units. Direct counts do not require + * ingredient strength: combination products and missing/invalid strengths + * do not change the supplied count. Ingredient-strength gaps, including + * missing ingredient or strength-unit references, do not prevent copying + * an explicit count. Drug or dose-form evidence gaps, unclassified or + * ambiguous forms, and gaps whose relevance cannot be established still + * prevent conversion. The whole/half/third rules still apply. + * For example, 20 TAB for amoxicillin/clavulanate is 20 tablets, and 30 TAB for + * a supported tablet produces quantity: 30; CAP for that tablet is + * omitted with unit_mismatch. Generic counts (1, {Each}), other + * annotations, packs, and free-text counts are not interpreted. + * See the FHIR R4 count example.
    • + *
    • clinical_injection_ingredient_amount: Injectable Solution (316949), + * Injectable Suspension (316950), or Injection (1649574). A source + * ingredient amount is converted to the vocabulary ingredient unit when + * necessary; an exact-unit amount is copied directly. Otherwise, a source + * amount convertible to the concentration's denominator unit is converted + * and multiplied by numerator value / the effective denominator reported in + * the audit evidence. Volume conversion requires a positive finite numerator, + * dimensionally distinct validated UCUM units, a null raw denominator value, and + * evidence.strength.denominator_basis with kind: PER_UNIT and + * value: 1. A null denominator alone is not interpreted as 1. Missing, unknown, + * non-applicable, quantified, or conflicting denominator evidence does + * not support volume conversion. For example, 80 mL with 2 mg/mL + * strength and a PER_UNIT basis produces quantity: 160 mg. A supplied + * 160 mg amount for the same drug produces quantity: 160 directly. A source + * of 0.08 L converts to 80 mL before producing the same 160 mg result. + * This output is already + * the ingredient amount: do not apply the drug-dose guide's + * dose = quantity × numerator formula to these injection rows, which + * would multiply by the concentration a second time. Use + * quantity_transformations.result_unit to identify the output unit.
    • + *
    + *

    Quantified products, packs, prefilled syringes, other liquids and dose + * forms, strength-based conversions of combination products, conflicting or incomplete required evidence, and + * unsupported source quantities leave quantity unset. Other supported + * fields and the clinical row are still returned. For example, a quantity + * supplied only as value: 2, unit: tablet is omitted with + * unsupported_unit, because a free-text unit cannot establish this policy. + * Rate-only administrations and MedicationStatement dosages do not establish + * a supported total quantity and are not used for quantity conversion.

    + *

    quantity_transformations audits each supplied quantity at one of the + * three direct paths above on a resource returned as a drug_exposure row, + * including an explicit null or malformed quantity. Each entry preserves + * the source, identifies the destination row, and reports DIRECT, DERIVED, or + * OMITTED. Successful entries identify their policy, evidence, result, + * and result unit; omitted entries give a stable reason. When a source code + * differs from the strength unit, unit_conversion records the intermediate + * converted amount and target unit. Converted results are DERIVED, even + * when no further concentration arithmetic is needed. A successful conversion + * can also be reported when a later policy check omits the final quantity. + * Dose-form and unit + * concept IDs refer to the response's vocab_version. Missing quantities + * produce no entry. Resources returned in other clinical tables have no + * quantity entry. Unsupported rates and statement dosages produce no entry, + * and resources in dropped have no quantity entry or destination row.

    *

    Medication codes are resolved whether they appear inline * (medicationCodeableConcept) or via a medicationReference to a contained, * relative (Type/id), or bundle-entry (urn:uuid) Medication resource. @@ -649,11 +1930,12 @@ public CreateOmopResponse create(CreateOmopRequest request) { * subject/patient reference cannot be tied to a person; or an event excluded * by eligibility — are reported under dropped rather than emitted as blank * rows. The Bundle must contain at least one Patient resource.

    - *

    Structural references resolve only to top-level resources supplied in the - * request, by Type/id or an exactly matching Bundle fullUrl (including - * urn:uuid). Contained references are supported for medication code lookup - * and PractitionerRole.practitioner enrichment; the latter also supports - * exact request-local identifier matching without a remote lookup. Every + *

    References to top-level resources supplied in the request resolve by + * Type/id or an exactly matching Bundle fullUrl (including urn:uuid). + * Parent-scoped contained references are supported for medication code lookup, + * DeviceUseStatement.device details, and PractitionerRole.practitioner + * enrichment; the latter also supports + * request-local identifier matching with the NPI rules above, without a remote lookup. Every * nonzero structural foreign key targets a row in the same response. Missing * optional links remain unset without a diagnostic; an explicit optional * reference that is unresolved, ambiguous, conflicting with the row's diff --git a/src/main/java/com/phenoml/api/resources/fhir2omop/RawFhir2OmopClient.java b/src/main/java/com/phenoml/api/resources/fhir2omop/RawFhir2OmopClient.java index 73d921cd..542d7171 100644 --- a/src/main/java/com/phenoml/api/resources/fhir2omop/RawFhir2OmopClient.java +++ b/src/main/java/com/phenoml/api/resources/fhir2omop/RawFhir2OmopClient.java @@ -4,6 +4,7 @@ package com.phenoml.api.resources.fhir2omop; import com.fasterxml.jackson.core.JsonProcessingException; +import com.phenoml.api.core.BodyProperties; import com.phenoml.api.core.ClientOptions; import com.phenoml.api.core.MediaTypes; import com.phenoml.api.core.ObjectMappers; @@ -35,11 +36,42 @@ public RawFhir2OmopClient(ClientOptions clientOptions) { /** * Maps a FHIR R4 resource or Bundle into OMOP Common Data Model v5.4 rows, - * grouped by destination table in tables. + * grouped by destination table in tables. Uncertain-status and unresolved- + * primary-concept clinical rows stay in those tables and are identified by + * the optional rows_for_review index for consumer review and filtering. + * Status-based retention covers uncertain supplied Condition verification, + * unknown/missing/unreadable Encounter status, and explicit unknown status + * on MedicationStatement, Observation, and Procedure. Other status exclusions + * follow the resource-specific eligibility policy below. *

    Set vocab_version to select the OMOP vocabulary release used for coded - * concept resolution. If omitted or empty, the API uses its default - * release. The response's vocab_version, when present, identifies the - * release used. Specify a release explicitly when reproducibility matters.

    + * and inferred concept resolution. If omitted or empty, the API uses its + * default release. The response's vocab_version, when present, identifies + * the release used. Specify a release explicitly when reproducibility matters.

    + *

    Supported concept fields use semantic mapping for text-only inputs and + * unresolved source codings, with whole-term equivalence validation. + * CodeableConcept text takes precedence over coding display. A single + * equivalent suggestion must be validated before a real OMOP concept ID is + * returned. The suggested vocabulary/code must + * itself be an active standard concept valid for the destination; broader + * crosswalk targets are not accepted for inferred suggestions. + * Deterministic mappings take precedence; ambiguous or domain-mismatched + * deterministic results are not reinterpreted. Units, routes, visits, + * demographics, and collection sites use their field-specific resolution + * policies. Semantic mapping returns MAPPED with a real concept ID on + * success, or UNMAPPED with concept ID 0 when no standard concept is + * selected. A bare code is not treated as a description. No source coding + * is invented.

    + *

    Contextual allergy finding decisions follow their dedicated policy and + * are not replaced by description-based mapping.

    + *

    Every inferred mapping has + * mappings.resolution.semantic_mapping.status: MAPPED, with the OMOP + * vocabulary release. Filter linked rows using the mapping's table/row ID + * to exclude inferred results. + * Original source coding and clinical-status eligibility are preserved. + * For clinical rows covered by rows_for_review, successful inference + * does not remove uncertain-status rows from the index; unresolved primary + * concepts also remain indexed. + * All shaped rows appear once in tables.

    *

    Standards basis: FHIR R4 (v4.0.1) defines * the accepted source elements and OMOP CDM * v5.4 defines the @@ -62,46 +94,262 @@ public RawFhir2OmopClient(ClientOptions clientOptions) { * first address can produce location, and more than one supplied race * produces observation race rows (see Patient demographics below) *

  • observation_period -> one request-local derived row per person, - * spanning the populated dates of that person's visit, clinical, and - * death rows; this is not enrollment or capture-completeness evidence
  • + * spanning dates from that person's visit and clinical rows not listed + * in rows_for_review, plus Specimen and Death rows. Source end dates + * contribute even when the output table has no end column. No period + * row is returned for a person without qualifying dates; this is not + * enrollment or capture-completeness evidence *
  • Location -> location and care_site
  • *
  • Organization -> care_site; its first address can produce location
  • *
  • HealthcareService -> care_site
  • *
  • Practitioner and PractitionerRole -> provider
  • *
  • Encounter -> visit_occurrence
  • - *
  • Condition -> condition_occurrence
  • - *
  • Procedure -> procedure_occurrence
  • - *
  • MedicationRequest, MedicationStatement, and - * MedicationAdministration -> drug_exposure
  • - *
  • Immunization -> drug_exposure
  • - *
  • Observation -> measurement or observation. For coded - * Observations, the resolved OMOP concept domain selects the table; value - * form only breaks ties. For text-only Observations, numeric values route - * to measurement and nonnumeric values to observation.
  • - *
  • AllergyIntolerance -> observation
  • + *
  • Specimen -> specimen, with an explicit Patient subject; missing + * collection date or material retains a partial row with diagnostics
  • + *
  • DeviceUseStatement -> device_exposure, using referenced Device.type
  • + *
  • Condition -> domain-selected clinical table; default condition_occurrence
  • + *
  • Procedure -> domain-selected clinical table; default procedure_occurrence
  • + *
  • MedicationRequest, MedicationStatement, MedicationAdministration, and + * MedicationDispense -> domain-selected clinical table; default drug_exposure
  • + *
  • Immunization -> domain-selected clinical table; default drug_exposure
  • + *
  • Observation -> domain-selected clinical table. Numeric values prefer + * measurement and other values prefer observation when the code is + * valid for both tables. + * If no supported standard concept is selected, numeric values retain the + * measurement default and nonnumeric values retain observation.
  • + *
  • AllergyIntolerance -> observation for supported contextual + * allergy/intolerance mappings; other coded statements use domain-based + * placement excluding drug_exposure and device_exposure; default observation
  • * + *

    Clinical placement follows the informative Vulcan IG's vocabulary-driven + * domain assignment. Supported destinations are condition_occurrence, + * procedure_occurrence, drug_exposure, device_exposure, measurement, and observation. + * Resolved clinical records are returned in the table corresponding to the + * domain of their mapped OMOP standard concept. The source resource + * determines eligibility, field values, and the default table when no + * supported mapping is available. + * AllergyIntolerance records do not produce drug_exposure or + * device_exposure rows. Device placement requires eligible use evidence: + * prescriptions, uncertain diagnoses, and explicit negative Observation + * results (false, SNOMED Negative, Known absent, or No, or interpretation + * code NEG from + * http://terminology.hl7.org/CodeSystem/v3-ObservationInterpretation) + * do not accept Device placement, even when other supplied values are positive. + * A recorder, asserter, or information source is retained as context rather + * than treated as the device event's provider.

    + *

    Coded AllergyIntolerance.code statements are interpreted using their + * original codings, optional type, and category. Supported substances + * produce an Observation finding separately from the causative substance + * in value_as_concept_id. Drug-domain substances are valid values and do + * not imply drug exposure or medication allergy. The finding is selected + * by an explicit mapping policy, not a published substance-to-finding + * crosswalk. Contextual findings report MAPPED, + * resolution.via: CONTEXTUAL. observation_source_concept_id retains + * the known OMOP identity of the original coding written to + * observation_source_value, including when the finding is unresolved; + * it is 0 when no source identity is known. The configured finding is + * never substituted for the original source concept.

    + *

    This adapts the informative R5 Vulcan Value-as-Concept + * pattern + * to R4's substance/product codes, optional type, and repeating category. + * It selects a contextual finding and resolves the substance separately; + * published Maps to value decomposition of a composite finding is not performed.

    + *

    For type: allergy, medication (optionally with biologic) maps to + * Allergy to drug (439224); other category sets use Allergic disposition + * (43530807). For type: intolerance, medication (optionally biologic) + * uses Intolerance to drug (4240582), food alone uses Intolerance to food + * (4340252), and other sets use Intolerance to substance (36684107). + * All selected concepts must be active and standard in the requested + * vocabulary release. Limited reviewed substance mappings can derive an + * absent category: for example, peanut and egg extracts supply food context, and + * Penicillin G supplies medication context. No category is inferred solely + * from Drug-domain membership.

    + *

    With absent type, a supported substance maps to a mechanism-neutral + * propensity finding: medication (optionally with biologic) uses Propensity + * to adverse reactions to drug (4305025), food alone uses Propensity to + * adverse reactions to food (4303802), and other category sets or an + * unknown category use Propensity to adverse reaction (4172024). The + * original type remains absent; no allergy or intolerance mechanism is + * inferred. The substance value is resolved independently. Existing + * supported findings can supply their own mechanism and negative + * statements retain their meaning. Conflicting evidence leaves the + * affected concept unmapped. + * A single identified substance can still populate the value when only + * its category context conflicts. Distinct conflicting substances, + * positive/negative mixtures, finding/substance mixtures, and multiple + * standard substance targets do not select a substance value by input + * order. Equivalent supported translations may share one value; + * unresolved translations are disclosed and are not used as substances. + * Finding-only, value-only, and neither-resolved partial rows are retained.

    + *

    mappings.resolution.allergy retains supplied type/categories, coded + * clinical/verification statuses, the date-source element, per-coding + * interpretation, derived categories, and reported information loss. The + * finding and substance have separate mappings linked to the same row and + * fields. Broader findings retain source specificity in this metadata. + * Codes outside contextual policy coverage retain existing domain-based + * mapping, with the contextual coverage outcome reported separately. + * Text-only inputs retain their text-only behavior. Reaction-substance + * fallback and published Maps to value relationships are not applied. + * Null placeholders in repeating FHIR category primitives carry no category + * value and are omitted from category interpretation and audit values. + * An invalid supplied type/category drops only the affected AllergyIntolerance + * before concept resolution. Other resources are still processed. The + * dropped entry identifies the resource and reports + * allergy_type_unrecognized or allergy_category_unrecognized.

    + *

    For example, a Condition coded with ICD10CM Z79.4 can produce a mapped + * observation row. Z86.73 and ICD-O-3 8140/3 also map to concepts in + * the Observation domain. Use mappings.omop_table and mappings.omop_id + * to locate each result; the FHIR resource type alone does not determine + * the output table.

    + *

    Clinical rows include available person/visit/provider links, concept and + * source concept IDs, source value, source type provenance, and start + * timing. Dates follow the source resource's timing rules below, regardless + * of the output table. For example, a Condition follows its + * onset[x] / recordedDate timing rule even when returned as an + * observation. End dates are populated only in tables with end columns; + * for rows not listed in rows_for_review, they also contribute to the + * request-local observation period when the output table has no end column.

    + *

    Source-specific fields are populated only for the corresponding source + * and destination: Condition status in condition_occurrence, medication + * dosage/supply and Immunization lot details in drug_exposure, and + * Observation values/units/ranges in measurement or observation where + * those columns exist. Mapping notes explain the output table and which + * fields are populated. Coded medication/immunization routes on rows outside + * drug_exposure, and Observation values on rows outside measurement / + * observation, are retained in mappings linked to the output row, with + * omop_field omitted and selected: false. Coded routes are checked + * against the OMOP Route domain; those Observation values are preserved + * without concept resolution.

    + *

    Note destinations are unsupported. If no standard concept + * valid for a supported destination is found, the resource produces a row + * in its default table with concept ID 0 and UNMAPPED status. Its + * source concept ID is also 0, except that allergy rows retain a known + * original source identity from their coding evidence. A DOMAIN_MISMATCH + * outcome is identified in the mapping + * note. Text-only clinical inputs can also produce rows in another supported + * table when semantic mapping selects a standard concept valid for that destination; + * without a validated match, they use their default tables. Domain-routed + * clinical records produce one row each even when their code has multiple + * vocabulary mapping targets; DeviceUseStatement event granularity is + * described below. Vocabulary Maps to value mappings are not applied.

    *

    Medication is reference data for medication resources; it does not * create its own row because OMOP CDM has no Medication table. Administrative * linkages (provider, care site, and location) are best-effort and limited to * references supplied in the request. Patient.managingOrganization is a - * record custodian, not a care-delivery site. Provider specialty is not - * mapped. Recorded Practitioner.gender is distinct from Person + * record custodian, not a care-delivery site. Provider specialties resolve + * independently to standard Provider concepts. Recorded Practitioner.gender + * is distinct from Person * demographics: male and female resolve to validated OMOP Gender * concepts in provider.gender_concept_id; other, unknown, and absent * gender remain unmapped. Address.country is resolved to location.country_concept_id, * and CMS Place of Service codings in Location.type are resolved to * care_site.place_of_service_concept_id. + * Within one request, Practitioners with the same single distinct supplied + * NPI value merge into one provider row only when all supplied mapped identity + * attributes in the entire NPI group agree. The NPI system may be + * http://hl7.org/fhir/sid/us-npi (preferred in FHIR R4), + * urn:oid:2.16.840.1.113883.4.6, or bare 2.16.840.1.113883.4.6; + * these forms are equivalent for matching. OID forms are accepted for compatibility. + * The same matching rule applies to Practitioners contained in valid role + * references. NPI values are compared exactly as supplied, without format + * or checksum validation. Blank values and unknown markers UNK and + * UNKNOWN are treated as missing and do not populate npi or participate + * in matching. Marker detection ignores case and surrounding whitespace; + * all other values are preserved without trimming. Other identifier systems and names + * do not trigger a merge. Missing attributes can enrich a compatible merged row. + * Compatibility compares the name rendered from each Practitioner's first + * name entry, its gender, its first non-empty DEA identifier, and its parsed + * birth year. Differences in birth month/day within one year do not conflict. + * Rendered names are compared exactly: Jane Smith and Dr. Jane Smith + * conflict even when they share an NPI. + * If any of these attributes conflict, every resource in that NPI group stays + * separate, including resources missing the conflicting attribute. Source + * attributes and unambiguous structural reference links are preserved; no + * conflicting value is selected. Each affected row has CONFLICTING + * diagnostics at name, gender, identifier, or birthDate, linked by + * omop_table: provider and omop_id. All source gender mappings link to + * their retained or merged provider row. + * Repeated copies of one NPI on a resource are allowed. A resource with + * multiple distinct NPIs stays separate, leaves npi unset, preserves its + * source-resource identity in provider_source_value, and reports a + * CONFLICTING diagnostic at identifier linked by + * omop_table: provider and omop_id. + * Row IDs are per response. The API does not merge across requests. For a + * Practitioner with one NPI, provider_source_value is that NPI, even when + * its group conflicts. Consumers can use it to deduplicate compatible + * providers across requests, but must preserve rows flagged by identity + * conflict diagnostics and check compatibility across responses. Without + * one unambiguous NPI, source-resource identity is preserved without a + * person-level deduplication guarantee. * A PractitionerRole that identifies one supplied Practitioner aliases * that canonical provider: by a top-level structural reference, a - * parent-contained #id reference, or an exact identifier.system and - * identifier.value match against a top-level Practitioner. No remote + * parent-contained #id reference, or an identifier.system and + * identifier.value match against a top-level Practitioner. Identifier + * matching uses the equivalent NPI systems and missing-value rules above; + * all other systems and all supplied identifiers match exactly. No remote * identifier lookup is performed. When Reference.type is present it must - * be Practitioner; duplicate contained IDs and identifier matches are - * ambiguous. An explicit reference that is unresolved, ambiguous, or - * unsupported retains a role-fallback provider row and is returned in - * diagnostics. provider_role_contexts preserves role-specific + * be Practitioner; duplicate contained IDs and identifier matches to + * different canonical providers are ambiguous. An explicit reference that + * is unresolved, ambiguous, or unsupported retains a role-fallback provider + * row and is returned in diagnostics. provider_role_contexts preserves role-specific * specialty and care-site context that a canonical OMOP provider row cannot * represent together.

    + *

    A role whose practitioner reference has only a blank or unknown NPI + * identifier follows the missing-identifier behavior: its fallback row has + * an UNSUPPORTED diagnostic explaining that a usable identifier or literal + * reference is required. An unambiguous literal reference still links to + * its Practitioner when the NPI is missing or unknown. Original supplied + * unknown markers remain in the role's practitioner_identifier metadata.

    + *

    Each PractitionerRole.specialty entry resolves independently using its + * supplied codings and explicit text from that entry. NUCC codings resolve + * directly; other supported codings may use the UMLS bridge. Text fallback + * is permitted when every coding has a nonblank code and a well-formed, + * unsupported system identifier. Supported systems, mixed supported and + * unsupported systems, and incomplete or malformed codings block fallback. + * A supported coding remains authoritative even when the text differs. + * Within one specialty entry, complete codings from well-formed unsupported + * systems do not block supported codings that independently resolve to the + * same standard Provider concept. Unsupported companions remain unsupported + * in the coding audit and acquire no crosswalk. Missing or malformed + * companions, failures of recognized codings, and disagreements between + * supported codings still block selection. For example, NUCC 208000000X alongside a complete local + * PEDS coding can resolve to Pediatric Medicine, with or without text + * and in either coding order. This uses the supported coding's mapping + * and source concept, rather than text fallback. + * Coding displays do not supply text fallback evidence. + * Unsupported system identifiers must be absolute URIs without embedded + * whitespace, user information, or fragments. HTTP(S) identifiers require + * a host; URNs require a namespace and a nonempty namespace-specific value. + * Bare labels such as Epic do not qualify. + * Text uses deterministic vocabulary assertions in order: standard Provider + * concept name, English OMOP synonym, then an English UMLS string on a + * CUI with a NUCC atom, then a NUCC preferred-term label with its grouping + * prefix removed. The last layer retains the entire classification and + * specialization label, including qualifications. An ambiguous layer stops + * resolution. No semantic search or ML matching is used. + * Each role's input remains visible in mappings, including repeated + * identical inputs across one provider's roles. + * specialty_concept_id is populated only when every distinct supplied + * input resolves to the same standard Provider concept. With no inputs, + * or an unresolved, ambiguous, or conflicting input, it stays 0. + * A role without a specialty contributes no input. The endpoint applies + * no frequency or hierarchy selection. Each input's outcome and matching + * route (layer, matched string, and UMLS CUI/SAB when applicable) appear + * in mappings with omop_field: specialty_concept_id. An individually + * resolved input can coexist with a provider concept of 0 when another + * input fails or disagrees. Specialty mappings have selected: false: + * the source text remains in specialty_source_value when supplied source + * values agree, and every original value remains in role contexts and mappings. + * Specialty mapping evidence includes specialty_resolution with the text + * fallback decision and each coding's independent outcome. A successful + * text fallback reports MAPPED and source concept ID 0; its local + * codings remain unsupported in that audit. For example, complete local + * codings with explicit Pediatrics text can resolve to Pediatric Medicine + * through NUCC_LABEL. Adding a different or unresolved specialty still + * keeps the provider's singular concept at 0. + * Compound phrasings without a vocabulary assertion remain unresolved. + * For example, Pulmonology stays unresolved while Pulmonologist + * resolves to Pulmonary Disease. Results depend on vocab_version.

    *

    Patient demographics:

    *
      *
    • Sex at birth, race, and ethnicity are read from these US Core @@ -165,22 +413,138 @@ public RawFhir2OmopClient(ClientOptions clientOptions) { * 0; and Vulcan's suggested observation rows for multiple * ethnicities are not produced.
    • *
    - *

    DiagnosticReport, ServiceRequest, CarePlan, DocumentReference, - * Composition, Specimen, DeviceUseStatement, Coverage, Claim, and - * other unsupported resource types are accepted in a Bundle but ignored: they + *

    Specimen produces a specimen row only with an explicitly resolvable + * top-level Patient subject. Missing collection dates or material retain + * useful partial rows with linked diagnostics; those rows may require + * downstream completion before loading into a strict CDM instance. + * References support Patient/id and exact Bundle + * fullUrl matches (including URNs and absolute URLs); contained, logical, + * display-only, missing, non-Patient, and unresolved subjects never use the + * sole Patient fallback. Reference.type, when supplied, must identify Patient. + * Unsafe patient attribution and ineligible status drop only that Specimen + * with dropped and diagnostics; a missing Patient in the entire request + * remains a 400. Missing material is concept ID 0 with a type diagnostic + * and no terminology mapping/summary count. Supplied unmapped material is + * also 0, with its separate vocabulary mapping outcome and retained source code. + * Material (type) resolves against the Specimen domain, coded UCUM + * collection quantity units against Unit, and collection.bodySite against + * Spec Anatomic Site. Supplied unmapped optional concepts are 0; absent + * unit and site IDs are explicit null. Text-only body sites preserve their + * source text with site concept ID 0 and an UNMAPPED mapping; they do not + * produce a text-based concept suggestion. Material descriptions follow + * the semantic-mapping policy above. Original source codes and text-only + * descriptions are preserved. + * Numeric quantity is copied without + * unit conversion; invalid or comparator-bearing quantities are omitted + * with diagnostics without dropping the row. Missing optional data is allowed. + * Collection time uses collection.collectedDateTime or + * collection.collectedPeriod.start; end-only, partial, invalid, and absent + * dates leave specimen_date/specimen_datetime omitted with linked diagnostics. + * Both collected forms together are conflicting: retain the row without + * selecting a date. Period end and receivedTime never substitute for collection + * start. specimen_source_contexts.collection_timing preserves raw dateTime, + * Period boundaries, receivedTime, and the selected source path when a date + * is populated, including partial precision and original timezone offsets. + * specimen_collection_date_missing, specimen_collection_date_partial, and + * specimen_collection_date_unusable distinguish absent, partial, and invalid + * source dates; specimen_collection_multiple_forms reports conflicting choices. + * specimen_type_concept_id: 32817 is EHR provenance, independent of material + * and availability. Status absent/available/unavailable/unsatisfactory is + * supported; entered-in-error and unrecognized statuses drop the specimen. + * specimen_source_contexts.status preserves the supplied availability + * status for emitted rows; it is omitted when no status was supplied. + * Disease status is not inferred. Source identity precedence is resource id, + * then first identifier with a nonempty value in source array order, then + * accessionIdentifier.value, then Bundle fullUrl. No identity is fabricated; + * specimen_source_contexts preserves all valued identifier system/value + * pairs, accession identity, resource id/fullUrl, and the selected source path. + * Parent/derived specimens emit independently when each has its own Patient; + * missing date/material remains partial rather than being inherited. Parent + * and Measurement-to-Specimen links are not represented. Consuming CDM + * loaders own required-date completion, durable IDs, and dataset completeness. + * Source baseline is FHIR R4 4.0.1, with supported common fields reviewed against + * US Core Specimen 7.0.0; full profile conformance is not validated here.

    + *

    Device use and exposure

    + *

    DeviceUseStatement accepts active, completed, stopped, and on-hold use; + * intended, entered-in-error, missing, and unrecognized statuses are omitted + * with source-specific diagnostics. Its subject must explicitly resolve to + * an emitted Patient. Group, contained-Patient, identifier-only, external + * fetching, and sole-Patient attribution are unsupported for this path. + * Device references support exact Type/id and Bundle fullUrl/URN matches, + * plus unambiguous contained Devices scoped to the referring statement. + * Missing, wrong-type, unresolved, or ambiguous Device details retain a + * known-patient use row with device concept 0 and diagnostics. Missing type + * evidence has no vocabulary mapping entry; supplied unmapped type evidence + * has a mapping outcome. Inventory-only Device records emit no exposure.

    + *

    timingDateTime supplies the start only; timingPeriod preserves supplied + * boundaries independently. Each timingTiming.event produces a separate + * row, including a partial row for an unusable event, with its zero-based + * event_index in device_exposure_contexts.exposures. Repeat rules without events + * retain one partial row; events plus rules produce only the event rows. + * No recurrence, continuous min/max interval, status-derived end date, or + * recordedOn fallback is inferred. Year/month precision and malformed + * individual dates leave affected columns absent. Competing timing choices + * or a provably reversed Period leave both bounds absent. Supplied precision, + * offsets, schedules, and omission/conflict reasons remain in source context + * and diagnostics. Other clinical paths retain their own timing semantics.

    + *

    device_exposure_contexts contains one entry per source record that produced + * device exposures. Its exposures array links all rows derived from that + * record by device_exposure_id and, for Timing events, event_index. Shared + * timing, device evidence, and quantity_basis appear once per source record. + * Contexts are not combined by resource id or device identity; distinct + * records remain separate even when their identifiers match or are absent.

    + *

    device_source_value uses original type/clinical coding, never inventory + * or resource identity. An unambiguous Device.udiCarrier.deviceIdentifier + * supplies unique_device_id. Competing identifiers leave it absent and are + * preserved with issuer/jurisdiction/carrier context. production_id remains + * absent; carriers, lot, serial, manufacture, and expiry evidence remain in + * device_exposure_contexts.

    + *

    DeviceUseStatement rows use device_type_concept_id 32817 (EHR), with + * provider_id, visit_occurrence_id, and visit_detail_id unset. + * Routed rows retain their source classification, explicit visit links to + * emitted same-patient visits, and compatible performer links; + * Condition/Procedure recorder/asserter and + * MedicationStatement informationSource do not supply device provider_id. + * Supplied source, encounter, context, recorder, asserter, performer, and + * informationSource values on DeviceUseStatement are retained only in + * device_exposure_contexts.

    + *

    Device exposure quantity is reported as follows:

    + *
      + *
    • When the source has no quantity, dose, rate, or numeric Observation + * result field, quantity is 1 and device_exposure_contexts.quantity_basis + * is default_absent_count.
    • + *
    • When such a field is supplied, including a null or malformed value, + * quantity is omitted and quantity_basis is unsupported_supplied_evidence. + * The original values remain in device_exposure_contexts.evidence, and + * an UNSUPPORTED diagnostic identifies each supplied field.
    • + *
    + *

    For example, an Observation with valueQuantity.value of 2 returned as a + * device exposure has no quantity; the value 2 remains in its source context. + * timingTiming.repeat.count remains in source context and does not set + * quantity. unit_concept_id and unit_source_concept_id are always null.

    + *

    Distinct eligible statements and routed clinical records remain separate, + * including overlapping use and equal Timing events. derivedFrom is retained + * as supporting context, not duplicate identity. Row counts therefore need + * not count distinct uses or physical devices. IDs are request-local exposure + * identities. Before loading a CDM dataset, callers must assign durable IDs + * and populate any required fields omitted from partial rows.

    + *

    Unsupported resource types are accepted in a Bundle but ignored: they * create no row and no dropped entry. dropped is reserved for supported * row-producing resources that cannot safely produce a positive OMOP row: * required subject/patient, clinical code/text, or medication data may be * unusable, or the resource may explicitly negate or fail the documented * clinical-event eligibility policy. A single-Patient Bundle uses the sole - * Patient only when subject/patient is absent. An explicit subject/patient + * Patient only when subject/patient is absent, except DeviceUseStatement + * and Specimen, which require explicit Patient subjects. An explicit subject/patient * reference that is unresolved, ambiguous, or unsupported drops the clinical * resource in every request scope.

    *

    Eligibility distinguishes clinical validity, performed/taken evidence, and - * administrative workflow state. Checks run before terminology resolution; - * entered-in-error and explicitly non-performed events never produce a positive - * clinical-event row, and an unrecognized required status fails closed. Condition - * requires absent or confirmed canonical-HL7 verificationStatus; clinical course is not a + * administrative workflow state. Entered-in-error and explicitly + * non-performed events never produce a positive clinical-event row, and + * unrecognized required statuses are dropped except the retained Condition and + * Encounter cases below. Condition accepts absent or confirmed canonical-HL7 + * verificationStatus without a status retention reason; uncertain or unreadable + * supplied verification is retained and indexed. Clinical course is not a * diagnosis-role mapping. Procedures accept completed or stopped events. * Medication requests are prescription evidence only: doNotPerform, drafts, * cancellations, and non-authorizing intents are dropped. Medication statements @@ -189,69 +553,142 @@ public RawFhir2OmopClient(ClientOptions clientOptions) { * administration also needs an effectiveDateTime or effectivePeriod.start * that supplies start evidence; a valid partial date remains an undated row. * An on-hold administration is started evidence that is temporarily paused and - * expected to continue. Immunizations require completed - * status. Observations require final, + * expected to continue. MedicationDispense accepts completed records, + * including completed partial fills. Stopped records require a valid + * whenHandedOver, including partial dates, as evidence of actual handover. + * Preparation, in-progress (ready for pickup), on-hold, cancelled, declined, + * entered-in-error, unknown, missing, and unrecognized statuses are dropped. + * A completed dispense with missing or unusable handover timing remains a + * partial row; preparation time never substitutes for handover. + * Immunizations require completed + * status. Observations accept final, * amended, or corrected status; registered, - * preliminary, cancelled, entered-in-error, unknown, and missing statuses are + * preliminary, cancelled, entered-in-error, and missing statuses are * dropped. AllergyIntolerance accepts absent, unconfirmed, or confirmed canonical-HL7 * verificationStatus as a reported allergy, but drops refuted, - * entered-in-error, and unreadable supplied verification statuses. Encounter + * entered-in-error, and unreadable supplied verification statuses. Invalid + * supplied allergy type/category also drops that resource, for both coded + * and text-only statements. Encounter * accepts arrived, triaged, in-progress, onleave, or finished status, but drops - * planned, cancelled, entered-in-error, unknown, and missing statuses. An eligible - * encounter without Period.end remains a partial visit; no end date is invented.

    - *

    Coded Observation routing is selected from the resolved OMOP concept - * domain. Numeric and nonnumeric value[x] forms establish the preferred - * target only when the code is valid for both tables. A text-only - * Observation has no resolver target, so numeric values route to - * measurement and nonnumeric values to observation. Numeric values - * populate value_as_number in the selected row; other non-coded values + * planned, cancelled, and entered-in-error statuses. Unknown, missing, and unreadable + * Encounter statuses are retained and indexed without imputation. An eligible + * encounter without Period.end remains a partial visit; no end date is invented. + * MedicationStatement, Observation, and Procedure with explicit unknown + * status are also retained and indexed in rows_for_review when otherwise + * shapeable. Missing or unreadable statuses on these three resource types + * remain dropped. Retention preserves source status and does not establish + * that an event occurred or medication was taken. Device placement still + * requires eligible use evidence; unknown-status records cannot produce + * device_exposure rows.

    + *

    The mapped OMOP concept's domain determines the clinical output table. + * For Observations, numeric and nonnumeric value[x] forms determine the + * preferred table only when the code is valid for both measurement and + * observation. Unresolved Observations retain measurement rows for + * numeric values and observation rows for other values. In these two + * tables, numeric values populate value_as_number; other non-coded values * populate value_as_string for an observation or value_source_value * for a measurement. Coded valueCodeableConcept values are resolved - * against the selected row's value_as_concept_id and use the selected + * against value_as_concept_id in these tables and use the selected * bare code in value_source_value, leaving an observation's * value_as_string empty; unmapped or target-invalid coded values remain - * 0. + * 0. Active standard concepts from any OMOP domain are valid for + * value_as_concept_id per CDM 5.4. For example, a blood-culture Measurement + * can record Staphylococcus aureus as its coded result, even though the + * organism concept belongs to OMOP's Observation domain. + * For other clinical output tables, Observation values are retained only + * in mappings, without concept resolution or an omop_field, and with + * selected: false. * Other unsupported value[x] forms and Observation components do not * populate separate converted values. A * numeric comparator (<, <=, >, >=) is represented only by a - * measurement's operator_concept_id; units remain source text and have - * unit_concept_id of 0.

    + * measurement's operator_concept_id.

    + *

    For Observations returned as measurement or observation rows, + * Quantity units resolve independently against the table's + * unit_concept_id. + * Only coded UCUM (http://unitsofmeasure.org or its + * OID 2.16.840.1.113883.6.8, bare or urn:oid:) is supported. Codes are + * case-sensitive: no spelling aliases, text-only guesses, or numeric + * conversion are applied. Coded UCUM units map to valid standard OMOP + * Unit concepts using the release selected by vocab_version. + * Mappings are direct or follow an active OMOP Maps to relationship; + * UCUM units do not use a UMLS bridge. + * Unknown codes, + * vocabulary gaps, text-only units, missing coding, and unsupported + * systems keep the row and use 0; absent units use JSON null (the + * field is present). Explicit dimensionless code 1 is a supplied unit + * and resolves or remains 0; it is never treated as absent. + * unit_source_value contains Quantity.code when supplied, otherwise + * Quantity.unit. A separate unit mappings entry preserves the original + * Quantity.system/code in source_system/source_code and the exact + * human-readable Quantity.unit in source_name, with its own outcome. + * Quantity.unit provides display text and does not affect unit matching. + * Measurement also returns nullable unit_source_concept_id, identifying + * the OMOP source concept for Quantity.system/code when available, + * otherwise 0 for a supplied unit. + * Observation has no such column. Resolving a unit does not change + * numeric values, comparators, or reference ranges. + * Supported coded UCUM units include resolution in their mappings, + * whether the unit resolves or remains unmapped: + * its outcome and reason code distinguish vocabulary gaps, target-domain + * mismatches, and ambiguity. Unresolved or ambiguous units retain the row + * with concept ID 0. Resolved mappings retain the selected concept's + * direct or crosswalk provenance. Text-only units, units with missing + * coding, and unsupported unit systems omit resolution; their note + * explains the unmapped outcome.

    + *

    For Observations returned in other clinical tables, units are not resolved + * or included as separate mapping outcomes. Device exposure unit IDs remain + * null, with supplied quantity and unit evidence retained in + * device_exposure_contexts.

    + *

    Compatibility: absent unit_concept_id values previously returned 0 + * and now return null. Coded units use their code in unit_source_value + * rather than their human-readable text; the text remains in mappings. + * The Measurement unit_source_concept_id field is new.

    *

    A standard OMOP concept_id is selected for each eligible primary clinical coding * after considering all of the resource's supplied codings. An unambiguous - * coded medication route is resolved independently to - * drug_exposure.route_concept_id. Alongside the OMOP rows grouped by + * coded medication or immunization route is checked independently against + * the OMOP Route domain. For drug_exposure rows, route_concept_id + * contains the resolved concept ID, or 0 if unmapped; for other output + * tables the route is reported only in + * mappings, without omop_field and with selected: false. + * Alongside the OMOP rows grouped by * table (tables), the response carries mappings (an entry for every * supported source coding from a resource that shaped a row, linked back to * that row; some, such * as demographic null flavors, are reported without being resolved), + * rows_for_review (an index of uncertain-status or primary-concept-0 clinical + * rows in tables, with original statuses, reasons, and source/row identity), * provider_role_contexts (source role details linked to provider rows), * dropped (resources that could not be shaped into a row), * diagnostics (explicit references that could not safely create a link, - * and conflicting or unsupported Patient demographic extensions), + * conflicting Practitioner identity attributes, conflicting or unsupported + * Patient demographic extensions, and conflicting Encounter classifications), * vocab_version (the OMOP vocabulary release codes were resolved * against), and a small summary of the resolution outcomes.

    *

    A concept_id of 0 is reported, not omitted (OMOP "no matching - * concept" semantics): it covers both a coding with no standard match - * (UNMAPPED) and an unverified suggestion for a text-only resource - * (UNCHECKED). Visit and unit concept fields currently remain 0; - * Person demographics and Provider recorded gender follow the policies - * above. Coded Observation values may populate value_as_concept_id. - * Concepts set by a fixed convention rather than terminology resolution - * are measurement operator_concept_id, set from a value comparator (<, - * <=, >, >=), and the multiple-race concepts described above. Clinical - * *_source_value fields contain the selected FHIR code (or source text - * for text-only resources). + * concept" semantics): it covers a supplied concept without a validated + * standard match (UNMAPPED). + * Supported Encounter.class or Encounter.type values + * may populate visit_concept_id. Person demographics and Provider + * recorded gender follow the policies above. + * Coded Observation values and supported allergy substances may populate + * value_as_concept_id. + * Measurement operator_concept_id represents a value comparator (<, + * <=, >, >=); multiple-race concepts follow the conventions described + * above. Clinical *_source_value fields contain the selected FHIR code + * (or source text for text-only resources). * The corresponding selected mappings entry preserves the coding system * and full source-coding provenance. * Known OID-form coding systems are accepted as either FHIR OID URNs (for * example, urn:oid:2.16.840.1.113883.6.1 for LOINC) or bare OIDs, and - * are normalized to their canonical system URLs before terminology - * resolution. mappings[].source_system reports that canonical URL, so the - * OID and URL forms produce the same mapping. An unknown OID is not - * rewritten and may be UNMAPPED. + * produce the same mappings as their canonical system URLs. Coding + * mappings report that canonical URL in source_system; Quantity unit + * mappings preserve the original Quantity.system, including OID spelling. + * An unknown OID remains unchanged in source_system and may be + * UNMAPPED. * Other *_source_value fields preserve row-specific raw source values, * such as resource identifiers, names, units, or status codes. - * MedicationRequest uses 32838 (EHR prescription) for + * MedicationRequest uses 32838 (EHR prescription) and + * MedicationDispense uses 32825 (EHR dispensing record) for * drug_type_concept_id; other current resources use 32817 (EHR). This * is a coarse provenance policy: it does not infer patient-reported, * medication-history, or other more-specific type concepts from FHIR @@ -264,8 +701,8 @@ public RawFhir2OmopClient(ClientOptions clientOptions) { * (YYYY-MM-DDTHH:MM:SS, with fractional seconds to microseconds when * supplied): 2024-01-15T23:30:00-05:00 becomes 2024-01-15T23:30:00. * A datetime without a timezone is read as local time. - *

  • A partial date (2024 or 2024-03) leaves both columns unset. It - * still counts as that source's value, so later sources in the list + *
  • A partial date (2024 or 2024-03) leaves both columns unset. + * It still counts as that source's value, so later sources in the list * below are not used.
  • *
  • Free-text timing (such as onsetString), Age and Range forms, and * values that are not dates are ignored, so a later source in the list @@ -275,11 +712,18 @@ public RawFhir2OmopClient(ClientOptions clientOptions) { * completed, and a row is not dated from another resource such as its * Encounter.
  • *
  • Valid date values never cause a request to be rejected, and the response - * does not report which date elements were unusable. An on-hold + * reports missing, partial, malformed, or conflicting timing for Specimen, + * DeviceUseStatement, and MedicationDispense in diagnostics while retaining + * partial rows for downstream load completion. Other resources do not report which + * date elements were unusable. An on-hold * MedicationAdministration is an eligibility exception: it needs a supplied - * effective start as evidence that administration began.
  • + * effective start as evidence that administration began. + * A stopped MedicationDispense similarly requires supplied handover evidence. * - *

    Timing sources, in priority order where several are listed:

    + *

    Timing sources, in priority order where several are listed. These rules + * apply regardless of the clinical output table; end dates are populated + * only where the table has an end column, and verbatim_end_date is set + * only for medication resources returned as drug_exposure rows:

    *
      *
    • Patient: birthDate sets year_of_birth, month_of_birth, and * day_of_birth from the parts it supplies; birth_datetime is not @@ -291,6 +735,12 @@ public RawFhir2OmopClient(ClientOptions clientOptions) { * end from abatementDateTime or abatementPeriod.end.
    • *
    • Procedure: start from performedDateTime or * performedPeriod.start; end from performedPeriod.end only.
    • + *
    • DeviceUseStatement: timingDateTime sets the start only; + * timingPeriod.start and timingPeriod.end supply independent bounds; + * each timingTiming.event produces one start-only row. A schedule + * without explicit events produces one row without dates.
    • + *
    • Specimen: collection.collectedDateTime or collection.collectedPeriod.start; + * missing day precision retains an undated specimen with diagnostics.
    • *
    • Observation: effectiveDateTime, effectivePeriod.start, or * effectiveInstant.
    • *
    • AllergyIntolerance: recordedDate, then onsetDateTime or @@ -307,21 +757,212 @@ public RawFhir2OmopClient(ClientOptions clientOptions) { *
    • MedicationRequest: authoredOn, the order date, sets the start; it * is not evidence of administration. No end is set, and the validity * period is not used as an exposure duration.
    • + *
    • MedicationDispense: whenHandedOver sets the start. Missing, partial, + * or invalid handover leaves the start unset with a diagnostic. Comparable + * handover/preparation values proving handover before preparation also + * leave the start unset with a CONFLICTING diagnostic. whenPrepared, + * authorizing orders, and related administrations never supply timing. + * No exposure end or verbatim_end_date is inferred, including from + * daysSupply. Raw handover/preparation values retain precision and offsets + * in medication_dispense_source_contexts.source.
    • *
    • Differences from the Vulcan maps: birth_datetime is not set from * birthDate, Condition also reads onsetPeriod.start, and * AllergyIntolerance falls back to its onset when recordedDate is * missing.
    • *
    - *

    Medication details:

    + *

    Medication details for resources returned as drug_exposure rows:

    *
      *
    • For MedicationRequest, dispenseRequest.numberOfRepeatsAllowed sets * refills and a whole-day expectedSupplyDuration sets days_supply.
    • + *
    • For MedicationDispense, daysSupply sets days_supply only for a + * positive whole number of coded UCUM d days without a supplied comparator. + * The canonical UCUM URL, bare OID, and OID URN are accepted. Other units, + * fractions, text-only units, and malformed values leave it absent with a + * diagnostic; absent daysSupply is allowed. Refills are not inherited.
    • *
    • All non-empty dosage text is preserved in sig.
    • *
    • Coded dosage routes and Immunization.route are target-validated in * the OMOP Route domain. Conflicting routes are left unset; route * codings shared by every dosage instruction identify the same route.
    • *
    • Immunization.lotNumber is preserved in lot_number.
    • *
    + *

    MedicationDispense requires an explicit subject resolving to a Patient + * supplied in the request; Groups and missing subjects are unsupported. + * Source fields are based on FHIR R4 4.0.1 and reviewed against + * US Core MedicationDispense 7.0.0. + * Full profile conformance is not validated. Although US Core requires + * handover on completed records, this API retains such incomplete records + * with missing-handover diagnostics as partial conversion output. + * Supply either a usable medicationCodeableConcept (coded or text-only), + * or a medicationReference resolving to a Medication with usable code/text. + * Both choice forms together are conflicting and drop the dispense. + * Exact relative, Bundle fullUrl, and unambiguous contained Medication + * references are supported. Unresolved references are not rescued by display + * text; missing or ambiguous medication identity drops the dispense with a + * diagnostic. A usable but unmapped drug retains concept ID 0 and its + * mapping outcome in a row indexed for review. + * context links only to a supplied Encounter for the same patient. + * The first resolvable Practitioner/PractitionerRole in performer.actor + * supplies provider_id; other actor types do not supply a provider. + * Failed explicit links have diagnostics. Authorizing prescriptions are + * checked for request-local MedicationRequest identity and patient agreement; + * failures do not discard the dispense. They never supply medication, + * instructions, refills, timing, or prescription provenance to a dispense. + * Dispense instructions alone supply sig and route; conflicting routes + * remain unset with a diagnostic and original instructions in source context. + * Orders, dispenses, and administrations emit independently, with separate + * source identity and no deduplication merely because they concern the same drug. + * Every emitted dispensing drug row has a whenHandedOver/daysSupply + * diagnostic beginning medication_dispense_end_date_absent because the + * CDM-required exposure end remains absent. Missing/partial/unusable handover + * diagnostics begin medication_dispense_handover_missing, + * medication_dispense_handover_partial, or medication_dispense_handover_unusable.

    + *

    Encounter details:

    + *
      + *
    • + *

      Encounter classifications must supply both Coding.system and + * Coding.code to map to a standard OMOP Visit concept. For HL7 + * encounter class codes with system + * http://terminology.hl7.org/CodeSystem/v3-ActCode or + * http://hl7.org/fhir/v3/ActCode, the supported mappings are:

      + *
        + *
      • IMP (inpatient encounter) -> 9201 (Inpatient Visit)
      • + *
      • AMB (ambulatory encounter) -> 9202 (Outpatient Visit)
      • + *
      • EMER (emergency encounter) -> 9203 (Emergency Room Visit)
      • + *
      • VR (virtual encounter) -> 722455 (Telehealth)
      • + *
      • HH (home health encounter) -> 581476 (Home Visit)
      • + *
      + *

      OBSENC (observation encounter) has no direct class mapping. Other + * class or type codes can be used when they have a valid standard OMOP + * Visit mapping in the selected vocabulary release. Unsupported + * classifications retain visit_concept_id of 0 unless a supported + * type supplies the Visit as described below; they never default to + * outpatient.

      + *
    • + *
    • + *

      A mapped Encounter.class determines visit_concept_id. A mapped + * type that disagrees with it produces a CONFLICTING diagnostic with + * path class/type, while the class mapping remains selected. + * If class is missing or unmapped, type supplies the Visit only when + * all mapped type classifications agree on the same standard Visit + * concept. Codings within one type CodeableConcept are alternative + * encodings of that classification. When separate type classifications + * map to different Visit concepts, no type is selected, + * visit_concept_id remains 0, and a CONFLICTING diagnostic has + * path type.

      + *
    • + *
    • + *

      visit_source_value contains the selected class or type code; + * mappings preserves its coding system and identifies which + * classification was selected. visit_source_concept_id identifies + * the selected original source coding's OMOP concept when available, + * and is 0 otherwise, including for the HL7 encounter class codes + * listed above. Each eligible Encounter produces one Visit; + * classHistory, partOf, and admission/discharge concepts are not + * mapped.

      + *
    • + *
    • + *

      Direct MedicationRequest.dispenseRequest.quantity, MedicationDispense.quantity, + * MedicationAdministration.dosage.dose, and Immunization.doseQuantity + * can set quantity under the formulation policies below. Quantities + * must be positive finite numbers with a supported system/code pair + * and no supplied comparator. Ingredient amounts and injection volumes + * use http://unitsofmeasure.org (or its bare OID or + * urn:oid:2.16.840.1.113883.6.8) with case-sensitive codes matching or + * convertible to vocabulary-backed strength units. Exact unit matches, + * including [U] for insulin and heparin strengths, are accepted without + * a unit conversion. + * When codes differ, both source and strength units must belong to the + * same supported group: mass (ug, mg, g), volume (uL, mL, L), + * or milliequivalent representations (meq, 10*-3.eq). Other conversions, + * including kg, ML, Mg, [drp], mol, mmol, %, 1, and generic + * annotations such as {vial} or {syringe}, leave quantity unset with + * unit_mismatch. Codes are not case-corrected or inferred from display text. + * The original source system is preserved in the quantity audit. + * Fixed-form counts also accept the explicit codings below. Free-text, + * local, invalid, and cross-dimensional units are not interpreted.

      + *
    • + *
    + *

    Drug quantity policies follow CDM v5.4 DRUG_EXPOSURE conventions. + * The selected drug must be a Clinical Drug or Branded Drug, with one + * active dose form and valid vocabulary evidence for the chosen policy. + * Strength-based conversions also require one ingredient strength. Supported RxNorm dose forms:

    + *
      + *
    • fixed_dose_form_count: Oral Tablet (317541), Oral Capsule + * (316965), Extended Release Oral Tablet (316945), Extended + * Release Oral Capsule (316943), Delayed Release Oral Tablet (10312), + * or Delayed Release Oral Capsule (316995). A source ingredient amount is divided + * by the single positive per-unit ingredient amount after conversion to that + * strength unit when necessary. + * Concentration-based strengths cannot derive counts from ingredient amounts. The + * result is a count of the selected dose form, limited to whole units or + * multiples of one-half or one-third. Derived counts within a relative + * tolerance of 1e-12 of an allowed fraction are normalized to that count + * to remove arithmetic roundoff; other fractions remain unsupported. + * For example, 1,000 mg for a drug + * with 500 mg per tablet produces quantity: 2 tablets (DERIVED). A source + * of 1 g is converted to 1,000 mg and produces the same count. + * Explicit counts are copied (DIRECT) when their coding matches the + * selected tablet or capsule form, including the corresponding extended- + * and delayed-release forms: TAB / CAP under + * http://terminology.hl7.org/CodeSystem/v3-orderableDrugForm, or + * {tbl} / {Capsule} under http://unitsofmeasure.org. These exact + * case-sensitive count codings identify the supplied form; they are not + * dimensionally converted as UCUM units. Direct counts do not require + * ingredient strength: combination products and missing/invalid strengths + * do not change the supplied count. Ingredient-strength gaps, including + * missing ingredient or strength-unit references, do not prevent copying + * an explicit count. Drug or dose-form evidence gaps, unclassified or + * ambiguous forms, and gaps whose relevance cannot be established still + * prevent conversion. The whole/half/third rules still apply. + * For example, 20 TAB for amoxicillin/clavulanate is 20 tablets, and 30 TAB for + * a supported tablet produces quantity: 30; CAP for that tablet is + * omitted with unit_mismatch. Generic counts (1, {Each}), other + * annotations, packs, and free-text counts are not interpreted. + * See the FHIR R4 count example.
    • + *
    • clinical_injection_ingredient_amount: Injectable Solution (316949), + * Injectable Suspension (316950), or Injection (1649574). A source + * ingredient amount is converted to the vocabulary ingredient unit when + * necessary; an exact-unit amount is copied directly. Otherwise, a source + * amount convertible to the concentration's denominator unit is converted + * and multiplied by numerator value / the effective denominator reported in + * the audit evidence. Volume conversion requires a positive finite numerator, + * dimensionally distinct validated UCUM units, a null raw denominator value, and + * evidence.strength.denominator_basis with kind: PER_UNIT and + * value: 1. A null denominator alone is not interpreted as 1. Missing, unknown, + * non-applicable, quantified, or conflicting denominator evidence does + * not support volume conversion. For example, 80 mL with 2 mg/mL + * strength and a PER_UNIT basis produces quantity: 160 mg. A supplied + * 160 mg amount for the same drug produces quantity: 160 directly. A source + * of 0.08 L converts to 80 mL before producing the same 160 mg result. + * This output is already + * the ingredient amount: do not apply the drug-dose guide's + * dose = quantity × numerator formula to these injection rows, which + * would multiply by the concentration a second time. Use + * quantity_transformations.result_unit to identify the output unit.
    • + *
    + *

    Quantified products, packs, prefilled syringes, other liquids and dose + * forms, strength-based conversions of combination products, conflicting or incomplete required evidence, and + * unsupported source quantities leave quantity unset. Other supported + * fields and the clinical row are still returned. For example, a quantity + * supplied only as value: 2, unit: tablet is omitted with + * unsupported_unit, because a free-text unit cannot establish this policy. + * Rate-only administrations and MedicationStatement dosages do not establish + * a supported total quantity and are not used for quantity conversion.

    + *

    quantity_transformations audits each supplied quantity at one of the + * three direct paths above on a resource returned as a drug_exposure row, + * including an explicit null or malformed quantity. Each entry preserves + * the source, identifies the destination row, and reports DIRECT, DERIVED, or + * OMITTED. Successful entries identify their policy, evidence, result, + * and result unit; omitted entries give a stable reason. When a source code + * differs from the strength unit, unit_conversion records the intermediate + * converted amount and target unit. Converted results are DERIVED, even + * when no further concentration arithmetic is needed. A successful conversion + * can also be reported when a later policy check omits the final quantity. + * Dose-form and unit + * concept IDs refer to the response's vocab_version. Missing quantities + * produce no entry. Resources returned in other clinical tables have no + * quantity entry. Unsupported rates and statement dosages produce no entry, + * and resources in dropped have no quantity entry or destination row.

    *

    Medication codes are resolved whether they appear inline * (medicationCodeableConcept) or via a medicationReference to a contained, * relative (Type/id), or bundle-entry (urn:uuid) Medication resource. @@ -333,11 +974,12 @@ public RawFhir2OmopClient(ClientOptions clientOptions) { * subject/patient reference cannot be tied to a person; or an event excluded * by eligibility — are reported under dropped rather than emitted as blank * rows. The Bundle must contain at least one Patient resource.

    - *

    Structural references resolve only to top-level resources supplied in the - * request, by Type/id or an exactly matching Bundle fullUrl (including - * urn:uuid). Contained references are supported for medication code lookup - * and PractitionerRole.practitioner enrichment; the latter also supports - * exact request-local identifier matching without a remote lookup. Every + *

    References to top-level resources supplied in the request resolve by + * Type/id or an exactly matching Bundle fullUrl (including urn:uuid). + * Parent-scoped contained references are supported for medication code lookup, + * DeviceUseStatement.device details, and PractitionerRole.practitioner + * enrichment; the latter also supports + * request-local identifier matching with the NPI rules above, without a remote lookup. Every * nonzero structural foreign key targets a row in the same response. Missing * optional links remain unset without a diagnostic; an explicit optional * reference that is unresolved, ambiguous, conflicting with the row's @@ -359,11 +1001,42 @@ public PhenomlClientHttpResponse create(CreateOmopRequest re /** * Maps a FHIR R4 resource or Bundle into OMOP Common Data Model v5.4 rows, - * grouped by destination table in tables. + * grouped by destination table in tables. Uncertain-status and unresolved- + * primary-concept clinical rows stay in those tables and are identified by + * the optional rows_for_review index for consumer review and filtering. + * Status-based retention covers uncertain supplied Condition verification, + * unknown/missing/unreadable Encounter status, and explicit unknown status + * on MedicationStatement, Observation, and Procedure. Other status exclusions + * follow the resource-specific eligibility policy below. *

    Set vocab_version to select the OMOP vocabulary release used for coded - * concept resolution. If omitted or empty, the API uses its default - * release. The response's vocab_version, when present, identifies the - * release used. Specify a release explicitly when reproducibility matters.

    + * and inferred concept resolution. If omitted or empty, the API uses its + * default release. The response's vocab_version, when present, identifies + * the release used. Specify a release explicitly when reproducibility matters.

    + *

    Supported concept fields use semantic mapping for text-only inputs and + * unresolved source codings, with whole-term equivalence validation. + * CodeableConcept text takes precedence over coding display. A single + * equivalent suggestion must be validated before a real OMOP concept ID is + * returned. The suggested vocabulary/code must + * itself be an active standard concept valid for the destination; broader + * crosswalk targets are not accepted for inferred suggestions. + * Deterministic mappings take precedence; ambiguous or domain-mismatched + * deterministic results are not reinterpreted. Units, routes, visits, + * demographics, and collection sites use their field-specific resolution + * policies. Semantic mapping returns MAPPED with a real concept ID on + * success, or UNMAPPED with concept ID 0 when no standard concept is + * selected. A bare code is not treated as a description. No source coding + * is invented.

    + *

    Contextual allergy finding decisions follow their dedicated policy and + * are not replaced by description-based mapping.

    + *

    Every inferred mapping has + * mappings.resolution.semantic_mapping.status: MAPPED, with the OMOP + * vocabulary release. Filter linked rows using the mapping's table/row ID + * to exclude inferred results. + * Original source coding and clinical-status eligibility are preserved. + * For clinical rows covered by rows_for_review, successful inference + * does not remove uncertain-status rows from the index; unresolved primary + * concepts also remain indexed. + * All shaped rows appear once in tables.

    *

    Standards basis: FHIR R4 (v4.0.1) defines * the accepted source elements and OMOP CDM * v5.4 defines the @@ -386,46 +1059,262 @@ public PhenomlClientHttpResponse create(CreateOmopRequest re * first address can produce location, and more than one supplied race * produces observation race rows (see Patient demographics below) *

  • observation_period -> one request-local derived row per person, - * spanning the populated dates of that person's visit, clinical, and - * death rows; this is not enrollment or capture-completeness evidence
  • + * spanning dates from that person's visit and clinical rows not listed + * in rows_for_review, plus Specimen and Death rows. Source end dates + * contribute even when the output table has no end column. No period + * row is returned for a person without qualifying dates; this is not + * enrollment or capture-completeness evidence *
  • Location -> location and care_site
  • *
  • Organization -> care_site; its first address can produce location
  • *
  • HealthcareService -> care_site
  • *
  • Practitioner and PractitionerRole -> provider
  • *
  • Encounter -> visit_occurrence
  • - *
  • Condition -> condition_occurrence
  • - *
  • Procedure -> procedure_occurrence
  • - *
  • MedicationRequest, MedicationStatement, and - * MedicationAdministration -> drug_exposure
  • - *
  • Immunization -> drug_exposure
  • - *
  • Observation -> measurement or observation. For coded - * Observations, the resolved OMOP concept domain selects the table; value - * form only breaks ties. For text-only Observations, numeric values route - * to measurement and nonnumeric values to observation.
  • - *
  • AllergyIntolerance -> observation
  • + *
  • Specimen -> specimen, with an explicit Patient subject; missing + * collection date or material retains a partial row with diagnostics
  • + *
  • DeviceUseStatement -> device_exposure, using referenced Device.type
  • + *
  • Condition -> domain-selected clinical table; default condition_occurrence
  • + *
  • Procedure -> domain-selected clinical table; default procedure_occurrence
  • + *
  • MedicationRequest, MedicationStatement, MedicationAdministration, and + * MedicationDispense -> domain-selected clinical table; default drug_exposure
  • + *
  • Immunization -> domain-selected clinical table; default drug_exposure
  • + *
  • Observation -> domain-selected clinical table. Numeric values prefer + * measurement and other values prefer observation when the code is + * valid for both tables. + * If no supported standard concept is selected, numeric values retain the + * measurement default and nonnumeric values retain observation.
  • + *
  • AllergyIntolerance -> observation for supported contextual + * allergy/intolerance mappings; other coded statements use domain-based + * placement excluding drug_exposure and device_exposure; default observation
  • * + *

    Clinical placement follows the informative Vulcan IG's vocabulary-driven + * domain assignment. Supported destinations are condition_occurrence, + * procedure_occurrence, drug_exposure, device_exposure, measurement, and observation. + * Resolved clinical records are returned in the table corresponding to the + * domain of their mapped OMOP standard concept. The source resource + * determines eligibility, field values, and the default table when no + * supported mapping is available. + * AllergyIntolerance records do not produce drug_exposure or + * device_exposure rows. Device placement requires eligible use evidence: + * prescriptions, uncertain diagnoses, and explicit negative Observation + * results (false, SNOMED Negative, Known absent, or No, or interpretation + * code NEG from + * http://terminology.hl7.org/CodeSystem/v3-ObservationInterpretation) + * do not accept Device placement, even when other supplied values are positive. + * A recorder, asserter, or information source is retained as context rather + * than treated as the device event's provider.

    + *

    Coded AllergyIntolerance.code statements are interpreted using their + * original codings, optional type, and category. Supported substances + * produce an Observation finding separately from the causative substance + * in value_as_concept_id. Drug-domain substances are valid values and do + * not imply drug exposure or medication allergy. The finding is selected + * by an explicit mapping policy, not a published substance-to-finding + * crosswalk. Contextual findings report MAPPED, + * resolution.via: CONTEXTUAL. observation_source_concept_id retains + * the known OMOP identity of the original coding written to + * observation_source_value, including when the finding is unresolved; + * it is 0 when no source identity is known. The configured finding is + * never substituted for the original source concept.

    + *

    This adapts the informative R5 Vulcan Value-as-Concept + * pattern + * to R4's substance/product codes, optional type, and repeating category. + * It selects a contextual finding and resolves the substance separately; + * published Maps to value decomposition of a composite finding is not performed.

    + *

    For type: allergy, medication (optionally with biologic) maps to + * Allergy to drug (439224); other category sets use Allergic disposition + * (43530807). For type: intolerance, medication (optionally biologic) + * uses Intolerance to drug (4240582), food alone uses Intolerance to food + * (4340252), and other sets use Intolerance to substance (36684107). + * All selected concepts must be active and standard in the requested + * vocabulary release. Limited reviewed substance mappings can derive an + * absent category: for example, peanut and egg extracts supply food context, and + * Penicillin G supplies medication context. No category is inferred solely + * from Drug-domain membership.

    + *

    With absent type, a supported substance maps to a mechanism-neutral + * propensity finding: medication (optionally with biologic) uses Propensity + * to adverse reactions to drug (4305025), food alone uses Propensity to + * adverse reactions to food (4303802), and other category sets or an + * unknown category use Propensity to adverse reaction (4172024). The + * original type remains absent; no allergy or intolerance mechanism is + * inferred. The substance value is resolved independently. Existing + * supported findings can supply their own mechanism and negative + * statements retain their meaning. Conflicting evidence leaves the + * affected concept unmapped. + * A single identified substance can still populate the value when only + * its category context conflicts. Distinct conflicting substances, + * positive/negative mixtures, finding/substance mixtures, and multiple + * standard substance targets do not select a substance value by input + * order. Equivalent supported translations may share one value; + * unresolved translations are disclosed and are not used as substances. + * Finding-only, value-only, and neither-resolved partial rows are retained.

    + *

    mappings.resolution.allergy retains supplied type/categories, coded + * clinical/verification statuses, the date-source element, per-coding + * interpretation, derived categories, and reported information loss. The + * finding and substance have separate mappings linked to the same row and + * fields. Broader findings retain source specificity in this metadata. + * Codes outside contextual policy coverage retain existing domain-based + * mapping, with the contextual coverage outcome reported separately. + * Text-only inputs retain their text-only behavior. Reaction-substance + * fallback and published Maps to value relationships are not applied. + * Null placeholders in repeating FHIR category primitives carry no category + * value and are omitted from category interpretation and audit values. + * An invalid supplied type/category drops only the affected AllergyIntolerance + * before concept resolution. Other resources are still processed. The + * dropped entry identifies the resource and reports + * allergy_type_unrecognized or allergy_category_unrecognized.

    + *

    For example, a Condition coded with ICD10CM Z79.4 can produce a mapped + * observation row. Z86.73 and ICD-O-3 8140/3 also map to concepts in + * the Observation domain. Use mappings.omop_table and mappings.omop_id + * to locate each result; the FHIR resource type alone does not determine + * the output table.

    + *

    Clinical rows include available person/visit/provider links, concept and + * source concept IDs, source value, source type provenance, and start + * timing. Dates follow the source resource's timing rules below, regardless + * of the output table. For example, a Condition follows its + * onset[x] / recordedDate timing rule even when returned as an + * observation. End dates are populated only in tables with end columns; + * for rows not listed in rows_for_review, they also contribute to the + * request-local observation period when the output table has no end column.

    + *

    Source-specific fields are populated only for the corresponding source + * and destination: Condition status in condition_occurrence, medication + * dosage/supply and Immunization lot details in drug_exposure, and + * Observation values/units/ranges in measurement or observation where + * those columns exist. Mapping notes explain the output table and which + * fields are populated. Coded medication/immunization routes on rows outside + * drug_exposure, and Observation values on rows outside measurement / + * observation, are retained in mappings linked to the output row, with + * omop_field omitted and selected: false. Coded routes are checked + * against the OMOP Route domain; those Observation values are preserved + * without concept resolution.

    + *

    Note destinations are unsupported. If no standard concept + * valid for a supported destination is found, the resource produces a row + * in its default table with concept ID 0 and UNMAPPED status. Its + * source concept ID is also 0, except that allergy rows retain a known + * original source identity from their coding evidence. A DOMAIN_MISMATCH + * outcome is identified in the mapping + * note. Text-only clinical inputs can also produce rows in another supported + * table when semantic mapping selects a standard concept valid for that destination; + * without a validated match, they use their default tables. Domain-routed + * clinical records produce one row each even when their code has multiple + * vocabulary mapping targets; DeviceUseStatement event granularity is + * described below. Vocabulary Maps to value mappings are not applied.

    *

    Medication is reference data for medication resources; it does not * create its own row because OMOP CDM has no Medication table. Administrative * linkages (provider, care site, and location) are best-effort and limited to * references supplied in the request. Patient.managingOrganization is a - * record custodian, not a care-delivery site. Provider specialty is not - * mapped. Recorded Practitioner.gender is distinct from Person + * record custodian, not a care-delivery site. Provider specialties resolve + * independently to standard Provider concepts. Recorded Practitioner.gender + * is distinct from Person * demographics: male and female resolve to validated OMOP Gender * concepts in provider.gender_concept_id; other, unknown, and absent * gender remain unmapped. Address.country is resolved to location.country_concept_id, * and CMS Place of Service codings in Location.type are resolved to * care_site.place_of_service_concept_id. + * Within one request, Practitioners with the same single distinct supplied + * NPI value merge into one provider row only when all supplied mapped identity + * attributes in the entire NPI group agree. The NPI system may be + * http://hl7.org/fhir/sid/us-npi (preferred in FHIR R4), + * urn:oid:2.16.840.1.113883.4.6, or bare 2.16.840.1.113883.4.6; + * these forms are equivalent for matching. OID forms are accepted for compatibility. + * The same matching rule applies to Practitioners contained in valid role + * references. NPI values are compared exactly as supplied, without format + * or checksum validation. Blank values and unknown markers UNK and + * UNKNOWN are treated as missing and do not populate npi or participate + * in matching. Marker detection ignores case and surrounding whitespace; + * all other values are preserved without trimming. Other identifier systems and names + * do not trigger a merge. Missing attributes can enrich a compatible merged row. + * Compatibility compares the name rendered from each Practitioner's first + * name entry, its gender, its first non-empty DEA identifier, and its parsed + * birth year. Differences in birth month/day within one year do not conflict. + * Rendered names are compared exactly: Jane Smith and Dr. Jane Smith + * conflict even when they share an NPI. + * If any of these attributes conflict, every resource in that NPI group stays + * separate, including resources missing the conflicting attribute. Source + * attributes and unambiguous structural reference links are preserved; no + * conflicting value is selected. Each affected row has CONFLICTING + * diagnostics at name, gender, identifier, or birthDate, linked by + * omop_table: provider and omop_id. All source gender mappings link to + * their retained or merged provider row. + * Repeated copies of one NPI on a resource are allowed. A resource with + * multiple distinct NPIs stays separate, leaves npi unset, preserves its + * source-resource identity in provider_source_value, and reports a + * CONFLICTING diagnostic at identifier linked by + * omop_table: provider and omop_id. + * Row IDs are per response. The API does not merge across requests. For a + * Practitioner with one NPI, provider_source_value is that NPI, even when + * its group conflicts. Consumers can use it to deduplicate compatible + * providers across requests, but must preserve rows flagged by identity + * conflict diagnostics and check compatibility across responses. Without + * one unambiguous NPI, source-resource identity is preserved without a + * person-level deduplication guarantee. * A PractitionerRole that identifies one supplied Practitioner aliases * that canonical provider: by a top-level structural reference, a - * parent-contained #id reference, or an exact identifier.system and - * identifier.value match against a top-level Practitioner. No remote + * parent-contained #id reference, or an identifier.system and + * identifier.value match against a top-level Practitioner. Identifier + * matching uses the equivalent NPI systems and missing-value rules above; + * all other systems and all supplied identifiers match exactly. No remote * identifier lookup is performed. When Reference.type is present it must - * be Practitioner; duplicate contained IDs and identifier matches are - * ambiguous. An explicit reference that is unresolved, ambiguous, or - * unsupported retains a role-fallback provider row and is returned in - * diagnostics. provider_role_contexts preserves role-specific + * be Practitioner; duplicate contained IDs and identifier matches to + * different canonical providers are ambiguous. An explicit reference that + * is unresolved, ambiguous, or unsupported retains a role-fallback provider + * row and is returned in diagnostics. provider_role_contexts preserves role-specific * specialty and care-site context that a canonical OMOP provider row cannot * represent together.

    + *

    A role whose practitioner reference has only a blank or unknown NPI + * identifier follows the missing-identifier behavior: its fallback row has + * an UNSUPPORTED diagnostic explaining that a usable identifier or literal + * reference is required. An unambiguous literal reference still links to + * its Practitioner when the NPI is missing or unknown. Original supplied + * unknown markers remain in the role's practitioner_identifier metadata.

    + *

    Each PractitionerRole.specialty entry resolves independently using its + * supplied codings and explicit text from that entry. NUCC codings resolve + * directly; other supported codings may use the UMLS bridge. Text fallback + * is permitted when every coding has a nonblank code and a well-formed, + * unsupported system identifier. Supported systems, mixed supported and + * unsupported systems, and incomplete or malformed codings block fallback. + * A supported coding remains authoritative even when the text differs. + * Within one specialty entry, complete codings from well-formed unsupported + * systems do not block supported codings that independently resolve to the + * same standard Provider concept. Unsupported companions remain unsupported + * in the coding audit and acquire no crosswalk. Missing or malformed + * companions, failures of recognized codings, and disagreements between + * supported codings still block selection. For example, NUCC 208000000X alongside a complete local + * PEDS coding can resolve to Pediatric Medicine, with or without text + * and in either coding order. This uses the supported coding's mapping + * and source concept, rather than text fallback. + * Coding displays do not supply text fallback evidence. + * Unsupported system identifiers must be absolute URIs without embedded + * whitespace, user information, or fragments. HTTP(S) identifiers require + * a host; URNs require a namespace and a nonempty namespace-specific value. + * Bare labels such as Epic do not qualify. + * Text uses deterministic vocabulary assertions in order: standard Provider + * concept name, English OMOP synonym, then an English UMLS string on a + * CUI with a NUCC atom, then a NUCC preferred-term label with its grouping + * prefix removed. The last layer retains the entire classification and + * specialization label, including qualifications. An ambiguous layer stops + * resolution. No semantic search or ML matching is used. + * Each role's input remains visible in mappings, including repeated + * identical inputs across one provider's roles. + * specialty_concept_id is populated only when every distinct supplied + * input resolves to the same standard Provider concept. With no inputs, + * or an unresolved, ambiguous, or conflicting input, it stays 0. + * A role without a specialty contributes no input. The endpoint applies + * no frequency or hierarchy selection. Each input's outcome and matching + * route (layer, matched string, and UMLS CUI/SAB when applicable) appear + * in mappings with omop_field: specialty_concept_id. An individually + * resolved input can coexist with a provider concept of 0 when another + * input fails or disagrees. Specialty mappings have selected: false: + * the source text remains in specialty_source_value when supplied source + * values agree, and every original value remains in role contexts and mappings. + * Specialty mapping evidence includes specialty_resolution with the text + * fallback decision and each coding's independent outcome. A successful + * text fallback reports MAPPED and source concept ID 0; its local + * codings remain unsupported in that audit. For example, complete local + * codings with explicit Pediatrics text can resolve to Pediatric Medicine + * through NUCC_LABEL. Adding a different or unresolved specialty still + * keeps the provider's singular concept at 0. + * Compound phrasings without a vocabulary assertion remain unresolved. + * For example, Pulmonology stays unresolved while Pulmonologist + * resolves to Pulmonary Disease. Results depend on vocab_version.

    *

    Patient demographics:

    *
      *
    • Sex at birth, race, and ethnicity are read from these US Core @@ -489,22 +1378,138 @@ public PhenomlClientHttpResponse create(CreateOmopRequest re * 0; and Vulcan's suggested observation rows for multiple * ethnicities are not produced.
    • *
    - *

    DiagnosticReport, ServiceRequest, CarePlan, DocumentReference, - * Composition, Specimen, DeviceUseStatement, Coverage, Claim, and - * other unsupported resource types are accepted in a Bundle but ignored: they + *

    Specimen produces a specimen row only with an explicitly resolvable + * top-level Patient subject. Missing collection dates or material retain + * useful partial rows with linked diagnostics; those rows may require + * downstream completion before loading into a strict CDM instance. + * References support Patient/id and exact Bundle + * fullUrl matches (including URNs and absolute URLs); contained, logical, + * display-only, missing, non-Patient, and unresolved subjects never use the + * sole Patient fallback. Reference.type, when supplied, must identify Patient. + * Unsafe patient attribution and ineligible status drop only that Specimen + * with dropped and diagnostics; a missing Patient in the entire request + * remains a 400. Missing material is concept ID 0 with a type diagnostic + * and no terminology mapping/summary count. Supplied unmapped material is + * also 0, with its separate vocabulary mapping outcome and retained source code. + * Material (type) resolves against the Specimen domain, coded UCUM + * collection quantity units against Unit, and collection.bodySite against + * Spec Anatomic Site. Supplied unmapped optional concepts are 0; absent + * unit and site IDs are explicit null. Text-only body sites preserve their + * source text with site concept ID 0 and an UNMAPPED mapping; they do not + * produce a text-based concept suggestion. Material descriptions follow + * the semantic-mapping policy above. Original source codes and text-only + * descriptions are preserved. + * Numeric quantity is copied without + * unit conversion; invalid or comparator-bearing quantities are omitted + * with diagnostics without dropping the row. Missing optional data is allowed. + * Collection time uses collection.collectedDateTime or + * collection.collectedPeriod.start; end-only, partial, invalid, and absent + * dates leave specimen_date/specimen_datetime omitted with linked diagnostics. + * Both collected forms together are conflicting: retain the row without + * selecting a date. Period end and receivedTime never substitute for collection + * start. specimen_source_contexts.collection_timing preserves raw dateTime, + * Period boundaries, receivedTime, and the selected source path when a date + * is populated, including partial precision and original timezone offsets. + * specimen_collection_date_missing, specimen_collection_date_partial, and + * specimen_collection_date_unusable distinguish absent, partial, and invalid + * source dates; specimen_collection_multiple_forms reports conflicting choices. + * specimen_type_concept_id: 32817 is EHR provenance, independent of material + * and availability. Status absent/available/unavailable/unsatisfactory is + * supported; entered-in-error and unrecognized statuses drop the specimen. + * specimen_source_contexts.status preserves the supplied availability + * status for emitted rows; it is omitted when no status was supplied. + * Disease status is not inferred. Source identity precedence is resource id, + * then first identifier with a nonempty value in source array order, then + * accessionIdentifier.value, then Bundle fullUrl. No identity is fabricated; + * specimen_source_contexts preserves all valued identifier system/value + * pairs, accession identity, resource id/fullUrl, and the selected source path. + * Parent/derived specimens emit independently when each has its own Patient; + * missing date/material remains partial rather than being inherited. Parent + * and Measurement-to-Specimen links are not represented. Consuming CDM + * loaders own required-date completion, durable IDs, and dataset completeness. + * Source baseline is FHIR R4 4.0.1, with supported common fields reviewed against + * US Core Specimen 7.0.0; full profile conformance is not validated here.

    + *

    Device use and exposure

    + *

    DeviceUseStatement accepts active, completed, stopped, and on-hold use; + * intended, entered-in-error, missing, and unrecognized statuses are omitted + * with source-specific diagnostics. Its subject must explicitly resolve to + * an emitted Patient. Group, contained-Patient, identifier-only, external + * fetching, and sole-Patient attribution are unsupported for this path. + * Device references support exact Type/id and Bundle fullUrl/URN matches, + * plus unambiguous contained Devices scoped to the referring statement. + * Missing, wrong-type, unresolved, or ambiguous Device details retain a + * known-patient use row with device concept 0 and diagnostics. Missing type + * evidence has no vocabulary mapping entry; supplied unmapped type evidence + * has a mapping outcome. Inventory-only Device records emit no exposure.

    + *

    timingDateTime supplies the start only; timingPeriod preserves supplied + * boundaries independently. Each timingTiming.event produces a separate + * row, including a partial row for an unusable event, with its zero-based + * event_index in device_exposure_contexts.exposures. Repeat rules without events + * retain one partial row; events plus rules produce only the event rows. + * No recurrence, continuous min/max interval, status-derived end date, or + * recordedOn fallback is inferred. Year/month precision and malformed + * individual dates leave affected columns absent. Competing timing choices + * or a provably reversed Period leave both bounds absent. Supplied precision, + * offsets, schedules, and omission/conflict reasons remain in source context + * and diagnostics. Other clinical paths retain their own timing semantics.

    + *

    device_exposure_contexts contains one entry per source record that produced + * device exposures. Its exposures array links all rows derived from that + * record by device_exposure_id and, for Timing events, event_index. Shared + * timing, device evidence, and quantity_basis appear once per source record. + * Contexts are not combined by resource id or device identity; distinct + * records remain separate even when their identifiers match or are absent.

    + *

    device_source_value uses original type/clinical coding, never inventory + * or resource identity. An unambiguous Device.udiCarrier.deviceIdentifier + * supplies unique_device_id. Competing identifiers leave it absent and are + * preserved with issuer/jurisdiction/carrier context. production_id remains + * absent; carriers, lot, serial, manufacture, and expiry evidence remain in + * device_exposure_contexts.

    + *

    DeviceUseStatement rows use device_type_concept_id 32817 (EHR), with + * provider_id, visit_occurrence_id, and visit_detail_id unset. + * Routed rows retain their source classification, explicit visit links to + * emitted same-patient visits, and compatible performer links; + * Condition/Procedure recorder/asserter and + * MedicationStatement informationSource do not supply device provider_id. + * Supplied source, encounter, context, recorder, asserter, performer, and + * informationSource values on DeviceUseStatement are retained only in + * device_exposure_contexts.

    + *

    Device exposure quantity is reported as follows:

    + *
      + *
    • When the source has no quantity, dose, rate, or numeric Observation + * result field, quantity is 1 and device_exposure_contexts.quantity_basis + * is default_absent_count.
    • + *
    • When such a field is supplied, including a null or malformed value, + * quantity is omitted and quantity_basis is unsupported_supplied_evidence. + * The original values remain in device_exposure_contexts.evidence, and + * an UNSUPPORTED diagnostic identifies each supplied field.
    • + *
    + *

    For example, an Observation with valueQuantity.value of 2 returned as a + * device exposure has no quantity; the value 2 remains in its source context. + * timingTiming.repeat.count remains in source context and does not set + * quantity. unit_concept_id and unit_source_concept_id are always null.

    + *

    Distinct eligible statements and routed clinical records remain separate, + * including overlapping use and equal Timing events. derivedFrom is retained + * as supporting context, not duplicate identity. Row counts therefore need + * not count distinct uses or physical devices. IDs are request-local exposure + * identities. Before loading a CDM dataset, callers must assign durable IDs + * and populate any required fields omitted from partial rows.

    + *

    Unsupported resource types are accepted in a Bundle but ignored: they * create no row and no dropped entry. dropped is reserved for supported * row-producing resources that cannot safely produce a positive OMOP row: * required subject/patient, clinical code/text, or medication data may be * unusable, or the resource may explicitly negate or fail the documented * clinical-event eligibility policy. A single-Patient Bundle uses the sole - * Patient only when subject/patient is absent. An explicit subject/patient + * Patient only when subject/patient is absent, except DeviceUseStatement + * and Specimen, which require explicit Patient subjects. An explicit subject/patient * reference that is unresolved, ambiguous, or unsupported drops the clinical * resource in every request scope.

    *

    Eligibility distinguishes clinical validity, performed/taken evidence, and - * administrative workflow state. Checks run before terminology resolution; - * entered-in-error and explicitly non-performed events never produce a positive - * clinical-event row, and an unrecognized required status fails closed. Condition - * requires absent or confirmed canonical-HL7 verificationStatus; clinical course is not a + * administrative workflow state. Entered-in-error and explicitly + * non-performed events never produce a positive clinical-event row, and + * unrecognized required statuses are dropped except the retained Condition and + * Encounter cases below. Condition accepts absent or confirmed canonical-HL7 + * verificationStatus without a status retention reason; uncertain or unreadable + * supplied verification is retained and indexed. Clinical course is not a * diagnosis-role mapping. Procedures accept completed or stopped events. * Medication requests are prescription evidence only: doNotPerform, drafts, * cancellations, and non-authorizing intents are dropped. Medication statements @@ -513,69 +1518,142 @@ public PhenomlClientHttpResponse create(CreateOmopRequest re * administration also needs an effectiveDateTime or effectivePeriod.start * that supplies start evidence; a valid partial date remains an undated row. * An on-hold administration is started evidence that is temporarily paused and - * expected to continue. Immunizations require completed - * status. Observations require final, + * expected to continue. MedicationDispense accepts completed records, + * including completed partial fills. Stopped records require a valid + * whenHandedOver, including partial dates, as evidence of actual handover. + * Preparation, in-progress (ready for pickup), on-hold, cancelled, declined, + * entered-in-error, unknown, missing, and unrecognized statuses are dropped. + * A completed dispense with missing or unusable handover timing remains a + * partial row; preparation time never substitutes for handover. + * Immunizations require completed + * status. Observations accept final, * amended, or corrected status; registered, - * preliminary, cancelled, entered-in-error, unknown, and missing statuses are + * preliminary, cancelled, entered-in-error, and missing statuses are * dropped. AllergyIntolerance accepts absent, unconfirmed, or confirmed canonical-HL7 * verificationStatus as a reported allergy, but drops refuted, - * entered-in-error, and unreadable supplied verification statuses. Encounter + * entered-in-error, and unreadable supplied verification statuses. Invalid + * supplied allergy type/category also drops that resource, for both coded + * and text-only statements. Encounter * accepts arrived, triaged, in-progress, onleave, or finished status, but drops - * planned, cancelled, entered-in-error, unknown, and missing statuses. An eligible - * encounter without Period.end remains a partial visit; no end date is invented.

    - *

    Coded Observation routing is selected from the resolved OMOP concept - * domain. Numeric and nonnumeric value[x] forms establish the preferred - * target only when the code is valid for both tables. A text-only - * Observation has no resolver target, so numeric values route to - * measurement and nonnumeric values to observation. Numeric values - * populate value_as_number in the selected row; other non-coded values + * planned, cancelled, and entered-in-error statuses. Unknown, missing, and unreadable + * Encounter statuses are retained and indexed without imputation. An eligible + * encounter without Period.end remains a partial visit; no end date is invented. + * MedicationStatement, Observation, and Procedure with explicit unknown + * status are also retained and indexed in rows_for_review when otherwise + * shapeable. Missing or unreadable statuses on these three resource types + * remain dropped. Retention preserves source status and does not establish + * that an event occurred or medication was taken. Device placement still + * requires eligible use evidence; unknown-status records cannot produce + * device_exposure rows.

    + *

    The mapped OMOP concept's domain determines the clinical output table. + * For Observations, numeric and nonnumeric value[x] forms determine the + * preferred table only when the code is valid for both measurement and + * observation. Unresolved Observations retain measurement rows for + * numeric values and observation rows for other values. In these two + * tables, numeric values populate value_as_number; other non-coded values * populate value_as_string for an observation or value_source_value * for a measurement. Coded valueCodeableConcept values are resolved - * against the selected row's value_as_concept_id and use the selected + * against value_as_concept_id in these tables and use the selected * bare code in value_source_value, leaving an observation's * value_as_string empty; unmapped or target-invalid coded values remain - * 0. + * 0. Active standard concepts from any OMOP domain are valid for + * value_as_concept_id per CDM 5.4. For example, a blood-culture Measurement + * can record Staphylococcus aureus as its coded result, even though the + * organism concept belongs to OMOP's Observation domain. + * For other clinical output tables, Observation values are retained only + * in mappings, without concept resolution or an omop_field, and with + * selected: false. * Other unsupported value[x] forms and Observation components do not * populate separate converted values. A * numeric comparator (<, <=, >, >=) is represented only by a - * measurement's operator_concept_id; units remain source text and have - * unit_concept_id of 0.

    + * measurement's operator_concept_id.

    + *

    For Observations returned as measurement or observation rows, + * Quantity units resolve independently against the table's + * unit_concept_id. + * Only coded UCUM (http://unitsofmeasure.org or its + * OID 2.16.840.1.113883.6.8, bare or urn:oid:) is supported. Codes are + * case-sensitive: no spelling aliases, text-only guesses, or numeric + * conversion are applied. Coded UCUM units map to valid standard OMOP + * Unit concepts using the release selected by vocab_version. + * Mappings are direct or follow an active OMOP Maps to relationship; + * UCUM units do not use a UMLS bridge. + * Unknown codes, + * vocabulary gaps, text-only units, missing coding, and unsupported + * systems keep the row and use 0; absent units use JSON null (the + * field is present). Explicit dimensionless code 1 is a supplied unit + * and resolves or remains 0; it is never treated as absent. + * unit_source_value contains Quantity.code when supplied, otherwise + * Quantity.unit. A separate unit mappings entry preserves the original + * Quantity.system/code in source_system/source_code and the exact + * human-readable Quantity.unit in source_name, with its own outcome. + * Quantity.unit provides display text and does not affect unit matching. + * Measurement also returns nullable unit_source_concept_id, identifying + * the OMOP source concept for Quantity.system/code when available, + * otherwise 0 for a supplied unit. + * Observation has no such column. Resolving a unit does not change + * numeric values, comparators, or reference ranges. + * Supported coded UCUM units include resolution in their mappings, + * whether the unit resolves or remains unmapped: + * its outcome and reason code distinguish vocabulary gaps, target-domain + * mismatches, and ambiguity. Unresolved or ambiguous units retain the row + * with concept ID 0. Resolved mappings retain the selected concept's + * direct or crosswalk provenance. Text-only units, units with missing + * coding, and unsupported unit systems omit resolution; their note + * explains the unmapped outcome.

    + *

    For Observations returned in other clinical tables, units are not resolved + * or included as separate mapping outcomes. Device exposure unit IDs remain + * null, with supplied quantity and unit evidence retained in + * device_exposure_contexts.

    + *

    Compatibility: absent unit_concept_id values previously returned 0 + * and now return null. Coded units use their code in unit_source_value + * rather than their human-readable text; the text remains in mappings. + * The Measurement unit_source_concept_id field is new.

    *

    A standard OMOP concept_id is selected for each eligible primary clinical coding * after considering all of the resource's supplied codings. An unambiguous - * coded medication route is resolved independently to - * drug_exposure.route_concept_id. Alongside the OMOP rows grouped by + * coded medication or immunization route is checked independently against + * the OMOP Route domain. For drug_exposure rows, route_concept_id + * contains the resolved concept ID, or 0 if unmapped; for other output + * tables the route is reported only in + * mappings, without omop_field and with selected: false. + * Alongside the OMOP rows grouped by * table (tables), the response carries mappings (an entry for every * supported source coding from a resource that shaped a row, linked back to * that row; some, such * as demographic null flavors, are reported without being resolved), + * rows_for_review (an index of uncertain-status or primary-concept-0 clinical + * rows in tables, with original statuses, reasons, and source/row identity), * provider_role_contexts (source role details linked to provider rows), * dropped (resources that could not be shaped into a row), * diagnostics (explicit references that could not safely create a link, - * and conflicting or unsupported Patient demographic extensions), + * conflicting Practitioner identity attributes, conflicting or unsupported + * Patient demographic extensions, and conflicting Encounter classifications), * vocab_version (the OMOP vocabulary release codes were resolved * against), and a small summary of the resolution outcomes.

    *

    A concept_id of 0 is reported, not omitted (OMOP "no matching - * concept" semantics): it covers both a coding with no standard match - * (UNMAPPED) and an unverified suggestion for a text-only resource - * (UNCHECKED). Visit and unit concept fields currently remain 0; - * Person demographics and Provider recorded gender follow the policies - * above. Coded Observation values may populate value_as_concept_id. - * Concepts set by a fixed convention rather than terminology resolution - * are measurement operator_concept_id, set from a value comparator (<, - * <=, >, >=), and the multiple-race concepts described above. Clinical - * *_source_value fields contain the selected FHIR code (or source text - * for text-only resources). + * concept" semantics): it covers a supplied concept without a validated + * standard match (UNMAPPED). + * Supported Encounter.class or Encounter.type values + * may populate visit_concept_id. Person demographics and Provider + * recorded gender follow the policies above. + * Coded Observation values and supported allergy substances may populate + * value_as_concept_id. + * Measurement operator_concept_id represents a value comparator (<, + * <=, >, >=); multiple-race concepts follow the conventions described + * above. Clinical *_source_value fields contain the selected FHIR code + * (or source text for text-only resources). * The corresponding selected mappings entry preserves the coding system * and full source-coding provenance. * Known OID-form coding systems are accepted as either FHIR OID URNs (for * example, urn:oid:2.16.840.1.113883.6.1 for LOINC) or bare OIDs, and - * are normalized to their canonical system URLs before terminology - * resolution. mappings[].source_system reports that canonical URL, so the - * OID and URL forms produce the same mapping. An unknown OID is not - * rewritten and may be UNMAPPED. + * produce the same mappings as their canonical system URLs. Coding + * mappings report that canonical URL in source_system; Quantity unit + * mappings preserve the original Quantity.system, including OID spelling. + * An unknown OID remains unchanged in source_system and may be + * UNMAPPED. * Other *_source_value fields preserve row-specific raw source values, * such as resource identifiers, names, units, or status codes. - * MedicationRequest uses 32838 (EHR prescription) for + * MedicationRequest uses 32838 (EHR prescription) and + * MedicationDispense uses 32825 (EHR dispensing record) for * drug_type_concept_id; other current resources use 32817 (EHR). This * is a coarse provenance policy: it does not infer patient-reported, * medication-history, or other more-specific type concepts from FHIR @@ -588,8 +1666,8 @@ public PhenomlClientHttpResponse create(CreateOmopRequest re * (YYYY-MM-DDTHH:MM:SS, with fractional seconds to microseconds when * supplied): 2024-01-15T23:30:00-05:00 becomes 2024-01-15T23:30:00. * A datetime without a timezone is read as local time. - *

  • A partial date (2024 or 2024-03) leaves both columns unset. It - * still counts as that source's value, so later sources in the list + *
  • A partial date (2024 or 2024-03) leaves both columns unset. + * It still counts as that source's value, so later sources in the list * below are not used.
  • *
  • Free-text timing (such as onsetString), Age and Range forms, and * values that are not dates are ignored, so a later source in the list @@ -599,11 +1677,18 @@ public PhenomlClientHttpResponse create(CreateOmopRequest re * completed, and a row is not dated from another resource such as its * Encounter.
  • *
  • Valid date values never cause a request to be rejected, and the response - * does not report which date elements were unusable. An on-hold + * reports missing, partial, malformed, or conflicting timing for Specimen, + * DeviceUseStatement, and MedicationDispense in diagnostics while retaining + * partial rows for downstream load completion. Other resources do not report which + * date elements were unusable. An on-hold * MedicationAdministration is an eligibility exception: it needs a supplied - * effective start as evidence that administration began.
  • + * effective start as evidence that administration began. + * A stopped MedicationDispense similarly requires supplied handover evidence. * - *

    Timing sources, in priority order where several are listed:

    + *

    Timing sources, in priority order where several are listed. These rules + * apply regardless of the clinical output table; end dates are populated + * only where the table has an end column, and verbatim_end_date is set + * only for medication resources returned as drug_exposure rows:

    *
      *
    • Patient: birthDate sets year_of_birth, month_of_birth, and * day_of_birth from the parts it supplies; birth_datetime is not @@ -615,6 +1700,12 @@ public PhenomlClientHttpResponse create(CreateOmopRequest re * end from abatementDateTime or abatementPeriod.end.
    • *
    • Procedure: start from performedDateTime or * performedPeriod.start; end from performedPeriod.end only.
    • + *
    • DeviceUseStatement: timingDateTime sets the start only; + * timingPeriod.start and timingPeriod.end supply independent bounds; + * each timingTiming.event produces one start-only row. A schedule + * without explicit events produces one row without dates.
    • + *
    • Specimen: collection.collectedDateTime or collection.collectedPeriod.start; + * missing day precision retains an undated specimen with diagnostics.
    • *
    • Observation: effectiveDateTime, effectivePeriod.start, or * effectiveInstant.
    • *
    • AllergyIntolerance: recordedDate, then onsetDateTime or @@ -631,21 +1722,212 @@ public PhenomlClientHttpResponse create(CreateOmopRequest re *
    • MedicationRequest: authoredOn, the order date, sets the start; it * is not evidence of administration. No end is set, and the validity * period is not used as an exposure duration.
    • + *
    • MedicationDispense: whenHandedOver sets the start. Missing, partial, + * or invalid handover leaves the start unset with a diagnostic. Comparable + * handover/preparation values proving handover before preparation also + * leave the start unset with a CONFLICTING diagnostic. whenPrepared, + * authorizing orders, and related administrations never supply timing. + * No exposure end or verbatim_end_date is inferred, including from + * daysSupply. Raw handover/preparation values retain precision and offsets + * in medication_dispense_source_contexts.source.
    • *
    • Differences from the Vulcan maps: birth_datetime is not set from * birthDate, Condition also reads onsetPeriod.start, and * AllergyIntolerance falls back to its onset when recordedDate is * missing.
    • *
    - *

    Medication details:

    + *

    Medication details for resources returned as drug_exposure rows:

    *
      *
    • For MedicationRequest, dispenseRequest.numberOfRepeatsAllowed sets * refills and a whole-day expectedSupplyDuration sets days_supply.
    • + *
    • For MedicationDispense, daysSupply sets days_supply only for a + * positive whole number of coded UCUM d days without a supplied comparator. + * The canonical UCUM URL, bare OID, and OID URN are accepted. Other units, + * fractions, text-only units, and malformed values leave it absent with a + * diagnostic; absent daysSupply is allowed. Refills are not inherited.
    • *
    • All non-empty dosage text is preserved in sig.
    • *
    • Coded dosage routes and Immunization.route are target-validated in * the OMOP Route domain. Conflicting routes are left unset; route * codings shared by every dosage instruction identify the same route.
    • *
    • Immunization.lotNumber is preserved in lot_number.
    • *
    + *

    MedicationDispense requires an explicit subject resolving to a Patient + * supplied in the request; Groups and missing subjects are unsupported. + * Source fields are based on FHIR R4 4.0.1 and reviewed against + * US Core MedicationDispense 7.0.0. + * Full profile conformance is not validated. Although US Core requires + * handover on completed records, this API retains such incomplete records + * with missing-handover diagnostics as partial conversion output. + * Supply either a usable medicationCodeableConcept (coded or text-only), + * or a medicationReference resolving to a Medication with usable code/text. + * Both choice forms together are conflicting and drop the dispense. + * Exact relative, Bundle fullUrl, and unambiguous contained Medication + * references are supported. Unresolved references are not rescued by display + * text; missing or ambiguous medication identity drops the dispense with a + * diagnostic. A usable but unmapped drug retains concept ID 0 and its + * mapping outcome in a row indexed for review. + * context links only to a supplied Encounter for the same patient. + * The first resolvable Practitioner/PractitionerRole in performer.actor + * supplies provider_id; other actor types do not supply a provider. + * Failed explicit links have diagnostics. Authorizing prescriptions are + * checked for request-local MedicationRequest identity and patient agreement; + * failures do not discard the dispense. They never supply medication, + * instructions, refills, timing, or prescription provenance to a dispense. + * Dispense instructions alone supply sig and route; conflicting routes + * remain unset with a diagnostic and original instructions in source context. + * Orders, dispenses, and administrations emit independently, with separate + * source identity and no deduplication merely because they concern the same drug. + * Every emitted dispensing drug row has a whenHandedOver/daysSupply + * diagnostic beginning medication_dispense_end_date_absent because the + * CDM-required exposure end remains absent. Missing/partial/unusable handover + * diagnostics begin medication_dispense_handover_missing, + * medication_dispense_handover_partial, or medication_dispense_handover_unusable.

    + *

    Encounter details:

    + *
      + *
    • + *

      Encounter classifications must supply both Coding.system and + * Coding.code to map to a standard OMOP Visit concept. For HL7 + * encounter class codes with system + * http://terminology.hl7.org/CodeSystem/v3-ActCode or + * http://hl7.org/fhir/v3/ActCode, the supported mappings are:

      + *
        + *
      • IMP (inpatient encounter) -> 9201 (Inpatient Visit)
      • + *
      • AMB (ambulatory encounter) -> 9202 (Outpatient Visit)
      • + *
      • EMER (emergency encounter) -> 9203 (Emergency Room Visit)
      • + *
      • VR (virtual encounter) -> 722455 (Telehealth)
      • + *
      • HH (home health encounter) -> 581476 (Home Visit)
      • + *
      + *

      OBSENC (observation encounter) has no direct class mapping. Other + * class or type codes can be used when they have a valid standard OMOP + * Visit mapping in the selected vocabulary release. Unsupported + * classifications retain visit_concept_id of 0 unless a supported + * type supplies the Visit as described below; they never default to + * outpatient.

      + *
    • + *
    • + *

      A mapped Encounter.class determines visit_concept_id. A mapped + * type that disagrees with it produces a CONFLICTING diagnostic with + * path class/type, while the class mapping remains selected. + * If class is missing or unmapped, type supplies the Visit only when + * all mapped type classifications agree on the same standard Visit + * concept. Codings within one type CodeableConcept are alternative + * encodings of that classification. When separate type classifications + * map to different Visit concepts, no type is selected, + * visit_concept_id remains 0, and a CONFLICTING diagnostic has + * path type.

      + *
    • + *
    • + *

      visit_source_value contains the selected class or type code; + * mappings preserves its coding system and identifies which + * classification was selected. visit_source_concept_id identifies + * the selected original source coding's OMOP concept when available, + * and is 0 otherwise, including for the HL7 encounter class codes + * listed above. Each eligible Encounter produces one Visit; + * classHistory, partOf, and admission/discharge concepts are not + * mapped.

      + *
    • + *
    • + *

      Direct MedicationRequest.dispenseRequest.quantity, MedicationDispense.quantity, + * MedicationAdministration.dosage.dose, and Immunization.doseQuantity + * can set quantity under the formulation policies below. Quantities + * must be positive finite numbers with a supported system/code pair + * and no supplied comparator. Ingredient amounts and injection volumes + * use http://unitsofmeasure.org (or its bare OID or + * urn:oid:2.16.840.1.113883.6.8) with case-sensitive codes matching or + * convertible to vocabulary-backed strength units. Exact unit matches, + * including [U] for insulin and heparin strengths, are accepted without + * a unit conversion. + * When codes differ, both source and strength units must belong to the + * same supported group: mass (ug, mg, g), volume (uL, mL, L), + * or milliequivalent representations (meq, 10*-3.eq). Other conversions, + * including kg, ML, Mg, [drp], mol, mmol, %, 1, and generic + * annotations such as {vial} or {syringe}, leave quantity unset with + * unit_mismatch. Codes are not case-corrected or inferred from display text. + * The original source system is preserved in the quantity audit. + * Fixed-form counts also accept the explicit codings below. Free-text, + * local, invalid, and cross-dimensional units are not interpreted.

      + *
    • + *
    + *

    Drug quantity policies follow CDM v5.4 DRUG_EXPOSURE conventions. + * The selected drug must be a Clinical Drug or Branded Drug, with one + * active dose form and valid vocabulary evidence for the chosen policy. + * Strength-based conversions also require one ingredient strength. Supported RxNorm dose forms:

    + *
      + *
    • fixed_dose_form_count: Oral Tablet (317541), Oral Capsule + * (316965), Extended Release Oral Tablet (316945), Extended + * Release Oral Capsule (316943), Delayed Release Oral Tablet (10312), + * or Delayed Release Oral Capsule (316995). A source ingredient amount is divided + * by the single positive per-unit ingredient amount after conversion to that + * strength unit when necessary. + * Concentration-based strengths cannot derive counts from ingredient amounts. The + * result is a count of the selected dose form, limited to whole units or + * multiples of one-half or one-third. Derived counts within a relative + * tolerance of 1e-12 of an allowed fraction are normalized to that count + * to remove arithmetic roundoff; other fractions remain unsupported. + * For example, 1,000 mg for a drug + * with 500 mg per tablet produces quantity: 2 tablets (DERIVED). A source + * of 1 g is converted to 1,000 mg and produces the same count. + * Explicit counts are copied (DIRECT) when their coding matches the + * selected tablet or capsule form, including the corresponding extended- + * and delayed-release forms: TAB / CAP under + * http://terminology.hl7.org/CodeSystem/v3-orderableDrugForm, or + * {tbl} / {Capsule} under http://unitsofmeasure.org. These exact + * case-sensitive count codings identify the supplied form; they are not + * dimensionally converted as UCUM units. Direct counts do not require + * ingredient strength: combination products and missing/invalid strengths + * do not change the supplied count. Ingredient-strength gaps, including + * missing ingredient or strength-unit references, do not prevent copying + * an explicit count. Drug or dose-form evidence gaps, unclassified or + * ambiguous forms, and gaps whose relevance cannot be established still + * prevent conversion. The whole/half/third rules still apply. + * For example, 20 TAB for amoxicillin/clavulanate is 20 tablets, and 30 TAB for + * a supported tablet produces quantity: 30; CAP for that tablet is + * omitted with unit_mismatch. Generic counts (1, {Each}), other + * annotations, packs, and free-text counts are not interpreted. + * See the FHIR R4 count example.
    • + *
    • clinical_injection_ingredient_amount: Injectable Solution (316949), + * Injectable Suspension (316950), or Injection (1649574). A source + * ingredient amount is converted to the vocabulary ingredient unit when + * necessary; an exact-unit amount is copied directly. Otherwise, a source + * amount convertible to the concentration's denominator unit is converted + * and multiplied by numerator value / the effective denominator reported in + * the audit evidence. Volume conversion requires a positive finite numerator, + * dimensionally distinct validated UCUM units, a null raw denominator value, and + * evidence.strength.denominator_basis with kind: PER_UNIT and + * value: 1. A null denominator alone is not interpreted as 1. Missing, unknown, + * non-applicable, quantified, or conflicting denominator evidence does + * not support volume conversion. For example, 80 mL with 2 mg/mL + * strength and a PER_UNIT basis produces quantity: 160 mg. A supplied + * 160 mg amount for the same drug produces quantity: 160 directly. A source + * of 0.08 L converts to 80 mL before producing the same 160 mg result. + * This output is already + * the ingredient amount: do not apply the drug-dose guide's + * dose = quantity × numerator formula to these injection rows, which + * would multiply by the concentration a second time. Use + * quantity_transformations.result_unit to identify the output unit.
    • + *
    + *

    Quantified products, packs, prefilled syringes, other liquids and dose + * forms, strength-based conversions of combination products, conflicting or incomplete required evidence, and + * unsupported source quantities leave quantity unset. Other supported + * fields and the clinical row are still returned. For example, a quantity + * supplied only as value: 2, unit: tablet is omitted with + * unsupported_unit, because a free-text unit cannot establish this policy. + * Rate-only administrations and MedicationStatement dosages do not establish + * a supported total quantity and are not used for quantity conversion.

    + *

    quantity_transformations audits each supplied quantity at one of the + * three direct paths above on a resource returned as a drug_exposure row, + * including an explicit null or malformed quantity. Each entry preserves + * the source, identifies the destination row, and reports DIRECT, DERIVED, or + * OMITTED. Successful entries identify their policy, evidence, result, + * and result unit; omitted entries give a stable reason. When a source code + * differs from the strength unit, unit_conversion records the intermediate + * converted amount and target unit. Converted results are DERIVED, even + * when no further concentration arithmetic is needed. A successful conversion + * can also be reported when a later policy check omits the final quantity. + * Dose-form and unit + * concept IDs refer to the response's vocab_version. Missing quantities + * produce no entry. Resources returned in other clinical tables have no + * quantity entry. Unsupported rates and statement dosages produce no entry, + * and resources in dropped have no quantity entry or destination row.

    *

    Medication codes are resolved whether they appear inline * (medicationCodeableConcept) or via a medicationReference to a contained, * relative (Type/id), or bundle-entry (urn:uuid) Medication resource. @@ -657,11 +1939,12 @@ public PhenomlClientHttpResponse create(CreateOmopRequest re * subject/patient reference cannot be tied to a person; or an event excluded * by eligibility — are reported under dropped rather than emitted as blank * rows. The Bundle must contain at least one Patient resource.

    - *

    Structural references resolve only to top-level resources supplied in the - * request, by Type/id or an exactly matching Bundle fullUrl (including - * urn:uuid). Contained references are supported for medication code lookup - * and PractitionerRole.practitioner enrichment; the latter also supports - * exact request-local identifier matching without a remote lookup. Every + *

    References to top-level resources supplied in the request resolve by + * Type/id or an exactly matching Bundle fullUrl (including urn:uuid). + * Parent-scoped contained references are supported for medication code lookup, + * DeviceUseStatement.device details, and PractitionerRole.practitioner + * enrichment; the latter also supports + * request-local identifier matching with the NPI rules above, without a remote lookup. Every * nonzero structural foreign key targets a row in the same response. Missing * optional links remain unset without a diagnostic; an explicit optional * reference that is unresolved, ambiguous, conflicting with the row's @@ -690,7 +1973,9 @@ public PhenomlClientHttpResponse create( RequestBody body; try { body = RequestBody.create( - ObjectMappers.JSON_MAPPER.writeValueAsBytes(request), MediaTypes.APPLICATION_JSON); + ObjectMappers.JSON_MAPPER.writeValueAsBytes(BodyProperties.merge( + request, requestOptions != null ? requestOptions.getBodyProperties() : null)), + MediaTypes.APPLICATION_JSON); } catch (JsonProcessingException e) { throw new PhenomlClientException("Failed to serialize request", e); } diff --git a/src/main/java/com/phenoml/api/resources/fhir2omop/requests/CreateOmopRequest.java b/src/main/java/com/phenoml/api/resources/fhir2omop/requests/CreateOmopRequest.java index d96cb4ba..4b5a7da0 100644 --- a/src/main/java/com/phenoml/api/resources/fhir2omop/requests/CreateOmopRequest.java +++ b/src/main/java/com/phenoml/api/resources/fhir2omop/requests/CreateOmopRequest.java @@ -38,8 +38,8 @@ private CreateOmopRequest( /** * @return OMOP vocabulary release to use for coded concept resolution. If - * omitted or empty, the API uses its default release. Specify a - * release explicitly when reproducibility matters. The response's + * omitted or empty, the API uses its default release (v20260829). + * Specify a release explicitly when reproducibility matters. The response's * vocab_version, when present, identifies the release used. */ @JsonProperty("vocab_version") @@ -51,11 +51,11 @@ public Optional getVocabVersion() { * @return FHIR resources (single resource or Bundle). Must contain at least one * Patient resource. Supported row-producing resources are Patient, * Location, Organization, HealthcareService, Practitioner, - * PractitionerRole, Encounter, Condition, Procedure, MedicationRequest, - * MedicationStatement, MedicationAdministration, Immunization, - * Observation, and AllergyIntolerance. Standalone Medication resources - * are consumed by medication references rather than mapped to their own - * table. Unsupported resource types are accepted in a Bundle but ignored. + * PractitionerRole, Encounter, Specimen, DeviceUseStatement, Condition, Procedure, + * MedicationRequest, MedicationStatement, MedicationAdministration, MedicationDispense, + * Immunization, Observation, and AllergyIntolerance. Medication and Device + * resources provide referenced details without independently producing rows. + * Unsupported resource types are accepted in a Bundle but ignored. */ @JsonProperty("fhir_resources") public Map getFhirResources() { @@ -97,7 +97,6 @@ public static final class Builder { private Map fhirResources = new LinkedHashMap<>(); - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -110,8 +109,8 @@ public Builder from(CreateOmopRequest other) { /** *

    OMOP vocabulary release to use for coded concept resolution. If - * omitted or empty, the API uses its default release. Specify a - * release explicitly when reproducibility matters. The response's + * omitted or empty, the API uses its default release (v20260829). + * Specify a release explicitly when reproducibility matters. The response's * vocab_version, when present, identifies the release used.

    */ @JsonSetter(value = "vocab_version", nulls = Nulls.SKIP) @@ -129,11 +128,11 @@ public Builder vocabVersion(String vocabVersion) { *

    FHIR resources (single resource or Bundle). Must contain at least one * Patient resource. Supported row-producing resources are Patient, * Location, Organization, HealthcareService, Practitioner, - * PractitionerRole, Encounter, Condition, Procedure, MedicationRequest, - * MedicationStatement, MedicationAdministration, Immunization, - * Observation, and AllergyIntolerance. Standalone Medication resources - * are consumed by medication references rather than mapped to their own - * table. Unsupported resource types are accepted in a Bundle but ignored.

    + * PractitionerRole, Encounter, Specimen, DeviceUseStatement, Condition, Procedure, + * MedicationRequest, MedicationStatement, MedicationAdministration, MedicationDispense, + * Immunization, Observation, and AllergyIntolerance. Medication and Device + * resources provide referenced details without independently producing rows. + * Unsupported resource types are accepted in a Bundle but ignored.

    */ @JsonSetter(value = "fhir_resources", nulls = Nulls.SKIP) public Builder fhirResources(Map fhirResources) { @@ -160,6 +159,14 @@ public CreateOmopRequest build() { return new CreateOmopRequest(vocabVersion, fhirResources, additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("vocab_version") || key.equalsIgnoreCase("fhir_resources")) { + return; + } + this.additionalProperties.put(key, value); + } + public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); return this; diff --git a/src/main/java/com/phenoml/api/resources/fhir2omop/types/AllergyCodingEvidence.java b/src/main/java/com/phenoml/api/resources/fhir2omop/types/AllergyCodingEvidence.java new file mode 100644 index 00000000..5d3ebe44 --- /dev/null +++ b/src/main/java/com/phenoml/api/resources/fhir2omop/types/AllergyCodingEvidence.java @@ -0,0 +1,356 @@ +/** + * This file was auto-generated by Fern from our API Definition. + */ +package com.phenoml.api.resources.fhir2omop.types; + +import com.fasterxml.jackson.annotation.JsonAnyGetter; +import com.fasterxml.jackson.annotation.JsonAnySetter; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonSetter; +import com.fasterxml.jackson.annotation.Nulls; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.phenoml.api.core.ObjectMappers; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import org.jetbrains.annotations.NotNull; + +@JsonInclude(JsonInclude.Include.NON_ABSENT) +@JsonDeserialize(builder = AllergyCodingEvidence.Builder.class) +public final class AllergyCodingEvidence { + private final Coding coding; + + private final AllergyCodingEvidenceMeaning meaning; + + private final Optional sourceConceptId; + + private final List standardConceptIds; + + private final Optional category; + + private final Optional reasonCode; + + private final Map additionalProperties; + + private AllergyCodingEvidence( + Coding coding, + AllergyCodingEvidenceMeaning meaning, + Optional sourceConceptId, + List standardConceptIds, + Optional category, + Optional reasonCode, + Map additionalProperties) { + this.coding = coding; + this.meaning = meaning; + this.sourceConceptId = sourceConceptId; + this.standardConceptIds = standardConceptIds; + this.category = category; + this.reasonCode = reasonCode; + this.additionalProperties = additionalProperties; + } + + @JsonProperty("coding") + public Coding getCoding() { + return coding; + } + + @JsonProperty("meaning") + public AllergyCodingEvidenceMeaning getMeaning() { + return meaning; + } + + /** + * @return Original coding's OMOP source identity, when known. This evidence does not override the linked row's source-concept semantics. + */ + @JsonProperty("source_concept_id") + public Optional getSourceConceptId() { + return sourceConceptId; + } + + /** + * @return Standard targets reported for the original code. Empty when none are available; multiple targets do not choose one ingredient as the complete substance value. + */ + @JsonProperty("standard_concept_ids") + public List getStandardConceptIds() { + return standardConceptIds; + } + + /** + * @return Reviewed substance category, when available. + */ + @JsonProperty("category") + public Optional getCategory() { + return category; + } + + /** + * @return Machine-readable interpretation gap, when available. + */ + @JsonProperty("reason_code") + public Optional getReasonCode() { + return reasonCode; + } + + @java.lang.Override + public boolean equals(Object other) { + if (this == other) return true; + return other instanceof AllergyCodingEvidence && equalTo((AllergyCodingEvidence) other); + } + + @JsonAnyGetter + public Map getAdditionalProperties() { + return this.additionalProperties; + } + + private boolean equalTo(AllergyCodingEvidence other) { + return coding.equals(other.coding) + && meaning.equals(other.meaning) + && sourceConceptId.equals(other.sourceConceptId) + && standardConceptIds.equals(other.standardConceptIds) + && category.equals(other.category) + && reasonCode.equals(other.reasonCode); + } + + @java.lang.Override + public int hashCode() { + return Objects.hash( + this.coding, + this.meaning, + this.sourceConceptId, + this.standardConceptIds, + this.category, + this.reasonCode); + } + + @java.lang.Override + public String toString() { + return ObjectMappers.stringify(this); + } + + public static CodingStage builder() { + return new Builder(); + } + + public interface CodingStage { + MeaningStage coding(@NotNull Coding coding); + + Builder from(AllergyCodingEvidence other); + } + + public interface MeaningStage { + _FinalStage meaning(@NotNull AllergyCodingEvidenceMeaning meaning); + } + + public interface _FinalStage { + AllergyCodingEvidence build(); + + _FinalStage additionalProperty(String key, Object value); + + _FinalStage additionalProperties(Map additionalProperties); + + /** + *

    Original coding's OMOP source identity, when known. This evidence does not override the linked row's source-concept semantics.

    + */ + _FinalStage sourceConceptId(Optional sourceConceptId); + + _FinalStage sourceConceptId(Long sourceConceptId); + + /** + *

    Standard targets reported for the original code. Empty when none are available; multiple targets do not choose one ingredient as the complete substance value.

    + */ + _FinalStage standardConceptIds(List standardConceptIds); + + _FinalStage addStandardConceptIds(Long standardConceptIds); + + _FinalStage addAllStandardConceptIds(List standardConceptIds); + + /** + *

    Reviewed substance category, when available.

    + */ + _FinalStage category(Optional category); + + _FinalStage category(String category); + + /** + *

    Machine-readable interpretation gap, when available.

    + */ + _FinalStage reasonCode(Optional reasonCode); + + _FinalStage reasonCode(String reasonCode); + } + + @JsonIgnoreProperties(ignoreUnknown = true) + public static final class Builder implements CodingStage, MeaningStage, _FinalStage { + private Coding coding; + + private AllergyCodingEvidenceMeaning meaning; + + private Optional reasonCode = Optional.empty(); + + private Optional category = Optional.empty(); + + private List standardConceptIds = new ArrayList<>(); + + private Optional sourceConceptId = Optional.empty(); + + private Map additionalProperties = new HashMap<>(); + + private Builder() {} + + @java.lang.Override + public Builder from(AllergyCodingEvidence other) { + coding(other.getCoding()); + meaning(other.getMeaning()); + sourceConceptId(other.getSourceConceptId()); + standardConceptIds(other.getStandardConceptIds()); + category(other.getCategory()); + reasonCode(other.getReasonCode()); + return this; + } + + @java.lang.Override + @JsonSetter("coding") + public MeaningStage coding(@NotNull Coding coding) { + this.coding = Objects.requireNonNull(coding, "coding must not be null"); + return this; + } + + @java.lang.Override + @JsonSetter("meaning") + public _FinalStage meaning(@NotNull AllergyCodingEvidenceMeaning meaning) { + this.meaning = Objects.requireNonNull(meaning, "meaning must not be null"); + return this; + } + + /** + *

    Machine-readable interpretation gap, when available.

    + * @return Reference to {@code this} so that method calls can be chained together. + */ + @java.lang.Override + public _FinalStage reasonCode(String reasonCode) { + this.reasonCode = Optional.ofNullable(reasonCode); + return this; + } + + /** + *

    Machine-readable interpretation gap, when available.

    + */ + @java.lang.Override + @JsonSetter(value = "reason_code", nulls = Nulls.SKIP) + public _FinalStage reasonCode(Optional reasonCode) { + this.reasonCode = reasonCode; + return this; + } + + /** + *

    Reviewed substance category, when available.

    + * @return Reference to {@code this} so that method calls can be chained together. + */ + @java.lang.Override + public _FinalStage category(String category) { + this.category = Optional.ofNullable(category); + return this; + } + + /** + *

    Reviewed substance category, when available.

    + */ + @java.lang.Override + @JsonSetter(value = "category", nulls = Nulls.SKIP) + public _FinalStage category(Optional category) { + this.category = category; + return this; + } + + /** + *

    Standard targets reported for the original code. Empty when none are available; multiple targets do not choose one ingredient as the complete substance value.

    + * @return Reference to {@code this} so that method calls can be chained together. + */ + @java.lang.Override + public _FinalStage addAllStandardConceptIds(List standardConceptIds) { + if (standardConceptIds != null) { + this.standardConceptIds.addAll(standardConceptIds); + } + return this; + } + + /** + *

    Standard targets reported for the original code. Empty when none are available; multiple targets do not choose one ingredient as the complete substance value.

    + * @return Reference to {@code this} so that method calls can be chained together. + */ + @java.lang.Override + public _FinalStage addStandardConceptIds(Long standardConceptIds) { + this.standardConceptIds.add(standardConceptIds); + return this; + } + + /** + *

    Standard targets reported for the original code. Empty when none are available; multiple targets do not choose one ingredient as the complete substance value.

    + */ + @java.lang.Override + @JsonSetter(value = "standard_concept_ids", nulls = Nulls.SKIP) + public _FinalStage standardConceptIds(List standardConceptIds) { + this.standardConceptIds.clear(); + if (standardConceptIds != null) { + this.standardConceptIds.addAll(standardConceptIds); + } + return this; + } + + /** + *

    Original coding's OMOP source identity, when known. This evidence does not override the linked row's source-concept semantics.

    + * @return Reference to {@code this} so that method calls can be chained together. + */ + @java.lang.Override + public _FinalStage sourceConceptId(Long sourceConceptId) { + this.sourceConceptId = Optional.ofNullable(sourceConceptId); + return this; + } + + /** + *

    Original coding's OMOP source identity, when known. This evidence does not override the linked row's source-concept semantics.

    + */ + @java.lang.Override + @JsonSetter(value = "source_concept_id", nulls = Nulls.SKIP) + public _FinalStage sourceConceptId(Optional sourceConceptId) { + this.sourceConceptId = sourceConceptId; + return this; + } + + @java.lang.Override + public AllergyCodingEvidence build() { + return new AllergyCodingEvidence( + coding, meaning, sourceConceptId, standardConceptIds, category, reasonCode, additionalProperties); + } + + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("coding") + || key.equalsIgnoreCase("meaning") + || key.equalsIgnoreCase("source_concept_id") + || key.equalsIgnoreCase("standard_concept_ids") + || key.equalsIgnoreCase("category") + || key.equalsIgnoreCase("reason_code")) { + return; + } + this.additionalProperties.put(key, value); + } + + @java.lang.Override + public Builder additionalProperty(String key, Object value) { + this.additionalProperties.put(key, value); + return this; + } + + @java.lang.Override + public Builder additionalProperties(Map additionalProperties) { + this.additionalProperties.putAll(additionalProperties); + return this; + } + } +} diff --git a/src/main/java/com/phenoml/api/resources/fhir2omop/types/AllergyCodingEvidenceMeaning.java b/src/main/java/com/phenoml/api/resources/fhir2omop/types/AllergyCodingEvidenceMeaning.java new file mode 100644 index 00000000..e5ee8476 --- /dev/null +++ b/src/main/java/com/phenoml/api/resources/fhir2omop/types/AllergyCodingEvidenceMeaning.java @@ -0,0 +1,119 @@ +/** + * This file was auto-generated by Fern from our API Definition. + */ +package com.phenoml.api.resources.fhir2omop.types; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; + +public final class AllergyCodingEvidenceMeaning { + public static final AllergyCodingEvidenceMeaning NEGATIVE = + new AllergyCodingEvidenceMeaning(Value.NEGATIVE, "NEGATIVE"); + + public static final AllergyCodingEvidenceMeaning SUBSTANCE = + new AllergyCodingEvidenceMeaning(Value.SUBSTANCE, "SUBSTANCE"); + + public static final AllergyCodingEvidenceMeaning UNINTERPRETABLE = + new AllergyCodingEvidenceMeaning(Value.UNINTERPRETABLE, "UNINTERPRETABLE"); + + public static final AllergyCodingEvidenceMeaning UNSUPPORTED = + new AllergyCodingEvidenceMeaning(Value.UNSUPPORTED, "UNSUPPORTED"); + + public static final AllergyCodingEvidenceMeaning FINDING = + new AllergyCodingEvidenceMeaning(Value.FINDING, "FINDING"); + + private final Value value; + + private final String string; + + AllergyCodingEvidenceMeaning(Value value, String string) { + this.value = value; + this.string = string; + } + + public Value getEnumValue() { + return value; + } + + @java.lang.Override + @JsonValue + public String toString() { + return this.string; + } + + @java.lang.Override + public boolean equals(Object other) { + return (this == other) + || (other instanceof AllergyCodingEvidenceMeaning + && this.string.equals(((AllergyCodingEvidenceMeaning) other).string)); + } + + @java.lang.Override + public int hashCode() { + return this.string.hashCode(); + } + + public T visit(Visitor visitor) { + switch (value) { + case NEGATIVE: + return visitor.visitNegative(); + case SUBSTANCE: + return visitor.visitSubstance(); + case UNINTERPRETABLE: + return visitor.visitUninterpretable(); + case UNSUPPORTED: + return visitor.visitUnsupported(); + case FINDING: + return visitor.visitFinding(); + case UNKNOWN: + default: + return visitor.visitUnknown(string); + } + } + + @JsonCreator(mode = JsonCreator.Mode.DELEGATING) + public static AllergyCodingEvidenceMeaning valueOf(String value) { + switch (value) { + case "NEGATIVE": + return NEGATIVE; + case "SUBSTANCE": + return SUBSTANCE; + case "UNINTERPRETABLE": + return UNINTERPRETABLE; + case "UNSUPPORTED": + return UNSUPPORTED; + case "FINDING": + return FINDING; + default: + return new AllergyCodingEvidenceMeaning(Value.UNKNOWN, value); + } + } + + public enum Value { + SUBSTANCE, + + FINDING, + + NEGATIVE, + + UNSUPPORTED, + + UNINTERPRETABLE, + + UNKNOWN + } + + public interface Visitor { + T visitSubstance(); + + T visitFinding(); + + T visitNegative(); + + T visitUnsupported(); + + T visitUninterpretable(); + + T visitUnknown(String unknownType); + } +} diff --git a/src/main/java/com/phenoml/api/resources/fhir2omop/types/AllergyMappingResolution.java b/src/main/java/com/phenoml/api/resources/fhir2omop/types/AllergyMappingResolution.java new file mode 100644 index 00000000..21ce1cc1 --- /dev/null +++ b/src/main/java/com/phenoml/api/resources/fhir2omop/types/AllergyMappingResolution.java @@ -0,0 +1,711 @@ +/** + * This file was auto-generated by Fern from our API Definition. + */ +package com.phenoml.api.resources.fhir2omop.types; + +import com.fasterxml.jackson.annotation.JsonAnyGetter; +import com.fasterxml.jackson.annotation.JsonAnySetter; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonSetter; +import com.fasterxml.jackson.annotation.Nulls; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.phenoml.api.core.ObjectMappers; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import org.jetbrains.annotations.NotNull; + +@JsonInclude(JsonInclude.Include.NON_ABSENT) +@JsonDeserialize(builder = AllergyMappingResolution.Builder.class) +public final class AllergyMappingResolution { + private final AllergyMappingResolutionMethod method; + + private final AllergyMappingResolutionStatus status; + + private final Optional reasonCode; + + private final Optional type; + + private final List categories; + + private final Optional> clinicalStatus; + + private final Optional> verificationStatus; + + private final Optional dateSource; + + private final List effectiveCategories; + + private final boolean categoryDerived; + + private final boolean incomplete; + + private final List informationLoss; + + private final List evidence; + + private final Map additionalProperties; + + private AllergyMappingResolution( + AllergyMappingResolutionMethod method, + AllergyMappingResolutionStatus status, + Optional reasonCode, + Optional type, + List categories, + Optional> clinicalStatus, + Optional> verificationStatus, + Optional dateSource, + List effectiveCategories, + boolean categoryDerived, + boolean incomplete, + List informationLoss, + List evidence, + Map additionalProperties) { + this.method = method; + this.status = status; + this.reasonCode = reasonCode; + this.type = type; + this.categories = categories; + this.clinicalStatus = clinicalStatus; + this.verificationStatus = verificationStatus; + this.dateSource = dateSource; + this.effectiveCategories = effectiveCategories; + this.categoryDerived = categoryDerived; + this.incomplete = incomplete; + this.informationLoss = informationLoss; + this.evidence = evidence; + this.additionalProperties = additionalProperties; + } + + /** + * @return Direct vocabulary finding mapping, contextual finding selection, selection using a derived category, or no contextual finding selected. + */ + @JsonProperty("method") + public AllergyMappingResolutionMethod getMethod() { + return method; + } + + @JsonProperty("status") + public AllergyMappingResolutionStatus getStatus() { + return status; + } + + /** + * @return Machine-readable reason for the contextual policy outcome, when available. + */ + @JsonProperty("reason_code") + public Optional getReasonCode() { + return reasonCode; + } + + /** + * @return Supplied FHIR type; omitted when absent. + */ + @JsonProperty("type") + public Optional getType() { + return type; + } + + /** + * @return Supplied FHIR category values, preserving their order and repeats; null primitive placeholders are omitted. Empty when no category value is supplied. + */ + @JsonProperty("categories") + public List getCategories() { + return categories; + } + + /** + * @return Supplied clinical-status codings; omitted when none are available. + */ + @JsonProperty("clinical_status") + public Optional> getClinicalStatus() { + return clinicalStatus; + } + + /** + * @return Supplied verification-status codings; omitted when absent. A retained unconfirmed statement is not promoted to confirmed. + */ + @JsonProperty("verification_status") + public Optional> getVerificationStatus() { + return verificationStatus; + } + + /** + * @return Source element selected by the existing date rule, including when its value has partial precision. Omitted when no usable or partial date is supplied; no date is invented. + */ + @JsonProperty("date_source") + public Optional getDateSource() { + return dateSource; + } + + /** + * @return Distinct sorted categories used by the contextual policy, including any reviewed derivation. + */ + @JsonProperty("effective_categories") + public List getEffectiveCategories() { + return effectiveCategories; + } + + /** + * @return Whether reviewed vocabulary evidence supplied an absent category. This can also accompany a direct finding. + */ + @JsonProperty("category_derived") + public boolean getCategoryDerived() { + return categoryDerived; + } + + /** + * @return Whether some coding evidence was uninterpretable or outside policy coverage. + */ + @JsonProperty("incomplete") + public boolean getIncomplete() { + return incomplete; + } + + /** + * @return Explanations of specificity omitted by the selected finding or published mapping. Source coding and context remain in the audit metadata. + */ + @JsonProperty("information_loss") + public List getInformationLoss() { + return informationLoss; + } + + @JsonProperty("evidence") + public List getEvidence() { + return evidence; + } + + @java.lang.Override + public boolean equals(Object other) { + if (this == other) return true; + return other instanceof AllergyMappingResolution && equalTo((AllergyMappingResolution) other); + } + + @JsonAnyGetter + public Map getAdditionalProperties() { + return this.additionalProperties; + } + + private boolean equalTo(AllergyMappingResolution other) { + return method.equals(other.method) + && status.equals(other.status) + && reasonCode.equals(other.reasonCode) + && type.equals(other.type) + && categories.equals(other.categories) + && clinicalStatus.equals(other.clinicalStatus) + && verificationStatus.equals(other.verificationStatus) + && dateSource.equals(other.dateSource) + && effectiveCategories.equals(other.effectiveCategories) + && categoryDerived == other.categoryDerived + && incomplete == other.incomplete + && informationLoss.equals(other.informationLoss) + && evidence.equals(other.evidence); + } + + @java.lang.Override + public int hashCode() { + return Objects.hash( + this.method, + this.status, + this.reasonCode, + this.type, + this.categories, + this.clinicalStatus, + this.verificationStatus, + this.dateSource, + this.effectiveCategories, + this.categoryDerived, + this.incomplete, + this.informationLoss, + this.evidence); + } + + @java.lang.Override + public String toString() { + return ObjectMappers.stringify(this); + } + + public static MethodStage builder() { + return new Builder(); + } + + public interface MethodStage { + /** + *

    Direct vocabulary finding mapping, contextual finding selection, selection using a derived category, or no contextual finding selected.

    + */ + StatusStage method(@NotNull AllergyMappingResolutionMethod method); + + Builder from(AllergyMappingResolution other); + } + + public interface StatusStage { + CategoryDerivedStage status(@NotNull AllergyMappingResolutionStatus status); + } + + public interface CategoryDerivedStage { + /** + *

    Whether reviewed vocabulary evidence supplied an absent category. This can also accompany a direct finding.

    + */ + IncompleteStage categoryDerived(boolean categoryDerived); + } + + public interface IncompleteStage { + /** + *

    Whether some coding evidence was uninterpretable or outside policy coverage.

    + */ + _FinalStage incomplete(boolean incomplete); + } + + public interface _FinalStage { + AllergyMappingResolution build(); + + _FinalStage additionalProperty(String key, Object value); + + _FinalStage additionalProperties(Map additionalProperties); + + /** + *

    Machine-readable reason for the contextual policy outcome, when available.

    + */ + _FinalStage reasonCode(Optional reasonCode); + + _FinalStage reasonCode(String reasonCode); + + /** + *

    Supplied FHIR type; omitted when absent.

    + */ + _FinalStage type(Optional type); + + _FinalStage type(AllergyMappingResolutionType type); + + /** + *

    Supplied FHIR category values, preserving their order and repeats; null primitive placeholders are omitted. Empty when no category value is supplied.

    + */ + _FinalStage categories(List categories); + + _FinalStage addCategories(AllergyMappingResolutionCategoriesItem categories); + + _FinalStage addAllCategories(List categories); + + /** + *

    Supplied clinical-status codings; omitted when none are available.

    + */ + _FinalStage clinicalStatus(Optional> clinicalStatus); + + _FinalStage clinicalStatus(List clinicalStatus); + + /** + *

    Supplied verification-status codings; omitted when absent. A retained unconfirmed statement is not promoted to confirmed.

    + */ + _FinalStage verificationStatus(Optional> verificationStatus); + + _FinalStage verificationStatus(List verificationStatus); + + /** + *

    Source element selected by the existing date rule, including when its value has partial precision. Omitted when no usable or partial date is supplied; no date is invented.

    + */ + _FinalStage dateSource(Optional dateSource); + + _FinalStage dateSource(AllergyMappingResolutionDateSource dateSource); + + /** + *

    Distinct sorted categories used by the contextual policy, including any reviewed derivation.

    + */ + _FinalStage effectiveCategories(List effectiveCategories); + + _FinalStage addEffectiveCategories(String effectiveCategories); + + _FinalStage addAllEffectiveCategories(List effectiveCategories); + + /** + *

    Explanations of specificity omitted by the selected finding or published mapping. Source coding and context remain in the audit metadata.

    + */ + _FinalStage informationLoss(List informationLoss); + + _FinalStage addInformationLoss(String informationLoss); + + _FinalStage addAllInformationLoss(List informationLoss); + + _FinalStage evidence(List evidence); + + _FinalStage addEvidence(AllergyCodingEvidence evidence); + + _FinalStage addAllEvidence(List evidence); + } + + @JsonIgnoreProperties(ignoreUnknown = true) + public static final class Builder + implements MethodStage, StatusStage, CategoryDerivedStage, IncompleteStage, _FinalStage { + private AllergyMappingResolutionMethod method; + + private AllergyMappingResolutionStatus status; + + private boolean categoryDerived; + + private boolean incomplete; + + private List evidence = new ArrayList<>(); + + private List informationLoss = new ArrayList<>(); + + private List effectiveCategories = new ArrayList<>(); + + private Optional dateSource = Optional.empty(); + + private Optional> verificationStatus = Optional.empty(); + + private Optional> clinicalStatus = Optional.empty(); + + private List categories = new ArrayList<>(); + + private Optional type = Optional.empty(); + + private Optional reasonCode = Optional.empty(); + + private Map additionalProperties = new HashMap<>(); + + private Builder() {} + + @java.lang.Override + public Builder from(AllergyMappingResolution other) { + method(other.getMethod()); + status(other.getStatus()); + reasonCode(other.getReasonCode()); + type(other.getType()); + categories(other.getCategories()); + clinicalStatus(other.getClinicalStatus()); + verificationStatus(other.getVerificationStatus()); + dateSource(other.getDateSource()); + effectiveCategories(other.getEffectiveCategories()); + categoryDerived(other.getCategoryDerived()); + incomplete(other.getIncomplete()); + informationLoss(other.getInformationLoss()); + evidence(other.getEvidence()); + return this; + } + + /** + *

    Direct vocabulary finding mapping, contextual finding selection, selection using a derived category, or no contextual finding selected.

    + * @return Reference to {@code this} so that method calls can be chained together. + */ + @java.lang.Override + @JsonSetter("method") + public StatusStage method(@NotNull AllergyMappingResolutionMethod method) { + this.method = Objects.requireNonNull(method, "method must not be null"); + return this; + } + + @java.lang.Override + @JsonSetter("status") + public CategoryDerivedStage status(@NotNull AllergyMappingResolutionStatus status) { + this.status = Objects.requireNonNull(status, "status must not be null"); + return this; + } + + /** + *

    Whether reviewed vocabulary evidence supplied an absent category. This can also accompany a direct finding.

    + * @return Reference to {@code this} so that method calls can be chained together. + */ + @java.lang.Override + @JsonSetter("category_derived") + public IncompleteStage categoryDerived(boolean categoryDerived) { + this.categoryDerived = categoryDerived; + return this; + } + + /** + *

    Whether some coding evidence was uninterpretable or outside policy coverage.

    + * @return Reference to {@code this} so that method calls can be chained together. + */ + @java.lang.Override + @JsonSetter("incomplete") + public _FinalStage incomplete(boolean incomplete) { + this.incomplete = incomplete; + return this; + } + + @java.lang.Override + public _FinalStage addAllEvidence(List evidence) { + if (evidence != null) { + this.evidence.addAll(evidence); + } + return this; + } + + @java.lang.Override + public _FinalStage addEvidence(AllergyCodingEvidence evidence) { + this.evidence.add(evidence); + return this; + } + + @java.lang.Override + @JsonSetter(value = "evidence", nulls = Nulls.SKIP) + public _FinalStage evidence(List evidence) { + this.evidence.clear(); + if (evidence != null) { + this.evidence.addAll(evidence); + } + return this; + } + + /** + *

    Explanations of specificity omitted by the selected finding or published mapping. Source coding and context remain in the audit metadata.

    + * @return Reference to {@code this} so that method calls can be chained together. + */ + @java.lang.Override + public _FinalStage addAllInformationLoss(List informationLoss) { + if (informationLoss != null) { + this.informationLoss.addAll(informationLoss); + } + return this; + } + + /** + *

    Explanations of specificity omitted by the selected finding or published mapping. Source coding and context remain in the audit metadata.

    + * @return Reference to {@code this} so that method calls can be chained together. + */ + @java.lang.Override + public _FinalStage addInformationLoss(String informationLoss) { + this.informationLoss.add(informationLoss); + return this; + } + + /** + *

    Explanations of specificity omitted by the selected finding or published mapping. Source coding and context remain in the audit metadata.

    + */ + @java.lang.Override + @JsonSetter(value = "information_loss", nulls = Nulls.SKIP) + public _FinalStage informationLoss(List informationLoss) { + this.informationLoss.clear(); + if (informationLoss != null) { + this.informationLoss.addAll(informationLoss); + } + return this; + } + + /** + *

    Distinct sorted categories used by the contextual policy, including any reviewed derivation.

    + * @return Reference to {@code this} so that method calls can be chained together. + */ + @java.lang.Override + public _FinalStage addAllEffectiveCategories(List effectiveCategories) { + if (effectiveCategories != null) { + this.effectiveCategories.addAll(effectiveCategories); + } + return this; + } + + /** + *

    Distinct sorted categories used by the contextual policy, including any reviewed derivation.

    + * @return Reference to {@code this} so that method calls can be chained together. + */ + @java.lang.Override + public _FinalStage addEffectiveCategories(String effectiveCategories) { + this.effectiveCategories.add(effectiveCategories); + return this; + } + + /** + *

    Distinct sorted categories used by the contextual policy, including any reviewed derivation.

    + */ + @java.lang.Override + @JsonSetter(value = "effective_categories", nulls = Nulls.SKIP) + public _FinalStage effectiveCategories(List effectiveCategories) { + this.effectiveCategories.clear(); + if (effectiveCategories != null) { + this.effectiveCategories.addAll(effectiveCategories); + } + return this; + } + + /** + *

    Source element selected by the existing date rule, including when its value has partial precision. Omitted when no usable or partial date is supplied; no date is invented.

    + * @return Reference to {@code this} so that method calls can be chained together. + */ + @java.lang.Override + public _FinalStage dateSource(AllergyMappingResolutionDateSource dateSource) { + this.dateSource = Optional.ofNullable(dateSource); + return this; + } + + /** + *

    Source element selected by the existing date rule, including when its value has partial precision. Omitted when no usable or partial date is supplied; no date is invented.

    + */ + @java.lang.Override + @JsonSetter(value = "date_source", nulls = Nulls.SKIP) + public _FinalStage dateSource(Optional dateSource) { + this.dateSource = dateSource; + return this; + } + + /** + *

    Supplied verification-status codings; omitted when absent. A retained unconfirmed statement is not promoted to confirmed.

    + * @return Reference to {@code this} so that method calls can be chained together. + */ + @java.lang.Override + public _FinalStage verificationStatus(List verificationStatus) { + this.verificationStatus = Optional.ofNullable(verificationStatus); + return this; + } + + /** + *

    Supplied verification-status codings; omitted when absent. A retained unconfirmed statement is not promoted to confirmed.

    + */ + @java.lang.Override + @JsonSetter(value = "verification_status", nulls = Nulls.SKIP) + public _FinalStage verificationStatus(Optional> verificationStatus) { + this.verificationStatus = verificationStatus; + return this; + } + + /** + *

    Supplied clinical-status codings; omitted when none are available.

    + * @return Reference to {@code this} so that method calls can be chained together. + */ + @java.lang.Override + public _FinalStage clinicalStatus(List clinicalStatus) { + this.clinicalStatus = Optional.ofNullable(clinicalStatus); + return this; + } + + /** + *

    Supplied clinical-status codings; omitted when none are available.

    + */ + @java.lang.Override + @JsonSetter(value = "clinical_status", nulls = Nulls.SKIP) + public _FinalStage clinicalStatus(Optional> clinicalStatus) { + this.clinicalStatus = clinicalStatus; + return this; + } + + /** + *

    Supplied FHIR category values, preserving their order and repeats; null primitive placeholders are omitted. Empty when no category value is supplied.

    + * @return Reference to {@code this} so that method calls can be chained together. + */ + @java.lang.Override + public _FinalStage addAllCategories(List categories) { + if (categories != null) { + this.categories.addAll(categories); + } + return this; + } + + /** + *

    Supplied FHIR category values, preserving their order and repeats; null primitive placeholders are omitted. Empty when no category value is supplied.

    + * @return Reference to {@code this} so that method calls can be chained together. + */ + @java.lang.Override + public _FinalStage addCategories(AllergyMappingResolutionCategoriesItem categories) { + this.categories.add(categories); + return this; + } + + /** + *

    Supplied FHIR category values, preserving their order and repeats; null primitive placeholders are omitted. Empty when no category value is supplied.

    + */ + @java.lang.Override + @JsonSetter(value = "categories", nulls = Nulls.SKIP) + public _FinalStage categories(List categories) { + this.categories.clear(); + if (categories != null) { + this.categories.addAll(categories); + } + return this; + } + + /** + *

    Supplied FHIR type; omitted when absent.

    + * @return Reference to {@code this} so that method calls can be chained together. + */ + @java.lang.Override + public _FinalStage type(AllergyMappingResolutionType type) { + this.type = Optional.ofNullable(type); + return this; + } + + /** + *

    Supplied FHIR type; omitted when absent.

    + */ + @java.lang.Override + @JsonSetter(value = "type", nulls = Nulls.SKIP) + public _FinalStage type(Optional type) { + this.type = type; + return this; + } + + /** + *

    Machine-readable reason for the contextual policy outcome, when available.

    + * @return Reference to {@code this} so that method calls can be chained together. + */ + @java.lang.Override + public _FinalStage reasonCode(String reasonCode) { + this.reasonCode = Optional.ofNullable(reasonCode); + return this; + } + + /** + *

    Machine-readable reason for the contextual policy outcome, when available.

    + */ + @java.lang.Override + @JsonSetter(value = "reason_code", nulls = Nulls.SKIP) + public _FinalStage reasonCode(Optional reasonCode) { + this.reasonCode = reasonCode; + return this; + } + + @java.lang.Override + public AllergyMappingResolution build() { + return new AllergyMappingResolution( + method, + status, + reasonCode, + type, + categories, + clinicalStatus, + verificationStatus, + dateSource, + effectiveCategories, + categoryDerived, + incomplete, + informationLoss, + evidence, + additionalProperties); + } + + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("method") + || key.equalsIgnoreCase("status") + || key.equalsIgnoreCase("reason_code") + || key.equalsIgnoreCase("type") + || key.equalsIgnoreCase("categories") + || key.equalsIgnoreCase("clinical_status") + || key.equalsIgnoreCase("verification_status") + || key.equalsIgnoreCase("date_source") + || key.equalsIgnoreCase("effective_categories") + || key.equalsIgnoreCase("category_derived") + || key.equalsIgnoreCase("incomplete") + || key.equalsIgnoreCase("information_loss") + || key.equalsIgnoreCase("evidence")) { + return; + } + this.additionalProperties.put(key, value); + } + + @java.lang.Override + public Builder additionalProperty(String key, Object value) { + this.additionalProperties.put(key, value); + return this; + } + + @java.lang.Override + public Builder additionalProperties(Map additionalProperties) { + this.additionalProperties.putAll(additionalProperties); + return this; + } + } +} diff --git a/src/main/java/com/phenoml/api/resources/fhir2omop/types/AllergyMappingResolutionCategoriesItem.java b/src/main/java/com/phenoml/api/resources/fhir2omop/types/AllergyMappingResolutionCategoriesItem.java new file mode 100644 index 00000000..cfad7aa3 --- /dev/null +++ b/src/main/java/com/phenoml/api/resources/fhir2omop/types/AllergyMappingResolutionCategoriesItem.java @@ -0,0 +1,108 @@ +/** + * This file was auto-generated by Fern from our API Definition. + */ +package com.phenoml.api.resources.fhir2omop.types; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; + +public final class AllergyMappingResolutionCategoriesItem { + public static final AllergyMappingResolutionCategoriesItem BIOLOGIC = + new AllergyMappingResolutionCategoriesItem(Value.BIOLOGIC, "biologic"); + + public static final AllergyMappingResolutionCategoriesItem ENVIRONMENT = + new AllergyMappingResolutionCategoriesItem(Value.ENVIRONMENT, "environment"); + + public static final AllergyMappingResolutionCategoriesItem MEDICATION = + new AllergyMappingResolutionCategoriesItem(Value.MEDICATION, "medication"); + + public static final AllergyMappingResolutionCategoriesItem FOOD = + new AllergyMappingResolutionCategoriesItem(Value.FOOD, "food"); + + private final Value value; + + private final String string; + + AllergyMappingResolutionCategoriesItem(Value value, String string) { + this.value = value; + this.string = string; + } + + public Value getEnumValue() { + return value; + } + + @java.lang.Override + @JsonValue + public String toString() { + return this.string; + } + + @java.lang.Override + public boolean equals(Object other) { + return (this == other) + || (other instanceof AllergyMappingResolutionCategoriesItem + && this.string.equals(((AllergyMappingResolutionCategoriesItem) other).string)); + } + + @java.lang.Override + public int hashCode() { + return this.string.hashCode(); + } + + public T visit(Visitor visitor) { + switch (value) { + case BIOLOGIC: + return visitor.visitBiologic(); + case ENVIRONMENT: + return visitor.visitEnvironment(); + case MEDICATION: + return visitor.visitMedication(); + case FOOD: + return visitor.visitFood(); + case UNKNOWN: + default: + return visitor.visitUnknown(string); + } + } + + @JsonCreator(mode = JsonCreator.Mode.DELEGATING) + public static AllergyMappingResolutionCategoriesItem valueOf(String value) { + switch (value) { + case "biologic": + return BIOLOGIC; + case "environment": + return ENVIRONMENT; + case "medication": + return MEDICATION; + case "food": + return FOOD; + default: + return new AllergyMappingResolutionCategoriesItem(Value.UNKNOWN, value); + } + } + + public enum Value { + FOOD, + + MEDICATION, + + ENVIRONMENT, + + BIOLOGIC, + + UNKNOWN + } + + public interface Visitor { + T visitFood(); + + T visitMedication(); + + T visitEnvironment(); + + T visitBiologic(); + + T visitUnknown(String unknownType); + } +} diff --git a/src/main/java/com/phenoml/api/resources/fhir2omop/types/AllergyMappingResolutionDateSource.java b/src/main/java/com/phenoml/api/resources/fhir2omop/types/AllergyMappingResolutionDateSource.java new file mode 100644 index 00000000..e9e66dbd --- /dev/null +++ b/src/main/java/com/phenoml/api/resources/fhir2omop/types/AllergyMappingResolutionDateSource.java @@ -0,0 +1,97 @@ +/** + * This file was auto-generated by Fern from our API Definition. + */ +package com.phenoml.api.resources.fhir2omop.types; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; + +public final class AllergyMappingResolutionDateSource { + public static final AllergyMappingResolutionDateSource ONSET_PERIOD_START = + new AllergyMappingResolutionDateSource(Value.ONSET_PERIOD_START, "onsetPeriod.start"); + + public static final AllergyMappingResolutionDateSource RECORDED_DATE = + new AllergyMappingResolutionDateSource(Value.RECORDED_DATE, "recordedDate"); + + public static final AllergyMappingResolutionDateSource ONSET_DATE_TIME = + new AllergyMappingResolutionDateSource(Value.ONSET_DATE_TIME, "onsetDateTime"); + + private final Value value; + + private final String string; + + AllergyMappingResolutionDateSource(Value value, String string) { + this.value = value; + this.string = string; + } + + public Value getEnumValue() { + return value; + } + + @java.lang.Override + @JsonValue + public String toString() { + return this.string; + } + + @java.lang.Override + public boolean equals(Object other) { + return (this == other) + || (other instanceof AllergyMappingResolutionDateSource + && this.string.equals(((AllergyMappingResolutionDateSource) other).string)); + } + + @java.lang.Override + public int hashCode() { + return this.string.hashCode(); + } + + public T visit(Visitor visitor) { + switch (value) { + case ONSET_PERIOD_START: + return visitor.visitOnsetPeriodStart(); + case RECORDED_DATE: + return visitor.visitRecordedDate(); + case ONSET_DATE_TIME: + return visitor.visitOnsetDateTime(); + case UNKNOWN: + default: + return visitor.visitUnknown(string); + } + } + + @JsonCreator(mode = JsonCreator.Mode.DELEGATING) + public static AllergyMappingResolutionDateSource valueOf(String value) { + switch (value) { + case "onsetPeriod.start": + return ONSET_PERIOD_START; + case "recordedDate": + return RECORDED_DATE; + case "onsetDateTime": + return ONSET_DATE_TIME; + default: + return new AllergyMappingResolutionDateSource(Value.UNKNOWN, value); + } + } + + public enum Value { + RECORDED_DATE, + + ONSET_DATE_TIME, + + ONSET_PERIOD_START, + + UNKNOWN + } + + public interface Visitor { + T visitRecordedDate(); + + T visitOnsetDateTime(); + + T visitOnsetPeriodStart(); + + T visitUnknown(String unknownType); + } +} diff --git a/src/main/java/com/phenoml/api/resources/fhir2omop/types/AllergyMappingResolutionMethod.java b/src/main/java/com/phenoml/api/resources/fhir2omop/types/AllergyMappingResolutionMethod.java new file mode 100644 index 00000000..513cca83 --- /dev/null +++ b/src/main/java/com/phenoml/api/resources/fhir2omop/types/AllergyMappingResolutionMethod.java @@ -0,0 +1,108 @@ +/** + * This file was auto-generated by Fern from our API Definition. + */ +package com.phenoml.api.resources.fhir2omop.types; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; + +public final class AllergyMappingResolutionMethod { + public static final AllergyMappingResolutionMethod DIRECT = + new AllergyMappingResolutionMethod(Value.DIRECT, "DIRECT"); + + public static final AllergyMappingResolutionMethod DERIVED = + new AllergyMappingResolutionMethod(Value.DERIVED, "DERIVED"); + + public static final AllergyMappingResolutionMethod UNRESOLVED = + new AllergyMappingResolutionMethod(Value.UNRESOLVED, "UNRESOLVED"); + + public static final AllergyMappingResolutionMethod CONTEXTUAL = + new AllergyMappingResolutionMethod(Value.CONTEXTUAL, "CONTEXTUAL"); + + private final Value value; + + private final String string; + + AllergyMappingResolutionMethod(Value value, String string) { + this.value = value; + this.string = string; + } + + public Value getEnumValue() { + return value; + } + + @java.lang.Override + @JsonValue + public String toString() { + return this.string; + } + + @java.lang.Override + public boolean equals(Object other) { + return (this == other) + || (other instanceof AllergyMappingResolutionMethod + && this.string.equals(((AllergyMappingResolutionMethod) other).string)); + } + + @java.lang.Override + public int hashCode() { + return this.string.hashCode(); + } + + public T visit(Visitor visitor) { + switch (value) { + case DIRECT: + return visitor.visitDirect(); + case DERIVED: + return visitor.visitDerived(); + case UNRESOLVED: + return visitor.visitUnresolved(); + case CONTEXTUAL: + return visitor.visitContextual(); + case UNKNOWN: + default: + return visitor.visitUnknown(string); + } + } + + @JsonCreator(mode = JsonCreator.Mode.DELEGATING) + public static AllergyMappingResolutionMethod valueOf(String value) { + switch (value) { + case "DIRECT": + return DIRECT; + case "DERIVED": + return DERIVED; + case "UNRESOLVED": + return UNRESOLVED; + case "CONTEXTUAL": + return CONTEXTUAL; + default: + return new AllergyMappingResolutionMethod(Value.UNKNOWN, value); + } + } + + public enum Value { + DIRECT, + + CONTEXTUAL, + + DERIVED, + + UNRESOLVED, + + UNKNOWN + } + + public interface Visitor { + T visitDirect(); + + T visitContextual(); + + T visitDerived(); + + T visitUnresolved(); + + T visitUnknown(String unknownType); + } +} diff --git a/src/main/java/com/phenoml/api/resources/fhir2omop/types/AllergyMappingResolutionStatus.java b/src/main/java/com/phenoml/api/resources/fhir2omop/types/AllergyMappingResolutionStatus.java new file mode 100644 index 00000000..092bbead --- /dev/null +++ b/src/main/java/com/phenoml/api/resources/fhir2omop/types/AllergyMappingResolutionStatus.java @@ -0,0 +1,108 @@ +/** + * This file was auto-generated by Fern from our API Definition. + */ +package com.phenoml.api.resources.fhir2omop.types; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; + +public final class AllergyMappingResolutionStatus { + public static final AllergyMappingResolutionStatus RESOLVED = + new AllergyMappingResolutionStatus(Value.RESOLVED, "RESOLVED"); + + public static final AllergyMappingResolutionStatus UNRESOLVED = + new AllergyMappingResolutionStatus(Value.UNRESOLVED, "UNRESOLVED"); + + public static final AllergyMappingResolutionStatus DOMAIN_MISMATCH = + new AllergyMappingResolutionStatus(Value.DOMAIN_MISMATCH, "DOMAIN_MISMATCH"); + + public static final AllergyMappingResolutionStatus AMBIGUOUS = + new AllergyMappingResolutionStatus(Value.AMBIGUOUS, "AMBIGUOUS"); + + private final Value value; + + private final String string; + + AllergyMappingResolutionStatus(Value value, String string) { + this.value = value; + this.string = string; + } + + public Value getEnumValue() { + return value; + } + + @java.lang.Override + @JsonValue + public String toString() { + return this.string; + } + + @java.lang.Override + public boolean equals(Object other) { + return (this == other) + || (other instanceof AllergyMappingResolutionStatus + && this.string.equals(((AllergyMappingResolutionStatus) other).string)); + } + + @java.lang.Override + public int hashCode() { + return this.string.hashCode(); + } + + public T visit(Visitor visitor) { + switch (value) { + case RESOLVED: + return visitor.visitResolved(); + case UNRESOLVED: + return visitor.visitUnresolved(); + case DOMAIN_MISMATCH: + return visitor.visitDomainMismatch(); + case AMBIGUOUS: + return visitor.visitAmbiguous(); + case UNKNOWN: + default: + return visitor.visitUnknown(string); + } + } + + @JsonCreator(mode = JsonCreator.Mode.DELEGATING) + public static AllergyMappingResolutionStatus valueOf(String value) { + switch (value) { + case "RESOLVED": + return RESOLVED; + case "UNRESOLVED": + return UNRESOLVED; + case "DOMAIN_MISMATCH": + return DOMAIN_MISMATCH; + case "AMBIGUOUS": + return AMBIGUOUS; + default: + return new AllergyMappingResolutionStatus(Value.UNKNOWN, value); + } + } + + public enum Value { + RESOLVED, + + UNRESOLVED, + + DOMAIN_MISMATCH, + + AMBIGUOUS, + + UNKNOWN + } + + public interface Visitor { + T visitResolved(); + + T visitUnresolved(); + + T visitDomainMismatch(); + + T visitAmbiguous(); + + T visitUnknown(String unknownType); + } +} diff --git a/src/main/java/com/phenoml/api/resources/fhir2omop/types/AllergyMappingResolutionType.java b/src/main/java/com/phenoml/api/resources/fhir2omop/types/AllergyMappingResolutionType.java new file mode 100644 index 00000000..faa19826 --- /dev/null +++ b/src/main/java/com/phenoml/api/resources/fhir2omop/types/AllergyMappingResolutionType.java @@ -0,0 +1,86 @@ +/** + * This file was auto-generated by Fern from our API Definition. + */ +package com.phenoml.api.resources.fhir2omop.types; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; + +public final class AllergyMappingResolutionType { + public static final AllergyMappingResolutionType ALLERGY = + new AllergyMappingResolutionType(Value.ALLERGY, "allergy"); + + public static final AllergyMappingResolutionType INTOLERANCE = + new AllergyMappingResolutionType(Value.INTOLERANCE, "intolerance"); + + private final Value value; + + private final String string; + + AllergyMappingResolutionType(Value value, String string) { + this.value = value; + this.string = string; + } + + public Value getEnumValue() { + return value; + } + + @java.lang.Override + @JsonValue + public String toString() { + return this.string; + } + + @java.lang.Override + public boolean equals(Object other) { + return (this == other) + || (other instanceof AllergyMappingResolutionType + && this.string.equals(((AllergyMappingResolutionType) other).string)); + } + + @java.lang.Override + public int hashCode() { + return this.string.hashCode(); + } + + public T visit(Visitor visitor) { + switch (value) { + case ALLERGY: + return visitor.visitAllergy(); + case INTOLERANCE: + return visitor.visitIntolerance(); + case UNKNOWN: + default: + return visitor.visitUnknown(string); + } + } + + @JsonCreator(mode = JsonCreator.Mode.DELEGATING) + public static AllergyMappingResolutionType valueOf(String value) { + switch (value) { + case "allergy": + return ALLERGY; + case "intolerance": + return INTOLERANCE; + default: + return new AllergyMappingResolutionType(Value.UNKNOWN, value); + } + } + + public enum Value { + ALLERGY, + + INTOLERANCE, + + UNKNOWN + } + + public interface Visitor { + T visitAllergy(); + + T visitIntolerance(); + + T visitUnknown(String unknownType); + } +} diff --git a/src/main/java/com/phenoml/api/resources/fhir2omop/types/CareSiteRow.java b/src/main/java/com/phenoml/api/resources/fhir2omop/types/CareSiteRow.java index 95e13694..61431965 100644 --- a/src/main/java/com/phenoml/api/resources/fhir2omop/types/CareSiteRow.java +++ b/src/main/java/com/phenoml/api/resources/fhir2omop/types/CareSiteRow.java @@ -135,7 +135,6 @@ public static final class Builder { private Optional placeOfServiceSourceValue = Optional.empty(); - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -227,6 +226,19 @@ public CareSiteRow build() { additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("care_site_id") + || key.equalsIgnoreCase("care_site_name") + || key.equalsIgnoreCase("place_of_service_concept_id") + || key.equalsIgnoreCase("location_id") + || key.equalsIgnoreCase("care_site_source_value") + || key.equalsIgnoreCase("place_of_service_source_value")) { + return; + } + this.additionalProperties.put(key, value); + } + public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); return this; diff --git a/src/main/java/com/phenoml/api/resources/fhir2omop/types/Coding.java b/src/main/java/com/phenoml/api/resources/fhir2omop/types/Coding.java index bec556c0..9d35ede0 100644 --- a/src/main/java/com/phenoml/api/resources/fhir2omop/types/Coding.java +++ b/src/main/java/com/phenoml/api/resources/fhir2omop/types/Coding.java @@ -91,7 +91,6 @@ public static final class Builder { private Optional display = Optional.empty(); - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -140,6 +139,14 @@ public Coding build() { return new Coding(system, code, display, additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("system") || key.equalsIgnoreCase("code") || key.equalsIgnoreCase("display")) { + return; + } + this.additionalProperties.put(key, value); + } + public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); return this; diff --git a/src/main/java/com/phenoml/api/resources/fhir2omop/types/ConditionOccurrenceRow.java b/src/main/java/com/phenoml/api/resources/fhir2omop/types/ConditionOccurrenceRow.java index e1a08221..25d2e855 100644 --- a/src/main/java/com/phenoml/api/resources/fhir2omop/types/ConditionOccurrenceRow.java +++ b/src/main/java/com/phenoml/api/resources/fhir2omop/types/ConditionOccurrenceRow.java @@ -95,7 +95,7 @@ public Optional getConditionConceptId() { } /** - * @return Date from Condition.onsetDateTime or onsetPeriod.start, otherwise Condition.recordedDate. + * @return Start date selected by the source resource's timing rules. For a Condition, onsetDateTime or onsetPeriod.start, otherwise recordedDate; see the endpoint's timing sources for other resources. */ @JsonProperty("condition_start_date") public Optional getConditionStartDate() { @@ -108,7 +108,7 @@ public Optional getConditionStartDatetime() { } /** - * @return Date from Condition.abatementDateTime or abatementPeriod.end. + * @return End date selected by the source resource's timing rules, when available. For a Condition, abatementDateTime or abatementPeriod.end; see the endpoint's timing sources for other resources. */ @JsonProperty("condition_end_date") public Optional getConditionEndDate() { @@ -232,7 +232,6 @@ public static final class Builder { private Optional conditionStatusSourceValue = Optional.empty(); - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -288,7 +287,7 @@ public Builder conditionConceptId(Long conditionConceptId) { } /** - *

    Date from Condition.onsetDateTime or onsetPeriod.start, otherwise Condition.recordedDate.

    + *

    Start date selected by the source resource's timing rules. For a Condition, onsetDateTime or onsetPeriod.start, otherwise recordedDate; see the endpoint's timing sources for other resources.

    */ @JsonSetter(value = "condition_start_date", nulls = Nulls.SKIP) public Builder conditionStartDate(Optional conditionStartDate) { @@ -313,7 +312,7 @@ public Builder conditionStartDatetime(String conditionStartDatetime) { } /** - *

    Date from Condition.abatementDateTime or abatementPeriod.end.

    + *

    End date selected by the source resource's timing rules, when available. For a Condition, abatementDateTime or abatementPeriod.end; see the endpoint's timing sources for other resources.

    */ @JsonSetter(value = "condition_end_date", nulls = Nulls.SKIP) public Builder conditionEndDate(Optional conditionEndDate) { @@ -421,6 +420,26 @@ public ConditionOccurrenceRow build() { additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("condition_occurrence_id") + || key.equalsIgnoreCase("person_id") + || key.equalsIgnoreCase("condition_concept_id") + || key.equalsIgnoreCase("condition_start_date") + || key.equalsIgnoreCase("condition_start_datetime") + || key.equalsIgnoreCase("condition_end_date") + || key.equalsIgnoreCase("condition_end_datetime") + || key.equalsIgnoreCase("condition_type_concept_id") + || key.equalsIgnoreCase("visit_occurrence_id") + || key.equalsIgnoreCase("provider_id") + || key.equalsIgnoreCase("condition_source_value") + || key.equalsIgnoreCase("condition_source_concept_id") + || key.equalsIgnoreCase("condition_status_source_value")) { + return; + } + this.additionalProperties.put(key, value); + } + public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); return this; diff --git a/src/main/java/com/phenoml/api/resources/fhir2omop/types/CreateOmopResponse.java b/src/main/java/com/phenoml/api/resources/fhir2omop/types/CreateOmopResponse.java index 688de2ca..46f7a49f 100644 --- a/src/main/java/com/phenoml/api/resources/fhir2omop/types/CreateOmopResponse.java +++ b/src/main/java/com/phenoml/api/resources/fhir2omop/types/CreateOmopResponse.java @@ -27,8 +27,18 @@ public final class CreateOmopResponse { private final Optional tables; + private final Optional> quantityTransformations; + + private final Optional> rowsForReview; + private final Optional> mappings; + private final Optional> deviceExposureContexts; + + private final Optional> specimenSourceContexts; + + private final Optional> medicationDispenseSourceContexts; + private final Optional> providerRoleContexts; private final Optional> dropped; @@ -45,7 +55,12 @@ private CreateOmopResponse( Optional success, Optional message, Optional tables, + Optional> quantityTransformations, + Optional> rowsForReview, Optional> mappings, + Optional> deviceExposureContexts, + Optional> specimenSourceContexts, + Optional> medicationDispenseSourceContexts, Optional> providerRoleContexts, Optional> dropped, Optional> diagnostics, @@ -55,7 +70,12 @@ private CreateOmopResponse( this.success = success; this.message = message; this.tables = tables; + this.quantityTransformations = quantityTransformations; + this.rowsForReview = rowsForReview; this.mappings = mappings; + this.deviceExposureContexts = deviceExposureContexts; + this.specimenSourceContexts = specimenSourceContexts; + this.medicationDispenseSourceContexts = medicationDispenseSourceContexts; this.providerRoleContexts = providerRoleContexts; this.dropped = dropped; this.diagnostics = diagnostics; @@ -80,13 +100,94 @@ public Optional getTables() { } /** - * @return One entry per supported source coding from a resource that shaped a row (or one entry for a text-only primary resource with no coding), describing how it resolved and linking back to the row it produced. A coded route or Observation valueCodeableConcept is a separate entry linked to its medication, vaccine, or observation row. A Patient demographic code links to its person row, or to its observation race row when the person has more than one race. + * @return Audits supplied direct quantities on emitted drug_exposure rows, including derivations and omissions. Resources returned in other clinical tables have no quantity entry. Omitted when no applicable quantity was supplied on a drug_exposure row. Entries use the response's vocabulary release and do not affect terminology mapping counts in summary. The examples use illustrative concept IDs; actual IDs and evidence belong to vocab_version. + */ + @JsonProperty("quantity_transformations") + public Optional> getQuantityTransformations() { + return quantityTransformations; + } + + /** + * @return Index of otherwise shapeable clinical rows with uncertain source status + * and/or primary concept ID 0. Each entry identifies an existing row in + * tables; row payloads are neither moved nor duplicated. Omitted when empty. + * Condition unconfirmed/provisional/differential or unreadable supplied + * verificationStatus and Encounter unknown/missing/unreadable status are + * indexed. Explicit unknown status on MedicationStatement, Observation, + * and Procedure is also indexed; missing or unreadable status on these + * three types remains dropped. Absent Condition verificationStatus keeps + * its existing policy. + * Refuted/entered-in-error Conditions and planned/cancelled/entered-in-error + * Encounters remain dropped; all patient-reference checks still apply. + * Other eligible clinical rows with primary concept 0 are indexed, + * including text-only clinical concepts without a standard match and + * unresolved Visit classifications. + * Unresolved units, routes, result values, or alternate codings alone + * do not index a mapped row. Administrative/demographic, Specimen, and Death + * rows are not indexed. No missing status, date, or concept is invented. + * Consumers must filter by this index to exclude uncertain or unresolved + * rows: tables contains all shaped rows, including uncertain events that + * were previously excluded. Existing concept-0 rows keep their placement. + * Validated visit references remain on the rows. After filtering, include + * referenced visits or clear optional visit_occurrence_id values pointing + * to excluded visits in the load copy. For example, to include indexed + * condition 3 and visit 2, keep their existing rows and IDs in tables; + * if only condition 3 is selected, clear its reference to excluded visit 2. + * Returned observation periods use unindexed clinical rows plus Specimen and Death; + * indexed dates never expand them. Recompute periods for a selected load + * if needed. Indexed rows may still lack CDM-required fields. + */ + @JsonProperty("rows_for_review") + public Optional> getRowsForReview() { + return rowsForReview; + } + + /** + * @return One entry per supported source coding from a resource that shaped a + * row, plus entries for text-only primary resources and supplied + * Quantity units or specimen sites without coding, describing each + * resolution or source-preservation outcome and linking it to its row. + * A coded route or Observation valueCodeableConcept is a separate + * entry linked to the output row. Observation values returned outside + * measurement / observation are preserved here without concept + * resolution or a corresponding row column. + * For Observations returned as measurement or observation rows, each + * supplied Quantity unit has a separate entry linked to that row; + * absent units have no entry. + * Each supplied specimen unit or site also has a separate entry; + * text-only sites are UNMAPPED without a resolution object. + * A Patient demographic code links to its person row, or to its + * observation race row when the person has more than one race. */ @JsonProperty("mappings") public Optional> getMappings() { return mappings; } + /** + * @return One entry per source record that produced device exposures. Each entry groups the derived row links and returns their shared timing, device evidence, and quantity explanation once. Original source values may contain clinical data. + */ + @JsonProperty("device_exposure_contexts") + public Optional> getDeviceExposureContexts() { + return deviceExposureContexts; + } + + /** + * @return Source identities, availability status, and collection timing for emitted specimens, linked by request-local specimen_id. Preserves valued identifier system/value pairs, accession identity, resource id/fullUrl, the chosen source-ID path, and supplied collection/received timing with original precision and offsets. + */ + @JsonProperty("specimen_source_contexts") + public Optional> getSpecimenSourceContexts() { + return specimenSourceContexts; + } + + /** + * @return Supplied dispensing status, timing, instructions, and references linked to each emitted dispensing row, including rows placed in another clinical table. Omitted when there are no emitted dispenses. This response metadata is independent of rows_for_review and does not establish administration or adherence. + */ + @JsonProperty("medication_dispense_source_contexts") + public Optional> getMedicationDispenseSourceContexts() { + return medicationDispenseSourceContexts; + } + /** * @return Additive FHIR provenance for every supplied PractitionerRole. Each * context identifies the canonical or role-fallback provider row and @@ -104,8 +205,12 @@ public Optional> getProviderRoleContexts() { * data was missing or unusable, including an explicit subject/patient * reference that was unresolved, ambiguous, or unsupported, or because * their clinical-event eligibility status excluded them. A resource that - * lacks only a date or another CDM-required field is returned as a row - * instead. Unsupported resource types are ignored and do not appear here. + * lacks only a date or another CDM-required field is usually returned + * as a partial row. Specimen requires an explicit resolvable Patient + * subject but retains missing dates/material with linked diagnostics. + * DeviceUseStatement also requires an explicit resolvable Patient subject; + * missing Device details or timing retain partial exposure rows. + * Unsupported resource types are ignored and do not appear here. * Eligibility exclusions use stable, resource-specific reason codes; * other shaping failures retain an explanatory reason string. */ @@ -118,13 +223,36 @@ public Optional> getDropped() { * @return Explanations for explicit references that could not safely produce * an OMOP link or canonicalize a PractitionerRole provider identity, or explicit * subject/patient references that caused a clinical row to be dropped. + * Specimen missing/unusable collection dates, material, subject, status, + * and invalid optional quantity are also explained here. Device-use status, + * attribution, missing detail, timing omissions/conflicts, UDI ambiguity, + * and unsupported device counts are also reported with row links when + * a row is retained. A shared issue can link to multiple event rows. + * MedicationDispense missing/unusable handover, absent exposure end, + * unsupported supplied daysSupply, conflicting routes, and unsafe + * medication or authorizing-prescription references are also reported. * Missing optional references are normal and do not produce a diagnostic. * References resolve only against resources supplied in this request. * Outcomes distinguish unresolved, ambiguous, conflicting, and unsupported - * references. Patient demographic extensions that conflict, or a birth - * sex without valueCode, are also reported here; their path is + * conditions as defined in ReferenceDiagnostic.outcome. Patient + * demographic extensions that conflict, or a birth sex without + * valueCode, are also reported here; their path is * extension:birthsex or extension:ethnicity and they have no - * reference. + * reference. Practitioner identity conflicts that prevent merging also + * have no reference: their paths are name, gender, birthDate, or + * identifier, their outcome is CONFLICTING, and omop_table: provider + * together with omop_id identifies the affected row. For a conflicting + * NPI group, every member is flagged, including members missing the conflicting attribute. + * The reason identifies the differing mapped attribute and explains why + * the entire group remains separate, rather than asserting that each + * member individually supplied a conflicting value. + * Multiple distinct NPIs on one Practitioner are also diagnosed at + * identifier with omop_table: provider and the retained row's omop_id. + * Conflicting Encounter classifications are also reported with outcome + * CONFLICTING and no reference: path class/type means a mapped + * type disagrees with the selected class, which still determines the + * Visit; path type means separate type classifications disagree and + * no type can supply a Visit when class is missing or unmapped. */ @JsonProperty("diagnostics") public Optional> getDiagnostics() { @@ -133,7 +261,7 @@ public Optional> getDiagnostics() { /** * @return The OMOP vocabulary release used for coded concept resolution - * (for example, "v20260227"), for reproducibility. Omitted when no + * (for example, "v20260829"), for reproducibility. Omitted when no * vocabulary resolution was performed. */ @JsonProperty("vocab_version") @@ -161,7 +289,12 @@ private boolean equalTo(CreateOmopResponse other) { return success.equals(other.success) && message.equals(other.message) && tables.equals(other.tables) + && quantityTransformations.equals(other.quantityTransformations) + && rowsForReview.equals(other.rowsForReview) && mappings.equals(other.mappings) + && deviceExposureContexts.equals(other.deviceExposureContexts) + && specimenSourceContexts.equals(other.specimenSourceContexts) + && medicationDispenseSourceContexts.equals(other.medicationDispenseSourceContexts) && providerRoleContexts.equals(other.providerRoleContexts) && dropped.equals(other.dropped) && diagnostics.equals(other.diagnostics) @@ -175,7 +308,12 @@ public int hashCode() { this.success, this.message, this.tables, + this.quantityTransformations, + this.rowsForReview, this.mappings, + this.deviceExposureContexts, + this.specimenSourceContexts, + this.medicationDispenseSourceContexts, this.providerRoleContexts, this.dropped, this.diagnostics, @@ -200,8 +338,18 @@ public static final class Builder { private Optional tables = Optional.empty(); + private Optional> quantityTransformations = Optional.empty(); + + private Optional> rowsForReview = Optional.empty(); + private Optional> mappings = Optional.empty(); + private Optional> deviceExposureContexts = Optional.empty(); + + private Optional> specimenSourceContexts = Optional.empty(); + + private Optional> medicationDispenseSourceContexts = Optional.empty(); + private Optional> providerRoleContexts = Optional.empty(); private Optional> dropped = Optional.empty(); @@ -212,7 +360,6 @@ public static final class Builder { private Optional summary = Optional.empty(); - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -221,7 +368,12 @@ public Builder from(CreateOmopResponse other) { success(other.getSuccess()); message(other.getMessage()); tables(other.getTables()); + quantityTransformations(other.getQuantityTransformations()); + rowsForReview(other.getRowsForReview()); mappings(other.getMappings()); + deviceExposureContexts(other.getDeviceExposureContexts()); + specimenSourceContexts(other.getSpecimenSourceContexts()); + medicationDispenseSourceContexts(other.getMedicationDispenseSourceContexts()); providerRoleContexts(other.getProviderRoleContexts()); dropped(other.getDropped()); diagnostics(other.getDiagnostics()); @@ -264,7 +416,76 @@ public Builder tables(OmopTables tables) { } /** - *

    One entry per supported source coding from a resource that shaped a row (or one entry for a text-only primary resource with no coding), describing how it resolved and linking back to the row it produced. A coded route or Observation valueCodeableConcept is a separate entry linked to its medication, vaccine, or observation row. A Patient demographic code links to its person row, or to its observation race row when the person has more than one race.

    + *

    Audits supplied direct quantities on emitted drug_exposure rows, including derivations and omissions. Resources returned in other clinical tables have no quantity entry. Omitted when no applicable quantity was supplied on a drug_exposure row. Entries use the response's vocabulary release and do not affect terminology mapping counts in summary. The examples use illustrative concept IDs; actual IDs and evidence belong to vocab_version.

    + */ + @JsonSetter(value = "quantity_transformations", nulls = Nulls.SKIP) + public Builder quantityTransformations(Optional> quantityTransformations) { + this.quantityTransformations = quantityTransformations; + return this; + } + + public Builder quantityTransformations(List quantityTransformations) { + this.quantityTransformations = Optional.ofNullable(quantityTransformations); + return this; + } + + /** + *

    Index of otherwise shapeable clinical rows with uncertain source status + * and/or primary concept ID 0. Each entry identifies an existing row in + * tables; row payloads are neither moved nor duplicated. Omitted when empty. + * Condition unconfirmed/provisional/differential or unreadable supplied + * verificationStatus and Encounter unknown/missing/unreadable status are + * indexed. Explicit unknown status on MedicationStatement, Observation, + * and Procedure is also indexed; missing or unreadable status on these + * three types remains dropped. Absent Condition verificationStatus keeps + * its existing policy. + * Refuted/entered-in-error Conditions and planned/cancelled/entered-in-error + * Encounters remain dropped; all patient-reference checks still apply. + * Other eligible clinical rows with primary concept 0 are indexed, + * including text-only clinical concepts without a standard match and + * unresolved Visit classifications. + * Unresolved units, routes, result values, or alternate codings alone + * do not index a mapped row. Administrative/demographic, Specimen, and Death + * rows are not indexed. No missing status, date, or concept is invented. + * Consumers must filter by this index to exclude uncertain or unresolved + * rows: tables contains all shaped rows, including uncertain events that + * were previously excluded. Existing concept-0 rows keep their placement. + * Validated visit references remain on the rows. After filtering, include + * referenced visits or clear optional visit_occurrence_id values pointing + * to excluded visits in the load copy. For example, to include indexed + * condition 3 and visit 2, keep their existing rows and IDs in tables; + * if only condition 3 is selected, clear its reference to excluded visit 2. + * Returned observation periods use unindexed clinical rows plus Specimen and Death; + * indexed dates never expand them. Recompute periods for a selected load + * if needed. Indexed rows may still lack CDM-required fields.

    + */ + @JsonSetter(value = "rows_for_review", nulls = Nulls.SKIP) + public Builder rowsForReview(Optional> rowsForReview) { + this.rowsForReview = rowsForReview; + return this; + } + + public Builder rowsForReview(List rowsForReview) { + this.rowsForReview = Optional.ofNullable(rowsForReview); + return this; + } + + /** + *

    One entry per supported source coding from a resource that shaped a + * row, plus entries for text-only primary resources and supplied + * Quantity units or specimen sites without coding, describing each + * resolution or source-preservation outcome and linking it to its row. + * A coded route or Observation valueCodeableConcept is a separate + * entry linked to the output row. Observation values returned outside + * measurement / observation are preserved here without concept + * resolution or a corresponding row column. + * For Observations returned as measurement or observation rows, each + * supplied Quantity unit has a separate entry linked to that row; + * absent units have no entry. + * Each supplied specimen unit or site also has a separate entry; + * text-only sites are UNMAPPED without a resolution object. + * A Patient demographic code links to its person row, or to its + * observation race row when the person has more than one race.

    */ @JsonSetter(value = "mappings", nulls = Nulls.SKIP) public Builder mappings(Optional> mappings) { @@ -277,6 +498,50 @@ public Builder mappings(List mappings) { return this; } + /** + *

    One entry per source record that produced device exposures. Each entry groups the derived row links and returns their shared timing, device evidence, and quantity explanation once. Original source values may contain clinical data.

    + */ + @JsonSetter(value = "device_exposure_contexts", nulls = Nulls.SKIP) + public Builder deviceExposureContexts(Optional> deviceExposureContexts) { + this.deviceExposureContexts = deviceExposureContexts; + return this; + } + + public Builder deviceExposureContexts(List deviceExposureContexts) { + this.deviceExposureContexts = Optional.ofNullable(deviceExposureContexts); + return this; + } + + /** + *

    Source identities, availability status, and collection timing for emitted specimens, linked by request-local specimen_id. Preserves valued identifier system/value pairs, accession identity, resource id/fullUrl, the chosen source-ID path, and supplied collection/received timing with original precision and offsets.

    + */ + @JsonSetter(value = "specimen_source_contexts", nulls = Nulls.SKIP) + public Builder specimenSourceContexts(Optional> specimenSourceContexts) { + this.specimenSourceContexts = specimenSourceContexts; + return this; + } + + public Builder specimenSourceContexts(List specimenSourceContexts) { + this.specimenSourceContexts = Optional.ofNullable(specimenSourceContexts); + return this; + } + + /** + *

    Supplied dispensing status, timing, instructions, and references linked to each emitted dispensing row, including rows placed in another clinical table. Omitted when there are no emitted dispenses. This response metadata is independent of rows_for_review and does not establish administration or adherence.

    + */ + @JsonSetter(value = "medication_dispense_source_contexts", nulls = Nulls.SKIP) + public Builder medicationDispenseSourceContexts( + Optional> medicationDispenseSourceContexts) { + this.medicationDispenseSourceContexts = medicationDispenseSourceContexts; + return this; + } + + public Builder medicationDispenseSourceContexts( + List medicationDispenseSourceContexts) { + this.medicationDispenseSourceContexts = Optional.ofNullable(medicationDispenseSourceContexts); + return this; + } + /** *

    Additive FHIR provenance for every supplied PractitionerRole. Each * context identifies the canonical or role-fallback provider row and @@ -300,8 +565,12 @@ public Builder providerRoleContexts(List providerRoleContex * data was missing or unusable, including an explicit subject/patient * reference that was unresolved, ambiguous, or unsupported, or because * their clinical-event eligibility status excluded them. A resource that - * lacks only a date or another CDM-required field is returned as a row - * instead. Unsupported resource types are ignored and do not appear here. + * lacks only a date or another CDM-required field is usually returned + * as a partial row. Specimen requires an explicit resolvable Patient + * subject but retains missing dates/material with linked diagnostics. + * DeviceUseStatement also requires an explicit resolvable Patient subject; + * missing Device details or timing retain partial exposure rows. + * Unsupported resource types are ignored and do not appear here. * Eligibility exclusions use stable, resource-specific reason codes; * other shaping failures retain an explanatory reason string.

    */ @@ -320,13 +589,36 @@ public Builder dropped(List dropped) { *

    Explanations for explicit references that could not safely produce * an OMOP link or canonicalize a PractitionerRole provider identity, or explicit * subject/patient references that caused a clinical row to be dropped. + * Specimen missing/unusable collection dates, material, subject, status, + * and invalid optional quantity are also explained here. Device-use status, + * attribution, missing detail, timing omissions/conflicts, UDI ambiguity, + * and unsupported device counts are also reported with row links when + * a row is retained. A shared issue can link to multiple event rows. + * MedicationDispense missing/unusable handover, absent exposure end, + * unsupported supplied daysSupply, conflicting routes, and unsafe + * medication or authorizing-prescription references are also reported. * Missing optional references are normal and do not produce a diagnostic. * References resolve only against resources supplied in this request. * Outcomes distinguish unresolved, ambiguous, conflicting, and unsupported - * references. Patient demographic extensions that conflict, or a birth - * sex without valueCode, are also reported here; their path is + * conditions as defined in ReferenceDiagnostic.outcome. Patient + * demographic extensions that conflict, or a birth sex without + * valueCode, are also reported here; their path is * extension:birthsex or extension:ethnicity and they have no - * reference.

    + * reference. Practitioner identity conflicts that prevent merging also + * have no reference: their paths are name, gender, birthDate, or + * identifier, their outcome is CONFLICTING, and omop_table: provider + * together with omop_id identifies the affected row. For a conflicting + * NPI group, every member is flagged, including members missing the conflicting attribute. + * The reason identifies the differing mapped attribute and explains why + * the entire group remains separate, rather than asserting that each + * member individually supplied a conflicting value. + * Multiple distinct NPIs on one Practitioner are also diagnosed at + * identifier with omop_table: provider and the retained row's omop_id. + * Conflicting Encounter classifications are also reported with outcome + * CONFLICTING and no reference: path class/type means a mapped + * type disagrees with the selected class, which still determines the + * Visit; path type means separate type classifications disagree and + * no type can supply a Visit when class is missing or unmapped.

    */ @JsonSetter(value = "diagnostics", nulls = Nulls.SKIP) public Builder diagnostics(Optional> diagnostics) { @@ -341,7 +633,7 @@ public Builder diagnostics(List diagnostics) { /** *

    The OMOP vocabulary release used for coded concept resolution - * (for example, "v20260227"), for reproducibility. Omitted when no + * (for example, "v20260829"), for reproducibility. Omitted when no * vocabulary resolution was performed.

    */ @JsonSetter(value = "vocab_version", nulls = Nulls.SKIP) @@ -371,7 +663,12 @@ public CreateOmopResponse build() { success, message, tables, + quantityTransformations, + rowsForReview, mappings, + deviceExposureContexts, + specimenSourceContexts, + medicationDispenseSourceContexts, providerRoleContexts, dropped, diagnostics, @@ -380,6 +677,27 @@ public CreateOmopResponse build() { additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("success") + || key.equalsIgnoreCase("message") + || key.equalsIgnoreCase("tables") + || key.equalsIgnoreCase("quantity_transformations") + || key.equalsIgnoreCase("rows_for_review") + || key.equalsIgnoreCase("mappings") + || key.equalsIgnoreCase("device_exposure_contexts") + || key.equalsIgnoreCase("specimen_source_contexts") + || key.equalsIgnoreCase("medication_dispense_source_contexts") + || key.equalsIgnoreCase("provider_role_contexts") + || key.equalsIgnoreCase("dropped") + || key.equalsIgnoreCase("diagnostics") + || key.equalsIgnoreCase("vocab_version") + || key.equalsIgnoreCase("summary")) { + return; + } + this.additionalProperties.put(key, value); + } + public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); return this; diff --git a/src/main/java/com/phenoml/api/resources/fhir2omop/types/DeathRow.java b/src/main/java/com/phenoml/api/resources/fhir2omop/types/DeathRow.java index 06e21885..819af676 100644 --- a/src/main/java/com/phenoml/api/resources/fhir2omop/types/DeathRow.java +++ b/src/main/java/com/phenoml/api/resources/fhir2omop/types/DeathRow.java @@ -151,7 +151,6 @@ public static final class Builder { private Optional causeSourceConceptId = Optional.empty(); - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -259,6 +258,20 @@ public DeathRow build() { additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("person_id") + || key.equalsIgnoreCase("death_date") + || key.equalsIgnoreCase("death_datetime") + || key.equalsIgnoreCase("death_type_concept_id") + || key.equalsIgnoreCase("cause_concept_id") + || key.equalsIgnoreCase("cause_source_value") + || key.equalsIgnoreCase("cause_source_concept_id")) { + return; + } + this.additionalProperties.put(key, value); + } + public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); return this; diff --git a/src/main/java/com/phenoml/api/resources/fhir2omop/types/DeviceExposureContext.java b/src/main/java/com/phenoml/api/resources/fhir2omop/types/DeviceExposureContext.java new file mode 100644 index 00000000..f5dd5a9d --- /dev/null +++ b/src/main/java/com/phenoml/api/resources/fhir2omop/types/DeviceExposureContext.java @@ -0,0 +1,340 @@ +/** + * This file was auto-generated by Fern from our API Definition. + */ +package com.phenoml.api.resources.fhir2omop.types; + +import com.fasterxml.jackson.annotation.JsonAnyGetter; +import com.fasterxml.jackson.annotation.JsonAnySetter; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonSetter; +import com.fasterxml.jackson.annotation.Nulls; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.phenoml.api.core.ObjectMappers; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import org.jetbrains.annotations.NotNull; + +@JsonInclude(JsonInclude.Include.NON_ABSENT) +@JsonDeserialize(builder = DeviceExposureContext.Builder.class) +public final class DeviceExposureContext { + private final String resourceType; + + private final Optional resourceId; + + private final Optional sourceFullUrl; + + private final List exposures; + + private final DeviceExposureContextQuantityBasis quantityBasis; + + private final Optional> evidence; + + private final Map additionalProperties; + + private DeviceExposureContext( + String resourceType, + Optional resourceId, + Optional sourceFullUrl, + List exposures, + DeviceExposureContextQuantityBasis quantityBasis, + Optional> evidence, + Map additionalProperties) { + this.resourceType = resourceType; + this.resourceId = resourceId; + this.sourceFullUrl = sourceFullUrl; + this.exposures = exposures; + this.quantityBasis = quantityBasis; + this.evidence = evidence; + this.additionalProperties = additionalProperties; + } + + @JsonProperty("resource_type") + public String getResourceType() { + return resourceType; + } + + @JsonProperty("resource_id") + public Optional getResourceId() { + return resourceId; + } + + @JsonProperty("source_full_url") + public Optional getSourceFullUrl() { + return sourceFullUrl; + } + + /** + * @return Links to all device exposure rows derived from this source record. + */ + @JsonProperty("exposures") + public List getExposures() { + return exposures; + } + + /** + * @return Applies to every linked exposure. default_absent_count accompanies quantity 1 when the source has no quantity, dose, rate, or numeric Observation result field. unsupported_supplied_evidence accompanies an omitted quantity and UNSUPPORTED diagnostics when such a field is supplied, including null or malformed values. + */ + @JsonProperty("quantity_basis") + public DeviceExposureContextQuantityBasis getQuantityBasis() { + return quantityBasis; + } + + /** + * @return Original supplied source values with source paths. Device detail paths begin with device.; other paths refer to the clinical source resource. Timing precision, offsets, schedules, statuses, relationships, UDI carriers, and unsupported quantities are preserved here. + */ + @JsonProperty("evidence") + public Optional> getEvidence() { + return evidence; + } + + @java.lang.Override + public boolean equals(Object other) { + if (this == other) return true; + return other instanceof DeviceExposureContext && equalTo((DeviceExposureContext) other); + } + + @JsonAnyGetter + public Map getAdditionalProperties() { + return this.additionalProperties; + } + + private boolean equalTo(DeviceExposureContext other) { + return resourceType.equals(other.resourceType) + && resourceId.equals(other.resourceId) + && sourceFullUrl.equals(other.sourceFullUrl) + && exposures.equals(other.exposures) + && quantityBasis.equals(other.quantityBasis) + && evidence.equals(other.evidence); + } + + @java.lang.Override + public int hashCode() { + return Objects.hash( + this.resourceType, + this.resourceId, + this.sourceFullUrl, + this.exposures, + this.quantityBasis, + this.evidence); + } + + @java.lang.Override + public String toString() { + return ObjectMappers.stringify(this); + } + + public static ResourceTypeStage builder() { + return new Builder(); + } + + public interface ResourceTypeStage { + QuantityBasisStage resourceType(@NotNull String resourceType); + + Builder from(DeviceExposureContext other); + } + + public interface QuantityBasisStage { + /** + *

    Applies to every linked exposure. default_absent_count accompanies quantity 1 when the source has no quantity, dose, rate, or numeric Observation result field. unsupported_supplied_evidence accompanies an omitted quantity and UNSUPPORTED diagnostics when such a field is supplied, including null or malformed values.

    + */ + _FinalStage quantityBasis(@NotNull DeviceExposureContextQuantityBasis quantityBasis); + } + + public interface _FinalStage { + DeviceExposureContext build(); + + _FinalStage additionalProperty(String key, Object value); + + _FinalStage additionalProperties(Map additionalProperties); + + _FinalStage resourceId(Optional resourceId); + + _FinalStage resourceId(String resourceId); + + _FinalStage sourceFullUrl(Optional sourceFullUrl); + + _FinalStage sourceFullUrl(String sourceFullUrl); + + /** + *

    Links to all device exposure rows derived from this source record.

    + */ + _FinalStage exposures(List exposures); + + _FinalStage addExposures(DeviceExposureLink exposures); + + _FinalStage addAllExposures(List exposures); + + /** + *

    Original supplied source values with source paths. Device detail paths begin with device.; other paths refer to the clinical source resource. Timing precision, offsets, schedules, statuses, relationships, UDI carriers, and unsupported quantities are preserved here.

    + */ + _FinalStage evidence(Optional> evidence); + + _FinalStage evidence(List evidence); + } + + @JsonIgnoreProperties(ignoreUnknown = true) + public static final class Builder implements ResourceTypeStage, QuantityBasisStage, _FinalStage { + private String resourceType; + + private DeviceExposureContextQuantityBasis quantityBasis; + + private Optional> evidence = Optional.empty(); + + private List exposures = new ArrayList<>(); + + private Optional sourceFullUrl = Optional.empty(); + + private Optional resourceId = Optional.empty(); + + private Map additionalProperties = new HashMap<>(); + + private Builder() {} + + @java.lang.Override + public Builder from(DeviceExposureContext other) { + resourceType(other.getResourceType()); + resourceId(other.getResourceId()); + sourceFullUrl(other.getSourceFullUrl()); + exposures(other.getExposures()); + quantityBasis(other.getQuantityBasis()); + evidence(other.getEvidence()); + return this; + } + + @java.lang.Override + @JsonSetter("resource_type") + public QuantityBasisStage resourceType(@NotNull String resourceType) { + this.resourceType = Objects.requireNonNull(resourceType, "resourceType must not be null"); + return this; + } + + /** + *

    Applies to every linked exposure. default_absent_count accompanies quantity 1 when the source has no quantity, dose, rate, or numeric Observation result field. unsupported_supplied_evidence accompanies an omitted quantity and UNSUPPORTED diagnostics when such a field is supplied, including null or malformed values.

    + * @return Reference to {@code this} so that method calls can be chained together. + */ + @java.lang.Override + @JsonSetter("quantity_basis") + public _FinalStage quantityBasis(@NotNull DeviceExposureContextQuantityBasis quantityBasis) { + this.quantityBasis = Objects.requireNonNull(quantityBasis, "quantityBasis must not be null"); + return this; + } + + /** + *

    Original supplied source values with source paths. Device detail paths begin with device.; other paths refer to the clinical source resource. Timing precision, offsets, schedules, statuses, relationships, UDI carriers, and unsupported quantities are preserved here.

    + * @return Reference to {@code this} so that method calls can be chained together. + */ + @java.lang.Override + public _FinalStage evidence(List evidence) { + this.evidence = Optional.ofNullable(evidence); + return this; + } + + /** + *

    Original supplied source values with source paths. Device detail paths begin with device.; other paths refer to the clinical source resource. Timing precision, offsets, schedules, statuses, relationships, UDI carriers, and unsupported quantities are preserved here.

    + */ + @java.lang.Override + @JsonSetter(value = "evidence", nulls = Nulls.SKIP) + public _FinalStage evidence(Optional> evidence) { + this.evidence = evidence; + return this; + } + + /** + *

    Links to all device exposure rows derived from this source record.

    + * @return Reference to {@code this} so that method calls can be chained together. + */ + @java.lang.Override + public _FinalStage addAllExposures(List exposures) { + if (exposures != null) { + this.exposures.addAll(exposures); + } + return this; + } + + /** + *

    Links to all device exposure rows derived from this source record.

    + * @return Reference to {@code this} so that method calls can be chained together. + */ + @java.lang.Override + public _FinalStage addExposures(DeviceExposureLink exposures) { + this.exposures.add(exposures); + return this; + } + + /** + *

    Links to all device exposure rows derived from this source record.

    + */ + @java.lang.Override + @JsonSetter(value = "exposures", nulls = Nulls.SKIP) + public _FinalStage exposures(List exposures) { + this.exposures.clear(); + if (exposures != null) { + this.exposures.addAll(exposures); + } + return this; + } + + @java.lang.Override + public _FinalStage sourceFullUrl(String sourceFullUrl) { + this.sourceFullUrl = Optional.ofNullable(sourceFullUrl); + return this; + } + + @java.lang.Override + @JsonSetter(value = "source_full_url", nulls = Nulls.SKIP) + public _FinalStage sourceFullUrl(Optional sourceFullUrl) { + this.sourceFullUrl = sourceFullUrl; + return this; + } + + @java.lang.Override + public _FinalStage resourceId(String resourceId) { + this.resourceId = Optional.ofNullable(resourceId); + return this; + } + + @java.lang.Override + @JsonSetter(value = "resource_id", nulls = Nulls.SKIP) + public _FinalStage resourceId(Optional resourceId) { + this.resourceId = resourceId; + return this; + } + + @java.lang.Override + public DeviceExposureContext build() { + return new DeviceExposureContext( + resourceType, resourceId, sourceFullUrl, exposures, quantityBasis, evidence, additionalProperties); + } + + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("resource_type") + || key.equalsIgnoreCase("resource_id") + || key.equalsIgnoreCase("source_full_url") + || key.equalsIgnoreCase("exposures") + || key.equalsIgnoreCase("quantity_basis") + || key.equalsIgnoreCase("evidence")) { + return; + } + this.additionalProperties.put(key, value); + } + + @java.lang.Override + public Builder additionalProperty(String key, Object value) { + this.additionalProperties.put(key, value); + return this; + } + + @java.lang.Override + public Builder additionalProperties(Map additionalProperties) { + this.additionalProperties.putAll(additionalProperties); + return this; + } + } +} diff --git a/src/main/java/com/phenoml/api/resources/fhir2omop/types/DeviceExposureContextQuantityBasis.java b/src/main/java/com/phenoml/api/resources/fhir2omop/types/DeviceExposureContextQuantityBasis.java new file mode 100644 index 00000000..9c0ca601 --- /dev/null +++ b/src/main/java/com/phenoml/api/resources/fhir2omop/types/DeviceExposureContextQuantityBasis.java @@ -0,0 +1,87 @@ +/** + * This file was auto-generated by Fern from our API Definition. + */ +package com.phenoml.api.resources.fhir2omop.types; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; + +public final class DeviceExposureContextQuantityBasis { + public static final DeviceExposureContextQuantityBasis UNSUPPORTED_SUPPLIED_EVIDENCE = + new DeviceExposureContextQuantityBasis( + Value.UNSUPPORTED_SUPPLIED_EVIDENCE, "unsupported_supplied_evidence"); + + public static final DeviceExposureContextQuantityBasis DEFAULT_ABSENT_COUNT = + new DeviceExposureContextQuantityBasis(Value.DEFAULT_ABSENT_COUNT, "default_absent_count"); + + private final Value value; + + private final String string; + + DeviceExposureContextQuantityBasis(Value value, String string) { + this.value = value; + this.string = string; + } + + public Value getEnumValue() { + return value; + } + + @java.lang.Override + @JsonValue + public String toString() { + return this.string; + } + + @java.lang.Override + public boolean equals(Object other) { + return (this == other) + || (other instanceof DeviceExposureContextQuantityBasis + && this.string.equals(((DeviceExposureContextQuantityBasis) other).string)); + } + + @java.lang.Override + public int hashCode() { + return this.string.hashCode(); + } + + public T visit(Visitor visitor) { + switch (value) { + case UNSUPPORTED_SUPPLIED_EVIDENCE: + return visitor.visitUnsupportedSuppliedEvidence(); + case DEFAULT_ABSENT_COUNT: + return visitor.visitDefaultAbsentCount(); + case UNKNOWN: + default: + return visitor.visitUnknown(string); + } + } + + @JsonCreator(mode = JsonCreator.Mode.DELEGATING) + public static DeviceExposureContextQuantityBasis valueOf(String value) { + switch (value) { + case "unsupported_supplied_evidence": + return UNSUPPORTED_SUPPLIED_EVIDENCE; + case "default_absent_count": + return DEFAULT_ABSENT_COUNT; + default: + return new DeviceExposureContextQuantityBasis(Value.UNKNOWN, value); + } + } + + public enum Value { + DEFAULT_ABSENT_COUNT, + + UNSUPPORTED_SUPPLIED_EVIDENCE, + + UNKNOWN + } + + public interface Visitor { + T visitDefaultAbsentCount(); + + T visitUnsupportedSuppliedEvidence(); + + T visitUnknown(String unknownType); + } +} diff --git a/src/main/java/com/phenoml/api/resources/fhir2omop/types/DeviceExposureEvidence.java b/src/main/java/com/phenoml/api/resources/fhir2omop/types/DeviceExposureEvidence.java new file mode 100644 index 00000000..9415c53d --- /dev/null +++ b/src/main/java/com/phenoml/api/resources/fhir2omop/types/DeviceExposureEvidence.java @@ -0,0 +1,168 @@ +/** + * This file was auto-generated by Fern from our API Definition. + */ +package com.phenoml.api.resources.fhir2omop.types; + +import com.fasterxml.jackson.annotation.JsonAnyGetter; +import com.fasterxml.jackson.annotation.JsonAnySetter; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonSetter; +import com.fasterxml.jackson.annotation.Nulls; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.phenoml.api.core.ObjectMappers; +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import org.jetbrains.annotations.NotNull; + +@JsonInclude(JsonInclude.Include.NON_ABSENT) +@JsonDeserialize(builder = DeviceExposureEvidence.Builder.class) +public final class DeviceExposureEvidence { + private final String path; + + private final Optional value; + + private final Map additionalProperties; + + private DeviceExposureEvidence(String path, Optional value, Map additionalProperties) { + this.path = path; + this.value = value; + this.additionalProperties = additionalProperties; + } + + @JsonProperty("path") + public String getPath() { + return path; + } + + /** + * @return Original supplied JSON value, including null or malformed source evidence. + */ + @JsonProperty("value") + public Optional getValue() { + return value; + } + + @java.lang.Override + public boolean equals(Object other) { + if (this == other) return true; + return other instanceof DeviceExposureEvidence && equalTo((DeviceExposureEvidence) other); + } + + @JsonAnyGetter + public Map getAdditionalProperties() { + return this.additionalProperties; + } + + private boolean equalTo(DeviceExposureEvidence other) { + return path.equals(other.path) && value.equals(other.value); + } + + @java.lang.Override + public int hashCode() { + return Objects.hash(this.path, this.value); + } + + @java.lang.Override + public String toString() { + return ObjectMappers.stringify(this); + } + + public static PathStage builder() { + return new Builder(); + } + + public interface PathStage { + _FinalStage path(@NotNull String path); + + Builder from(DeviceExposureEvidence other); + } + + public interface _FinalStage { + DeviceExposureEvidence build(); + + _FinalStage additionalProperty(String key, Object value); + + _FinalStage additionalProperties(Map additionalProperties); + + /** + *

    Original supplied JSON value, including null or malformed source evidence.

    + */ + _FinalStage value(Optional value); + + _FinalStage value(Object value); + } + + @JsonIgnoreProperties(ignoreUnknown = true) + public static final class Builder implements PathStage, _FinalStage { + private String path; + + private Optional value = Optional.empty(); + + private Map additionalProperties = new HashMap<>(); + + private Builder() {} + + @java.lang.Override + public Builder from(DeviceExposureEvidence other) { + path(other.getPath()); + value(other.getValue()); + return this; + } + + @java.lang.Override + @JsonSetter("path") + public _FinalStage path(@NotNull String path) { + this.path = Objects.requireNonNull(path, "path must not be null"); + return this; + } + + /** + *

    Original supplied JSON value, including null or malformed source evidence.

    + * @return Reference to {@code this} so that method calls can be chained together. + */ + @java.lang.Override + public _FinalStage value(Object value) { + this.value = Optional.ofNullable(value); + return this; + } + + /** + *

    Original supplied JSON value, including null or malformed source evidence.

    + */ + @java.lang.Override + @JsonSetter(value = "value", nulls = Nulls.SKIP) + public _FinalStage value(Optional value) { + this.value = value; + return this; + } + + @java.lang.Override + public DeviceExposureEvidence build() { + return new DeviceExposureEvidence(path, value, additionalProperties); + } + + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("path") || key.equalsIgnoreCase("value")) { + return; + } + this.additionalProperties.put(key, value); + } + + @java.lang.Override + public Builder additionalProperty(String key, Object value) { + this.additionalProperties.put(key, value); + return this; + } + + @java.lang.Override + public Builder additionalProperties(Map additionalProperties) { + this.additionalProperties.putAll(additionalProperties); + return this; + } + } +} diff --git a/src/main/java/com/phenoml/api/resources/fhir2omop/types/DeviceExposureLink.java b/src/main/java/com/phenoml/api/resources/fhir2omop/types/DeviceExposureLink.java new file mode 100644 index 00000000..4dd95c8f --- /dev/null +++ b/src/main/java/com/phenoml/api/resources/fhir2omop/types/DeviceExposureLink.java @@ -0,0 +1,178 @@ +/** + * This file was auto-generated by Fern from our API Definition. + */ +package com.phenoml.api.resources.fhir2omop.types; + +import com.fasterxml.jackson.annotation.JsonAnyGetter; +import com.fasterxml.jackson.annotation.JsonAnySetter; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonSetter; +import com.fasterxml.jackson.annotation.Nulls; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.phenoml.api.core.ObjectMappers; +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; + +@JsonInclude(JsonInclude.Include.NON_ABSENT) +@JsonDeserialize(builder = DeviceExposureLink.Builder.class) +public final class DeviceExposureLink { + private final long deviceExposureId; + + private final Optional eventIndex; + + private final Map additionalProperties; + + private DeviceExposureLink( + long deviceExposureId, Optional eventIndex, Map additionalProperties) { + this.deviceExposureId = deviceExposureId; + this.eventIndex = eventIndex; + this.additionalProperties = additionalProperties; + } + + /** + * @return Request-local ID of a row in tables.device_exposure. + */ + @JsonProperty("device_exposure_id") + public long getDeviceExposureId() { + return deviceExposureId; + } + + /** + * @return Zero-based index in this context's supplied timingTiming.event evidence, including unusable events retained as partial rows. Omitted for other timing forms. + */ + @JsonProperty("event_index") + public Optional getEventIndex() { + return eventIndex; + } + + @java.lang.Override + public boolean equals(Object other) { + if (this == other) return true; + return other instanceof DeviceExposureLink && equalTo((DeviceExposureLink) other); + } + + @JsonAnyGetter + public Map getAdditionalProperties() { + return this.additionalProperties; + } + + private boolean equalTo(DeviceExposureLink other) { + return deviceExposureId == other.deviceExposureId && eventIndex.equals(other.eventIndex); + } + + @java.lang.Override + public int hashCode() { + return Objects.hash(this.deviceExposureId, this.eventIndex); + } + + @java.lang.Override + public String toString() { + return ObjectMappers.stringify(this); + } + + public static DeviceExposureIdStage builder() { + return new Builder(); + } + + public interface DeviceExposureIdStage { + /** + *

    Request-local ID of a row in tables.device_exposure.

    + */ + _FinalStage deviceExposureId(long deviceExposureId); + + Builder from(DeviceExposureLink other); + } + + public interface _FinalStage { + DeviceExposureLink build(); + + _FinalStage additionalProperty(String key, Object value); + + _FinalStage additionalProperties(Map additionalProperties); + + /** + *

    Zero-based index in this context's supplied timingTiming.event evidence, including unusable events retained as partial rows. Omitted for other timing forms.

    + */ + _FinalStage eventIndex(Optional eventIndex); + + _FinalStage eventIndex(Integer eventIndex); + } + + @JsonIgnoreProperties(ignoreUnknown = true) + public static final class Builder implements DeviceExposureIdStage, _FinalStage { + private long deviceExposureId; + + private Optional eventIndex = Optional.empty(); + + private Map additionalProperties = new HashMap<>(); + + private Builder() {} + + @java.lang.Override + public Builder from(DeviceExposureLink other) { + deviceExposureId(other.getDeviceExposureId()); + eventIndex(other.getEventIndex()); + return this; + } + + /** + *

    Request-local ID of a row in tables.device_exposure.

    + * @return Reference to {@code this} so that method calls can be chained together. + */ + @java.lang.Override + @JsonSetter("device_exposure_id") + public _FinalStage deviceExposureId(long deviceExposureId) { + this.deviceExposureId = deviceExposureId; + return this; + } + + /** + *

    Zero-based index in this context's supplied timingTiming.event evidence, including unusable events retained as partial rows. Omitted for other timing forms.

    + * @return Reference to {@code this} so that method calls can be chained together. + */ + @java.lang.Override + public _FinalStage eventIndex(Integer eventIndex) { + this.eventIndex = Optional.ofNullable(eventIndex); + return this; + } + + /** + *

    Zero-based index in this context's supplied timingTiming.event evidence, including unusable events retained as partial rows. Omitted for other timing forms.

    + */ + @java.lang.Override + @JsonSetter(value = "event_index", nulls = Nulls.SKIP) + public _FinalStage eventIndex(Optional eventIndex) { + this.eventIndex = eventIndex; + return this; + } + + @java.lang.Override + public DeviceExposureLink build() { + return new DeviceExposureLink(deviceExposureId, eventIndex, additionalProperties); + } + + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("device_exposure_id") || key.equalsIgnoreCase("event_index")) { + return; + } + this.additionalProperties.put(key, value); + } + + @java.lang.Override + public Builder additionalProperty(String key, Object value) { + this.additionalProperties.put(key, value); + return this; + } + + @java.lang.Override + public Builder additionalProperties(Map additionalProperties) { + this.additionalProperties.putAll(additionalProperties); + return this; + } + } +} diff --git a/src/main/java/com/phenoml/api/resources/fhir2omop/types/DeviceExposureRow.java b/src/main/java/com/phenoml/api/resources/fhir2omop/types/DeviceExposureRow.java new file mode 100644 index 00000000..c8c365ac --- /dev/null +++ b/src/main/java/com/phenoml/api/resources/fhir2omop/types/DeviceExposureRow.java @@ -0,0 +1,939 @@ +/** + * This file was auto-generated by Fern from our API Definition. + */ +package com.phenoml.api.resources.fhir2omop.types; + +import com.fasterxml.jackson.annotation.JsonAnyGetter; +import com.fasterxml.jackson.annotation.JsonAnySetter; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonSetter; +import com.fasterxml.jackson.annotation.Nulls; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.phenoml.api.core.ObjectMappers; +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; + +@JsonInclude(JsonInclude.Include.NON_ABSENT) +@JsonDeserialize(builder = DeviceExposureRow.Builder.class) +public final class DeviceExposureRow { + private final long deviceExposureId; + + private final long personId; + + private final long deviceConceptId; + + private final Optional deviceExposureStartDate; + + private final Optional deviceExposureStartDatetime; + + private final Optional deviceExposureEndDate; + + private final Optional deviceExposureEndDatetime; + + private final long deviceTypeConceptId; + + private final Optional uniqueDeviceId; + + private final Optional productionId; + + private final Optional quantity; + + private final Optional providerId; + + private final Optional visitOccurrenceId; + + private final Optional visitDetailId; + + private final Optional deviceSourceValue; + + private final long deviceSourceConceptId; + + private final Optional unitConceptId; + + private final Optional unitSourceValue; + + private final Optional unitSourceConceptId; + + private final Map additionalProperties; + + private DeviceExposureRow( + long deviceExposureId, + long personId, + long deviceConceptId, + Optional deviceExposureStartDate, + Optional deviceExposureStartDatetime, + Optional deviceExposureEndDate, + Optional deviceExposureEndDatetime, + long deviceTypeConceptId, + Optional uniqueDeviceId, + Optional productionId, + Optional quantity, + Optional providerId, + Optional visitOccurrenceId, + Optional visitDetailId, + Optional deviceSourceValue, + long deviceSourceConceptId, + Optional unitConceptId, + Optional unitSourceValue, + Optional unitSourceConceptId, + Map additionalProperties) { + this.deviceExposureId = deviceExposureId; + this.personId = personId; + this.deviceConceptId = deviceConceptId; + this.deviceExposureStartDate = deviceExposureStartDate; + this.deviceExposureStartDatetime = deviceExposureStartDatetime; + this.deviceExposureEndDate = deviceExposureEndDate; + this.deviceExposureEndDatetime = deviceExposureEndDatetime; + this.deviceTypeConceptId = deviceTypeConceptId; + this.uniqueDeviceId = uniqueDeviceId; + this.productionId = productionId; + this.quantity = quantity; + this.providerId = providerId; + this.visitOccurrenceId = visitOccurrenceId; + this.visitDetailId = visitDetailId; + this.deviceSourceValue = deviceSourceValue; + this.deviceSourceConceptId = deviceSourceConceptId; + this.unitConceptId = unitConceptId; + this.unitSourceValue = unitSourceValue; + this.unitSourceConceptId = unitSourceConceptId; + this.additionalProperties = additionalProperties; + } + + /** + * @return Request-local exposure identity, distinct from physical or model device identity. + */ + @JsonProperty("device_exposure_id") + public long getDeviceExposureId() { + return deviceExposureId; + } + + /** + * @return Emitted Patient identity. + */ + @JsonProperty("person_id") + public long getPersonId() { + return personId; + } + + /** + * @return Validated standard Device concept; 0 for missing or unresolved type evidence. + */ + @JsonProperty("device_concept_id") + public long getDeviceConceptId() { + return deviceConceptId; + } + + /** + * @return Source use start at day precision; absent when unknown or unusable. + */ + @JsonProperty("device_exposure_start_date") + public Optional getDeviceExposureStartDate() { + return deviceExposureStartDate; + } + + /** + * @return Source local wall-clock start, without UTC offset; absent for date-only input. + */ + @JsonProperty("device_exposure_start_datetime") + public Optional getDeviceExposureStartDatetime() { + return deviceExposureStartDatetime; + } + + /** + * @return Explicit source end; absent when unknown or unusable. + */ + @JsonProperty("device_exposure_end_date") + public Optional getDeviceExposureEndDate() { + return deviceExposureEndDate; + } + + /** + * @return Source local wall-clock end, without UTC offset; absent for date-only input. + */ + @JsonProperty("device_exposure_end_datetime") + public Optional getDeviceExposureEndDatetime() { + return deviceExposureEndDatetime; + } + + /** + * @return Coarse source provenance; DeviceUseStatement uses generic EHR 32817. + */ + @JsonProperty("device_type_concept_id") + public long getDeviceTypeConceptId() { + return deviceTypeConceptId; + } + + /** + * @return Unambiguous supplied UDI-DI; competing values remain in source context. + */ + @JsonProperty("unique_device_id") + public Optional getUniqueDeviceId() { + return uniqueDeviceId; + } + + /** + * @return Unset; supplied production components and carriers remain in source context. + */ + @JsonProperty("production_id") + public Optional getProductionId() { + return productionId; + } + + /** + * @return 1 when the source has no quantity, dose, rate, or numeric Observation result field; omitted when such a field is supplied, including null or malformed values. See device_exposure_contexts.quantity_basis. + */ + @JsonProperty("quantity") + public Optional getQuantity() { + return quantity; + } + + /** + * @return Compatible explicit performer on routed rows; absent for DeviceUseStatement and reporting-only actors. + */ + @JsonProperty("provider_id") + public Optional getProviderId() { + return providerId; + } + + /** + * @return Explicit emitted same-patient visit for routed rows; absent for DeviceUseStatement. + */ + @JsonProperty("visit_occurrence_id") + public Optional getVisitOccurrenceId() { + return visitOccurrenceId; + } + + /** + * @return Unset; no visit-detail linkage is inferred. + */ + @JsonProperty("visit_detail_id") + public Optional getVisitDetailId() { + return visitDetailId; + } + + /** + * @return Original selected type or clinical code, or supplied text; never a resource/inventory identifier. + */ + @JsonProperty("device_source_value") + public Optional getDeviceSourceValue() { + return deviceSourceValue; + } + + /** + * @return Original source concept when established by vocabulary resolution; otherwise 0. + */ + @JsonProperty("device_source_concept_id") + public long getDeviceSourceConceptId() { + return deviceSourceConceptId; + } + + /** + * @return Always null. + */ + @JsonProperty("unit_concept_id") + public Optional getUnitConceptId() { + return unitConceptId; + } + + /** + * @return Unset; unsupported clinical units remain in source context. + */ + @JsonProperty("unit_source_value") + public Optional getUnitSourceValue() { + return unitSourceValue; + } + + /** + * @return Always null. + */ + @JsonProperty("unit_source_concept_id") + public Optional getUnitSourceConceptId() { + return unitSourceConceptId; + } + + @java.lang.Override + public boolean equals(Object other) { + if (this == other) return true; + return other instanceof DeviceExposureRow && equalTo((DeviceExposureRow) other); + } + + @JsonAnyGetter + public Map getAdditionalProperties() { + return this.additionalProperties; + } + + private boolean equalTo(DeviceExposureRow other) { + return deviceExposureId == other.deviceExposureId + && personId == other.personId + && deviceConceptId == other.deviceConceptId + && deviceExposureStartDate.equals(other.deviceExposureStartDate) + && deviceExposureStartDatetime.equals(other.deviceExposureStartDatetime) + && deviceExposureEndDate.equals(other.deviceExposureEndDate) + && deviceExposureEndDatetime.equals(other.deviceExposureEndDatetime) + && deviceTypeConceptId == other.deviceTypeConceptId + && uniqueDeviceId.equals(other.uniqueDeviceId) + && productionId.equals(other.productionId) + && quantity.equals(other.quantity) + && providerId.equals(other.providerId) + && visitOccurrenceId.equals(other.visitOccurrenceId) + && visitDetailId.equals(other.visitDetailId) + && deviceSourceValue.equals(other.deviceSourceValue) + && deviceSourceConceptId == other.deviceSourceConceptId + && unitConceptId.equals(other.unitConceptId) + && unitSourceValue.equals(other.unitSourceValue) + && unitSourceConceptId.equals(other.unitSourceConceptId); + } + + @java.lang.Override + public int hashCode() { + return Objects.hash( + this.deviceExposureId, + this.personId, + this.deviceConceptId, + this.deviceExposureStartDate, + this.deviceExposureStartDatetime, + this.deviceExposureEndDate, + this.deviceExposureEndDatetime, + this.deviceTypeConceptId, + this.uniqueDeviceId, + this.productionId, + this.quantity, + this.providerId, + this.visitOccurrenceId, + this.visitDetailId, + this.deviceSourceValue, + this.deviceSourceConceptId, + this.unitConceptId, + this.unitSourceValue, + this.unitSourceConceptId); + } + + @java.lang.Override + public String toString() { + return ObjectMappers.stringify(this); + } + + public static DeviceExposureIdStage builder() { + return new Builder(); + } + + public interface DeviceExposureIdStage { + /** + *

    Request-local exposure identity, distinct from physical or model device identity.

    + */ + PersonIdStage deviceExposureId(long deviceExposureId); + + Builder from(DeviceExposureRow other); + } + + public interface PersonIdStage { + /** + *

    Emitted Patient identity.

    + */ + DeviceConceptIdStage personId(long personId); + } + + public interface DeviceConceptIdStage { + /** + *

    Validated standard Device concept; 0 for missing or unresolved type evidence.

    + */ + DeviceTypeConceptIdStage deviceConceptId(long deviceConceptId); + } + + public interface DeviceTypeConceptIdStage { + /** + *

    Coarse source provenance; DeviceUseStatement uses generic EHR 32817.

    + */ + DeviceSourceConceptIdStage deviceTypeConceptId(long deviceTypeConceptId); + } + + public interface DeviceSourceConceptIdStage { + /** + *

    Original source concept when established by vocabulary resolution; otherwise 0.

    + */ + _FinalStage deviceSourceConceptId(long deviceSourceConceptId); + } + + public interface _FinalStage { + DeviceExposureRow build(); + + _FinalStage additionalProperty(String key, Object value); + + _FinalStage additionalProperties(Map additionalProperties); + + /** + *

    Source use start at day precision; absent when unknown or unusable.

    + */ + _FinalStage deviceExposureStartDate(Optional deviceExposureStartDate); + + _FinalStage deviceExposureStartDate(String deviceExposureStartDate); + + /** + *

    Source local wall-clock start, without UTC offset; absent for date-only input.

    + */ + _FinalStage deviceExposureStartDatetime(Optional deviceExposureStartDatetime); + + _FinalStage deviceExposureStartDatetime(String deviceExposureStartDatetime); + + /** + *

    Explicit source end; absent when unknown or unusable.

    + */ + _FinalStage deviceExposureEndDate(Optional deviceExposureEndDate); + + _FinalStage deviceExposureEndDate(String deviceExposureEndDate); + + /** + *

    Source local wall-clock end, without UTC offset; absent for date-only input.

    + */ + _FinalStage deviceExposureEndDatetime(Optional deviceExposureEndDatetime); + + _FinalStage deviceExposureEndDatetime(String deviceExposureEndDatetime); + + /** + *

    Unambiguous supplied UDI-DI; competing values remain in source context.

    + */ + _FinalStage uniqueDeviceId(Optional uniqueDeviceId); + + _FinalStage uniqueDeviceId(String uniqueDeviceId); + + /** + *

    Unset; supplied production components and carriers remain in source context.

    + */ + _FinalStage productionId(Optional productionId); + + _FinalStage productionId(String productionId); + + /** + *

    1 when the source has no quantity, dose, rate, or numeric Observation result field; omitted when such a field is supplied, including null or malformed values. See device_exposure_contexts.quantity_basis.

    + */ + _FinalStage quantity(Optional quantity); + + _FinalStage quantity(Long quantity); + + /** + *

    Compatible explicit performer on routed rows; absent for DeviceUseStatement and reporting-only actors.

    + */ + _FinalStage providerId(Optional providerId); + + _FinalStage providerId(Long providerId); + + /** + *

    Explicit emitted same-patient visit for routed rows; absent for DeviceUseStatement.

    + */ + _FinalStage visitOccurrenceId(Optional visitOccurrenceId); + + _FinalStage visitOccurrenceId(Long visitOccurrenceId); + + /** + *

    Unset; no visit-detail linkage is inferred.

    + */ + _FinalStage visitDetailId(Optional visitDetailId); + + _FinalStage visitDetailId(Long visitDetailId); + + /** + *

    Original selected type or clinical code, or supplied text; never a resource/inventory identifier.

    + */ + _FinalStage deviceSourceValue(Optional deviceSourceValue); + + _FinalStage deviceSourceValue(String deviceSourceValue); + + /** + *

    Always null.

    + */ + _FinalStage unitConceptId(Optional unitConceptId); + + _FinalStage unitConceptId(Long unitConceptId); + + /** + *

    Unset; unsupported clinical units remain in source context.

    + */ + _FinalStage unitSourceValue(Optional unitSourceValue); + + _FinalStage unitSourceValue(String unitSourceValue); + + /** + *

    Always null.

    + */ + _FinalStage unitSourceConceptId(Optional unitSourceConceptId); + + _FinalStage unitSourceConceptId(Long unitSourceConceptId); + } + + @JsonIgnoreProperties(ignoreUnknown = true) + public static final class Builder + implements DeviceExposureIdStage, + PersonIdStage, + DeviceConceptIdStage, + DeviceTypeConceptIdStage, + DeviceSourceConceptIdStage, + _FinalStage { + private long deviceExposureId; + + private long personId; + + private long deviceConceptId; + + private long deviceTypeConceptId; + + private long deviceSourceConceptId; + + private Optional unitSourceConceptId = Optional.empty(); + + private Optional unitSourceValue = Optional.empty(); + + private Optional unitConceptId = Optional.empty(); + + private Optional deviceSourceValue = Optional.empty(); + + private Optional visitDetailId = Optional.empty(); + + private Optional visitOccurrenceId = Optional.empty(); + + private Optional providerId = Optional.empty(); + + private Optional quantity = Optional.empty(); + + private Optional productionId = Optional.empty(); + + private Optional uniqueDeviceId = Optional.empty(); + + private Optional deviceExposureEndDatetime = Optional.empty(); + + private Optional deviceExposureEndDate = Optional.empty(); + + private Optional deviceExposureStartDatetime = Optional.empty(); + + private Optional deviceExposureStartDate = Optional.empty(); + + private Map additionalProperties = new HashMap<>(); + + private Builder() {} + + @java.lang.Override + public Builder from(DeviceExposureRow other) { + deviceExposureId(other.getDeviceExposureId()); + personId(other.getPersonId()); + deviceConceptId(other.getDeviceConceptId()); + deviceExposureStartDate(other.getDeviceExposureStartDate()); + deviceExposureStartDatetime(other.getDeviceExposureStartDatetime()); + deviceExposureEndDate(other.getDeviceExposureEndDate()); + deviceExposureEndDatetime(other.getDeviceExposureEndDatetime()); + deviceTypeConceptId(other.getDeviceTypeConceptId()); + uniqueDeviceId(other.getUniqueDeviceId()); + productionId(other.getProductionId()); + quantity(other.getQuantity()); + providerId(other.getProviderId()); + visitOccurrenceId(other.getVisitOccurrenceId()); + visitDetailId(other.getVisitDetailId()); + deviceSourceValue(other.getDeviceSourceValue()); + deviceSourceConceptId(other.getDeviceSourceConceptId()); + unitConceptId(other.getUnitConceptId()); + unitSourceValue(other.getUnitSourceValue()); + unitSourceConceptId(other.getUnitSourceConceptId()); + return this; + } + + /** + *

    Request-local exposure identity, distinct from physical or model device identity.

    + * @return Reference to {@code this} so that method calls can be chained together. + */ + @java.lang.Override + @JsonSetter("device_exposure_id") + public PersonIdStage deviceExposureId(long deviceExposureId) { + this.deviceExposureId = deviceExposureId; + return this; + } + + /** + *

    Emitted Patient identity.

    + * @return Reference to {@code this} so that method calls can be chained together. + */ + @java.lang.Override + @JsonSetter("person_id") + public DeviceConceptIdStage personId(long personId) { + this.personId = personId; + return this; + } + + /** + *

    Validated standard Device concept; 0 for missing or unresolved type evidence.

    + * @return Reference to {@code this} so that method calls can be chained together. + */ + @java.lang.Override + @JsonSetter("device_concept_id") + public DeviceTypeConceptIdStage deviceConceptId(long deviceConceptId) { + this.deviceConceptId = deviceConceptId; + return this; + } + + /** + *

    Coarse source provenance; DeviceUseStatement uses generic EHR 32817.

    + * @return Reference to {@code this} so that method calls can be chained together. + */ + @java.lang.Override + @JsonSetter("device_type_concept_id") + public DeviceSourceConceptIdStage deviceTypeConceptId(long deviceTypeConceptId) { + this.deviceTypeConceptId = deviceTypeConceptId; + return this; + } + + /** + *

    Original source concept when established by vocabulary resolution; otherwise 0.

    + * @return Reference to {@code this} so that method calls can be chained together. + */ + @java.lang.Override + @JsonSetter("device_source_concept_id") + public _FinalStage deviceSourceConceptId(long deviceSourceConceptId) { + this.deviceSourceConceptId = deviceSourceConceptId; + return this; + } + + /** + *

    Always null.

    + * @return Reference to {@code this} so that method calls can be chained together. + */ + @java.lang.Override + public _FinalStage unitSourceConceptId(Long unitSourceConceptId) { + this.unitSourceConceptId = Optional.ofNullable(unitSourceConceptId); + return this; + } + + /** + *

    Always null.

    + */ + @java.lang.Override + @JsonSetter(value = "unit_source_concept_id", nulls = Nulls.SKIP) + public _FinalStage unitSourceConceptId(Optional unitSourceConceptId) { + this.unitSourceConceptId = unitSourceConceptId; + return this; + } + + /** + *

    Unset; unsupported clinical units remain in source context.

    + * @return Reference to {@code this} so that method calls can be chained together. + */ + @java.lang.Override + public _FinalStage unitSourceValue(String unitSourceValue) { + this.unitSourceValue = Optional.ofNullable(unitSourceValue); + return this; + } + + /** + *

    Unset; unsupported clinical units remain in source context.

    + */ + @java.lang.Override + @JsonSetter(value = "unit_source_value", nulls = Nulls.SKIP) + public _FinalStage unitSourceValue(Optional unitSourceValue) { + this.unitSourceValue = unitSourceValue; + return this; + } + + /** + *

    Always null.

    + * @return Reference to {@code this} so that method calls can be chained together. + */ + @java.lang.Override + public _FinalStage unitConceptId(Long unitConceptId) { + this.unitConceptId = Optional.ofNullable(unitConceptId); + return this; + } + + /** + *

    Always null.

    + */ + @java.lang.Override + @JsonSetter(value = "unit_concept_id", nulls = Nulls.SKIP) + public _FinalStage unitConceptId(Optional unitConceptId) { + this.unitConceptId = unitConceptId; + return this; + } + + /** + *

    Original selected type or clinical code, or supplied text; never a resource/inventory identifier.

    + * @return Reference to {@code this} so that method calls can be chained together. + */ + @java.lang.Override + public _FinalStage deviceSourceValue(String deviceSourceValue) { + this.deviceSourceValue = Optional.ofNullable(deviceSourceValue); + return this; + } + + /** + *

    Original selected type or clinical code, or supplied text; never a resource/inventory identifier.

    + */ + @java.lang.Override + @JsonSetter(value = "device_source_value", nulls = Nulls.SKIP) + public _FinalStage deviceSourceValue(Optional deviceSourceValue) { + this.deviceSourceValue = deviceSourceValue; + return this; + } + + /** + *

    Unset; no visit-detail linkage is inferred.

    + * @return Reference to {@code this} so that method calls can be chained together. + */ + @java.lang.Override + public _FinalStage visitDetailId(Long visitDetailId) { + this.visitDetailId = Optional.ofNullable(visitDetailId); + return this; + } + + /** + *

    Unset; no visit-detail linkage is inferred.

    + */ + @java.lang.Override + @JsonSetter(value = "visit_detail_id", nulls = Nulls.SKIP) + public _FinalStage visitDetailId(Optional visitDetailId) { + this.visitDetailId = visitDetailId; + return this; + } + + /** + *

    Explicit emitted same-patient visit for routed rows; absent for DeviceUseStatement.

    + * @return Reference to {@code this} so that method calls can be chained together. + */ + @java.lang.Override + public _FinalStage visitOccurrenceId(Long visitOccurrenceId) { + this.visitOccurrenceId = Optional.ofNullable(visitOccurrenceId); + return this; + } + + /** + *

    Explicit emitted same-patient visit for routed rows; absent for DeviceUseStatement.

    + */ + @java.lang.Override + @JsonSetter(value = "visit_occurrence_id", nulls = Nulls.SKIP) + public _FinalStage visitOccurrenceId(Optional visitOccurrenceId) { + this.visitOccurrenceId = visitOccurrenceId; + return this; + } + + /** + *

    Compatible explicit performer on routed rows; absent for DeviceUseStatement and reporting-only actors.

    + * @return Reference to {@code this} so that method calls can be chained together. + */ + @java.lang.Override + public _FinalStage providerId(Long providerId) { + this.providerId = Optional.ofNullable(providerId); + return this; + } + + /** + *

    Compatible explicit performer on routed rows; absent for DeviceUseStatement and reporting-only actors.

    + */ + @java.lang.Override + @JsonSetter(value = "provider_id", nulls = Nulls.SKIP) + public _FinalStage providerId(Optional providerId) { + this.providerId = providerId; + return this; + } + + /** + *

    1 when the source has no quantity, dose, rate, or numeric Observation result field; omitted when such a field is supplied, including null or malformed values. See device_exposure_contexts.quantity_basis.

    + * @return Reference to {@code this} so that method calls can be chained together. + */ + @java.lang.Override + public _FinalStage quantity(Long quantity) { + this.quantity = Optional.ofNullable(quantity); + return this; + } + + /** + *

    1 when the source has no quantity, dose, rate, or numeric Observation result field; omitted when such a field is supplied, including null or malformed values. See device_exposure_contexts.quantity_basis.

    + */ + @java.lang.Override + @JsonSetter(value = "quantity", nulls = Nulls.SKIP) + public _FinalStage quantity(Optional quantity) { + this.quantity = quantity; + return this; + } + + /** + *

    Unset; supplied production components and carriers remain in source context.

    + * @return Reference to {@code this} so that method calls can be chained together. + */ + @java.lang.Override + public _FinalStage productionId(String productionId) { + this.productionId = Optional.ofNullable(productionId); + return this; + } + + /** + *

    Unset; supplied production components and carriers remain in source context.

    + */ + @java.lang.Override + @JsonSetter(value = "production_id", nulls = Nulls.SKIP) + public _FinalStage productionId(Optional productionId) { + this.productionId = productionId; + return this; + } + + /** + *

    Unambiguous supplied UDI-DI; competing values remain in source context.

    + * @return Reference to {@code this} so that method calls can be chained together. + */ + @java.lang.Override + public _FinalStage uniqueDeviceId(String uniqueDeviceId) { + this.uniqueDeviceId = Optional.ofNullable(uniqueDeviceId); + return this; + } + + /** + *

    Unambiguous supplied UDI-DI; competing values remain in source context.

    + */ + @java.lang.Override + @JsonSetter(value = "unique_device_id", nulls = Nulls.SKIP) + public _FinalStage uniqueDeviceId(Optional uniqueDeviceId) { + this.uniqueDeviceId = uniqueDeviceId; + return this; + } + + /** + *

    Source local wall-clock end, without UTC offset; absent for date-only input.

    + * @return Reference to {@code this} so that method calls can be chained together. + */ + @java.lang.Override + public _FinalStage deviceExposureEndDatetime(String deviceExposureEndDatetime) { + this.deviceExposureEndDatetime = Optional.ofNullable(deviceExposureEndDatetime); + return this; + } + + /** + *

    Source local wall-clock end, without UTC offset; absent for date-only input.

    + */ + @java.lang.Override + @JsonSetter(value = "device_exposure_end_datetime", nulls = Nulls.SKIP) + public _FinalStage deviceExposureEndDatetime(Optional deviceExposureEndDatetime) { + this.deviceExposureEndDatetime = deviceExposureEndDatetime; + return this; + } + + /** + *

    Explicit source end; absent when unknown or unusable.

    + * @return Reference to {@code this} so that method calls can be chained together. + */ + @java.lang.Override + public _FinalStage deviceExposureEndDate(String deviceExposureEndDate) { + this.deviceExposureEndDate = Optional.ofNullable(deviceExposureEndDate); + return this; + } + + /** + *

    Explicit source end; absent when unknown or unusable.

    + */ + @java.lang.Override + @JsonSetter(value = "device_exposure_end_date", nulls = Nulls.SKIP) + public _FinalStage deviceExposureEndDate(Optional deviceExposureEndDate) { + this.deviceExposureEndDate = deviceExposureEndDate; + return this; + } + + /** + *

    Source local wall-clock start, without UTC offset; absent for date-only input.

    + * @return Reference to {@code this} so that method calls can be chained together. + */ + @java.lang.Override + public _FinalStage deviceExposureStartDatetime(String deviceExposureStartDatetime) { + this.deviceExposureStartDatetime = Optional.ofNullable(deviceExposureStartDatetime); + return this; + } + + /** + *

    Source local wall-clock start, without UTC offset; absent for date-only input.

    + */ + @java.lang.Override + @JsonSetter(value = "device_exposure_start_datetime", nulls = Nulls.SKIP) + public _FinalStage deviceExposureStartDatetime(Optional deviceExposureStartDatetime) { + this.deviceExposureStartDatetime = deviceExposureStartDatetime; + return this; + } + + /** + *

    Source use start at day precision; absent when unknown or unusable.

    + * @return Reference to {@code this} so that method calls can be chained together. + */ + @java.lang.Override + public _FinalStage deviceExposureStartDate(String deviceExposureStartDate) { + this.deviceExposureStartDate = Optional.ofNullable(deviceExposureStartDate); + return this; + } + + /** + *

    Source use start at day precision; absent when unknown or unusable.

    + */ + @java.lang.Override + @JsonSetter(value = "device_exposure_start_date", nulls = Nulls.SKIP) + public _FinalStage deviceExposureStartDate(Optional deviceExposureStartDate) { + this.deviceExposureStartDate = deviceExposureStartDate; + return this; + } + + @java.lang.Override + public DeviceExposureRow build() { + return new DeviceExposureRow( + deviceExposureId, + personId, + deviceConceptId, + deviceExposureStartDate, + deviceExposureStartDatetime, + deviceExposureEndDate, + deviceExposureEndDatetime, + deviceTypeConceptId, + uniqueDeviceId, + productionId, + quantity, + providerId, + visitOccurrenceId, + visitDetailId, + deviceSourceValue, + deviceSourceConceptId, + unitConceptId, + unitSourceValue, + unitSourceConceptId, + additionalProperties); + } + + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("device_exposure_id") + || key.equalsIgnoreCase("person_id") + || key.equalsIgnoreCase("device_concept_id") + || key.equalsIgnoreCase("device_exposure_start_date") + || key.equalsIgnoreCase("device_exposure_start_datetime") + || key.equalsIgnoreCase("device_exposure_end_date") + || key.equalsIgnoreCase("device_exposure_end_datetime") + || key.equalsIgnoreCase("device_type_concept_id") + || key.equalsIgnoreCase("unique_device_id") + || key.equalsIgnoreCase("production_id") + || key.equalsIgnoreCase("quantity") + || key.equalsIgnoreCase("provider_id") + || key.equalsIgnoreCase("visit_occurrence_id") + || key.equalsIgnoreCase("visit_detail_id") + || key.equalsIgnoreCase("device_source_value") + || key.equalsIgnoreCase("device_source_concept_id") + || key.equalsIgnoreCase("unit_concept_id") + || key.equalsIgnoreCase("unit_source_value") + || key.equalsIgnoreCase("unit_source_concept_id")) { + return; + } + this.additionalProperties.put(key, value); + } + + @java.lang.Override + public Builder additionalProperty(String key, Object value) { + this.additionalProperties.put(key, value); + return this; + } + + @java.lang.Override + public Builder additionalProperties(Map additionalProperties) { + this.additionalProperties.putAll(additionalProperties); + return this; + } + } +} diff --git a/src/main/java/com/phenoml/api/resources/fhir2omop/types/DroppedResource.java b/src/main/java/com/phenoml/api/resources/fhir2omop/types/DroppedResource.java index 59124d7b..6e609c35 100644 --- a/src/main/java/com/phenoml/api/resources/fhir2omop/types/DroppedResource.java +++ b/src/main/java/com/phenoml/api/resources/fhir2omop/types/DroppedResource.java @@ -93,7 +93,6 @@ public static final class Builder { private Optional reason = Optional.empty(); - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -142,6 +141,16 @@ public DroppedResource build() { return new DroppedResource(resourceType, resourceId, reason, additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("resource_type") + || key.equalsIgnoreCase("resource_id") + || key.equalsIgnoreCase("reason")) { + return; + } + this.additionalProperties.put(key, value); + } + public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); return this; diff --git a/src/main/java/com/phenoml/api/resources/fhir2omop/types/DrugExposureRow.java b/src/main/java/com/phenoml/api/resources/fhir2omop/types/DrugExposureRow.java index 205b0b78..ab679c7c 100644 --- a/src/main/java/com/phenoml/api/resources/fhir2omop/types/DrugExposureRow.java +++ b/src/main/java/com/phenoml/api/resources/fhir2omop/types/DrugExposureRow.java @@ -44,6 +44,8 @@ public final class DrugExposureRow { private final Optional daysSupply; + private final Optional quantity; + private final Optional sig; private final Optional routeConceptId; @@ -75,6 +77,7 @@ private DrugExposureRow( Optional stopReason, Optional refills, Optional daysSupply, + Optional quantity, Optional sig, Optional routeConceptId, Optional lotNumber, @@ -96,6 +99,7 @@ private DrugExposureRow( this.stopReason = stopReason; this.refills = refills; this.daysSupply = daysSupply; + this.quantity = quantity; this.sig = sig; this.routeConceptId = routeConceptId; this.lotNumber = lotNumber; @@ -123,7 +127,7 @@ public Optional getDrugConceptId() { } /** - * @return Date from the resource's timing source, such as effective[x], occurrenceDateTime, or MedicationRequest.authoredOn. + * @return Start date selected by the source resource's timing rules, such as effective[x], occurrenceDateTime, MedicationRequest.authoredOn, or MedicationDispense.whenHandedOver; see the endpoint's timing sources for all supported resources. */ @JsonProperty("drug_exposure_start_date") public Optional getDrugExposureStartDate() { @@ -136,7 +140,7 @@ public Optional getDrugExposureStartDatetime() { } /** - * @return Date from an explicit FHIR Period end, or the same day as the start for a single-event administration or immunization. Unset when the source supplies neither. + * @return End date selected by the source resource's timing rules, when available. For medication resources, an explicit effectivePeriod.end or the start date of a single-event administration or immunization; see the endpoint's timing sources for other resources. */ @JsonProperty("drug_exposure_end_date") public Optional getDrugExposureEndDate() { @@ -156,6 +160,9 @@ public Optional getVerbatimEndDate() { return verbatimEndDate; } + /** + * @return MedicationDispense uses 32825 (EHR dispensing record); MedicationRequest uses 32838 (EHR prescription). Other current resources use 32817 (EHR). These identify record provenance, not proof of medication consumption. + */ @JsonProperty("drug_type_concept_id") public Optional getDrugTypeConceptId() { return drugTypeConceptId; @@ -175,13 +182,21 @@ public Optional getRefills() { } /** - * @return Direct positive whole-day MedicationRequest.dispenseRequest.expectedSupplyDuration; no dose or quantity conversion is applied. + * @return Direct positive whole-day MedicationRequest.dispenseRequest.expectedSupplyDuration or coded UCUM-day MedicationDispense.daysSupply; never derived from drug quantity, dosage, refills, or timing. Does not derive an exposure end date. */ @JsonProperty("days_supply") public Optional getDaysSupply() { return daysSupply; } + /** + * @return Formulation-specific quantity from a direct MedicationRequest dispense quantity, MedicationDispense quantity, MedicationAdministration dose, or Immunization dose quantity. Under fixed_dose_form_count this is a matched coded tablet/capsule count copied directly, or an ingredient amount divided by per-unit strength. Under clinical_injection_ingredient_amount this is the ingredient amount, converted to the ingredient unit or derived using explicit concentration evidence; exact-unit ingredient amounts are copied directly. See the endpoint's supported dose forms and prerequisites; quantity_transformations identifies the policy, source, calculation evidence, intermediate UCUM conversion, and output unit, or explains why a supplied quantity was omitted. + */ + @JsonProperty("quantity") + public Optional getQuantity() { + return quantity; + } + /** * @return Newline-joined non-empty FHIR Dosage.text instructions in source order. */ @@ -258,6 +273,7 @@ private boolean equalTo(DrugExposureRow other) { && stopReason.equals(other.stopReason) && refills.equals(other.refills) && daysSupply.equals(other.daysSupply) + && quantity.equals(other.quantity) && sig.equals(other.sig) && routeConceptId.equals(other.routeConceptId) && lotNumber.equals(other.lotNumber) @@ -283,6 +299,7 @@ public int hashCode() { this.stopReason, this.refills, this.daysSupply, + this.quantity, this.sig, this.routeConceptId, this.lotNumber, @@ -328,6 +345,8 @@ public static final class Builder { private Optional daysSupply = Optional.empty(); + private Optional quantity = Optional.empty(); + private Optional sig = Optional.empty(); private Optional routeConceptId = Optional.empty(); @@ -344,7 +363,6 @@ public static final class Builder { private Optional routeSourceValue = Optional.empty(); - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -362,6 +380,7 @@ public Builder from(DrugExposureRow other) { stopReason(other.getStopReason()); refills(other.getRefills()); daysSupply(other.getDaysSupply()); + quantity(other.getQuantity()); sig(other.getSig()); routeConceptId(other.getRouteConceptId()); lotNumber(other.getLotNumber()); @@ -407,7 +426,7 @@ public Builder drugConceptId(Long drugConceptId) { } /** - *

    Date from the resource's timing source, such as effective[x], occurrenceDateTime, or MedicationRequest.authoredOn.

    + *

    Start date selected by the source resource's timing rules, such as effective[x], occurrenceDateTime, MedicationRequest.authoredOn, or MedicationDispense.whenHandedOver; see the endpoint's timing sources for all supported resources.

    */ @JsonSetter(value = "drug_exposure_start_date", nulls = Nulls.SKIP) public Builder drugExposureStartDate(Optional drugExposureStartDate) { @@ -432,7 +451,7 @@ public Builder drugExposureStartDatetime(String drugExposureStartDatetime) { } /** - *

    Date from an explicit FHIR Period end, or the same day as the start for a single-event administration or immunization. Unset when the source supplies neither.

    + *

    End date selected by the source resource's timing rules, when available. For medication resources, an explicit effectivePeriod.end or the start date of a single-event administration or immunization; see the endpoint's timing sources for other resources.

    */ @JsonSetter(value = "drug_exposure_end_date", nulls = Nulls.SKIP) public Builder drugExposureEndDate(Optional drugExposureEndDate) { @@ -470,6 +489,9 @@ public Builder verbatimEndDate(String verbatimEndDate) { return this; } + /** + *

    MedicationDispense uses 32825 (EHR dispensing record); MedicationRequest uses 32838 (EHR prescription). Other current resources use 32817 (EHR). These identify record provenance, not proof of medication consumption.

    + */ @JsonSetter(value = "drug_type_concept_id", nulls = Nulls.SKIP) public Builder drugTypeConceptId(Optional drugTypeConceptId) { this.drugTypeConceptId = drugTypeConceptId; @@ -507,7 +529,7 @@ public Builder refills(Long refills) { } /** - *

    Direct positive whole-day MedicationRequest.dispenseRequest.expectedSupplyDuration; no dose or quantity conversion is applied.

    + *

    Direct positive whole-day MedicationRequest.dispenseRequest.expectedSupplyDuration or coded UCUM-day MedicationDispense.daysSupply; never derived from drug quantity, dosage, refills, or timing. Does not derive an exposure end date.

    */ @JsonSetter(value = "days_supply", nulls = Nulls.SKIP) public Builder daysSupply(Optional daysSupply) { @@ -520,6 +542,20 @@ public Builder daysSupply(Long daysSupply) { return this; } + /** + *

    Formulation-specific quantity from a direct MedicationRequest dispense quantity, MedicationDispense quantity, MedicationAdministration dose, or Immunization dose quantity. Under fixed_dose_form_count this is a matched coded tablet/capsule count copied directly, or an ingredient amount divided by per-unit strength. Under clinical_injection_ingredient_amount this is the ingredient amount, converted to the ingredient unit or derived using explicit concentration evidence; exact-unit ingredient amounts are copied directly. See the endpoint's supported dose forms and prerequisites; quantity_transformations identifies the policy, source, calculation evidence, intermediate UCUM conversion, and output unit, or explains why a supplied quantity was omitted.

    + */ + @JsonSetter(value = "quantity", nulls = Nulls.SKIP) + public Builder quantity(Optional quantity) { + this.quantity = quantity; + return this; + } + + public Builder quantity(Double quantity) { + this.quantity = Optional.ofNullable(quantity); + return this; + } + /** *

    Newline-joined non-empty FHIR Dosage.text instructions in source order.

    */ @@ -634,6 +670,7 @@ public DrugExposureRow build() { stopReason, refills, daysSupply, + quantity, sig, routeConceptId, lotNumber, @@ -645,6 +682,34 @@ public DrugExposureRow build() { additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("drug_exposure_id") + || key.equalsIgnoreCase("person_id") + || key.equalsIgnoreCase("drug_concept_id") + || key.equalsIgnoreCase("drug_exposure_start_date") + || key.equalsIgnoreCase("drug_exposure_start_datetime") + || key.equalsIgnoreCase("drug_exposure_end_date") + || key.equalsIgnoreCase("drug_exposure_end_datetime") + || key.equalsIgnoreCase("verbatim_end_date") + || key.equalsIgnoreCase("drug_type_concept_id") + || key.equalsIgnoreCase("stop_reason") + || key.equalsIgnoreCase("refills") + || key.equalsIgnoreCase("days_supply") + || key.equalsIgnoreCase("quantity") + || key.equalsIgnoreCase("sig") + || key.equalsIgnoreCase("route_concept_id") + || key.equalsIgnoreCase("lot_number") + || key.equalsIgnoreCase("visit_occurrence_id") + || key.equalsIgnoreCase("provider_id") + || key.equalsIgnoreCase("drug_source_value") + || key.equalsIgnoreCase("drug_source_concept_id") + || key.equalsIgnoreCase("route_source_value")) { + return; + } + this.additionalProperties.put(key, value); + } + public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); return this; diff --git a/src/main/java/com/phenoml/api/resources/fhir2omop/types/LocationRow.java b/src/main/java/com/phenoml/api/resources/fhir2omop/types/LocationRow.java index cb96ad3f..15644894 100644 --- a/src/main/java/com/phenoml/api/resources/fhir2omop/types/LocationRow.java +++ b/src/main/java/com/phenoml/api/resources/fhir2omop/types/LocationRow.java @@ -213,7 +213,6 @@ public static final class Builder { private Optional longitude = Optional.empty(); - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -383,6 +382,25 @@ public LocationRow build() { additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("location_id") + || key.equalsIgnoreCase("address_1") + || key.equalsIgnoreCase("address_2") + || key.equalsIgnoreCase("city") + || key.equalsIgnoreCase("state") + || key.equalsIgnoreCase("zip") + || key.equalsIgnoreCase("county") + || key.equalsIgnoreCase("location_source_value") + || key.equalsIgnoreCase("country_concept_id") + || key.equalsIgnoreCase("country_source_value") + || key.equalsIgnoreCase("latitude") + || key.equalsIgnoreCase("longitude")) { + return; + } + this.additionalProperties.put(key, value); + } + public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); return this; diff --git a/src/main/java/com/phenoml/api/resources/fhir2omop/types/MappingEntry.java b/src/main/java/com/phenoml/api/resources/fhir2omop/types/MappingEntry.java index 0df16e2c..ba35f3f2 100644 --- a/src/main/java/com/phenoml/api/resources/fhir2omop/types/MappingEntry.java +++ b/src/main/java/com/phenoml/api/resources/fhir2omop/types/MappingEntry.java @@ -36,6 +36,8 @@ public final class MappingEntry { private final Optional sourceName; + private final Optional sourceCodingIndex; + private final Optional targetVocabulary; private final Optional targetCode; @@ -46,6 +48,8 @@ public final class MappingEntry { private final boolean selected; + private final Optional resolution; + private final Optional note; private final Map additionalProperties; @@ -59,11 +63,13 @@ private MappingEntry( Optional sourceSystem, Optional sourceCode, Optional sourceName, + Optional sourceCodingIndex, Optional targetVocabulary, Optional targetCode, Optional targetName, Optional mappingStatus, boolean selected, + Optional resolution, Optional note, Map additionalProperties) { this.resourceType = resourceType; @@ -74,11 +80,13 @@ private MappingEntry( this.sourceSystem = sourceSystem; this.sourceCode = sourceCode; this.sourceName = sourceName; + this.sourceCodingIndex = sourceCodingIndex; this.targetVocabulary = targetVocabulary; this.targetCode = targetCode; this.targetName = targetName; this.mappingStatus = mappingStatus; this.selected = selected; + this.resolution = resolution; this.note = note; this.additionalProperties = additionalProperties; } @@ -109,7 +117,7 @@ public Optional getOmopId() { } /** - * @return The OMOP concept-ID field populated from this source coding, such as condition_concept_id, route_concept_id, or race_concept_id. + * @return The OMOP concept-ID field populated from this source coding, such as condition_concept_id, route_concept_id, or race_concept_id. Omitted when the entry preserves source information without populating a row column. */ @JsonProperty("omop_field") public Optional getOmopField() { @@ -131,6 +139,14 @@ public Optional getSourceName() { return sourceName; } + /** + * @return Zero-based index of this coding in its originating specialty entry's coding array. When specialty audit is available, it also identifies the corresponding resolution.specialty_resolution.coding_results outcome. Present only for coded specialty mappings, including empty Coding objects; these mappings retain the supplied coding order. + */ + @JsonProperty("source_coding_index") + public Optional getSourceCodingIndex() { + return sourceCodingIndex; + } + @JsonProperty("target_vocabulary") public Optional getTargetVocabulary() { return targetVocabulary; @@ -154,10 +170,16 @@ public Optional getTargetName() { /** * @return ALREADY_STANDARD (source coding is already a standard OMOP concept), - * MAPPED (source coding was mapped to a standard concept), UNCHECKED (a - * standard code was suggested for a text-only resource but not verified - * against the OMOP vocabulary, so concept_id stays 0), or UNMAPPED - * (no standard concept found). + * MAPPED (source coding or descriptive source text was mapped to a standard + * concept), UNCHECKED (a suggested code has not been validated against + * the OMOP vocabulary, so concept_id is 0), or UNMAPPED + * (no target concept selected, including missing mappings, + * target-domain mismatches and ambiguity, or source information + * retained without concept resolution as explained in note). For + * specialties, this describes the entry-level result selected from + * supported codings or explicit text. A local coding can remain + * unsupported even when the entry resolves; its independent outcome + * remains in resolution.specialty_resolution.coding_results. */ @JsonProperty("mapping_status") public Optional getMappingStatus() { @@ -165,15 +187,22 @@ public Optional getMappingStatus() { } /** - * @return Whether this source coding was selected for the linked row's *_source_value field. Always present; false for alternate codings and text-only rows. For a Patient demographic, it marks the code that determined the PERSON field or race observation row, even when that code has no concept; it is false for race and ethnicity null flavors, conflicting values, a Patient gender overridden by birth sex, and race categories that did not determine the field. + * @return Whether this source coding was selected for the linked row's *_source_value field. Always present; false for alternate codings, text-only rows, and entries without a corresponding row column. For a Patient demographic, it marks the code that determined the PERSON field or race observation row, even when that code has no concept; it is false for race and ethnicity null flavors, conflicting values, a Patient gender overridden by birth sex, and race categories that did not determine the field. */ @JsonProperty("selected") public boolean getSelected() { return selected; } + @JsonProperty("resolution") + public Optional getResolution() { + return resolution; + } + /** - * @return Additional context for the entry. A coded route is noted as + * @return Additional context for the entry, including the output table, + * populated fields, source information without a corresponding row + * column, and DOMAIN_MISMATCH outcomes. A coded route is noted as * FHIR route. Patient demographic entries name their source and, when * not applied, why: *
      @@ -216,11 +245,13 @@ private boolean equalTo(MappingEntry other) { && sourceSystem.equals(other.sourceSystem) && sourceCode.equals(other.sourceCode) && sourceName.equals(other.sourceName) + && sourceCodingIndex.equals(other.sourceCodingIndex) && targetVocabulary.equals(other.targetVocabulary) && targetCode.equals(other.targetCode) && targetName.equals(other.targetName) && mappingStatus.equals(other.mappingStatus) && selected == other.selected + && resolution.equals(other.resolution) && note.equals(other.note); } @@ -235,11 +266,13 @@ public int hashCode() { this.sourceSystem, this.sourceCode, this.sourceName, + this.sourceCodingIndex, this.targetVocabulary, this.targetCode, this.targetName, this.mappingStatus, this.selected, + this.resolution, this.note); } @@ -254,7 +287,7 @@ public static SelectedStage builder() { public interface SelectedStage { /** - *

      Whether this source coding was selected for the linked row's *_source_value field. Always present; false for alternate codings and text-only rows. For a Patient demographic, it marks the code that determined the PERSON field or race observation row, even when that code has no concept; it is false for race and ethnicity null flavors, conflicting values, a Patient gender overridden by birth sex, and race categories that did not determine the field.

      + *

      Whether this source coding was selected for the linked row's *_source_value field. Always present; false for alternate codings, text-only rows, and entries without a corresponding row column. For a Patient demographic, it marks the code that determined the PERSON field or race observation row, even when that code has no concept; it is false for race and ethnicity null flavors, conflicting values, a Patient gender overridden by birth sex, and race categories that did not determine the field.

      */ _FinalStage selected(boolean selected); @@ -290,7 +323,7 @@ public interface _FinalStage { _FinalStage omopId(Long omopId); /** - *

      The OMOP concept-ID field populated from this source coding, such as condition_concept_id, route_concept_id, or race_concept_id.

      + *

      The OMOP concept-ID field populated from this source coding, such as condition_concept_id, route_concept_id, or race_concept_id. Omitted when the entry preserves source information without populating a row column.

      */ _FinalStage omopField(Optional omopField); @@ -308,6 +341,13 @@ public interface _FinalStage { _FinalStage sourceName(String sourceName); + /** + *

      Zero-based index of this coding in its originating specialty entry's coding array. When specialty audit is available, it also identifies the corresponding resolution.specialty_resolution.coding_results outcome. Present only for coded specialty mappings, including empty Coding objects; these mappings retain the supplied coding order.

      + */ + _FinalStage sourceCodingIndex(Optional sourceCodingIndex); + + _FinalStage sourceCodingIndex(Integer sourceCodingIndex); + _FinalStage targetVocabulary(Optional targetVocabulary); _FinalStage targetVocabulary(String targetVocabulary); @@ -328,17 +368,29 @@ public interface _FinalStage { /** *

      ALREADY_STANDARD (source coding is already a standard OMOP concept), - * MAPPED (source coding was mapped to a standard concept), UNCHECKED (a - * standard code was suggested for a text-only resource but not verified - * against the OMOP vocabulary, so concept_id stays 0), or UNMAPPED - * (no standard concept found).

      + * MAPPED (source coding or descriptive source text was mapped to a standard + * concept), UNCHECKED (a suggested code has not been validated against + * the OMOP vocabulary, so concept_id is 0), or UNMAPPED + * (no target concept selected, including missing mappings, + * target-domain mismatches and ambiguity, or source information + * retained without concept resolution as explained in note). For + * specialties, this describes the entry-level result selected from + * supported codings or explicit text. A local coding can remain + * unsupported even when the entry resolves; its independent outcome + * remains in resolution.specialty_resolution.coding_results.

      */ _FinalStage mappingStatus(Optional mappingStatus); _FinalStage mappingStatus(MappingEntryMappingStatus mappingStatus); + _FinalStage resolution(Optional resolution); + + _FinalStage resolution(MappingResolution resolution); + /** - *

      Additional context for the entry. A coded route is noted as + *

      Additional context for the entry, including the output table, + * populated fields, source information without a corresponding row + * column, and DOMAIN_MISMATCH outcomes. A coded route is noted as * FHIR route. Patient demographic entries name their source and, when * not applied, why:

      *
        @@ -367,6 +419,8 @@ public static final class Builder implements SelectedStage, _FinalStage { private Optional note = Optional.empty(); + private Optional resolution = Optional.empty(); + private Optional mappingStatus = Optional.empty(); private Optional targetName = Optional.empty(); @@ -375,6 +429,8 @@ public static final class Builder implements SelectedStage, _FinalStage { private Optional targetVocabulary = Optional.empty(); + private Optional sourceCodingIndex = Optional.empty(); + private Optional sourceName = Optional.empty(); private Optional sourceCode = Optional.empty(); @@ -391,7 +447,6 @@ public static final class Builder implements SelectedStage, _FinalStage { private Optional resourceType = Optional.empty(); - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -406,17 +461,19 @@ public Builder from(MappingEntry other) { sourceSystem(other.getSourceSystem()); sourceCode(other.getSourceCode()); sourceName(other.getSourceName()); + sourceCodingIndex(other.getSourceCodingIndex()); targetVocabulary(other.getTargetVocabulary()); targetCode(other.getTargetCode()); targetName(other.getTargetName()); mappingStatus(other.getMappingStatus()); selected(other.getSelected()); + resolution(other.getResolution()); note(other.getNote()); return this; } /** - *

        Whether this source coding was selected for the linked row's *_source_value field. Always present; false for alternate codings and text-only rows. For a Patient demographic, it marks the code that determined the PERSON field or race observation row, even when that code has no concept; it is false for race and ethnicity null flavors, conflicting values, a Patient gender overridden by birth sex, and race categories that did not determine the field.

        + *

        Whether this source coding was selected for the linked row's *_source_value field. Always present; false for alternate codings, text-only rows, and entries without a corresponding row column. For a Patient demographic, it marks the code that determined the PERSON field or race observation row, even when that code has no concept; it is false for race and ethnicity null flavors, conflicting values, a Patient gender overridden by birth sex, and race categories that did not determine the field.

        * @return Reference to {@code this} so that method calls can be chained together. */ @java.lang.Override @@ -427,7 +484,9 @@ public _FinalStage selected(boolean selected) { } /** - *

        Additional context for the entry. A coded route is noted as + *

        Additional context for the entry, including the output table, + * populated fields, source information without a corresponding row + * column, and DOMAIN_MISMATCH outcomes. A coded route is noted as * FHIR route. Patient demographic entries name their source and, when * not applied, why:

        *
          @@ -453,7 +512,9 @@ public _FinalStage note(String note) { } /** - *

          Additional context for the entry. A coded route is noted as + *

          Additional context for the entry, including the output table, + * populated fields, source information without a corresponding row + * column, and DOMAIN_MISMATCH outcomes. A coded route is noted as * FHIR route. Patient demographic entries name their source and, when * not applied, why:

          *
            @@ -478,12 +539,31 @@ public _FinalStage note(Optional note) { return this; } + @java.lang.Override + public _FinalStage resolution(MappingResolution resolution) { + this.resolution = Optional.ofNullable(resolution); + return this; + } + + @java.lang.Override + @JsonSetter(value = "resolution", nulls = Nulls.SKIP) + public _FinalStage resolution(Optional resolution) { + this.resolution = resolution; + return this; + } + /** *

            ALREADY_STANDARD (source coding is already a standard OMOP concept), - * MAPPED (source coding was mapped to a standard concept), UNCHECKED (a - * standard code was suggested for a text-only resource but not verified - * against the OMOP vocabulary, so concept_id stays 0), or UNMAPPED - * (no standard concept found).

            + * MAPPED (source coding or descriptive source text was mapped to a standard + * concept), UNCHECKED (a suggested code has not been validated against + * the OMOP vocabulary, so concept_id is 0), or UNMAPPED + * (no target concept selected, including missing mappings, + * target-domain mismatches and ambiguity, or source information + * retained without concept resolution as explained in note). For + * specialties, this describes the entry-level result selected from + * supported codings or explicit text. A local coding can remain + * unsupported even when the entry resolves; its independent outcome + * remains in resolution.specialty_resolution.coding_results.

            * @return Reference to {@code this} so that method calls can be chained together. */ @java.lang.Override @@ -494,10 +574,16 @@ public _FinalStage mappingStatus(MappingEntryMappingStatus mappingStatus) { /** *

            ALREADY_STANDARD (source coding is already a standard OMOP concept), - * MAPPED (source coding was mapped to a standard concept), UNCHECKED (a - * standard code was suggested for a text-only resource but not verified - * against the OMOP vocabulary, so concept_id stays 0), or UNMAPPED - * (no standard concept found).

            + * MAPPED (source coding or descriptive source text was mapped to a standard + * concept), UNCHECKED (a suggested code has not been validated against + * the OMOP vocabulary, so concept_id is 0), or UNMAPPED + * (no target concept selected, including missing mappings, + * target-domain mismatches and ambiguity, or source information + * retained without concept resolution as explained in note). For + * specialties, this describes the entry-level result selected from + * supported codings or explicit text. A local coding can remain + * unsupported even when the entry resolves; its independent outcome + * remains in resolution.specialty_resolution.coding_results.

            */ @java.lang.Override @JsonSetter(value = "mapping_status", nulls = Nulls.SKIP) @@ -558,6 +644,26 @@ public _FinalStage targetVocabulary(Optional targetVocabulary) { return this; } + /** + *

            Zero-based index of this coding in its originating specialty entry's coding array. When specialty audit is available, it also identifies the corresponding resolution.specialty_resolution.coding_results outcome. Present only for coded specialty mappings, including empty Coding objects; these mappings retain the supplied coding order.

            + * @return Reference to {@code this} so that method calls can be chained together. + */ + @java.lang.Override + public _FinalStage sourceCodingIndex(Integer sourceCodingIndex) { + this.sourceCodingIndex = Optional.ofNullable(sourceCodingIndex); + return this; + } + + /** + *

            Zero-based index of this coding in its originating specialty entry's coding array. When specialty audit is available, it also identifies the corresponding resolution.specialty_resolution.coding_results outcome. Present only for coded specialty mappings, including empty Coding objects; these mappings retain the supplied coding order.

            + */ + @java.lang.Override + @JsonSetter(value = "source_coding_index", nulls = Nulls.SKIP) + public _FinalStage sourceCodingIndex(Optional sourceCodingIndex) { + this.sourceCodingIndex = sourceCodingIndex; + return this; + } + @java.lang.Override public _FinalStage sourceName(String sourceName) { this.sourceName = Optional.ofNullable(sourceName); @@ -598,7 +704,7 @@ public _FinalStage sourceSystem(Optional sourceSystem) { } /** - *

            The OMOP concept-ID field populated from this source coding, such as condition_concept_id, route_concept_id, or race_concept_id.

            + *

            The OMOP concept-ID field populated from this source coding, such as condition_concept_id, route_concept_id, or race_concept_id. Omitted when the entry preserves source information without populating a row column.

            * @return Reference to {@code this} so that method calls can be chained together. */ @java.lang.Override @@ -608,7 +714,7 @@ public _FinalStage omopField(String omopField) { } /** - *

            The OMOP concept-ID field populated from this source coding, such as condition_concept_id, route_concept_id, or race_concept_id.

            + *

            The OMOP concept-ID field populated from this source coding, such as condition_concept_id, route_concept_id, or race_concept_id. Omitted when the entry preserves source information without populating a row column.

            */ @java.lang.Override @JsonSetter(value = "omop_field", nulls = Nulls.SKIP) @@ -691,15 +797,40 @@ public MappingEntry build() { sourceSystem, sourceCode, sourceName, + sourceCodingIndex, targetVocabulary, targetCode, targetName, mappingStatus, selected, + resolution, note, additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("resource_type") + || key.equalsIgnoreCase("resource_id") + || key.equalsIgnoreCase("omop_table") + || key.equalsIgnoreCase("omop_id") + || key.equalsIgnoreCase("omop_field") + || key.equalsIgnoreCase("source_system") + || key.equalsIgnoreCase("source_code") + || key.equalsIgnoreCase("source_name") + || key.equalsIgnoreCase("source_coding_index") + || key.equalsIgnoreCase("target_vocabulary") + || key.equalsIgnoreCase("target_code") + || key.equalsIgnoreCase("target_name") + || key.equalsIgnoreCase("mapping_status") + || key.equalsIgnoreCase("selected") + || key.equalsIgnoreCase("resolution") + || key.equalsIgnoreCase("note")) { + return; + } + this.additionalProperties.put(key, value); + } + @java.lang.Override public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); diff --git a/src/main/java/com/phenoml/api/resources/fhir2omop/types/MappingPath.java b/src/main/java/com/phenoml/api/resources/fhir2omop/types/MappingPath.java new file mode 100644 index 00000000..8030f876 --- /dev/null +++ b/src/main/java/com/phenoml/api/resources/fhir2omop/types/MappingPath.java @@ -0,0 +1,396 @@ +/** + * This file was auto-generated by Fern from our API Definition. + */ +package com.phenoml.api.resources.fhir2omop.types; + +import com.fasterxml.jackson.annotation.JsonAnyGetter; +import com.fasterxml.jackson.annotation.JsonAnySetter; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonSetter; +import com.fasterxml.jackson.annotation.Nulls; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.phenoml.api.core.ObjectMappers; +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import org.jetbrains.annotations.NotNull; + +@JsonInclude(JsonInclude.Include.NON_ABSENT) +@JsonDeserialize(builder = MappingPath.Builder.class) +public final class MappingPath { + private final MappingPathMappingStatus mappingStatus; + + private final long sourceConceptId; + + private final Optional sourceVocabulary; + + private final Optional sourceCode; + + private final Optional cui; + + private final Optional sab; + + private final Optional matchedText; + + private final Map additionalProperties; + + private MappingPath( + MappingPathMappingStatus mappingStatus, + long sourceConceptId, + Optional sourceVocabulary, + Optional sourceCode, + Optional cui, + Optional sab, + Optional matchedText, + Map additionalProperties) { + this.mappingStatus = mappingStatus; + this.sourceConceptId = sourceConceptId; + this.sourceVocabulary = sourceVocabulary; + this.sourceCode = sourceCode; + this.cui = cui; + this.sab = sab; + this.matchedText = matchedText; + this.additionalProperties = additionalProperties; + } + + /** + * @return Path outcome. MAPPED denotes an OMOP Maps to crosswalk; POLICY_MAPPED identifies an explicit vocabulary mapping policy and is retained here even though the enclosing mapping_status reports MAPPED. + */ + @JsonProperty("mapping_status") + public MappingPathMappingStatus getMappingStatus() { + return mappingStatus; + } + + /** + * @return OMOP concept at the start of this mapping path. + */ + @JsonProperty("source_concept_id") + public long getSourceConceptId() { + return sourceConceptId; + } + + @JsonProperty("source_vocabulary") + public Optional getSourceVocabulary() { + return sourceVocabulary; + } + + /** + * @return Code of the path's source concept in its OMOP vocabulary. + */ + @JsonProperty("source_code") + public Optional getSourceCode() { + return sourceCode; + } + + /** + * @return Shared UMLS concept identifier for UMLS_BRIDGE, UMLS_STRING, or NUCC_LABEL, when supplied. + */ + @JsonProperty("cui") + public Optional getCui() { + return cui; + } + + /** + * @return UMLS sibling source abbreviation, such as NUCCHCPT for NUCC routes. + */ + @JsonProperty("sab") + public Optional getSab() { + return sab; + } + + /** + * @return Actual vocabulary string matched by a specialty text layer. For NUCC_LABEL this is the full original preferred term, including its grouping prefix; source_text retains the supplied text. + */ + @JsonProperty("matched_text") + public Optional getMatchedText() { + return matchedText; + } + + @java.lang.Override + public boolean equals(Object other) { + if (this == other) return true; + return other instanceof MappingPath && equalTo((MappingPath) other); + } + + @JsonAnyGetter + public Map getAdditionalProperties() { + return this.additionalProperties; + } + + private boolean equalTo(MappingPath other) { + return mappingStatus.equals(other.mappingStatus) + && sourceConceptId == other.sourceConceptId + && sourceVocabulary.equals(other.sourceVocabulary) + && sourceCode.equals(other.sourceCode) + && cui.equals(other.cui) + && sab.equals(other.sab) + && matchedText.equals(other.matchedText); + } + + @java.lang.Override + public int hashCode() { + return Objects.hash( + this.mappingStatus, + this.sourceConceptId, + this.sourceVocabulary, + this.sourceCode, + this.cui, + this.sab, + this.matchedText); + } + + @java.lang.Override + public String toString() { + return ObjectMappers.stringify(this); + } + + public static MappingStatusStage builder() { + return new Builder(); + } + + public interface MappingStatusStage { + /** + *

            Path outcome. MAPPED denotes an OMOP Maps to crosswalk; POLICY_MAPPED identifies an explicit vocabulary mapping policy and is retained here even though the enclosing mapping_status reports MAPPED.

            + */ + SourceConceptIdStage mappingStatus(@NotNull MappingPathMappingStatus mappingStatus); + + Builder from(MappingPath other); + } + + public interface SourceConceptIdStage { + /** + *

            OMOP concept at the start of this mapping path.

            + */ + _FinalStage sourceConceptId(long sourceConceptId); + } + + public interface _FinalStage { + MappingPath build(); + + _FinalStage additionalProperty(String key, Object value); + + _FinalStage additionalProperties(Map additionalProperties); + + _FinalStage sourceVocabulary(Optional sourceVocabulary); + + _FinalStage sourceVocabulary(String sourceVocabulary); + + /** + *

            Code of the path's source concept in its OMOP vocabulary.

            + */ + _FinalStage sourceCode(Optional sourceCode); + + _FinalStage sourceCode(String sourceCode); + + /** + *

            Shared UMLS concept identifier for UMLS_BRIDGE, UMLS_STRING, or NUCC_LABEL, when supplied.

            + */ + _FinalStage cui(Optional cui); + + _FinalStage cui(String cui); + + /** + *

            UMLS sibling source abbreviation, such as NUCCHCPT for NUCC routes.

            + */ + _FinalStage sab(Optional sab); + + _FinalStage sab(String sab); + + /** + *

            Actual vocabulary string matched by a specialty text layer. For NUCC_LABEL this is the full original preferred term, including its grouping prefix; source_text retains the supplied text.

            + */ + _FinalStage matchedText(Optional matchedText); + + _FinalStage matchedText(String matchedText); + } + + @JsonIgnoreProperties(ignoreUnknown = true) + public static final class Builder implements MappingStatusStage, SourceConceptIdStage, _FinalStage { + private MappingPathMappingStatus mappingStatus; + + private long sourceConceptId; + + private Optional matchedText = Optional.empty(); + + private Optional sab = Optional.empty(); + + private Optional cui = Optional.empty(); + + private Optional sourceCode = Optional.empty(); + + private Optional sourceVocabulary = Optional.empty(); + + private Map additionalProperties = new HashMap<>(); + + private Builder() {} + + @java.lang.Override + public Builder from(MappingPath other) { + mappingStatus(other.getMappingStatus()); + sourceConceptId(other.getSourceConceptId()); + sourceVocabulary(other.getSourceVocabulary()); + sourceCode(other.getSourceCode()); + cui(other.getCui()); + sab(other.getSab()); + matchedText(other.getMatchedText()); + return this; + } + + /** + *

            Path outcome. MAPPED denotes an OMOP Maps to crosswalk; POLICY_MAPPED identifies an explicit vocabulary mapping policy and is retained here even though the enclosing mapping_status reports MAPPED.

            + * @return Reference to {@code this} so that method calls can be chained together. + */ + @java.lang.Override + @JsonSetter("mapping_status") + public SourceConceptIdStage mappingStatus(@NotNull MappingPathMappingStatus mappingStatus) { + this.mappingStatus = Objects.requireNonNull(mappingStatus, "mappingStatus must not be null"); + return this; + } + + /** + *

            OMOP concept at the start of this mapping path.

            + * @return Reference to {@code this} so that method calls can be chained together. + */ + @java.lang.Override + @JsonSetter("source_concept_id") + public _FinalStage sourceConceptId(long sourceConceptId) { + this.sourceConceptId = sourceConceptId; + return this; + } + + /** + *

            Actual vocabulary string matched by a specialty text layer. For NUCC_LABEL this is the full original preferred term, including its grouping prefix; source_text retains the supplied text.

            + * @return Reference to {@code this} so that method calls can be chained together. + */ + @java.lang.Override + public _FinalStage matchedText(String matchedText) { + this.matchedText = Optional.ofNullable(matchedText); + return this; + } + + /** + *

            Actual vocabulary string matched by a specialty text layer. For NUCC_LABEL this is the full original preferred term, including its grouping prefix; source_text retains the supplied text.

            + */ + @java.lang.Override + @JsonSetter(value = "matched_text", nulls = Nulls.SKIP) + public _FinalStage matchedText(Optional matchedText) { + this.matchedText = matchedText; + return this; + } + + /** + *

            UMLS sibling source abbreviation, such as NUCCHCPT for NUCC routes.

            + * @return Reference to {@code this} so that method calls can be chained together. + */ + @java.lang.Override + public _FinalStage sab(String sab) { + this.sab = Optional.ofNullable(sab); + return this; + } + + /** + *

            UMLS sibling source abbreviation, such as NUCCHCPT for NUCC routes.

            + */ + @java.lang.Override + @JsonSetter(value = "sab", nulls = Nulls.SKIP) + public _FinalStage sab(Optional sab) { + this.sab = sab; + return this; + } + + /** + *

            Shared UMLS concept identifier for UMLS_BRIDGE, UMLS_STRING, or NUCC_LABEL, when supplied.

            + * @return Reference to {@code this} so that method calls can be chained together. + */ + @java.lang.Override + public _FinalStage cui(String cui) { + this.cui = Optional.ofNullable(cui); + return this; + } + + /** + *

            Shared UMLS concept identifier for UMLS_BRIDGE, UMLS_STRING, or NUCC_LABEL, when supplied.

            + */ + @java.lang.Override + @JsonSetter(value = "cui", nulls = Nulls.SKIP) + public _FinalStage cui(Optional cui) { + this.cui = cui; + return this; + } + + /** + *

            Code of the path's source concept in its OMOP vocabulary.

            + * @return Reference to {@code this} so that method calls can be chained together. + */ + @java.lang.Override + public _FinalStage sourceCode(String sourceCode) { + this.sourceCode = Optional.ofNullable(sourceCode); + return this; + } + + /** + *

            Code of the path's source concept in its OMOP vocabulary.

            + */ + @java.lang.Override + @JsonSetter(value = "source_code", nulls = Nulls.SKIP) + public _FinalStage sourceCode(Optional sourceCode) { + this.sourceCode = sourceCode; + return this; + } + + @java.lang.Override + public _FinalStage sourceVocabulary(String sourceVocabulary) { + this.sourceVocabulary = Optional.ofNullable(sourceVocabulary); + return this; + } + + @java.lang.Override + @JsonSetter(value = "source_vocabulary", nulls = Nulls.SKIP) + public _FinalStage sourceVocabulary(Optional sourceVocabulary) { + this.sourceVocabulary = sourceVocabulary; + return this; + } + + @java.lang.Override + public MappingPath build() { + return new MappingPath( + mappingStatus, + sourceConceptId, + sourceVocabulary, + sourceCode, + cui, + sab, + matchedText, + additionalProperties); + } + + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("mapping_status") + || key.equalsIgnoreCase("source_concept_id") + || key.equalsIgnoreCase("source_vocabulary") + || key.equalsIgnoreCase("source_code") + || key.equalsIgnoreCase("cui") + || key.equalsIgnoreCase("sab") + || key.equalsIgnoreCase("matched_text")) { + return; + } + this.additionalProperties.put(key, value); + } + + @java.lang.Override + public Builder additionalProperty(String key, Object value) { + this.additionalProperties.put(key, value); + return this; + } + + @java.lang.Override + public Builder additionalProperties(Map additionalProperties) { + this.additionalProperties.putAll(additionalProperties); + return this; + } + } +} diff --git a/src/main/java/com/phenoml/api/resources/fhir2omop/types/MappingPathMappingStatus.java b/src/main/java/com/phenoml/api/resources/fhir2omop/types/MappingPathMappingStatus.java new file mode 100644 index 00000000..4c77e043 --- /dev/null +++ b/src/main/java/com/phenoml/api/resources/fhir2omop/types/MappingPathMappingStatus.java @@ -0,0 +1,96 @@ +/** + * This file was auto-generated by Fern from our API Definition. + */ +package com.phenoml.api.resources.fhir2omop.types; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; + +public final class MappingPathMappingStatus { + public static final MappingPathMappingStatus ALREADY_STANDARD = + new MappingPathMappingStatus(Value.ALREADY_STANDARD, "ALREADY_STANDARD"); + + public static final MappingPathMappingStatus MAPPED = new MappingPathMappingStatus(Value.MAPPED, "MAPPED"); + + public static final MappingPathMappingStatus POLICY_MAPPED = + new MappingPathMappingStatus(Value.POLICY_MAPPED, "POLICY_MAPPED"); + + private final Value value; + + private final String string; + + MappingPathMappingStatus(Value value, String string) { + this.value = value; + this.string = string; + } + + public Value getEnumValue() { + return value; + } + + @java.lang.Override + @JsonValue + public String toString() { + return this.string; + } + + @java.lang.Override + public boolean equals(Object other) { + return (this == other) + || (other instanceof MappingPathMappingStatus + && this.string.equals(((MappingPathMappingStatus) other).string)); + } + + @java.lang.Override + public int hashCode() { + return this.string.hashCode(); + } + + public T visit(Visitor visitor) { + switch (value) { + case ALREADY_STANDARD: + return visitor.visitAlreadyStandard(); + case MAPPED: + return visitor.visitMapped(); + case POLICY_MAPPED: + return visitor.visitPolicyMapped(); + case UNKNOWN: + default: + return visitor.visitUnknown(string); + } + } + + @JsonCreator(mode = JsonCreator.Mode.DELEGATING) + public static MappingPathMappingStatus valueOf(String value) { + switch (value) { + case "ALREADY_STANDARD": + return ALREADY_STANDARD; + case "MAPPED": + return MAPPED; + case "POLICY_MAPPED": + return POLICY_MAPPED; + default: + return new MappingPathMappingStatus(Value.UNKNOWN, value); + } + } + + public enum Value { + ALREADY_STANDARD, + + MAPPED, + + POLICY_MAPPED, + + UNKNOWN + } + + public interface Visitor { + T visitAlreadyStandard(); + + T visitMapped(); + + T visitPolicyMapped(); + + T visitUnknown(String unknownType); + } +} diff --git a/src/main/java/com/phenoml/api/resources/fhir2omop/types/MappingResolution.java b/src/main/java/com/phenoml/api/resources/fhir2omop/types/MappingResolution.java new file mode 100644 index 00000000..ba8f7ae1 --- /dev/null +++ b/src/main/java/com/phenoml/api/resources/fhir2omop/types/MappingResolution.java @@ -0,0 +1,528 @@ +/** + * This file was auto-generated by Fern from our API Definition. + */ +package com.phenoml.api.resources.fhir2omop.types; + +import com.fasterxml.jackson.annotation.JsonAnyGetter; +import com.fasterxml.jackson.annotation.JsonAnySetter; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonSetter; +import com.fasterxml.jackson.annotation.Nulls; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.phenoml.api.core.ObjectMappers; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import org.jetbrains.annotations.NotNull; + +@JsonInclude(JsonInclude.Include.NON_ABSENT) +@JsonDeserialize(builder = MappingResolution.Builder.class) +public final class MappingResolution { + private final Optional semanticMapping; + + private final Optional specialtyResolution; + + private final Optional sourceText; + + private final MappingResolutionStatus status; + + private final Optional reasonCode; + + private final Optional via; + + private final Optional> paths; + + private final Optional truncated; + + private final Optional allergy; + + private final Map additionalProperties; + + private MappingResolution( + Optional semanticMapping, + Optional specialtyResolution, + Optional sourceText, + MappingResolutionStatus status, + Optional reasonCode, + Optional via, + Optional> paths, + Optional truncated, + Optional allergy, + Map additionalProperties) { + this.semanticMapping = semanticMapping; + this.specialtyResolution = specialtyResolution; + this.sourceText = sourceText; + this.status = status; + this.reasonCode = reasonCode; + this.via = via; + this.paths = paths; + this.truncated = truncated; + this.allergy = allergy; + this.additionalProperties = additionalProperties; + } + + @JsonProperty("semantic_mapping") + public Optional getSemanticMapping() { + return semanticMapping; + } + + @JsonProperty("specialty_resolution") + public Optional getSpecialtyResolution() { + return specialtyResolution; + } + + /** + * @return Explicit source text used to select a specialty concept. Its vocabulary spelling or full preferred term is retained separately in paths[].matched_text. + */ + @JsonProperty("source_text") + public Optional getSourceText() { + return sourceText; + } + + /** + * @return Vocabulary resolution outcome. Any outcome other than RESOLVED yields mapping_status UNMAPPED and concept ID 0 while retaining an otherwise usable row. + */ + @JsonProperty("status") + public MappingResolutionStatus getStatus() { + return status; + } + + /** + * @return Machine-readable explanation for an unresolved mapping, when available: + *
              + *
            • NO_SOURCE_CODE: no source coding was supplied.
            • + *
            • NO_OMOP_SOURCE_CONCEPT: no OMOP source concept matches the supplied coding.
            • + *
            • NO_STANDARD_MAP: no usable standard OMOP mapping was found for the supplied coding.
            • + *
            • AMBIGUOUS: multiple candidate concepts could not be disambiguated.
            • + *
            • DOMAIN_MISMATCH: candidate standard concepts do not match the domain required by the linked OMOP field.
            • + *
            • UNSUPPORTED_SYSTEM: the source code system is unsupported.
            • + *
            • UNSUPPORTED_CODE: the source code is unsupported for the linked OMOP field.
            • + *
            • CONFLICTING_ALLERGY_EVIDENCE: allergy source meanings or context conflict.
            • + *
            • UNSUPPORTED_ALLERGY_SOURCE: no supported allergy source meaning was established.
            • + *
            • UNSUPPORTED_ALLERGY_CONTEXT: the source context has no supported finding policy.
            • + *
            • UNSUPPORTED_SOURCE_MEANING: a known source concept has no supported allergy interpretation.
            • + *
            + *

            Unknown reason codes are omitted; status still records the outcome.

            + */ + @JsonProperty("reason_code") + public Optional getReasonCode() { + return reasonCode; + } + + /** + * @return How the selected concept was reached. DIRECT may use an OMOP Maps to relationship; UMLS_BRIDGE identifies resolution through a shared UMLS concept; CONTEXTUAL identifies an explicit transformation of a substance-coded allergy/intolerance statement using its FHIR context. Specialty text uses CONCEPT_NAME, CONCEPT_SYNONYM, UMLS_STRING, then NUCC_LABEL in that order. NUCC_LABEL removes only the grouping prefix from a NUCC preferred term while retaining the full classification and specialization. Omitted when unavailable or no concept was selected. + */ + @JsonProperty("via") + public Optional getVia() { + return via; + } + + /** + * @return Mapping paths to the selected concept, in response order. Omitted when unavailable or no concept was selected. + */ + @JsonProperty("paths") + public Optional> getPaths() { + return paths; + } + + /** + * @return Present and true when candidate matches were truncated during resolution. Absence means no truncation was reported. + */ + @JsonProperty("truncated") + public Optional getTruncated() { + return truncated; + } + + @JsonProperty("allergy") + public Optional getAllergy() { + return allergy; + } + + @java.lang.Override + public boolean equals(Object other) { + if (this == other) return true; + return other instanceof MappingResolution && equalTo((MappingResolution) other); + } + + @JsonAnyGetter + public Map getAdditionalProperties() { + return this.additionalProperties; + } + + private boolean equalTo(MappingResolution other) { + return semanticMapping.equals(other.semanticMapping) + && specialtyResolution.equals(other.specialtyResolution) + && sourceText.equals(other.sourceText) + && status.equals(other.status) + && reasonCode.equals(other.reasonCode) + && via.equals(other.via) + && paths.equals(other.paths) + && truncated.equals(other.truncated) + && allergy.equals(other.allergy); + } + + @java.lang.Override + public int hashCode() { + return Objects.hash( + this.semanticMapping, + this.specialtyResolution, + this.sourceText, + this.status, + this.reasonCode, + this.via, + this.paths, + this.truncated, + this.allergy); + } + + @java.lang.Override + public String toString() { + return ObjectMappers.stringify(this); + } + + public static StatusStage builder() { + return new Builder(); + } + + public interface StatusStage { + /** + *

            Vocabulary resolution outcome. Any outcome other than RESOLVED yields mapping_status UNMAPPED and concept ID 0 while retaining an otherwise usable row.

            + */ + _FinalStage status(@NotNull MappingResolutionStatus status); + + Builder from(MappingResolution other); + } + + public interface _FinalStage { + MappingResolution build(); + + _FinalStage additionalProperty(String key, Object value); + + _FinalStage additionalProperties(Map additionalProperties); + + _FinalStage semanticMapping(Optional semanticMapping); + + _FinalStage semanticMapping(SemanticMappingResult semanticMapping); + + _FinalStage specialtyResolution(Optional specialtyResolution); + + _FinalStage specialtyResolution(SpecialtyResolution specialtyResolution); + + /** + *

            Explicit source text used to select a specialty concept. Its vocabulary spelling or full preferred term is retained separately in paths[].matched_text.

            + */ + _FinalStage sourceText(Optional sourceText); + + _FinalStage sourceText(String sourceText); + + /** + *

            Machine-readable explanation for an unresolved mapping, when available:

            + *
              + *
            • NO_SOURCE_CODE: no source coding was supplied.
            • + *
            • NO_OMOP_SOURCE_CONCEPT: no OMOP source concept matches the supplied coding.
            • + *
            • NO_STANDARD_MAP: no usable standard OMOP mapping was found for the supplied coding.
            • + *
            • AMBIGUOUS: multiple candidate concepts could not be disambiguated.
            • + *
            • DOMAIN_MISMATCH: candidate standard concepts do not match the domain required by the linked OMOP field.
            • + *
            • UNSUPPORTED_SYSTEM: the source code system is unsupported.
            • + *
            • UNSUPPORTED_CODE: the source code is unsupported for the linked OMOP field.
            • + *
            • CONFLICTING_ALLERGY_EVIDENCE: allergy source meanings or context conflict.
            • + *
            • UNSUPPORTED_ALLERGY_SOURCE: no supported allergy source meaning was established.
            • + *
            • UNSUPPORTED_ALLERGY_CONTEXT: the source context has no supported finding policy.
            • + *
            • UNSUPPORTED_SOURCE_MEANING: a known source concept has no supported allergy interpretation.
            • + *
            + *

            Unknown reason codes are omitted; status still records the outcome.

            + */ + _FinalStage reasonCode(Optional reasonCode); + + _FinalStage reasonCode(MappingResolutionReasonCode reasonCode); + + /** + *

            How the selected concept was reached. DIRECT may use an OMOP Maps to relationship; UMLS_BRIDGE identifies resolution through a shared UMLS concept; CONTEXTUAL identifies an explicit transformation of a substance-coded allergy/intolerance statement using its FHIR context. Specialty text uses CONCEPT_NAME, CONCEPT_SYNONYM, UMLS_STRING, then NUCC_LABEL in that order. NUCC_LABEL removes only the grouping prefix from a NUCC preferred term while retaining the full classification and specialization. Omitted when unavailable or no concept was selected.

            + */ + _FinalStage via(Optional via); + + _FinalStage via(MappingResolutionVia via); + + /** + *

            Mapping paths to the selected concept, in response order. Omitted when unavailable or no concept was selected.

            + */ + _FinalStage paths(Optional> paths); + + _FinalStage paths(List paths); + + /** + *

            Present and true when candidate matches were truncated during resolution. Absence means no truncation was reported.

            + */ + _FinalStage truncated(Optional truncated); + + _FinalStage truncated(Boolean truncated); + + _FinalStage allergy(Optional allergy); + + _FinalStage allergy(AllergyMappingResolution allergy); + } + + @JsonIgnoreProperties(ignoreUnknown = true) + public static final class Builder implements StatusStage, _FinalStage { + private MappingResolutionStatus status; + + private Optional allergy = Optional.empty(); + + private Optional truncated = Optional.empty(); + + private Optional> paths = Optional.empty(); + + private Optional via = Optional.empty(); + + private Optional reasonCode = Optional.empty(); + + private Optional sourceText = Optional.empty(); + + private Optional specialtyResolution = Optional.empty(); + + private Optional semanticMapping = Optional.empty(); + + private Map additionalProperties = new HashMap<>(); + + private Builder() {} + + @java.lang.Override + public Builder from(MappingResolution other) { + semanticMapping(other.getSemanticMapping()); + specialtyResolution(other.getSpecialtyResolution()); + sourceText(other.getSourceText()); + status(other.getStatus()); + reasonCode(other.getReasonCode()); + via(other.getVia()); + paths(other.getPaths()); + truncated(other.getTruncated()); + allergy(other.getAllergy()); + return this; + } + + /** + *

            Vocabulary resolution outcome. Any outcome other than RESOLVED yields mapping_status UNMAPPED and concept ID 0 while retaining an otherwise usable row.

            + * @return Reference to {@code this} so that method calls can be chained together. + */ + @java.lang.Override + @JsonSetter("status") + public _FinalStage status(@NotNull MappingResolutionStatus status) { + this.status = Objects.requireNonNull(status, "status must not be null"); + return this; + } + + @java.lang.Override + public _FinalStage allergy(AllergyMappingResolution allergy) { + this.allergy = Optional.ofNullable(allergy); + return this; + } + + @java.lang.Override + @JsonSetter(value = "allergy", nulls = Nulls.SKIP) + public _FinalStage allergy(Optional allergy) { + this.allergy = allergy; + return this; + } + + /** + *

            Present and true when candidate matches were truncated during resolution. Absence means no truncation was reported.

            + * @return Reference to {@code this} so that method calls can be chained together. + */ + @java.lang.Override + public _FinalStage truncated(Boolean truncated) { + this.truncated = Optional.ofNullable(truncated); + return this; + } + + /** + *

            Present and true when candidate matches were truncated during resolution. Absence means no truncation was reported.

            + */ + @java.lang.Override + @JsonSetter(value = "truncated", nulls = Nulls.SKIP) + public _FinalStage truncated(Optional truncated) { + this.truncated = truncated; + return this; + } + + /** + *

            Mapping paths to the selected concept, in response order. Omitted when unavailable or no concept was selected.

            + * @return Reference to {@code this} so that method calls can be chained together. + */ + @java.lang.Override + public _FinalStage paths(List paths) { + this.paths = Optional.ofNullable(paths); + return this; + } + + /** + *

            Mapping paths to the selected concept, in response order. Omitted when unavailable or no concept was selected.

            + */ + @java.lang.Override + @JsonSetter(value = "paths", nulls = Nulls.SKIP) + public _FinalStage paths(Optional> paths) { + this.paths = paths; + return this; + } + + /** + *

            How the selected concept was reached. DIRECT may use an OMOP Maps to relationship; UMLS_BRIDGE identifies resolution through a shared UMLS concept; CONTEXTUAL identifies an explicit transformation of a substance-coded allergy/intolerance statement using its FHIR context. Specialty text uses CONCEPT_NAME, CONCEPT_SYNONYM, UMLS_STRING, then NUCC_LABEL in that order. NUCC_LABEL removes only the grouping prefix from a NUCC preferred term while retaining the full classification and specialization. Omitted when unavailable or no concept was selected.

            + * @return Reference to {@code this} so that method calls can be chained together. + */ + @java.lang.Override + public _FinalStage via(MappingResolutionVia via) { + this.via = Optional.ofNullable(via); + return this; + } + + /** + *

            How the selected concept was reached. DIRECT may use an OMOP Maps to relationship; UMLS_BRIDGE identifies resolution through a shared UMLS concept; CONTEXTUAL identifies an explicit transformation of a substance-coded allergy/intolerance statement using its FHIR context. Specialty text uses CONCEPT_NAME, CONCEPT_SYNONYM, UMLS_STRING, then NUCC_LABEL in that order. NUCC_LABEL removes only the grouping prefix from a NUCC preferred term while retaining the full classification and specialization. Omitted when unavailable or no concept was selected.

            + */ + @java.lang.Override + @JsonSetter(value = "via", nulls = Nulls.SKIP) + public _FinalStage via(Optional via) { + this.via = via; + return this; + } + + /** + *

            Machine-readable explanation for an unresolved mapping, when available:

            + *
              + *
            • NO_SOURCE_CODE: no source coding was supplied.
            • + *
            • NO_OMOP_SOURCE_CONCEPT: no OMOP source concept matches the supplied coding.
            • + *
            • NO_STANDARD_MAP: no usable standard OMOP mapping was found for the supplied coding.
            • + *
            • AMBIGUOUS: multiple candidate concepts could not be disambiguated.
            • + *
            • DOMAIN_MISMATCH: candidate standard concepts do not match the domain required by the linked OMOP field.
            • + *
            • UNSUPPORTED_SYSTEM: the source code system is unsupported.
            • + *
            • UNSUPPORTED_CODE: the source code is unsupported for the linked OMOP field.
            • + *
            • CONFLICTING_ALLERGY_EVIDENCE: allergy source meanings or context conflict.
            • + *
            • UNSUPPORTED_ALLERGY_SOURCE: no supported allergy source meaning was established.
            • + *
            • UNSUPPORTED_ALLERGY_CONTEXT: the source context has no supported finding policy.
            • + *
            • UNSUPPORTED_SOURCE_MEANING: a known source concept has no supported allergy interpretation.
            • + *
            + *

            Unknown reason codes are omitted; status still records the outcome.

            + * @return Reference to {@code this} so that method calls can be chained together. + */ + @java.lang.Override + public _FinalStage reasonCode(MappingResolutionReasonCode reasonCode) { + this.reasonCode = Optional.ofNullable(reasonCode); + return this; + } + + /** + *

            Machine-readable explanation for an unresolved mapping, when available:

            + *
              + *
            • NO_SOURCE_CODE: no source coding was supplied.
            • + *
            • NO_OMOP_SOURCE_CONCEPT: no OMOP source concept matches the supplied coding.
            • + *
            • NO_STANDARD_MAP: no usable standard OMOP mapping was found for the supplied coding.
            • + *
            • AMBIGUOUS: multiple candidate concepts could not be disambiguated.
            • + *
            • DOMAIN_MISMATCH: candidate standard concepts do not match the domain required by the linked OMOP field.
            • + *
            • UNSUPPORTED_SYSTEM: the source code system is unsupported.
            • + *
            • UNSUPPORTED_CODE: the source code is unsupported for the linked OMOP field.
            • + *
            • CONFLICTING_ALLERGY_EVIDENCE: allergy source meanings or context conflict.
            • + *
            • UNSUPPORTED_ALLERGY_SOURCE: no supported allergy source meaning was established.
            • + *
            • UNSUPPORTED_ALLERGY_CONTEXT: the source context has no supported finding policy.
            • + *
            • UNSUPPORTED_SOURCE_MEANING: a known source concept has no supported allergy interpretation.
            • + *
            + *

            Unknown reason codes are omitted; status still records the outcome.

            + */ + @java.lang.Override + @JsonSetter(value = "reason_code", nulls = Nulls.SKIP) + public _FinalStage reasonCode(Optional reasonCode) { + this.reasonCode = reasonCode; + return this; + } + + /** + *

            Explicit source text used to select a specialty concept. Its vocabulary spelling or full preferred term is retained separately in paths[].matched_text.

            + * @return Reference to {@code this} so that method calls can be chained together. + */ + @java.lang.Override + public _FinalStage sourceText(String sourceText) { + this.sourceText = Optional.ofNullable(sourceText); + return this; + } + + /** + *

            Explicit source text used to select a specialty concept. Its vocabulary spelling or full preferred term is retained separately in paths[].matched_text.

            + */ + @java.lang.Override + @JsonSetter(value = "source_text", nulls = Nulls.SKIP) + public _FinalStage sourceText(Optional sourceText) { + this.sourceText = sourceText; + return this; + } + + @java.lang.Override + public _FinalStage specialtyResolution(SpecialtyResolution specialtyResolution) { + this.specialtyResolution = Optional.ofNullable(specialtyResolution); + return this; + } + + @java.lang.Override + @JsonSetter(value = "specialty_resolution", nulls = Nulls.SKIP) + public _FinalStage specialtyResolution(Optional specialtyResolution) { + this.specialtyResolution = specialtyResolution; + return this; + } + + @java.lang.Override + public _FinalStage semanticMapping(SemanticMappingResult semanticMapping) { + this.semanticMapping = Optional.ofNullable(semanticMapping); + return this; + } + + @java.lang.Override + @JsonSetter(value = "semantic_mapping", nulls = Nulls.SKIP) + public _FinalStage semanticMapping(Optional semanticMapping) { + this.semanticMapping = semanticMapping; + return this; + } + + @java.lang.Override + public MappingResolution build() { + return new MappingResolution( + semanticMapping, + specialtyResolution, + sourceText, + status, + reasonCode, + via, + paths, + truncated, + allergy, + additionalProperties); + } + + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("semantic_mapping") + || key.equalsIgnoreCase("specialty_resolution") + || key.equalsIgnoreCase("source_text") + || key.equalsIgnoreCase("status") + || key.equalsIgnoreCase("reason_code") + || key.equalsIgnoreCase("via") + || key.equalsIgnoreCase("paths") + || key.equalsIgnoreCase("truncated") + || key.equalsIgnoreCase("allergy")) { + return; + } + this.additionalProperties.put(key, value); + } + + @java.lang.Override + public Builder additionalProperty(String key, Object value) { + this.additionalProperties.put(key, value); + return this; + } + + @java.lang.Override + public Builder additionalProperties(Map additionalProperties) { + this.additionalProperties.putAll(additionalProperties); + return this; + } + } +} diff --git a/src/main/java/com/phenoml/api/resources/fhir2omop/types/MappingResolutionReasonCode.java b/src/main/java/com/phenoml/api/resources/fhir2omop/types/MappingResolutionReasonCode.java new file mode 100644 index 00000000..0aa3340f --- /dev/null +++ b/src/main/java/com/phenoml/api/resources/fhir2omop/types/MappingResolutionReasonCode.java @@ -0,0 +1,185 @@ +/** + * This file was auto-generated by Fern from our API Definition. + */ +package com.phenoml.api.resources.fhir2omop.types; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; + +public final class MappingResolutionReasonCode { + public static final MappingResolutionReasonCode UNSUPPORTED_ALLERGY_CONTEXT = + new MappingResolutionReasonCode(Value.UNSUPPORTED_ALLERGY_CONTEXT, "UNSUPPORTED_ALLERGY_CONTEXT"); + + public static final MappingResolutionReasonCode UNSUPPORTED_ALLERGY_SOURCE = + new MappingResolutionReasonCode(Value.UNSUPPORTED_ALLERGY_SOURCE, "UNSUPPORTED_ALLERGY_SOURCE"); + + public static final MappingResolutionReasonCode CONFLICTING_ALLERGY_EVIDENCE = + new MappingResolutionReasonCode(Value.CONFLICTING_ALLERGY_EVIDENCE, "CONFLICTING_ALLERGY_EVIDENCE"); + + public static final MappingResolutionReasonCode UNSUPPORTED_SOURCE_MEANING = + new MappingResolutionReasonCode(Value.UNSUPPORTED_SOURCE_MEANING, "UNSUPPORTED_SOURCE_MEANING"); + + public static final MappingResolutionReasonCode DOMAIN_MISMATCH = + new MappingResolutionReasonCode(Value.DOMAIN_MISMATCH, "DOMAIN_MISMATCH"); + + public static final MappingResolutionReasonCode NO_OMOP_SOURCE_CONCEPT = + new MappingResolutionReasonCode(Value.NO_OMOP_SOURCE_CONCEPT, "NO_OMOP_SOURCE_CONCEPT"); + + public static final MappingResolutionReasonCode AMBIGUOUS = + new MappingResolutionReasonCode(Value.AMBIGUOUS, "AMBIGUOUS"); + + public static final MappingResolutionReasonCode UNSUPPORTED_SYSTEM = + new MappingResolutionReasonCode(Value.UNSUPPORTED_SYSTEM, "UNSUPPORTED_SYSTEM"); + + public static final MappingResolutionReasonCode NO_STANDARD_MAP = + new MappingResolutionReasonCode(Value.NO_STANDARD_MAP, "NO_STANDARD_MAP"); + + public static final MappingResolutionReasonCode UNSUPPORTED_CODE = + new MappingResolutionReasonCode(Value.UNSUPPORTED_CODE, "UNSUPPORTED_CODE"); + + public static final MappingResolutionReasonCode NO_SOURCE_CODE = + new MappingResolutionReasonCode(Value.NO_SOURCE_CODE, "NO_SOURCE_CODE"); + + private final Value value; + + private final String string; + + MappingResolutionReasonCode(Value value, String string) { + this.value = value; + this.string = string; + } + + public Value getEnumValue() { + return value; + } + + @java.lang.Override + @JsonValue + public String toString() { + return this.string; + } + + @java.lang.Override + public boolean equals(Object other) { + return (this == other) + || (other instanceof MappingResolutionReasonCode + && this.string.equals(((MappingResolutionReasonCode) other).string)); + } + + @java.lang.Override + public int hashCode() { + return this.string.hashCode(); + } + + public T visit(Visitor visitor) { + switch (value) { + case UNSUPPORTED_ALLERGY_CONTEXT: + return visitor.visitUnsupportedAllergyContext(); + case UNSUPPORTED_ALLERGY_SOURCE: + return visitor.visitUnsupportedAllergySource(); + case CONFLICTING_ALLERGY_EVIDENCE: + return visitor.visitConflictingAllergyEvidence(); + case UNSUPPORTED_SOURCE_MEANING: + return visitor.visitUnsupportedSourceMeaning(); + case DOMAIN_MISMATCH: + return visitor.visitDomainMismatch(); + case NO_OMOP_SOURCE_CONCEPT: + return visitor.visitNoOmopSourceConcept(); + case AMBIGUOUS: + return visitor.visitAmbiguous(); + case UNSUPPORTED_SYSTEM: + return visitor.visitUnsupportedSystem(); + case NO_STANDARD_MAP: + return visitor.visitNoStandardMap(); + case UNSUPPORTED_CODE: + return visitor.visitUnsupportedCode(); + case NO_SOURCE_CODE: + return visitor.visitNoSourceCode(); + case UNKNOWN: + default: + return visitor.visitUnknown(string); + } + } + + @JsonCreator(mode = JsonCreator.Mode.DELEGATING) + public static MappingResolutionReasonCode valueOf(String value) { + switch (value) { + case "UNSUPPORTED_ALLERGY_CONTEXT": + return UNSUPPORTED_ALLERGY_CONTEXT; + case "UNSUPPORTED_ALLERGY_SOURCE": + return UNSUPPORTED_ALLERGY_SOURCE; + case "CONFLICTING_ALLERGY_EVIDENCE": + return CONFLICTING_ALLERGY_EVIDENCE; + case "UNSUPPORTED_SOURCE_MEANING": + return UNSUPPORTED_SOURCE_MEANING; + case "DOMAIN_MISMATCH": + return DOMAIN_MISMATCH; + case "NO_OMOP_SOURCE_CONCEPT": + return NO_OMOP_SOURCE_CONCEPT; + case "AMBIGUOUS": + return AMBIGUOUS; + case "UNSUPPORTED_SYSTEM": + return UNSUPPORTED_SYSTEM; + case "NO_STANDARD_MAP": + return NO_STANDARD_MAP; + case "UNSUPPORTED_CODE": + return UNSUPPORTED_CODE; + case "NO_SOURCE_CODE": + return NO_SOURCE_CODE; + default: + return new MappingResolutionReasonCode(Value.UNKNOWN, value); + } + } + + public enum Value { + NO_SOURCE_CODE, + + NO_OMOP_SOURCE_CONCEPT, + + NO_STANDARD_MAP, + + AMBIGUOUS, + + DOMAIN_MISMATCH, + + UNSUPPORTED_SYSTEM, + + UNSUPPORTED_CODE, + + CONFLICTING_ALLERGY_EVIDENCE, + + UNSUPPORTED_ALLERGY_SOURCE, + + UNSUPPORTED_ALLERGY_CONTEXT, + + UNSUPPORTED_SOURCE_MEANING, + + UNKNOWN + } + + public interface Visitor { + T visitNoSourceCode(); + + T visitNoOmopSourceConcept(); + + T visitNoStandardMap(); + + T visitAmbiguous(); + + T visitDomainMismatch(); + + T visitUnsupportedSystem(); + + T visitUnsupportedCode(); + + T visitConflictingAllergyEvidence(); + + T visitUnsupportedAllergySource(); + + T visitUnsupportedAllergyContext(); + + T visitUnsupportedSourceMeaning(); + + T visitUnknown(String unknownType); + } +} diff --git a/src/main/java/com/phenoml/api/resources/fhir2omop/types/MappingResolutionStatus.java b/src/main/java/com/phenoml/api/resources/fhir2omop/types/MappingResolutionStatus.java new file mode 100644 index 00000000..7e880065 --- /dev/null +++ b/src/main/java/com/phenoml/api/resources/fhir2omop/types/MappingResolutionStatus.java @@ -0,0 +1,106 @@ +/** + * This file was auto-generated by Fern from our API Definition. + */ +package com.phenoml.api.resources.fhir2omop.types; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; + +public final class MappingResolutionStatus { + public static final MappingResolutionStatus RESOLVED = new MappingResolutionStatus(Value.RESOLVED, "RESOLVED"); + + public static final MappingResolutionStatus UNRESOLVED = + new MappingResolutionStatus(Value.UNRESOLVED, "UNRESOLVED"); + + public static final MappingResolutionStatus DOMAIN_MISMATCH = + new MappingResolutionStatus(Value.DOMAIN_MISMATCH, "DOMAIN_MISMATCH"); + + public static final MappingResolutionStatus AMBIGUOUS = new MappingResolutionStatus(Value.AMBIGUOUS, "AMBIGUOUS"); + + private final Value value; + + private final String string; + + MappingResolutionStatus(Value value, String string) { + this.value = value; + this.string = string; + } + + public Value getEnumValue() { + return value; + } + + @java.lang.Override + @JsonValue + public String toString() { + return this.string; + } + + @java.lang.Override + public boolean equals(Object other) { + return (this == other) + || (other instanceof MappingResolutionStatus + && this.string.equals(((MappingResolutionStatus) other).string)); + } + + @java.lang.Override + public int hashCode() { + return this.string.hashCode(); + } + + public T visit(Visitor visitor) { + switch (value) { + case RESOLVED: + return visitor.visitResolved(); + case UNRESOLVED: + return visitor.visitUnresolved(); + case DOMAIN_MISMATCH: + return visitor.visitDomainMismatch(); + case AMBIGUOUS: + return visitor.visitAmbiguous(); + case UNKNOWN: + default: + return visitor.visitUnknown(string); + } + } + + @JsonCreator(mode = JsonCreator.Mode.DELEGATING) + public static MappingResolutionStatus valueOf(String value) { + switch (value) { + case "RESOLVED": + return RESOLVED; + case "UNRESOLVED": + return UNRESOLVED; + case "DOMAIN_MISMATCH": + return DOMAIN_MISMATCH; + case "AMBIGUOUS": + return AMBIGUOUS; + default: + return new MappingResolutionStatus(Value.UNKNOWN, value); + } + } + + public enum Value { + RESOLVED, + + UNRESOLVED, + + DOMAIN_MISMATCH, + + AMBIGUOUS, + + UNKNOWN + } + + public interface Visitor { + T visitResolved(); + + T visitUnresolved(); + + T visitDomainMismatch(); + + T visitAmbiguous(); + + T visitUnknown(String unknownType); + } +} diff --git a/src/main/java/com/phenoml/api/resources/fhir2omop/types/MappingResolutionVia.java b/src/main/java/com/phenoml/api/resources/fhir2omop/types/MappingResolutionVia.java new file mode 100644 index 00000000..7afa3106 --- /dev/null +++ b/src/main/java/com/phenoml/api/resources/fhir2omop/types/MappingResolutionVia.java @@ -0,0 +1,135 @@ +/** + * This file was auto-generated by Fern from our API Definition. + */ +package com.phenoml.api.resources.fhir2omop.types; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; + +public final class MappingResolutionVia { + public static final MappingResolutionVia DIRECT = new MappingResolutionVia(Value.DIRECT, "DIRECT"); + + public static final MappingResolutionVia CONCEPT_SYNONYM = + new MappingResolutionVia(Value.CONCEPT_SYNONYM, "CONCEPT_SYNONYM"); + + public static final MappingResolutionVia UMLS_BRIDGE = new MappingResolutionVia(Value.UMLS_BRIDGE, "UMLS_BRIDGE"); + + public static final MappingResolutionVia UMLS_STRING = new MappingResolutionVia(Value.UMLS_STRING, "UMLS_STRING"); + + public static final MappingResolutionVia CONTEXTUAL = new MappingResolutionVia(Value.CONTEXTUAL, "CONTEXTUAL"); + + public static final MappingResolutionVia NUCC_LABEL = new MappingResolutionVia(Value.NUCC_LABEL, "NUCC_LABEL"); + + public static final MappingResolutionVia CONCEPT_NAME = + new MappingResolutionVia(Value.CONCEPT_NAME, "CONCEPT_NAME"); + + private final Value value; + + private final String string; + + MappingResolutionVia(Value value, String string) { + this.value = value; + this.string = string; + } + + public Value getEnumValue() { + return value; + } + + @java.lang.Override + @JsonValue + public String toString() { + return this.string; + } + + @java.lang.Override + public boolean equals(Object other) { + return (this == other) + || (other instanceof MappingResolutionVia && this.string.equals(((MappingResolutionVia) other).string)); + } + + @java.lang.Override + public int hashCode() { + return this.string.hashCode(); + } + + public T visit(Visitor visitor) { + switch (value) { + case DIRECT: + return visitor.visitDirect(); + case CONCEPT_SYNONYM: + return visitor.visitConceptSynonym(); + case UMLS_BRIDGE: + return visitor.visitUmlsBridge(); + case UMLS_STRING: + return visitor.visitUmlsString(); + case CONTEXTUAL: + return visitor.visitContextual(); + case NUCC_LABEL: + return visitor.visitNuccLabel(); + case CONCEPT_NAME: + return visitor.visitConceptName(); + case UNKNOWN: + default: + return visitor.visitUnknown(string); + } + } + + @JsonCreator(mode = JsonCreator.Mode.DELEGATING) + public static MappingResolutionVia valueOf(String value) { + switch (value) { + case "DIRECT": + return DIRECT; + case "CONCEPT_SYNONYM": + return CONCEPT_SYNONYM; + case "UMLS_BRIDGE": + return UMLS_BRIDGE; + case "UMLS_STRING": + return UMLS_STRING; + case "CONTEXTUAL": + return CONTEXTUAL; + case "NUCC_LABEL": + return NUCC_LABEL; + case "CONCEPT_NAME": + return CONCEPT_NAME; + default: + return new MappingResolutionVia(Value.UNKNOWN, value); + } + } + + public enum Value { + DIRECT, + + UMLS_BRIDGE, + + CONTEXTUAL, + + CONCEPT_NAME, + + CONCEPT_SYNONYM, + + UMLS_STRING, + + NUCC_LABEL, + + UNKNOWN + } + + public interface Visitor { + T visitDirect(); + + T visitUmlsBridge(); + + T visitContextual(); + + T visitConceptName(); + + T visitConceptSynonym(); + + T visitUmlsString(); + + T visitNuccLabel(); + + T visitUnknown(String unknownType); + } +} diff --git a/src/main/java/com/phenoml/api/resources/fhir2omop/types/MeasurementRow.java b/src/main/java/com/phenoml/api/resources/fhir2omop/types/MeasurementRow.java index df0ad5db..afd43a2c 100644 --- a/src/main/java/com/phenoml/api/resources/fhir2omop/types/MeasurementRow.java +++ b/src/main/java/com/phenoml/api/resources/fhir2omop/types/MeasurementRow.java @@ -52,6 +52,8 @@ public final class MeasurementRow { private final Optional measurementSourceConceptId; + private final Optional unitSourceConceptId; + private final Optional unitSourceValue; private final Optional valueSourceValue; @@ -75,6 +77,7 @@ private MeasurementRow( Optional providerId, Optional measurementSourceValue, Optional measurementSourceConceptId, + Optional unitSourceConceptId, Optional unitSourceValue, Optional valueSourceValue, Map additionalProperties) { @@ -94,6 +97,7 @@ private MeasurementRow( this.providerId = providerId; this.measurementSourceValue = measurementSourceValue; this.measurementSourceConceptId = measurementSourceConceptId; + this.unitSourceConceptId = unitSourceConceptId; this.unitSourceValue = unitSourceValue; this.valueSourceValue = valueSourceValue; this.additionalProperties = additionalProperties; @@ -115,7 +119,7 @@ public Optional getMeasurementConceptId() { } /** - * @return Date from Observation.effectiveDateTime, effectivePeriod.start, or effectiveInstant. + * @return Start date selected by the source resource's timing rules. For an Observation, effectiveDateTime, effectivePeriod.start, or effectiveInstant; see the endpoint's timing sources for other resources. */ @JsonProperty("measurement_date") public Optional getMeasurementDate() { @@ -150,6 +154,9 @@ public Optional getValueAsConceptId() { return valueAsConceptId; } + /** + * @return Valid standard Unit concept for coded UCUM; 0 for supplied but unmapped units, null for absent units. Always present in JSON. + */ @JsonProperty("unit_concept_id") public Optional getUnitConceptId() { return unitConceptId; @@ -185,6 +192,17 @@ public Optional getMeasurementSourceConceptId() { return measurementSourceConceptId; } + /** + * @return OMOP source concept ID corresponding to Quantity.system/code; 0 for supplied units without a matching source concept, null for absent units. Always present in JSON. + */ + @JsonProperty("unit_source_concept_id") + public Optional getUnitSourceConceptId() { + return unitSourceConceptId; + } + + /** + * @return Original Quantity.code if supplied, otherwise Quantity.unit; human-readable unit text is preserved in the unit mapping's source_name. + */ @JsonProperty("unit_source_value") public Optional getUnitSourceValue() { return unitSourceValue; @@ -223,6 +241,7 @@ private boolean equalTo(MeasurementRow other) { && providerId.equals(other.providerId) && measurementSourceValue.equals(other.measurementSourceValue) && measurementSourceConceptId.equals(other.measurementSourceConceptId) + && unitSourceConceptId.equals(other.unitSourceConceptId) && unitSourceValue.equals(other.unitSourceValue) && valueSourceValue.equals(other.valueSourceValue); } @@ -246,6 +265,7 @@ public int hashCode() { this.providerId, this.measurementSourceValue, this.measurementSourceConceptId, + this.unitSourceConceptId, this.unitSourceValue, this.valueSourceValue); } @@ -293,11 +313,12 @@ public static final class Builder { private Optional measurementSourceConceptId = Optional.empty(); + private Optional unitSourceConceptId = Optional.empty(); + private Optional unitSourceValue = Optional.empty(); private Optional valueSourceValue = Optional.empty(); - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -319,6 +340,7 @@ public Builder from(MeasurementRow other) { providerId(other.getProviderId()); measurementSourceValue(other.getMeasurementSourceValue()); measurementSourceConceptId(other.getMeasurementSourceConceptId()); + unitSourceConceptId(other.getUnitSourceConceptId()); unitSourceValue(other.getUnitSourceValue()); valueSourceValue(other.getValueSourceValue()); return this; @@ -358,7 +380,7 @@ public Builder measurementConceptId(Long measurementConceptId) { } /** - *

            Date from Observation.effectiveDateTime, effectivePeriod.start, or effectiveInstant.

            + *

            Start date selected by the source resource's timing rules. For an Observation, effectiveDateTime, effectivePeriod.start, or effectiveInstant; see the endpoint's timing sources for other resources.

            */ @JsonSetter(value = "measurement_date", nulls = Nulls.SKIP) public Builder measurementDate(Optional measurementDate) { @@ -429,6 +451,9 @@ public Builder valueAsConceptId(Long valueAsConceptId) { return this; } + /** + *

            Valid standard Unit concept for coded UCUM; 0 for supplied but unmapped units, null for absent units. Always present in JSON.

            + */ @JsonSetter(value = "unit_concept_id", nulls = Nulls.SKIP) public Builder unitConceptId(Optional unitConceptId) { this.unitConceptId = unitConceptId; @@ -506,6 +531,23 @@ public Builder measurementSourceConceptId(Long measurementSourceConceptId) { return this; } + /** + *

            OMOP source concept ID corresponding to Quantity.system/code; 0 for supplied units without a matching source concept, null for absent units. Always present in JSON.

            + */ + @JsonSetter(value = "unit_source_concept_id", nulls = Nulls.SKIP) + public Builder unitSourceConceptId(Optional unitSourceConceptId) { + this.unitSourceConceptId = unitSourceConceptId; + return this; + } + + public Builder unitSourceConceptId(Long unitSourceConceptId) { + this.unitSourceConceptId = Optional.ofNullable(unitSourceConceptId); + return this; + } + + /** + *

            Original Quantity.code if supplied, otherwise Quantity.unit; human-readable unit text is preserved in the unit mapping's source_name.

            + */ @JsonSetter(value = "unit_source_value", nulls = Nulls.SKIP) public Builder unitSourceValue(Optional unitSourceValue) { this.unitSourceValue = unitSourceValue; @@ -546,11 +588,38 @@ public MeasurementRow build() { providerId, measurementSourceValue, measurementSourceConceptId, + unitSourceConceptId, unitSourceValue, valueSourceValue, additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("measurement_id") + || key.equalsIgnoreCase("person_id") + || key.equalsIgnoreCase("measurement_concept_id") + || key.equalsIgnoreCase("measurement_date") + || key.equalsIgnoreCase("measurement_datetime") + || key.equalsIgnoreCase("measurement_type_concept_id") + || key.equalsIgnoreCase("value_as_number") + || key.equalsIgnoreCase("operator_concept_id") + || key.equalsIgnoreCase("value_as_concept_id") + || key.equalsIgnoreCase("unit_concept_id") + || key.equalsIgnoreCase("range_low") + || key.equalsIgnoreCase("range_high") + || key.equalsIgnoreCase("visit_occurrence_id") + || key.equalsIgnoreCase("provider_id") + || key.equalsIgnoreCase("measurement_source_value") + || key.equalsIgnoreCase("measurement_source_concept_id") + || key.equalsIgnoreCase("unit_source_concept_id") + || key.equalsIgnoreCase("unit_source_value") + || key.equalsIgnoreCase("value_source_value")) { + return; + } + this.additionalProperties.put(key, value); + } + public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); return this; diff --git a/src/main/java/com/phenoml/api/resources/fhir2omop/types/MedicationDispenseSourceContext.java b/src/main/java/com/phenoml/api/resources/fhir2omop/types/MedicationDispenseSourceContext.java new file mode 100644 index 00000000..ff562d29 --- /dev/null +++ b/src/main/java/com/phenoml/api/resources/fhir2omop/types/MedicationDispenseSourceContext.java @@ -0,0 +1,305 @@ +/** + * This file was auto-generated by Fern from our API Definition. + */ +package com.phenoml.api.resources.fhir2omop.types; + +import com.fasterxml.jackson.annotation.JsonAnyGetter; +import com.fasterxml.jackson.annotation.JsonAnySetter; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonSetter; +import com.fasterxml.jackson.annotation.Nulls; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.phenoml.api.core.ObjectMappers; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import org.jetbrains.annotations.NotNull; + +@JsonInclude(JsonInclude.Include.NON_ABSENT) +@JsonDeserialize(builder = MedicationDispenseSourceContext.Builder.class) +public final class MedicationDispenseSourceContext { + private final Optional resourceId; + + private final Optional fullUrl; + + private final MedicationDispenseSourceContextOmopTable omopTable; + + private final long omopId; + + private final Map source; + + private final Map additionalProperties; + + private MedicationDispenseSourceContext( + Optional resourceId, + Optional fullUrl, + MedicationDispenseSourceContextOmopTable omopTable, + long omopId, + Map source, + Map additionalProperties) { + this.resourceId = resourceId; + this.fullUrl = fullUrl; + this.omopTable = omopTable; + this.omopId = omopId; + this.source = source; + this.additionalProperties = additionalProperties; + } + + /** + * @return Original MedicationDispense.id, when supplied. + */ + @JsonProperty("resource_id") + public Optional getResourceId() { + return resourceId; + } + + /** + * @return Original Bundle entry fullUrl, when supplied. + */ + @JsonProperty("full_url") + public Optional getFullUrl() { + return fullUrl; + } + + @JsonProperty("omop_table") + public MedicationDispenseSourceContextOmopTable getOmopTable() { + return omopTable; + } + + @JsonProperty("omop_id") + public long getOmopId() { + return omopId; + } + + /** + * @return Only supplied fields are present, using original FHIR names and values: status, statusReasonCodeableConcept, statusReasonReference, category, type, whenPrepared, whenHandedOver, daysSupply, performer, context, authorizingPrescription, dosageInstruction, and medicationReference. Raw values preserve original precision, offsets, explicit nulls, and malformed values. Presence is not a promise that a value was mapped; diagnostics explain unusable values and mappings describe coded outcomes. Direct quantities are audited separately in quantity_transformations for drug_exposure rows. + */ + @JsonProperty("source") + public Map getSource() { + return source; + } + + @java.lang.Override + public boolean equals(Object other) { + if (this == other) return true; + return other instanceof MedicationDispenseSourceContext && equalTo((MedicationDispenseSourceContext) other); + } + + @JsonAnyGetter + public Map getAdditionalProperties() { + return this.additionalProperties; + } + + private boolean equalTo(MedicationDispenseSourceContext other) { + return resourceId.equals(other.resourceId) + && fullUrl.equals(other.fullUrl) + && omopTable.equals(other.omopTable) + && omopId == other.omopId + && source.equals(other.source); + } + + @java.lang.Override + public int hashCode() { + return Objects.hash(this.resourceId, this.fullUrl, this.omopTable, this.omopId, this.source); + } + + @java.lang.Override + public String toString() { + return ObjectMappers.stringify(this); + } + + public static OmopTableStage builder() { + return new Builder(); + } + + public interface OmopTableStage { + OmopIdStage omopTable(@NotNull MedicationDispenseSourceContextOmopTable omopTable); + + Builder from(MedicationDispenseSourceContext other); + } + + public interface OmopIdStage { + _FinalStage omopId(long omopId); + } + + public interface _FinalStage { + MedicationDispenseSourceContext build(); + + _FinalStage additionalProperty(String key, Object value); + + _FinalStage additionalProperties(Map additionalProperties); + + /** + *

            Original MedicationDispense.id, when supplied.

            + */ + _FinalStage resourceId(Optional resourceId); + + _FinalStage resourceId(String resourceId); + + /** + *

            Original Bundle entry fullUrl, when supplied.

            + */ + _FinalStage fullUrl(Optional fullUrl); + + _FinalStage fullUrl(String fullUrl); + + /** + *

            Only supplied fields are present, using original FHIR names and values: status, statusReasonCodeableConcept, statusReasonReference, category, type, whenPrepared, whenHandedOver, daysSupply, performer, context, authorizingPrescription, dosageInstruction, and medicationReference. Raw values preserve original precision, offsets, explicit nulls, and malformed values. Presence is not a promise that a value was mapped; diagnostics explain unusable values and mappings describe coded outcomes. Direct quantities are audited separately in quantity_transformations for drug_exposure rows.

            + */ + _FinalStage source(Map source); + + _FinalStage putAllSource(Map source); + + _FinalStage source(String key, Object value); + } + + @JsonIgnoreProperties(ignoreUnknown = true) + public static final class Builder implements OmopTableStage, OmopIdStage, _FinalStage { + private MedicationDispenseSourceContextOmopTable omopTable; + + private long omopId; + + private Map source = new LinkedHashMap<>(); + + private Optional fullUrl = Optional.empty(); + + private Optional resourceId = Optional.empty(); + + private Map additionalProperties = new HashMap<>(); + + private Builder() {} + + @java.lang.Override + public Builder from(MedicationDispenseSourceContext other) { + resourceId(other.getResourceId()); + fullUrl(other.getFullUrl()); + omopTable(other.getOmopTable()); + omopId(other.getOmopId()); + source(other.getSource()); + return this; + } + + @java.lang.Override + @JsonSetter("omop_table") + public OmopIdStage omopTable(@NotNull MedicationDispenseSourceContextOmopTable omopTable) { + this.omopTable = Objects.requireNonNull(omopTable, "omopTable must not be null"); + return this; + } + + @java.lang.Override + @JsonSetter("omop_id") + public _FinalStage omopId(long omopId) { + this.omopId = omopId; + return this; + } + + /** + *

            Only supplied fields are present, using original FHIR names and values: status, statusReasonCodeableConcept, statusReasonReference, category, type, whenPrepared, whenHandedOver, daysSupply, performer, context, authorizingPrescription, dosageInstruction, and medicationReference. Raw values preserve original precision, offsets, explicit nulls, and malformed values. Presence is not a promise that a value was mapped; diagnostics explain unusable values and mappings describe coded outcomes. Direct quantities are audited separately in quantity_transformations for drug_exposure rows.

            + * @return Reference to {@code this} so that method calls can be chained together. + */ + @java.lang.Override + public _FinalStage source(String key, Object value) { + this.source.put(key, value); + return this; + } + + /** + *

            Only supplied fields are present, using original FHIR names and values: status, statusReasonCodeableConcept, statusReasonReference, category, type, whenPrepared, whenHandedOver, daysSupply, performer, context, authorizingPrescription, dosageInstruction, and medicationReference. Raw values preserve original precision, offsets, explicit nulls, and malformed values. Presence is not a promise that a value was mapped; diagnostics explain unusable values and mappings describe coded outcomes. Direct quantities are audited separately in quantity_transformations for drug_exposure rows.

            + * @return Reference to {@code this} so that method calls can be chained together. + */ + @java.lang.Override + public _FinalStage putAllSource(Map source) { + if (source != null) { + this.source.putAll(source); + } + return this; + } + + /** + *

            Only supplied fields are present, using original FHIR names and values: status, statusReasonCodeableConcept, statusReasonReference, category, type, whenPrepared, whenHandedOver, daysSupply, performer, context, authorizingPrescription, dosageInstruction, and medicationReference. Raw values preserve original precision, offsets, explicit nulls, and malformed values. Presence is not a promise that a value was mapped; diagnostics explain unusable values and mappings describe coded outcomes. Direct quantities are audited separately in quantity_transformations for drug_exposure rows.

            + */ + @java.lang.Override + @JsonSetter(value = "source", nulls = Nulls.SKIP) + public _FinalStage source(Map source) { + this.source.clear(); + if (source != null) { + this.source.putAll(source); + } + return this; + } + + /** + *

            Original Bundle entry fullUrl, when supplied.

            + * @return Reference to {@code this} so that method calls can be chained together. + */ + @java.lang.Override + public _FinalStage fullUrl(String fullUrl) { + this.fullUrl = Optional.ofNullable(fullUrl); + return this; + } + + /** + *

            Original Bundle entry fullUrl, when supplied.

            + */ + @java.lang.Override + @JsonSetter(value = "full_url", nulls = Nulls.SKIP) + public _FinalStage fullUrl(Optional fullUrl) { + this.fullUrl = fullUrl; + return this; + } + + /** + *

            Original MedicationDispense.id, when supplied.

            + * @return Reference to {@code this} so that method calls can be chained together. + */ + @java.lang.Override + public _FinalStage resourceId(String resourceId) { + this.resourceId = Optional.ofNullable(resourceId); + return this; + } + + /** + *

            Original MedicationDispense.id, when supplied.

            + */ + @java.lang.Override + @JsonSetter(value = "resource_id", nulls = Nulls.SKIP) + public _FinalStage resourceId(Optional resourceId) { + this.resourceId = resourceId; + return this; + } + + @java.lang.Override + public MedicationDispenseSourceContext build() { + return new MedicationDispenseSourceContext( + resourceId, fullUrl, omopTable, omopId, source, additionalProperties); + } + + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("resource_id") + || key.equalsIgnoreCase("full_url") + || key.equalsIgnoreCase("omop_table") + || key.equalsIgnoreCase("omop_id") + || key.equalsIgnoreCase("source")) { + return; + } + this.additionalProperties.put(key, value); + } + + @java.lang.Override + public Builder additionalProperty(String key, Object value) { + this.additionalProperties.put(key, value); + return this; + } + + @java.lang.Override + public Builder additionalProperties(Map additionalProperties) { + this.additionalProperties.putAll(additionalProperties); + return this; + } + } +} diff --git a/src/main/java/com/phenoml/api/resources/fhir2omop/types/MedicationDispenseSourceContextOmopTable.java b/src/main/java/com/phenoml/api/resources/fhir2omop/types/MedicationDispenseSourceContextOmopTable.java new file mode 100644 index 00000000..1bfda925 --- /dev/null +++ b/src/main/java/com/phenoml/api/resources/fhir2omop/types/MedicationDispenseSourceContextOmopTable.java @@ -0,0 +1,130 @@ +/** + * This file was auto-generated by Fern from our API Definition. + */ +package com.phenoml.api.resources.fhir2omop.types; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; + +public final class MedicationDispenseSourceContextOmopTable { + public static final MedicationDispenseSourceContextOmopTable PROCEDURE_OCCURRENCE = + new MedicationDispenseSourceContextOmopTable(Value.PROCEDURE_OCCURRENCE, "procedure_occurrence"); + + public static final MedicationDispenseSourceContextOmopTable CONDITION_OCCURRENCE = + new MedicationDispenseSourceContextOmopTable(Value.CONDITION_OCCURRENCE, "condition_occurrence"); + + public static final MedicationDispenseSourceContextOmopTable DRUG_EXPOSURE = + new MedicationDispenseSourceContextOmopTable(Value.DRUG_EXPOSURE, "drug_exposure"); + + public static final MedicationDispenseSourceContextOmopTable OBSERVATION = + new MedicationDispenseSourceContextOmopTable(Value.OBSERVATION, "observation"); + + public static final MedicationDispenseSourceContextOmopTable DEVICE_EXPOSURE = + new MedicationDispenseSourceContextOmopTable(Value.DEVICE_EXPOSURE, "device_exposure"); + + public static final MedicationDispenseSourceContextOmopTable MEASUREMENT = + new MedicationDispenseSourceContextOmopTable(Value.MEASUREMENT, "measurement"); + + private final Value value; + + private final String string; + + MedicationDispenseSourceContextOmopTable(Value value, String string) { + this.value = value; + this.string = string; + } + + public Value getEnumValue() { + return value; + } + + @java.lang.Override + @JsonValue + public String toString() { + return this.string; + } + + @java.lang.Override + public boolean equals(Object other) { + return (this == other) + || (other instanceof MedicationDispenseSourceContextOmopTable + && this.string.equals(((MedicationDispenseSourceContextOmopTable) other).string)); + } + + @java.lang.Override + public int hashCode() { + return this.string.hashCode(); + } + + public T visit(Visitor visitor) { + switch (value) { + case PROCEDURE_OCCURRENCE: + return visitor.visitProcedureOccurrence(); + case CONDITION_OCCURRENCE: + return visitor.visitConditionOccurrence(); + case DRUG_EXPOSURE: + return visitor.visitDrugExposure(); + case OBSERVATION: + return visitor.visitObservation(); + case DEVICE_EXPOSURE: + return visitor.visitDeviceExposure(); + case MEASUREMENT: + return visitor.visitMeasurement(); + case UNKNOWN: + default: + return visitor.visitUnknown(string); + } + } + + @JsonCreator(mode = JsonCreator.Mode.DELEGATING) + public static MedicationDispenseSourceContextOmopTable valueOf(String value) { + switch (value) { + case "procedure_occurrence": + return PROCEDURE_OCCURRENCE; + case "condition_occurrence": + return CONDITION_OCCURRENCE; + case "drug_exposure": + return DRUG_EXPOSURE; + case "observation": + return OBSERVATION; + case "device_exposure": + return DEVICE_EXPOSURE; + case "measurement": + return MEASUREMENT; + default: + return new MedicationDispenseSourceContextOmopTable(Value.UNKNOWN, value); + } + } + + public enum Value { + DRUG_EXPOSURE, + + CONDITION_OCCURRENCE, + + PROCEDURE_OCCURRENCE, + + DEVICE_EXPOSURE, + + MEASUREMENT, + + OBSERVATION, + + UNKNOWN + } + + public interface Visitor { + T visitDrugExposure(); + + T visitConditionOccurrence(); + + T visitProcedureOccurrence(); + + T visitDeviceExposure(); + + T visitMeasurement(); + + T visitObservation(); + + T visitUnknown(String unknownType); + } +} diff --git a/src/main/java/com/phenoml/api/resources/fhir2omop/types/ObservationPeriodRow.java b/src/main/java/com/phenoml/api/resources/fhir2omop/types/ObservationPeriodRow.java index 952ed962..82a67287 100644 --- a/src/main/java/com/phenoml/api/resources/fhir2omop/types/ObservationPeriodRow.java +++ b/src/main/java/com/phenoml/api/resources/fhir2omop/types/ObservationPeriodRow.java @@ -58,7 +58,10 @@ public Optional getPersonId() { } /** - * @return Earliest populated date among the person's visit, clinical, and death rows in this request; not enrollment evidence. + * @return Earliest date selected by the source timing rules from the person's + * visit and clinical rows not listed in rows_for_review, plus Specimen + * and Death rows in this request. No period row is returned without + * qualifying dates; not enrollment evidence. */ @JsonProperty("observation_period_start_date") public Optional getObservationPeriodStartDate() { @@ -66,7 +69,11 @@ public Optional getObservationPeriodStartDate() { } /** - * @return Latest populated date, including end dates, among the person's visit, clinical, and death rows in this request; not enrollment evidence. + * @return Latest date selected by the source timing rules from the person's + * visit and clinical rows not listed in rows_for_review, plus Specimen + * and Death rows in this request. Includes source end dates from + * unindexed rows even when the output table has no end column. + * No period row is returned without qualifying dates; not enrollment evidence. */ @JsonProperty("observation_period_end_date") public Optional getObservationPeriodEndDate() { @@ -128,7 +135,6 @@ public static final class Builder { private Optional periodTypeConceptId = Optional.empty(); - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -165,7 +171,10 @@ public Builder personId(Long personId) { } /** - *

            Earliest populated date among the person's visit, clinical, and death rows in this request; not enrollment evidence.

            + *

            Earliest date selected by the source timing rules from the person's + * visit and clinical rows not listed in rows_for_review, plus Specimen + * and Death rows in this request. No period row is returned without + * qualifying dates; not enrollment evidence.

            */ @JsonSetter(value = "observation_period_start_date", nulls = Nulls.SKIP) public Builder observationPeriodStartDate(Optional observationPeriodStartDate) { @@ -179,7 +188,11 @@ public Builder observationPeriodStartDate(String observationPeriodStartDate) { } /** - *

            Latest populated date, including end dates, among the person's visit, clinical, and death rows in this request; not enrollment evidence.

            + *

            Latest date selected by the source timing rules from the person's + * visit and clinical rows not listed in rows_for_review, plus Specimen + * and Death rows in this request. Includes source end dates from + * unindexed rows even when the output table has no end column. + * No period row is returned without qualifying dates; not enrollment evidence.

            */ @JsonSetter(value = "observation_period_end_date", nulls = Nulls.SKIP) public Builder observationPeriodEndDate(Optional observationPeriodEndDate) { @@ -213,6 +226,18 @@ public ObservationPeriodRow build() { additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("observation_period_id") + || key.equalsIgnoreCase("person_id") + || key.equalsIgnoreCase("observation_period_start_date") + || key.equalsIgnoreCase("observation_period_end_date") + || key.equalsIgnoreCase("period_type_concept_id")) { + return; + } + this.additionalProperties.put(key, value); + } + public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); return this; diff --git a/src/main/java/com/phenoml/api/resources/fhir2omop/types/ObservationRow.java b/src/main/java/com/phenoml/api/resources/fhir2omop/types/ObservationRow.java index 5ccd839d..87363a95 100644 --- a/src/main/java/com/phenoml/api/resources/fhir2omop/types/ObservationRow.java +++ b/src/main/java/com/phenoml/api/resources/fhir2omop/types/ObservationRow.java @@ -107,7 +107,7 @@ public Optional getObservationConceptId() { } /** - * @return For an Observation, date from effectiveDateTime, effectivePeriod.start, or effectiveInstant. For an AllergyIntolerance, date from recordedDate, otherwise onsetDateTime or onsetPeriod.start. + * @return Start date selected by the source resource's timing rules. For an Observation, effectiveDateTime, effectivePeriod.start, or effectiveInstant. For an AllergyIntolerance, recordedDate, otherwise onsetDateTime or onsetPeriod.start; see the endpoint's timing sources for other resources. */ @JsonProperty("observation_date") public Optional getObservationDate() { @@ -139,6 +139,9 @@ public Optional getValueAsConceptId() { return valueAsConceptId; } + /** + * @return Valid standard Unit concept for coded UCUM; 0 for supplied but unmapped units, null for absent units. Always present in JSON. + */ @JsonProperty("unit_concept_id") public Optional getUnitConceptId() { return unitConceptId; @@ -159,11 +162,17 @@ public Optional getObservationSourceValue() { return observationSourceValue; } + /** + * @return For coded AllergyIntolerance rows, the known OMOP identity of the original coding rendered in observation_source_value, even when the finding is unresolved. 0 when no original source identity is known; the configured contextual finding is not a source concept. + */ @JsonProperty("observation_source_concept_id") public Optional getObservationSourceConceptId() { return observationSourceConceptId; } + /** + * @return Original Quantity.code if supplied, otherwise Quantity.unit; human-readable unit text is preserved in the unit mapping's source_name. + */ @JsonProperty("unit_source_value") public Optional getUnitSourceValue() { return unitSourceValue; @@ -268,7 +277,6 @@ public static final class Builder { private Optional valueSourceValue = Optional.empty(); - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -327,7 +335,7 @@ public Builder observationConceptId(Long observationConceptId) { } /** - *

            For an Observation, date from effectiveDateTime, effectivePeriod.start, or effectiveInstant. For an AllergyIntolerance, date from recordedDate, otherwise onsetDateTime or onsetPeriod.start.

            + *

            Start date selected by the source resource's timing rules. For an Observation, effectiveDateTime, effectivePeriod.start, or effectiveInstant. For an AllergyIntolerance, recordedDate, otherwise onsetDateTime or onsetPeriod.start; see the endpoint's timing sources for other resources.

            */ @JsonSetter(value = "observation_date", nulls = Nulls.SKIP) public Builder observationDate(Optional observationDate) { @@ -395,6 +403,9 @@ public Builder valueAsConceptId(Long valueAsConceptId) { return this; } + /** + *

            Valid standard Unit concept for coded UCUM; 0 for supplied but unmapped units, null for absent units. Always present in JSON.

            + */ @JsonSetter(value = "unit_concept_id", nulls = Nulls.SKIP) public Builder unitConceptId(Optional unitConceptId) { this.unitConceptId = unitConceptId; @@ -439,6 +450,9 @@ public Builder observationSourceValue(String observationSourceValue) { return this; } + /** + *

            For coded AllergyIntolerance rows, the known OMOP identity of the original coding rendered in observation_source_value, even when the finding is unresolved. 0 when no original source identity is known; the configured contextual finding is not a source concept.

            + */ @JsonSetter(value = "observation_source_concept_id", nulls = Nulls.SKIP) public Builder observationSourceConceptId(Optional observationSourceConceptId) { this.observationSourceConceptId = observationSourceConceptId; @@ -450,6 +464,9 @@ public Builder observationSourceConceptId(Long observationSourceConceptId) { return this; } + /** + *

            Original Quantity.code if supplied, otherwise Quantity.unit; human-readable unit text is preserved in the unit mapping's source_name.

            + */ @JsonSetter(value = "unit_source_value", nulls = Nulls.SKIP) public Builder unitSourceValue(Optional unitSourceValue) { this.unitSourceValue = unitSourceValue; @@ -493,6 +510,29 @@ public ObservationRow build() { additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("observation_id") + || key.equalsIgnoreCase("person_id") + || key.equalsIgnoreCase("observation_concept_id") + || key.equalsIgnoreCase("observation_date") + || key.equalsIgnoreCase("observation_datetime") + || key.equalsIgnoreCase("observation_type_concept_id") + || key.equalsIgnoreCase("value_as_number") + || key.equalsIgnoreCase("value_as_string") + || key.equalsIgnoreCase("value_as_concept_id") + || key.equalsIgnoreCase("unit_concept_id") + || key.equalsIgnoreCase("visit_occurrence_id") + || key.equalsIgnoreCase("provider_id") + || key.equalsIgnoreCase("observation_source_value") + || key.equalsIgnoreCase("observation_source_concept_id") + || key.equalsIgnoreCase("unit_source_value") + || key.equalsIgnoreCase("value_source_value")) { + return; + } + this.additionalProperties.put(key, value); + } + public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); return this; diff --git a/src/main/java/com/phenoml/api/resources/fhir2omop/types/OmopTables.java b/src/main/java/com/phenoml/api/resources/fhir2omop/types/OmopTables.java index 1cb5f558..521567de 100644 --- a/src/main/java/com/phenoml/api/resources/fhir2omop/types/OmopTables.java +++ b/src/main/java/com/phenoml/api/resources/fhir2omop/types/OmopTables.java @@ -43,6 +43,10 @@ public final class OmopTables { private final Optional> measurement; + private final Optional> deviceExposure; + + private final Optional> specimen; + private final Optional> observation; private final Map additionalProperties; @@ -59,6 +63,8 @@ private OmopTables( Optional> drugExposure, Optional> procedureOccurrence, Optional> measurement, + Optional> deviceExposure, + Optional> specimen, Optional> observation, Map additionalProperties) { this.location = location; @@ -72,6 +78,8 @@ private OmopTables( this.drugExposure = drugExposure; this.procedureOccurrence = procedureOccurrence; this.measurement = measurement; + this.deviceExposure = deviceExposure; + this.specimen = specimen; this.observation = observation; this.additionalProperties = additionalProperties; } @@ -131,6 +139,16 @@ public Optional> getMeasurement() { return measurement; } + @JsonProperty("device_exposure") + public Optional> getDeviceExposure() { + return deviceExposure; + } + + @JsonProperty("specimen") + public Optional> getSpecimen() { + return specimen; + } + @JsonProperty("observation") public Optional> getObservation() { return observation; @@ -159,6 +177,8 @@ private boolean equalTo(OmopTables other) { && drugExposure.equals(other.drugExposure) && procedureOccurrence.equals(other.procedureOccurrence) && measurement.equals(other.measurement) + && deviceExposure.equals(other.deviceExposure) + && specimen.equals(other.specimen) && observation.equals(other.observation); } @@ -176,6 +196,8 @@ public int hashCode() { this.drugExposure, this.procedureOccurrence, this.measurement, + this.deviceExposure, + this.specimen, this.observation); } @@ -212,9 +234,12 @@ public static final class Builder { private Optional> measurement = Optional.empty(); + private Optional> deviceExposure = Optional.empty(); + + private Optional> specimen = Optional.empty(); + private Optional> observation = Optional.empty(); - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -231,6 +256,8 @@ public Builder from(OmopTables other) { drugExposure(other.getDrugExposure()); procedureOccurrence(other.getProcedureOccurrence()); measurement(other.getMeasurement()); + deviceExposure(other.getDeviceExposure()); + specimen(other.getSpecimen()); observation(other.getObservation()); return this; } @@ -356,6 +383,28 @@ public Builder measurement(List measurement) { return this; } + @JsonSetter(value = "device_exposure", nulls = Nulls.SKIP) + public Builder deviceExposure(Optional> deviceExposure) { + this.deviceExposure = deviceExposure; + return this; + } + + public Builder deviceExposure(List deviceExposure) { + this.deviceExposure = Optional.ofNullable(deviceExposure); + return this; + } + + @JsonSetter(value = "specimen", nulls = Nulls.SKIP) + public Builder specimen(Optional> specimen) { + this.specimen = specimen; + return this; + } + + public Builder specimen(List specimen) { + this.specimen = Optional.ofNullable(specimen); + return this; + } + @JsonSetter(value = "observation", nulls = Nulls.SKIP) public Builder observation(Optional> observation) { this.observation = observation; @@ -380,10 +429,33 @@ public OmopTables build() { drugExposure, procedureOccurrence, measurement, + deviceExposure, + specimen, observation, additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("location") + || key.equalsIgnoreCase("care_site") + || key.equalsIgnoreCase("provider") + || key.equalsIgnoreCase("person") + || key.equalsIgnoreCase("death") + || key.equalsIgnoreCase("observation_period") + || key.equalsIgnoreCase("visit_occurrence") + || key.equalsIgnoreCase("condition_occurrence") + || key.equalsIgnoreCase("drug_exposure") + || key.equalsIgnoreCase("procedure_occurrence") + || key.equalsIgnoreCase("measurement") + || key.equalsIgnoreCase("device_exposure") + || key.equalsIgnoreCase("specimen") + || key.equalsIgnoreCase("observation")) { + return; + } + this.additionalProperties.put(key, value); + } + public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); return this; diff --git a/src/main/java/com/phenoml/api/resources/fhir2omop/types/PersonRow.java b/src/main/java/com/phenoml/api/resources/fhir2omop/types/PersonRow.java index c072fbba..00a04b50 100644 --- a/src/main/java/com/phenoml/api/resources/fhir2omop/types/PersonRow.java +++ b/src/main/java/com/phenoml/api/resources/fhir2omop/types/PersonRow.java @@ -330,7 +330,6 @@ public static final class Builder { private Optional ethnicitySourceConceptId = Optional.empty(); - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -617,6 +616,31 @@ public PersonRow build() { additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("person_id") + || key.equalsIgnoreCase("gender_concept_id") + || key.equalsIgnoreCase("year_of_birth") + || key.equalsIgnoreCase("month_of_birth") + || key.equalsIgnoreCase("day_of_birth") + || key.equalsIgnoreCase("birth_datetime") + || key.equalsIgnoreCase("race_concept_id") + || key.equalsIgnoreCase("ethnicity_concept_id") + || key.equalsIgnoreCase("location_id") + || key.equalsIgnoreCase("provider_id") + || key.equalsIgnoreCase("care_site_id") + || key.equalsIgnoreCase("person_source_value") + || key.equalsIgnoreCase("gender_source_value") + || key.equalsIgnoreCase("gender_source_concept_id") + || key.equalsIgnoreCase("race_source_value") + || key.equalsIgnoreCase("race_source_concept_id") + || key.equalsIgnoreCase("ethnicity_source_value") + || key.equalsIgnoreCase("ethnicity_source_concept_id")) { + return; + } + this.additionalProperties.put(key, value); + } + public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); return this; diff --git a/src/main/java/com/phenoml/api/resources/fhir2omop/types/ProcedureOccurrenceRow.java b/src/main/java/com/phenoml/api/resources/fhir2omop/types/ProcedureOccurrenceRow.java index 589c0ec9..8e9aa350 100644 --- a/src/main/java/com/phenoml/api/resources/fhir2omop/types/ProcedureOccurrenceRow.java +++ b/src/main/java/com/phenoml/api/resources/fhir2omop/types/ProcedureOccurrenceRow.java @@ -91,7 +91,7 @@ public Optional getProcedureConceptId() { } /** - * @return Date from Procedure.performedDateTime or performedPeriod.start. + * @return Start date selected by the source resource's timing rules. For a Procedure, performedDateTime or performedPeriod.start; see the endpoint's timing sources for other resources. */ @JsonProperty("procedure_date") public Optional getProcedureDate() { @@ -104,7 +104,7 @@ public Optional getProcedureDatetime() { } /** - * @return Date from Procedure.performedPeriod.end. + * @return End date selected by the source resource's timing rules, when available. For a Procedure, performedPeriod.end; see the endpoint's timing sources for other resources. */ @JsonProperty("procedure_end_date") public Optional getProcedureEndDate() { @@ -219,7 +219,6 @@ public static final class Builder { private Optional procedureSourceConceptId = Optional.empty(); - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -274,7 +273,7 @@ public Builder procedureConceptId(Long procedureConceptId) { } /** - *

            Date from Procedure.performedDateTime or performedPeriod.start.

            + *

            Start date selected by the source resource's timing rules. For a Procedure, performedDateTime or performedPeriod.start; see the endpoint's timing sources for other resources.

            */ @JsonSetter(value = "procedure_date", nulls = Nulls.SKIP) public Builder procedureDate(Optional procedureDate) { @@ -299,7 +298,7 @@ public Builder procedureDatetime(String procedureDatetime) { } /** - *

            Date from Procedure.performedPeriod.end.

            + *

            End date selected by the source resource's timing rules, when available. For a Procedure, performedPeriod.end; see the endpoint's timing sources for other resources.

            */ @JsonSetter(value = "procedure_end_date", nulls = Nulls.SKIP) public Builder procedureEndDate(Optional procedureEndDate) { @@ -395,6 +394,25 @@ public ProcedureOccurrenceRow build() { additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("procedure_occurrence_id") + || key.equalsIgnoreCase("person_id") + || key.equalsIgnoreCase("procedure_concept_id") + || key.equalsIgnoreCase("procedure_date") + || key.equalsIgnoreCase("procedure_datetime") + || key.equalsIgnoreCase("procedure_end_date") + || key.equalsIgnoreCase("procedure_end_datetime") + || key.equalsIgnoreCase("procedure_type_concept_id") + || key.equalsIgnoreCase("visit_occurrence_id") + || key.equalsIgnoreCase("provider_id") + || key.equalsIgnoreCase("procedure_source_value") + || key.equalsIgnoreCase("procedure_source_concept_id")) { + return; + } + this.additionalProperties.put(key, value); + } + public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); return this; diff --git a/src/main/java/com/phenoml/api/resources/fhir2omop/types/ProviderRoleCareSite.java b/src/main/java/com/phenoml/api/resources/fhir2omop/types/ProviderRoleCareSite.java index af0b53c5..02798b12 100644 --- a/src/main/java/com/phenoml/api/resources/fhir2omop/types/ProviderRoleCareSite.java +++ b/src/main/java/com/phenoml/api/resources/fhir2omop/types/ProviderRoleCareSite.java @@ -117,7 +117,6 @@ public static final class Builder implements PathStage, ReferenceStage, _FinalSt private Optional careSiteId = Optional.empty(); - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -166,6 +165,16 @@ public ProviderRoleCareSite build() { return new ProviderRoleCareSite(path, reference, careSiteId, additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("path") + || key.equalsIgnoreCase("reference") + || key.equalsIgnoreCase("care_site_id")) { + return; + } + this.additionalProperties.put(key, value); + } + @java.lang.Override public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); diff --git a/src/main/java/com/phenoml/api/resources/fhir2omop/types/ProviderRoleCodeableConcept.java b/src/main/java/com/phenoml/api/resources/fhir2omop/types/ProviderRoleCodeableConcept.java index d355287d..8eb189f5 100644 --- a/src/main/java/com/phenoml/api/resources/fhir2omop/types/ProviderRoleCodeableConcept.java +++ b/src/main/java/com/phenoml/api/resources/fhir2omop/types/ProviderRoleCodeableConcept.java @@ -79,7 +79,6 @@ public static final class Builder { private Optional> codings = Optional.empty(); - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -116,6 +115,14 @@ public ProviderRoleCodeableConcept build() { return new ProviderRoleCodeableConcept(sourceValue, codings, additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("source_value") || key.equalsIgnoreCase("codings")) { + return; + } + this.additionalProperties.put(key, value); + } + public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); return this; diff --git a/src/main/java/com/phenoml/api/resources/fhir2omop/types/ProviderRoleContext.java b/src/main/java/com/phenoml/api/resources/fhir2omop/types/ProviderRoleContext.java index d8619865..780de6bc 100644 --- a/src/main/java/com/phenoml/api/resources/fhir2omop/types/ProviderRoleContext.java +++ b/src/main/java/com/phenoml/api/resources/fhir2omop/types/ProviderRoleContext.java @@ -258,7 +258,6 @@ public static final class Builder implements ProviderIdStage, _FinalStage { private Optional roleSourceValue = Optional.empty(); - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -441,6 +440,23 @@ public ProviderRoleContext build() { additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("provider_id") + || key.equalsIgnoreCase("role_source_value") + || key.equalsIgnoreCase("practitioner_reference") + || key.equalsIgnoreCase("practitioner_identifier") + || key.equalsIgnoreCase("role_codes") + || key.equalsIgnoreCase("specialties") + || key.equalsIgnoreCase("care_sites") + || key.equalsIgnoreCase("active") + || key.equalsIgnoreCase("period_start") + || key.equalsIgnoreCase("period_end")) { + return; + } + this.additionalProperties.put(key, value); + } + @java.lang.Override public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); diff --git a/src/main/java/com/phenoml/api/resources/fhir2omop/types/ProviderRolePractitionerIdentifier.java b/src/main/java/com/phenoml/api/resources/fhir2omop/types/ProviderRolePractitionerIdentifier.java index 1f55bc87..db9337bb 100644 --- a/src/main/java/com/phenoml/api/resources/fhir2omop/types/ProviderRolePractitionerIdentifier.java +++ b/src/main/java/com/phenoml/api/resources/fhir2omop/types/ProviderRolePractitionerIdentifier.java @@ -98,7 +98,6 @@ public static final class Builder implements ValueStage, _FinalStage { private Optional system = Optional.empty(); - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -135,6 +134,14 @@ public ProviderRolePractitionerIdentifier build() { return new ProviderRolePractitionerIdentifier(system, value, additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("system") || key.equalsIgnoreCase("value")) { + return; + } + this.additionalProperties.put(key, value); + } + @java.lang.Override public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); diff --git a/src/main/java/com/phenoml/api/resources/fhir2omop/types/ProviderRow.java b/src/main/java/com/phenoml/api/resources/fhir2omop/types/ProviderRow.java index a4c9f674..2aa4bd39 100644 --- a/src/main/java/com/phenoml/api/resources/fhir2omop/types/ProviderRow.java +++ b/src/main/java/com/phenoml/api/resources/fhir2omop/types/ProviderRow.java @@ -89,6 +89,9 @@ public Optional getProviderName() { return providerName; } + /** + * @return Single distinct source NPI value, preserved without format or checksum validation, from an identifier with system http://hl7.org/fhir/sid/us-npi, urn:oid:2.16.840.1.113883.4.6, or 2.16.840.1.113883.4.6. Blank values and UNK/UNKNOWN markers (case-insensitive, ignoring surrounding whitespace) are treated as missing. Omitted when no usable NPI value exists or multiple distinct values are supplied. + */ @JsonProperty("npi") public Optional getNpi() { return npi; @@ -99,6 +102,9 @@ public Optional getDea() { return dea; } + /** + * @return Standard Provider concept shared by every distinct supplied role specialty; otherwise 0. Inputs resolve independently, without frequency or hierarchy selection. + */ @JsonProperty("specialty_concept_id") public Optional getSpecialtyConceptId() { return specialtyConceptId; @@ -126,18 +132,44 @@ public Optional getGenderConceptId() { } /** - * @return The source practitioner identity. A Practitioner contained by a PractitionerRole is scoped as PractitionerRole/<role-source-value>#<contained-id> so identical local contained IDs do not collide; an id-less parent uses an explicitly marked response-local role ordinal such as @role-index:1. + * @return The single distinct source NPI, when present. Compatible providers + * can use this value as a cross-request deduplication key; row IDs are + * per response, and the API does not merge across requests. Conflicting + * NPI groups retain separate rows sharing this value. Preserve rows + * flagged by identity diagnostics using omop_table: provider and + * omop_id, and check identity compatibility across responses before + * deduplicating. + * Without one unambiguous NPI, this is the source identity of the + * Practitioner or fallback role. + * Missing NPI values and unknown markers are excluded when selecting + * that source identity. When no source identity is available, an + * explicitly marked response-local provider ordinal such as + * @provider-index:1 supplies a local scope. + * Multiple distinct NPIs leave npi unset and use the resource ID, + * full URL, or an explicitly marked response-local provider ordinal + * such as @provider-index:1 when neither is supplied. + * A Practitioner contained by a PractitionerRole without one unambiguous + * NPI is scoped as PractitionerRole/<role-source-value>#<contained-id> + * so identical local contained IDs do not collide; an id-less parent + * uses an explicitly marked response-local role ordinal such as + * @role-index:1. */ @JsonProperty("provider_source_value") public Optional getProviderSourceValue() { return providerSourceValue; } + /** + * @return Supplied specialty text (or coding display/code) when source values across roles agree, including unresolved text. Conflicting source values remain in provider_role_contexts and mappings. + */ @JsonProperty("specialty_source_value") public Optional getSpecialtySourceValue() { return specialtySourceValue; } + /** + * @return Shared input OMOP source concept when all specialties agree on both target and source concept. Text, UMLS routes, conflicting inputs, or differing source concepts leave 0. + */ @JsonProperty("specialty_source_concept_id") public Optional getSpecialtySourceConceptId() { return specialtySourceConceptId; @@ -152,7 +184,7 @@ public Optional getGenderSourceValue() { } /** - * @return Remains 0 for FHIR administrative-gender enum-policy results. + * @return Remains 0 because FHIR administrative-gender codes do not themselves identify OMOP source concepts. */ @JsonProperty("gender_source_concept_id") public Optional getGenderSourceConceptId() { @@ -241,7 +273,6 @@ public static final class Builder { private Optional genderSourceConceptId = Optional.empty(); - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -285,6 +316,9 @@ public Builder providerName(String providerName) { return this; } + /** + *

            Single distinct source NPI value, preserved without format or checksum validation, from an identifier with system http://hl7.org/fhir/sid/us-npi, urn:oid:2.16.840.1.113883.4.6, or 2.16.840.1.113883.4.6. Blank values and UNK/UNKNOWN markers (case-insensitive, ignoring surrounding whitespace) are treated as missing. Omitted when no usable NPI value exists or multiple distinct values are supplied.

            + */ @JsonSetter(value = "npi", nulls = Nulls.SKIP) public Builder npi(Optional npi) { this.npi = npi; @@ -307,6 +341,9 @@ public Builder dea(String dea) { return this; } + /** + *

            Standard Provider concept shared by every distinct supplied role specialty; otherwise 0. Inputs resolve independently, without frequency or hierarchy selection.

            + */ @JsonSetter(value = "specialty_concept_id", nulls = Nulls.SKIP) public Builder specialtyConceptId(Optional specialtyConceptId) { this.specialtyConceptId = specialtyConceptId; @@ -358,7 +395,27 @@ public Builder genderConceptId(Long genderConceptId) { } /** - *

            The source practitioner identity. A Practitioner contained by a PractitionerRole is scoped as PractitionerRole/<role-source-value>#<contained-id> so identical local contained IDs do not collide; an id-less parent uses an explicitly marked response-local role ordinal such as @role-index:1.

            + *

            The single distinct source NPI, when present. Compatible providers + * can use this value as a cross-request deduplication key; row IDs are + * per response, and the API does not merge across requests. Conflicting + * NPI groups retain separate rows sharing this value. Preserve rows + * flagged by identity diagnostics using omop_table: provider and + * omop_id, and check identity compatibility across responses before + * deduplicating. + * Without one unambiguous NPI, this is the source identity of the + * Practitioner or fallback role. + * Missing NPI values and unknown markers are excluded when selecting + * that source identity. When no source identity is available, an + * explicitly marked response-local provider ordinal such as + * @provider-index:1 supplies a local scope. + * Multiple distinct NPIs leave npi unset and use the resource ID, + * full URL, or an explicitly marked response-local provider ordinal + * such as @provider-index:1 when neither is supplied. + * A Practitioner contained by a PractitionerRole without one unambiguous + * NPI is scoped as PractitionerRole/<role-source-value>#<contained-id> + * so identical local contained IDs do not collide; an id-less parent + * uses an explicitly marked response-local role ordinal such as + * @role-index:1.

            */ @JsonSetter(value = "provider_source_value", nulls = Nulls.SKIP) public Builder providerSourceValue(Optional providerSourceValue) { @@ -371,6 +428,9 @@ public Builder providerSourceValue(String providerSourceValue) { return this; } + /** + *

            Supplied specialty text (or coding display/code) when source values across roles agree, including unresolved text. Conflicting source values remain in provider_role_contexts and mappings.

            + */ @JsonSetter(value = "specialty_source_value", nulls = Nulls.SKIP) public Builder specialtySourceValue(Optional specialtySourceValue) { this.specialtySourceValue = specialtySourceValue; @@ -382,6 +442,9 @@ public Builder specialtySourceValue(String specialtySourceValue) { return this; } + /** + *

            Shared input OMOP source concept when all specialties agree on both target and source concept. Text, UMLS routes, conflicting inputs, or differing source concepts leave 0.

            + */ @JsonSetter(value = "specialty_source_concept_id", nulls = Nulls.SKIP) public Builder specialtySourceConceptId(Optional specialtySourceConceptId) { this.specialtySourceConceptId = specialtySourceConceptId; @@ -408,7 +471,7 @@ public Builder genderSourceValue(String genderSourceValue) { } /** - *

            Remains 0 for FHIR administrative-gender enum-policy results.

            + *

            Remains 0 because FHIR administrative-gender codes do not themselves identify OMOP source concepts.

            */ @JsonSetter(value = "gender_source_concept_id", nulls = Nulls.SKIP) public Builder genderSourceConceptId(Optional genderSourceConceptId) { @@ -439,6 +502,26 @@ public ProviderRow build() { additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("provider_id") + || key.equalsIgnoreCase("provider_name") + || key.equalsIgnoreCase("npi") + || key.equalsIgnoreCase("dea") + || key.equalsIgnoreCase("specialty_concept_id") + || key.equalsIgnoreCase("care_site_id") + || key.equalsIgnoreCase("year_of_birth") + || key.equalsIgnoreCase("gender_concept_id") + || key.equalsIgnoreCase("provider_source_value") + || key.equalsIgnoreCase("specialty_source_value") + || key.equalsIgnoreCase("specialty_source_concept_id") + || key.equalsIgnoreCase("gender_source_value") + || key.equalsIgnoreCase("gender_source_concept_id")) { + return; + } + this.additionalProperties.put(key, value); + } + public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); return this; diff --git a/src/main/java/com/phenoml/api/resources/fhir2omop/types/QuantityDenominatorBasis.java b/src/main/java/com/phenoml/api/resources/fhir2omop/types/QuantityDenominatorBasis.java new file mode 100644 index 00000000..18368a59 --- /dev/null +++ b/src/main/java/com/phenoml/api/resources/fhir2omop/types/QuantityDenominatorBasis.java @@ -0,0 +1,156 @@ +/** + * This file was auto-generated by Fern from our API Definition. + */ +package com.phenoml.api.resources.fhir2omop.types; + +import com.fasterxml.jackson.annotation.JsonAnyGetter; +import com.fasterxml.jackson.annotation.JsonAnySetter; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonSetter; +import com.fasterxml.jackson.annotation.Nulls; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.phenoml.api.core.ObjectMappers; +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import org.jetbrains.annotations.NotNull; + +@JsonInclude(JsonInclude.Include.NON_ABSENT) +@JsonDeserialize(builder = QuantityDenominatorBasis.Builder.class) +public final class QuantityDenominatorBasis { + private final QuantityDenominatorBasisKind kind; + + private final Optional value; + + private final Map additionalProperties; + + private QuantityDenominatorBasis( + QuantityDenominatorBasisKind kind, Optional value, Map additionalProperties) { + this.kind = kind; + this.value = value; + this.additionalProperties = additionalProperties; + } + + @JsonProperty("kind") + public QuantityDenominatorBasisKind getKind() { + return kind; + } + + @JsonProperty("value") + public Optional getValue() { + return value; + } + + @java.lang.Override + public boolean equals(Object other) { + if (this == other) return true; + return other instanceof QuantityDenominatorBasis && equalTo((QuantityDenominatorBasis) other); + } + + @JsonAnyGetter + public Map getAdditionalProperties() { + return this.additionalProperties; + } + + private boolean equalTo(QuantityDenominatorBasis other) { + return kind.equals(other.kind) && value.equals(other.value); + } + + @java.lang.Override + public int hashCode() { + return Objects.hash(this.kind, this.value); + } + + @java.lang.Override + public String toString() { + return ObjectMappers.stringify(this); + } + + public static KindStage builder() { + return new Builder(); + } + + public interface KindStage { + _FinalStage kind(@NotNull QuantityDenominatorBasisKind kind); + + Builder from(QuantityDenominatorBasis other); + } + + public interface _FinalStage { + QuantityDenominatorBasis build(); + + _FinalStage additionalProperty(String key, Object value); + + _FinalStage additionalProperties(Map additionalProperties); + + _FinalStage value(Optional value); + + _FinalStage value(Double value); + } + + @JsonIgnoreProperties(ignoreUnknown = true) + public static final class Builder implements KindStage, _FinalStage { + private QuantityDenominatorBasisKind kind; + + private Optional value = Optional.empty(); + + private Map additionalProperties = new HashMap<>(); + + private Builder() {} + + @java.lang.Override + public Builder from(QuantityDenominatorBasis other) { + kind(other.getKind()); + value(other.getValue()); + return this; + } + + @java.lang.Override + @JsonSetter("kind") + public _FinalStage kind(@NotNull QuantityDenominatorBasisKind kind) { + this.kind = Objects.requireNonNull(kind, "kind must not be null"); + return this; + } + + @java.lang.Override + public _FinalStage value(Double value) { + this.value = Optional.ofNullable(value); + return this; + } + + @java.lang.Override + @JsonSetter(value = "value", nulls = Nulls.SKIP) + public _FinalStage value(Optional value) { + this.value = value; + return this; + } + + @java.lang.Override + public QuantityDenominatorBasis build() { + return new QuantityDenominatorBasis(kind, value, additionalProperties); + } + + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("kind") || key.equalsIgnoreCase("value")) { + return; + } + this.additionalProperties.put(key, value); + } + + @java.lang.Override + public Builder additionalProperty(String key, Object value) { + this.additionalProperties.put(key, value); + return this; + } + + @java.lang.Override + public Builder additionalProperties(Map additionalProperties) { + this.additionalProperties.putAll(additionalProperties); + return this; + } + } +} diff --git a/src/main/java/com/phenoml/api/resources/fhir2omop/types/QuantityDenominatorBasisKind.java b/src/main/java/com/phenoml/api/resources/fhir2omop/types/QuantityDenominatorBasisKind.java new file mode 100644 index 00000000..553c9169 --- /dev/null +++ b/src/main/java/com/phenoml/api/resources/fhir2omop/types/QuantityDenominatorBasisKind.java @@ -0,0 +1,108 @@ +/** + * This file was auto-generated by Fern from our API Definition. + */ +package com.phenoml.api.resources.fhir2omop.types; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; + +public final class QuantityDenominatorBasisKind { + public static final QuantityDenominatorBasisKind NOT_APPLICABLE = + new QuantityDenominatorBasisKind(Value.NOT_APPLICABLE, "NOT_APPLICABLE"); + + public static final QuantityDenominatorBasisKind PER_UNIT = + new QuantityDenominatorBasisKind(Value.PER_UNIT, "PER_UNIT"); + + public static final QuantityDenominatorBasisKind UNKNOWN = + new QuantityDenominatorBasisKind(Value.UNKNOWN, "UNKNOWN"); + + public static final QuantityDenominatorBasisKind QUANTIFIED_TOTAL = + new QuantityDenominatorBasisKind(Value.QUANTIFIED_TOTAL, "QUANTIFIED_TOTAL"); + + private final Value value; + + private final String string; + + QuantityDenominatorBasisKind(Value value, String string) { + this.value = value; + this.string = string; + } + + public Value getEnumValue() { + return value; + } + + @java.lang.Override + @JsonValue + public String toString() { + return this.string; + } + + @java.lang.Override + public boolean equals(Object other) { + return (this == other) + || (other instanceof QuantityDenominatorBasisKind + && this.string.equals(((QuantityDenominatorBasisKind) other).string)); + } + + @java.lang.Override + public int hashCode() { + return this.string.hashCode(); + } + + public T visit(Visitor visitor) { + switch (value) { + case NOT_APPLICABLE: + return visitor.visitNotApplicable(); + case PER_UNIT: + return visitor.visitPerUnit(); + case UNKNOWN: + return visitor.visitUnknown(); + case QUANTIFIED_TOTAL: + return visitor.visitQuantifiedTotal(); + case _UNKNOWN: + default: + return visitor.visitUnknown(string); + } + } + + @JsonCreator(mode = JsonCreator.Mode.DELEGATING) + public static QuantityDenominatorBasisKind valueOf(String value) { + switch (value) { + case "NOT_APPLICABLE": + return NOT_APPLICABLE; + case "PER_UNIT": + return PER_UNIT; + case "UNKNOWN": + return UNKNOWN; + case "QUANTIFIED_TOTAL": + return QUANTIFIED_TOTAL; + default: + return new QuantityDenominatorBasisKind(Value._UNKNOWN, value); + } + } + + public enum Value { + PER_UNIT, + + QUANTIFIED_TOTAL, + + NOT_APPLICABLE, + + UNKNOWN, + + _UNKNOWN + } + + public interface Visitor { + T visitPerUnit(); + + T visitQuantifiedTotal(); + + T visitNotApplicable(); + + T visitUnknown(); + + T visitUnknown(String unknownType); + } +} diff --git a/src/main/java/com/phenoml/api/resources/fhir2omop/types/QuantityEvidence.java b/src/main/java/com/phenoml/api/resources/fhir2omop/types/QuantityEvidence.java new file mode 100644 index 00000000..c887027c --- /dev/null +++ b/src/main/java/com/phenoml/api/resources/fhir2omop/types/QuantityEvidence.java @@ -0,0 +1,221 @@ +/** + * This file was auto-generated by Fern from our API Definition. + */ +package com.phenoml.api.resources.fhir2omop.types; + +import com.fasterxml.jackson.annotation.JsonAnyGetter; +import com.fasterxml.jackson.annotation.JsonAnySetter; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonSetter; +import com.fasterxml.jackson.annotation.Nulls; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.phenoml.api.core.ObjectMappers; +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; + +@JsonInclude(JsonInclude.Include.NON_ABSENT) +@JsonDeserialize(builder = QuantityEvidence.Builder.class) +public final class QuantityEvidence { + private final long drugConceptId; + + private final Optional drugConceptClass; + + private final Optional doseForm; + + private final Optional strength; + + private final Map additionalProperties; + + private QuantityEvidence( + long drugConceptId, + Optional drugConceptClass, + Optional doseForm, + Optional strength, + Map additionalProperties) { + this.drugConceptId = drugConceptId; + this.drugConceptClass = drugConceptClass; + this.doseForm = doseForm; + this.strength = strength; + this.additionalProperties = additionalProperties; + } + + @JsonProperty("drug_concept_id") + public long getDrugConceptId() { + return drugConceptId; + } + + @JsonProperty("drug_concept_class") + public Optional getDrugConceptClass() { + return drugConceptClass; + } + + @JsonProperty("dose_form") + public Optional getDoseForm() { + return doseForm; + } + + @JsonProperty("strength") + public Optional getStrength() { + return strength; + } + + @java.lang.Override + public boolean equals(Object other) { + if (this == other) return true; + return other instanceof QuantityEvidence && equalTo((QuantityEvidence) other); + } + + @JsonAnyGetter + public Map getAdditionalProperties() { + return this.additionalProperties; + } + + private boolean equalTo(QuantityEvidence other) { + return drugConceptId == other.drugConceptId + && drugConceptClass.equals(other.drugConceptClass) + && doseForm.equals(other.doseForm) + && strength.equals(other.strength); + } + + @java.lang.Override + public int hashCode() { + return Objects.hash(this.drugConceptId, this.drugConceptClass, this.doseForm, this.strength); + } + + @java.lang.Override + public String toString() { + return ObjectMappers.stringify(this); + } + + public static DrugConceptIdStage builder() { + return new Builder(); + } + + public interface DrugConceptIdStage { + _FinalStage drugConceptId(long drugConceptId); + + Builder from(QuantityEvidence other); + } + + public interface _FinalStage { + QuantityEvidence build(); + + _FinalStage additionalProperty(String key, Object value); + + _FinalStage additionalProperties(Map additionalProperties); + + _FinalStage drugConceptClass(Optional drugConceptClass); + + _FinalStage drugConceptClass(String drugConceptClass); + + _FinalStage doseForm(Optional doseForm); + + _FinalStage doseForm(QuantityUnit doseForm); + + _FinalStage strength(Optional strength); + + _FinalStage strength(QuantityStrength strength); + } + + @JsonIgnoreProperties(ignoreUnknown = true) + public static final class Builder implements DrugConceptIdStage, _FinalStage { + private long drugConceptId; + + private Optional strength = Optional.empty(); + + private Optional doseForm = Optional.empty(); + + private Optional drugConceptClass = Optional.empty(); + + private Map additionalProperties = new HashMap<>(); + + private Builder() {} + + @java.lang.Override + public Builder from(QuantityEvidence other) { + drugConceptId(other.getDrugConceptId()); + drugConceptClass(other.getDrugConceptClass()); + doseForm(other.getDoseForm()); + strength(other.getStrength()); + return this; + } + + @java.lang.Override + @JsonSetter("drug_concept_id") + public _FinalStage drugConceptId(long drugConceptId) { + this.drugConceptId = drugConceptId; + return this; + } + + @java.lang.Override + public _FinalStage strength(QuantityStrength strength) { + this.strength = Optional.ofNullable(strength); + return this; + } + + @java.lang.Override + @JsonSetter(value = "strength", nulls = Nulls.SKIP) + public _FinalStage strength(Optional strength) { + this.strength = strength; + return this; + } + + @java.lang.Override + public _FinalStage doseForm(QuantityUnit doseForm) { + this.doseForm = Optional.ofNullable(doseForm); + return this; + } + + @java.lang.Override + @JsonSetter(value = "dose_form", nulls = Nulls.SKIP) + public _FinalStage doseForm(Optional doseForm) { + this.doseForm = doseForm; + return this; + } + + @java.lang.Override + public _FinalStage drugConceptClass(String drugConceptClass) { + this.drugConceptClass = Optional.ofNullable(drugConceptClass); + return this; + } + + @java.lang.Override + @JsonSetter(value = "drug_concept_class", nulls = Nulls.SKIP) + public _FinalStage drugConceptClass(Optional drugConceptClass) { + this.drugConceptClass = drugConceptClass; + return this; + } + + @java.lang.Override + public QuantityEvidence build() { + return new QuantityEvidence(drugConceptId, drugConceptClass, doseForm, strength, additionalProperties); + } + + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("drug_concept_id") + || key.equalsIgnoreCase("drug_concept_class") + || key.equalsIgnoreCase("dose_form") + || key.equalsIgnoreCase("strength")) { + return; + } + this.additionalProperties.put(key, value); + } + + @java.lang.Override + public Builder additionalProperty(String key, Object value) { + this.additionalProperties.put(key, value); + return this; + } + + @java.lang.Override + public Builder additionalProperties(Map additionalProperties) { + this.additionalProperties.putAll(additionalProperties); + return this; + } + } +} diff --git a/src/main/java/com/phenoml/api/resources/fhir2omop/types/QuantityStrength.java b/src/main/java/com/phenoml/api/resources/fhir2omop/types/QuantityStrength.java new file mode 100644 index 00000000..aeb06c4a --- /dev/null +++ b/src/main/java/com/phenoml/api/resources/fhir2omop/types/QuantityStrength.java @@ -0,0 +1,362 @@ +/** + * This file was auto-generated by Fern from our API Definition. + */ +package com.phenoml.api.resources.fhir2omop.types; + +import com.fasterxml.jackson.annotation.JsonAnyGetter; +import com.fasterxml.jackson.annotation.JsonAnySetter; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonSetter; +import com.fasterxml.jackson.annotation.Nulls; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.phenoml.api.core.ObjectMappers; +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; + +@JsonInclude(JsonInclude.Include.NON_ABSENT) +@JsonDeserialize(builder = QuantityStrength.Builder.class) +public final class QuantityStrength { + private final long ingredientConceptId; + + private final Optional amountValue; + + private final Optional amountUnit; + + private final Optional numeratorValue; + + private final Optional numeratorUnit; + + private final Optional denominatorValue; + + private final Optional denominatorUnit; + + private final Optional denominatorBasis; + + private final Map additionalProperties; + + private QuantityStrength( + long ingredientConceptId, + Optional amountValue, + Optional amountUnit, + Optional numeratorValue, + Optional numeratorUnit, + Optional denominatorValue, + Optional denominatorUnit, + Optional denominatorBasis, + Map additionalProperties) { + this.ingredientConceptId = ingredientConceptId; + this.amountValue = amountValue; + this.amountUnit = amountUnit; + this.numeratorValue = numeratorValue; + this.numeratorUnit = numeratorUnit; + this.denominatorValue = denominatorValue; + this.denominatorUnit = denominatorUnit; + this.denominatorBasis = denominatorBasis; + this.additionalProperties = additionalProperties; + } + + @JsonProperty("ingredient_concept_id") + public long getIngredientConceptId() { + return ingredientConceptId; + } + + @JsonProperty("amount_value") + public Optional getAmountValue() { + return amountValue; + } + + @JsonProperty("amount_unit") + public Optional getAmountUnit() { + return amountUnit; + } + + @JsonProperty("numerator_value") + public Optional getNumeratorValue() { + return numeratorValue; + } + + @JsonProperty("numerator_unit") + public Optional getNumeratorUnit() { + return numeratorUnit; + } + + @JsonProperty("denominator_value") + public Optional getDenominatorValue() { + return denominatorValue; + } + + @JsonProperty("denominator_unit") + public Optional getDenominatorUnit() { + return denominatorUnit; + } + + @JsonProperty("denominator_basis") + public Optional getDenominatorBasis() { + return denominatorBasis; + } + + @java.lang.Override + public boolean equals(Object other) { + if (this == other) return true; + return other instanceof QuantityStrength && equalTo((QuantityStrength) other); + } + + @JsonAnyGetter + public Map getAdditionalProperties() { + return this.additionalProperties; + } + + private boolean equalTo(QuantityStrength other) { + return ingredientConceptId == other.ingredientConceptId + && amountValue.equals(other.amountValue) + && amountUnit.equals(other.amountUnit) + && numeratorValue.equals(other.numeratorValue) + && numeratorUnit.equals(other.numeratorUnit) + && denominatorValue.equals(other.denominatorValue) + && denominatorUnit.equals(other.denominatorUnit) + && denominatorBasis.equals(other.denominatorBasis); + } + + @java.lang.Override + public int hashCode() { + return Objects.hash( + this.ingredientConceptId, + this.amountValue, + this.amountUnit, + this.numeratorValue, + this.numeratorUnit, + this.denominatorValue, + this.denominatorUnit, + this.denominatorBasis); + } + + @java.lang.Override + public String toString() { + return ObjectMappers.stringify(this); + } + + public static IngredientConceptIdStage builder() { + return new Builder(); + } + + public interface IngredientConceptIdStage { + _FinalStage ingredientConceptId(long ingredientConceptId); + + Builder from(QuantityStrength other); + } + + public interface _FinalStage { + QuantityStrength build(); + + _FinalStage additionalProperty(String key, Object value); + + _FinalStage additionalProperties(Map additionalProperties); + + _FinalStage amountValue(Optional amountValue); + + _FinalStage amountValue(Double amountValue); + + _FinalStage amountUnit(Optional amountUnit); + + _FinalStage amountUnit(QuantityUnit amountUnit); + + _FinalStage numeratorValue(Optional numeratorValue); + + _FinalStage numeratorValue(Double numeratorValue); + + _FinalStage numeratorUnit(Optional numeratorUnit); + + _FinalStage numeratorUnit(QuantityUnit numeratorUnit); + + _FinalStage denominatorValue(Optional denominatorValue); + + _FinalStage denominatorValue(Double denominatorValue); + + _FinalStage denominatorUnit(Optional denominatorUnit); + + _FinalStage denominatorUnit(QuantityUnit denominatorUnit); + + _FinalStage denominatorBasis(Optional denominatorBasis); + + _FinalStage denominatorBasis(QuantityDenominatorBasis denominatorBasis); + } + + @JsonIgnoreProperties(ignoreUnknown = true) + public static final class Builder implements IngredientConceptIdStage, _FinalStage { + private long ingredientConceptId; + + private Optional denominatorBasis = Optional.empty(); + + private Optional denominatorUnit = Optional.empty(); + + private Optional denominatorValue = Optional.empty(); + + private Optional numeratorUnit = Optional.empty(); + + private Optional numeratorValue = Optional.empty(); + + private Optional amountUnit = Optional.empty(); + + private Optional amountValue = Optional.empty(); + + private Map additionalProperties = new HashMap<>(); + + private Builder() {} + + @java.lang.Override + public Builder from(QuantityStrength other) { + ingredientConceptId(other.getIngredientConceptId()); + amountValue(other.getAmountValue()); + amountUnit(other.getAmountUnit()); + numeratorValue(other.getNumeratorValue()); + numeratorUnit(other.getNumeratorUnit()); + denominatorValue(other.getDenominatorValue()); + denominatorUnit(other.getDenominatorUnit()); + denominatorBasis(other.getDenominatorBasis()); + return this; + } + + @java.lang.Override + @JsonSetter("ingredient_concept_id") + public _FinalStage ingredientConceptId(long ingredientConceptId) { + this.ingredientConceptId = ingredientConceptId; + return this; + } + + @java.lang.Override + public _FinalStage denominatorBasis(QuantityDenominatorBasis denominatorBasis) { + this.denominatorBasis = Optional.ofNullable(denominatorBasis); + return this; + } + + @java.lang.Override + @JsonSetter(value = "denominator_basis", nulls = Nulls.SKIP) + public _FinalStage denominatorBasis(Optional denominatorBasis) { + this.denominatorBasis = denominatorBasis; + return this; + } + + @java.lang.Override + public _FinalStage denominatorUnit(QuantityUnit denominatorUnit) { + this.denominatorUnit = Optional.ofNullable(denominatorUnit); + return this; + } + + @java.lang.Override + @JsonSetter(value = "denominator_unit", nulls = Nulls.SKIP) + public _FinalStage denominatorUnit(Optional denominatorUnit) { + this.denominatorUnit = denominatorUnit; + return this; + } + + @java.lang.Override + public _FinalStage denominatorValue(Double denominatorValue) { + this.denominatorValue = Optional.ofNullable(denominatorValue); + return this; + } + + @java.lang.Override + @JsonSetter(value = "denominator_value", nulls = Nulls.SKIP) + public _FinalStage denominatorValue(Optional denominatorValue) { + this.denominatorValue = denominatorValue; + return this; + } + + @java.lang.Override + public _FinalStage numeratorUnit(QuantityUnit numeratorUnit) { + this.numeratorUnit = Optional.ofNullable(numeratorUnit); + return this; + } + + @java.lang.Override + @JsonSetter(value = "numerator_unit", nulls = Nulls.SKIP) + public _FinalStage numeratorUnit(Optional numeratorUnit) { + this.numeratorUnit = numeratorUnit; + return this; + } + + @java.lang.Override + public _FinalStage numeratorValue(Double numeratorValue) { + this.numeratorValue = Optional.ofNullable(numeratorValue); + return this; + } + + @java.lang.Override + @JsonSetter(value = "numerator_value", nulls = Nulls.SKIP) + public _FinalStage numeratorValue(Optional numeratorValue) { + this.numeratorValue = numeratorValue; + return this; + } + + @java.lang.Override + public _FinalStage amountUnit(QuantityUnit amountUnit) { + this.amountUnit = Optional.ofNullable(amountUnit); + return this; + } + + @java.lang.Override + @JsonSetter(value = "amount_unit", nulls = Nulls.SKIP) + public _FinalStage amountUnit(Optional amountUnit) { + this.amountUnit = amountUnit; + return this; + } + + @java.lang.Override + public _FinalStage amountValue(Double amountValue) { + this.amountValue = Optional.ofNullable(amountValue); + return this; + } + + @java.lang.Override + @JsonSetter(value = "amount_value", nulls = Nulls.SKIP) + public _FinalStage amountValue(Optional amountValue) { + this.amountValue = amountValue; + return this; + } + + @java.lang.Override + public QuantityStrength build() { + return new QuantityStrength( + ingredientConceptId, + amountValue, + amountUnit, + numeratorValue, + numeratorUnit, + denominatorValue, + denominatorUnit, + denominatorBasis, + additionalProperties); + } + + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("ingredient_concept_id") + || key.equalsIgnoreCase("amount_value") + || key.equalsIgnoreCase("amount_unit") + || key.equalsIgnoreCase("numerator_value") + || key.equalsIgnoreCase("numerator_unit") + || key.equalsIgnoreCase("denominator_value") + || key.equalsIgnoreCase("denominator_unit") + || key.equalsIgnoreCase("denominator_basis")) { + return; + } + this.additionalProperties.put(key, value); + } + + @java.lang.Override + public Builder additionalProperty(String key, Object value) { + this.additionalProperties.put(key, value); + return this; + } + + @java.lang.Override + public Builder additionalProperties(Map additionalProperties) { + this.additionalProperties.putAll(additionalProperties); + return this; + } + } +} diff --git a/src/main/java/com/phenoml/api/resources/fhir2omop/types/QuantityTransformation.java b/src/main/java/com/phenoml/api/resources/fhir2omop/types/QuantityTransformation.java new file mode 100644 index 00000000..1165f17f --- /dev/null +++ b/src/main/java/com/phenoml/api/resources/fhir2omop/types/QuantityTransformation.java @@ -0,0 +1,640 @@ +/** + * This file was auto-generated by Fern from our API Definition. + */ +package com.phenoml.api.resources.fhir2omop.types; + +import com.fasterxml.jackson.annotation.JsonAnyGetter; +import com.fasterxml.jackson.annotation.JsonAnySetter; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonSetter; +import com.fasterxml.jackson.annotation.Nulls; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.phenoml.api.core.ObjectMappers; +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import org.jetbrains.annotations.NotNull; + +@JsonInclude(JsonInclude.Include.NON_ABSENT) +@JsonDeserialize(builder = QuantityTransformation.Builder.class) +public final class QuantityTransformation { + private final QuantityTransformationResourceType resourceType; + + private final String resourceId; + + private final QuantityTransformationSourcePath sourcePath; + + private final Optional sourceQuantity; + + private final long omopId; + + private final QuantityTransformationOutcome outcome; + + private final Optional policy; + + private final Optional reason; + + private final Optional evidence; + + private final Optional unitConversion; + + private final Optional result; + + private final Optional resultUnit; + + private final Map additionalProperties; + + private QuantityTransformation( + QuantityTransformationResourceType resourceType, + String resourceId, + QuantityTransformationSourcePath sourcePath, + Optional sourceQuantity, + long omopId, + QuantityTransformationOutcome outcome, + Optional policy, + Optional reason, + Optional evidence, + Optional unitConversion, + Optional result, + Optional resultUnit, + Map additionalProperties) { + this.resourceType = resourceType; + this.resourceId = resourceId; + this.sourcePath = sourcePath; + this.sourceQuantity = sourceQuantity; + this.omopId = omopId; + this.outcome = outcome; + this.policy = policy; + this.reason = reason; + this.evidence = evidence; + this.unitConversion = unitConversion; + this.result = result; + this.resultUnit = resultUnit; + this.additionalProperties = additionalProperties; + } + + @JsonProperty("resource_type") + public QuantityTransformationResourceType getResourceType() { + return resourceType; + } + + /** + * @return Source FHIR resource id; empty when none was supplied. + */ + @JsonProperty("resource_id") + public String getResourceId() { + return resourceId; + } + + /** + * @return Exact source element path relative to resource_type. + */ + @JsonProperty("source_path") + public QuantityTransformationSourcePath getSourcePath() { + return sourcePath; + } + + /** + * @return Original supplied Quantity object, including value, system, code, unit, comparator, and other supplied fields. Explicit nulls and malformed values are also preserved as supplied. + */ + @JsonProperty("source_quantity") + public Optional getSourceQuantity() { + return sourceQuantity; + } + + @JsonProperty("omop_table") + public String getOmopTable() { + return "drug_exposure"; + } + + @JsonProperty("omop_field") + public String getOmopField() { + return "quantity"; + } + + /** + * @return The drug_exposure_id of the row in this response. + */ + @JsonProperty("omop_id") + public long getOmopId() { + return omopId; + } + + /** + * @return DIRECT copies a matched fixed-form count or an ingredient amount in the same unit; DERIVED converts units or calculates a dose-form count or ingredient amount under the named policy; OMITTED leaves quantity unset while retaining the row. + */ + @JsonProperty("outcome") + public QuantityTransformationOutcome getOutcome() { + return outcome; + } + + /** + * @return The formulation policy documented in the endpoint description. May be present for an omission occurring after policy selection. + */ + @JsonProperty("policy") + public Optional getPolicy() { + return policy; + } + + /** + * @return Stable omission reason: + *
              + *
            • unsupported_quantity: supplied value is not a Quantity object with a positive finite numeric value.
            • + *
            • unsupported_comparator: a comparator field was supplied.
            • + *
            • unsupported_unit: source system/code does not identify a UCUM-coded quantity or a supported HL7 TAB/CAP count.
            • + *
            • drug_unresolved: no standard drug concept was resolved.
            • + *
            • missing_drug_evidence: formulation and strength evidence is unavailable.
            • + *
            • incomplete_drug_evidence: required vocabulary evidence is missing or invalid.
            • + *
            • ambiguous_drug_evidence: more than one dose form exists, or more than one strength exists for a conversion requiring strength. Direct counts allow combination products.
            • + *
            • missing_classification: the selected drug's concept class is unavailable.
            • + *
            • unsupported_drug_class: the drug is outside Clinical Drug or Branded Drug, including quantified products and packs.
            • + *
            • unsupported_dose_form: the dose form has no supported classification for these policies.
            • + *
            • unsupported_strength: strength does not establish the selected policy, including missing, non-positive, non-finite, or conflicting values or an absent/unsupported denominator basis for volume conversion.
            • + *
            • unit_mismatch: the source neither matches a validated vocabulary-backed strength unit nor converts within the documented supported unit groups, or an explicit count disagrees with the selected tablet/capsule form.
            • + *
            • unsupported_fraction: a fixed-form count is not whole units or multiples of one-half or one-third.
            • + *
            • invalid_result: arithmetic does not produce a positive finite result.
            • + *
            + */ + @JsonProperty("reason") + public Optional getReason() { + return reason; + } + + @JsonProperty("evidence") + public Optional getEvidence() { + return evidence; + } + + @JsonProperty("unit_conversion") + public Optional getUnitConversion() { + return unitConversion; + } + + /** + * @return Same numeric value as the linked row's quantity. + */ + @JsonProperty("result") + public Optional getResult() { + return result; + } + + @JsonProperty("result_unit") + public Optional getResultUnit() { + return resultUnit; + } + + @java.lang.Override + public boolean equals(Object other) { + if (this == other) return true; + return other instanceof QuantityTransformation && equalTo((QuantityTransformation) other); + } + + @JsonAnyGetter + public Map getAdditionalProperties() { + return this.additionalProperties; + } + + private boolean equalTo(QuantityTransformation other) { + return resourceType.equals(other.resourceType) + && resourceId.equals(other.resourceId) + && sourcePath.equals(other.sourcePath) + && sourceQuantity.equals(other.sourceQuantity) + && omopId == other.omopId + && outcome.equals(other.outcome) + && policy.equals(other.policy) + && reason.equals(other.reason) + && evidence.equals(other.evidence) + && unitConversion.equals(other.unitConversion) + && result.equals(other.result) + && resultUnit.equals(other.resultUnit); + } + + @java.lang.Override + public int hashCode() { + return Objects.hash( + this.resourceType, + this.resourceId, + this.sourcePath, + this.sourceQuantity, + this.omopId, + this.outcome, + this.policy, + this.reason, + this.evidence, + this.unitConversion, + this.result, + this.resultUnit); + } + + @java.lang.Override + public String toString() { + return ObjectMappers.stringify(this); + } + + public static ResourceTypeStage builder() { + return new Builder(); + } + + public interface ResourceTypeStage { + ResourceIdStage resourceType(@NotNull QuantityTransformationResourceType resourceType); + + Builder from(QuantityTransformation other); + } + + public interface ResourceIdStage { + /** + *

            Source FHIR resource id; empty when none was supplied.

            + */ + SourcePathStage resourceId(@NotNull String resourceId); + } + + public interface SourcePathStage { + /** + *

            Exact source element path relative to resource_type.

            + */ + OmopIdStage sourcePath(@NotNull QuantityTransformationSourcePath sourcePath); + } + + public interface OmopIdStage { + /** + *

            The drug_exposure_id of the row in this response.

            + */ + OutcomeStage omopId(long omopId); + } + + public interface OutcomeStage { + /** + *

            DIRECT copies a matched fixed-form count or an ingredient amount in the same unit; DERIVED converts units or calculates a dose-form count or ingredient amount under the named policy; OMITTED leaves quantity unset while retaining the row.

            + */ + _FinalStage outcome(@NotNull QuantityTransformationOutcome outcome); + } + + public interface _FinalStage { + QuantityTransformation build(); + + _FinalStage additionalProperty(String key, Object value); + + _FinalStage additionalProperties(Map additionalProperties); + + /** + *

            Original supplied Quantity object, including value, system, code, unit, comparator, and other supplied fields. Explicit nulls and malformed values are also preserved as supplied.

            + */ + _FinalStage sourceQuantity(Optional sourceQuantity); + + _FinalStage sourceQuantity(Object sourceQuantity); + + /** + *

            The formulation policy documented in the endpoint description. May be present for an omission occurring after policy selection.

            + */ + _FinalStage policy(Optional policy); + + _FinalStage policy(QuantityTransformationPolicy policy); + + /** + *

            Stable omission reason:

            + *
              + *
            • unsupported_quantity: supplied value is not a Quantity object with a positive finite numeric value.
            • + *
            • unsupported_comparator: a comparator field was supplied.
            • + *
            • unsupported_unit: source system/code does not identify a UCUM-coded quantity or a supported HL7 TAB/CAP count.
            • + *
            • drug_unresolved: no standard drug concept was resolved.
            • + *
            • missing_drug_evidence: formulation and strength evidence is unavailable.
            • + *
            • incomplete_drug_evidence: required vocabulary evidence is missing or invalid.
            • + *
            • ambiguous_drug_evidence: more than one dose form exists, or more than one strength exists for a conversion requiring strength. Direct counts allow combination products.
            • + *
            • missing_classification: the selected drug's concept class is unavailable.
            • + *
            • unsupported_drug_class: the drug is outside Clinical Drug or Branded Drug, including quantified products and packs.
            • + *
            • unsupported_dose_form: the dose form has no supported classification for these policies.
            • + *
            • unsupported_strength: strength does not establish the selected policy, including missing, non-positive, non-finite, or conflicting values or an absent/unsupported denominator basis for volume conversion.
            • + *
            • unit_mismatch: the source neither matches a validated vocabulary-backed strength unit nor converts within the documented supported unit groups, or an explicit count disagrees with the selected tablet/capsule form.
            • + *
            • unsupported_fraction: a fixed-form count is not whole units or multiples of one-half or one-third.
            • + *
            • invalid_result: arithmetic does not produce a positive finite result.
            • + *
            + */ + _FinalStage reason(Optional reason); + + _FinalStage reason(QuantityTransformationReason reason); + + _FinalStage evidence(Optional evidence); + + _FinalStage evidence(QuantityEvidence evidence); + + _FinalStage unitConversion(Optional unitConversion); + + _FinalStage unitConversion(QuantityUnitConversion unitConversion); + + /** + *

            Same numeric value as the linked row's quantity.

            + */ + _FinalStage result(Optional result); + + _FinalStage result(Double result); + + _FinalStage resultUnit(Optional resultUnit); + + _FinalStage resultUnit(QuantityUnit resultUnit); + } + + @JsonIgnoreProperties(ignoreUnknown = true) + public static final class Builder + implements ResourceTypeStage, ResourceIdStage, SourcePathStage, OmopIdStage, OutcomeStage, _FinalStage { + private QuantityTransformationResourceType resourceType; + + private String resourceId; + + private QuantityTransformationSourcePath sourcePath; + + private long omopId; + + private QuantityTransformationOutcome outcome; + + private Optional resultUnit = Optional.empty(); + + private Optional result = Optional.empty(); + + private Optional unitConversion = Optional.empty(); + + private Optional evidence = Optional.empty(); + + private Optional reason = Optional.empty(); + + private Optional policy = Optional.empty(); + + private Optional sourceQuantity = Optional.empty(); + + private Map additionalProperties = new HashMap<>(); + + private Builder() {} + + @java.lang.Override + public Builder from(QuantityTransformation other) { + resourceType(other.getResourceType()); + resourceId(other.getResourceId()); + sourcePath(other.getSourcePath()); + sourceQuantity(other.getSourceQuantity()); + omopId(other.getOmopId()); + outcome(other.getOutcome()); + policy(other.getPolicy()); + reason(other.getReason()); + evidence(other.getEvidence()); + unitConversion(other.getUnitConversion()); + result(other.getResult()); + resultUnit(other.getResultUnit()); + return this; + } + + @java.lang.Override + @JsonSetter("resource_type") + public ResourceIdStage resourceType(@NotNull QuantityTransformationResourceType resourceType) { + this.resourceType = Objects.requireNonNull(resourceType, "resourceType must not be null"); + return this; + } + + /** + *

            Source FHIR resource id; empty when none was supplied.

            + * @return Reference to {@code this} so that method calls can be chained together. + */ + @java.lang.Override + @JsonSetter("resource_id") + public SourcePathStage resourceId(@NotNull String resourceId) { + this.resourceId = Objects.requireNonNull(resourceId, "resourceId must not be null"); + return this; + } + + /** + *

            Exact source element path relative to resource_type.

            + * @return Reference to {@code this} so that method calls can be chained together. + */ + @java.lang.Override + @JsonSetter("source_path") + public OmopIdStage sourcePath(@NotNull QuantityTransformationSourcePath sourcePath) { + this.sourcePath = Objects.requireNonNull(sourcePath, "sourcePath must not be null"); + return this; + } + + /** + *

            The drug_exposure_id of the row in this response.

            + * @return Reference to {@code this} so that method calls can be chained together. + */ + @java.lang.Override + @JsonSetter("omop_id") + public OutcomeStage omopId(long omopId) { + this.omopId = omopId; + return this; + } + + /** + *

            DIRECT copies a matched fixed-form count or an ingredient amount in the same unit; DERIVED converts units or calculates a dose-form count or ingredient amount under the named policy; OMITTED leaves quantity unset while retaining the row.

            + * @return Reference to {@code this} so that method calls can be chained together. + */ + @java.lang.Override + @JsonSetter("outcome") + public _FinalStage outcome(@NotNull QuantityTransformationOutcome outcome) { + this.outcome = Objects.requireNonNull(outcome, "outcome must not be null"); + return this; + } + + @java.lang.Override + public _FinalStage resultUnit(QuantityUnit resultUnit) { + this.resultUnit = Optional.ofNullable(resultUnit); + return this; + } + + @java.lang.Override + @JsonSetter(value = "result_unit", nulls = Nulls.SKIP) + public _FinalStage resultUnit(Optional resultUnit) { + this.resultUnit = resultUnit; + return this; + } + + /** + *

            Same numeric value as the linked row's quantity.

            + * @return Reference to {@code this} so that method calls can be chained together. + */ + @java.lang.Override + public _FinalStage result(Double result) { + this.result = Optional.ofNullable(result); + return this; + } + + /** + *

            Same numeric value as the linked row's quantity.

            + */ + @java.lang.Override + @JsonSetter(value = "result", nulls = Nulls.SKIP) + public _FinalStage result(Optional result) { + this.result = result; + return this; + } + + @java.lang.Override + public _FinalStage unitConversion(QuantityUnitConversion unitConversion) { + this.unitConversion = Optional.ofNullable(unitConversion); + return this; + } + + @java.lang.Override + @JsonSetter(value = "unit_conversion", nulls = Nulls.SKIP) + public _FinalStage unitConversion(Optional unitConversion) { + this.unitConversion = unitConversion; + return this; + } + + @java.lang.Override + public _FinalStage evidence(QuantityEvidence evidence) { + this.evidence = Optional.ofNullable(evidence); + return this; + } + + @java.lang.Override + @JsonSetter(value = "evidence", nulls = Nulls.SKIP) + public _FinalStage evidence(Optional evidence) { + this.evidence = evidence; + return this; + } + + /** + *

            Stable omission reason:

            + *
              + *
            • unsupported_quantity: supplied value is not a Quantity object with a positive finite numeric value.
            • + *
            • unsupported_comparator: a comparator field was supplied.
            • + *
            • unsupported_unit: source system/code does not identify a UCUM-coded quantity or a supported HL7 TAB/CAP count.
            • + *
            • drug_unresolved: no standard drug concept was resolved.
            • + *
            • missing_drug_evidence: formulation and strength evidence is unavailable.
            • + *
            • incomplete_drug_evidence: required vocabulary evidence is missing or invalid.
            • + *
            • ambiguous_drug_evidence: more than one dose form exists, or more than one strength exists for a conversion requiring strength. Direct counts allow combination products.
            • + *
            • missing_classification: the selected drug's concept class is unavailable.
            • + *
            • unsupported_drug_class: the drug is outside Clinical Drug or Branded Drug, including quantified products and packs.
            • + *
            • unsupported_dose_form: the dose form has no supported classification for these policies.
            • + *
            • unsupported_strength: strength does not establish the selected policy, including missing, non-positive, non-finite, or conflicting values or an absent/unsupported denominator basis for volume conversion.
            • + *
            • unit_mismatch: the source neither matches a validated vocabulary-backed strength unit nor converts within the documented supported unit groups, or an explicit count disagrees with the selected tablet/capsule form.
            • + *
            • unsupported_fraction: a fixed-form count is not whole units or multiples of one-half or one-third.
            • + *
            • invalid_result: arithmetic does not produce a positive finite result.
            • + *
            + * @return Reference to {@code this} so that method calls can be chained together. + */ + @java.lang.Override + public _FinalStage reason(QuantityTransformationReason reason) { + this.reason = Optional.ofNullable(reason); + return this; + } + + /** + *

            Stable omission reason:

            + *
              + *
            • unsupported_quantity: supplied value is not a Quantity object with a positive finite numeric value.
            • + *
            • unsupported_comparator: a comparator field was supplied.
            • + *
            • unsupported_unit: source system/code does not identify a UCUM-coded quantity or a supported HL7 TAB/CAP count.
            • + *
            • drug_unresolved: no standard drug concept was resolved.
            • + *
            • missing_drug_evidence: formulation and strength evidence is unavailable.
            • + *
            • incomplete_drug_evidence: required vocabulary evidence is missing or invalid.
            • + *
            • ambiguous_drug_evidence: more than one dose form exists, or more than one strength exists for a conversion requiring strength. Direct counts allow combination products.
            • + *
            • missing_classification: the selected drug's concept class is unavailable.
            • + *
            • unsupported_drug_class: the drug is outside Clinical Drug or Branded Drug, including quantified products and packs.
            • + *
            • unsupported_dose_form: the dose form has no supported classification for these policies.
            • + *
            • unsupported_strength: strength does not establish the selected policy, including missing, non-positive, non-finite, or conflicting values or an absent/unsupported denominator basis for volume conversion.
            • + *
            • unit_mismatch: the source neither matches a validated vocabulary-backed strength unit nor converts within the documented supported unit groups, or an explicit count disagrees with the selected tablet/capsule form.
            • + *
            • unsupported_fraction: a fixed-form count is not whole units or multiples of one-half or one-third.
            • + *
            • invalid_result: arithmetic does not produce a positive finite result.
            • + *
            + */ + @java.lang.Override + @JsonSetter(value = "reason", nulls = Nulls.SKIP) + public _FinalStage reason(Optional reason) { + this.reason = reason; + return this; + } + + /** + *

            The formulation policy documented in the endpoint description. May be present for an omission occurring after policy selection.

            + * @return Reference to {@code this} so that method calls can be chained together. + */ + @java.lang.Override + public _FinalStage policy(QuantityTransformationPolicy policy) { + this.policy = Optional.ofNullable(policy); + return this; + } + + /** + *

            The formulation policy documented in the endpoint description. May be present for an omission occurring after policy selection.

            + */ + @java.lang.Override + @JsonSetter(value = "policy", nulls = Nulls.SKIP) + public _FinalStage policy(Optional policy) { + this.policy = policy; + return this; + } + + /** + *

            Original supplied Quantity object, including value, system, code, unit, comparator, and other supplied fields. Explicit nulls and malformed values are also preserved as supplied.

            + * @return Reference to {@code this} so that method calls can be chained together. + */ + @java.lang.Override + public _FinalStage sourceQuantity(Object sourceQuantity) { + this.sourceQuantity = Optional.ofNullable(sourceQuantity); + return this; + } + + /** + *

            Original supplied Quantity object, including value, system, code, unit, comparator, and other supplied fields. Explicit nulls and malformed values are also preserved as supplied.

            + */ + @java.lang.Override + @JsonSetter(value = "source_quantity", nulls = Nulls.SKIP) + public _FinalStage sourceQuantity(Optional sourceQuantity) { + this.sourceQuantity = sourceQuantity; + return this; + } + + @java.lang.Override + public QuantityTransformation build() { + return new QuantityTransformation( + resourceType, + resourceId, + sourcePath, + sourceQuantity, + omopId, + outcome, + policy, + reason, + evidence, + unitConversion, + result, + resultUnit, + additionalProperties); + } + + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("resource_type") + || key.equalsIgnoreCase("resource_id") + || key.equalsIgnoreCase("source_path") + || key.equalsIgnoreCase("source_quantity") + || key.equalsIgnoreCase("omop_table") + || key.equalsIgnoreCase("omop_field") + || key.equalsIgnoreCase("omop_id") + || key.equalsIgnoreCase("outcome") + || key.equalsIgnoreCase("policy") + || key.equalsIgnoreCase("reason") + || key.equalsIgnoreCase("evidence") + || key.equalsIgnoreCase("unit_conversion") + || key.equalsIgnoreCase("result") + || key.equalsIgnoreCase("result_unit")) { + return; + } + this.additionalProperties.put(key, value); + } + + @java.lang.Override + public Builder additionalProperty(String key, Object value) { + this.additionalProperties.put(key, value); + return this; + } + + @java.lang.Override + public Builder additionalProperties(Map additionalProperties) { + this.additionalProperties.putAll(additionalProperties); + return this; + } + } +} diff --git a/src/main/java/com/phenoml/api/resources/fhir2omop/types/QuantityTransformationOutcome.java b/src/main/java/com/phenoml/api/resources/fhir2omop/types/QuantityTransformationOutcome.java new file mode 100644 index 00000000..24d3fb6a --- /dev/null +++ b/src/main/java/com/phenoml/api/resources/fhir2omop/types/QuantityTransformationOutcome.java @@ -0,0 +1,97 @@ +/** + * This file was auto-generated by Fern from our API Definition. + */ +package com.phenoml.api.resources.fhir2omop.types; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; + +public final class QuantityTransformationOutcome { + public static final QuantityTransformationOutcome DIRECT = + new QuantityTransformationOutcome(Value.DIRECT, "DIRECT"); + + public static final QuantityTransformationOutcome DERIVED = + new QuantityTransformationOutcome(Value.DERIVED, "DERIVED"); + + public static final QuantityTransformationOutcome OMITTED = + new QuantityTransformationOutcome(Value.OMITTED, "OMITTED"); + + private final Value value; + + private final String string; + + QuantityTransformationOutcome(Value value, String string) { + this.value = value; + this.string = string; + } + + public Value getEnumValue() { + return value; + } + + @java.lang.Override + @JsonValue + public String toString() { + return this.string; + } + + @java.lang.Override + public boolean equals(Object other) { + return (this == other) + || (other instanceof QuantityTransformationOutcome + && this.string.equals(((QuantityTransformationOutcome) other).string)); + } + + @java.lang.Override + public int hashCode() { + return this.string.hashCode(); + } + + public T visit(Visitor visitor) { + switch (value) { + case DIRECT: + return visitor.visitDirect(); + case DERIVED: + return visitor.visitDerived(); + case OMITTED: + return visitor.visitOmitted(); + case UNKNOWN: + default: + return visitor.visitUnknown(string); + } + } + + @JsonCreator(mode = JsonCreator.Mode.DELEGATING) + public static QuantityTransformationOutcome valueOf(String value) { + switch (value) { + case "DIRECT": + return DIRECT; + case "DERIVED": + return DERIVED; + case "OMITTED": + return OMITTED; + default: + return new QuantityTransformationOutcome(Value.UNKNOWN, value); + } + } + + public enum Value { + DIRECT, + + DERIVED, + + OMITTED, + + UNKNOWN + } + + public interface Visitor { + T visitDirect(); + + T visitDerived(); + + T visitOmitted(); + + T visitUnknown(String unknownType); + } +} diff --git a/src/main/java/com/phenoml/api/resources/fhir2omop/types/QuantityTransformationPolicy.java b/src/main/java/com/phenoml/api/resources/fhir2omop/types/QuantityTransformationPolicy.java new file mode 100644 index 00000000..a3f37019 --- /dev/null +++ b/src/main/java/com/phenoml/api/resources/fhir2omop/types/QuantityTransformationPolicy.java @@ -0,0 +1,87 @@ +/** + * This file was auto-generated by Fern from our API Definition. + */ +package com.phenoml.api.resources.fhir2omop.types; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; + +public final class QuantityTransformationPolicy { + public static final QuantityTransformationPolicy CLINICAL_INJECTION_INGREDIENT_AMOUNT = + new QuantityTransformationPolicy( + Value.CLINICAL_INJECTION_INGREDIENT_AMOUNT, "clinical_injection_ingredient_amount"); + + public static final QuantityTransformationPolicy FIXED_DOSE_FORM_COUNT = + new QuantityTransformationPolicy(Value.FIXED_DOSE_FORM_COUNT, "fixed_dose_form_count"); + + private final Value value; + + private final String string; + + QuantityTransformationPolicy(Value value, String string) { + this.value = value; + this.string = string; + } + + public Value getEnumValue() { + return value; + } + + @java.lang.Override + @JsonValue + public String toString() { + return this.string; + } + + @java.lang.Override + public boolean equals(Object other) { + return (this == other) + || (other instanceof QuantityTransformationPolicy + && this.string.equals(((QuantityTransformationPolicy) other).string)); + } + + @java.lang.Override + public int hashCode() { + return this.string.hashCode(); + } + + public T visit(Visitor visitor) { + switch (value) { + case CLINICAL_INJECTION_INGREDIENT_AMOUNT: + return visitor.visitClinicalInjectionIngredientAmount(); + case FIXED_DOSE_FORM_COUNT: + return visitor.visitFixedDoseFormCount(); + case UNKNOWN: + default: + return visitor.visitUnknown(string); + } + } + + @JsonCreator(mode = JsonCreator.Mode.DELEGATING) + public static QuantityTransformationPolicy valueOf(String value) { + switch (value) { + case "clinical_injection_ingredient_amount": + return CLINICAL_INJECTION_INGREDIENT_AMOUNT; + case "fixed_dose_form_count": + return FIXED_DOSE_FORM_COUNT; + default: + return new QuantityTransformationPolicy(Value.UNKNOWN, value); + } + } + + public enum Value { + FIXED_DOSE_FORM_COUNT, + + CLINICAL_INJECTION_INGREDIENT_AMOUNT, + + UNKNOWN + } + + public interface Visitor { + T visitFixedDoseFormCount(); + + T visitClinicalInjectionIngredientAmount(); + + T visitUnknown(String unknownType); + } +} diff --git a/src/main/java/com/phenoml/api/resources/fhir2omop/types/QuantityTransformationReason.java b/src/main/java/com/phenoml/api/resources/fhir2omop/types/QuantityTransformationReason.java new file mode 100644 index 00000000..ad237720 --- /dev/null +++ b/src/main/java/com/phenoml/api/resources/fhir2omop/types/QuantityTransformationReason.java @@ -0,0 +1,218 @@ +/** + * This file was auto-generated by Fern from our API Definition. + */ +package com.phenoml.api.resources.fhir2omop.types; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; + +public final class QuantityTransformationReason { + public static final QuantityTransformationReason UNSUPPORTED_UNIT = + new QuantityTransformationReason(Value.UNSUPPORTED_UNIT, "unsupported_unit"); + + public static final QuantityTransformationReason UNSUPPORTED_FRACTION = + new QuantityTransformationReason(Value.UNSUPPORTED_FRACTION, "unsupported_fraction"); + + public static final QuantityTransformationReason DRUG_UNRESOLVED = + new QuantityTransformationReason(Value.DRUG_UNRESOLVED, "drug_unresolved"); + + public static final QuantityTransformationReason INVALID_RESULT = + new QuantityTransformationReason(Value.INVALID_RESULT, "invalid_result"); + + public static final QuantityTransformationReason UNSUPPORTED_QUANTITY = + new QuantityTransformationReason(Value.UNSUPPORTED_QUANTITY, "unsupported_quantity"); + + public static final QuantityTransformationReason UNSUPPORTED_DOSE_FORM = + new QuantityTransformationReason(Value.UNSUPPORTED_DOSE_FORM, "unsupported_dose_form"); + + public static final QuantityTransformationReason UNSUPPORTED_STRENGTH = + new QuantityTransformationReason(Value.UNSUPPORTED_STRENGTH, "unsupported_strength"); + + public static final QuantityTransformationReason UNIT_MISMATCH = + new QuantityTransformationReason(Value.UNIT_MISMATCH, "unit_mismatch"); + + public static final QuantityTransformationReason UNSUPPORTED_DRUG_CLASS = + new QuantityTransformationReason(Value.UNSUPPORTED_DRUG_CLASS, "unsupported_drug_class"); + + public static final QuantityTransformationReason UNSUPPORTED_COMPARATOR = + new QuantityTransformationReason(Value.UNSUPPORTED_COMPARATOR, "unsupported_comparator"); + + public static final QuantityTransformationReason MISSING_DRUG_EVIDENCE = + new QuantityTransformationReason(Value.MISSING_DRUG_EVIDENCE, "missing_drug_evidence"); + + public static final QuantityTransformationReason MISSING_CLASSIFICATION = + new QuantityTransformationReason(Value.MISSING_CLASSIFICATION, "missing_classification"); + + public static final QuantityTransformationReason INCOMPLETE_DRUG_EVIDENCE = + new QuantityTransformationReason(Value.INCOMPLETE_DRUG_EVIDENCE, "incomplete_drug_evidence"); + + public static final QuantityTransformationReason AMBIGUOUS_DRUG_EVIDENCE = + new QuantityTransformationReason(Value.AMBIGUOUS_DRUG_EVIDENCE, "ambiguous_drug_evidence"); + + private final Value value; + + private final String string; + + QuantityTransformationReason(Value value, String string) { + this.value = value; + this.string = string; + } + + public Value getEnumValue() { + return value; + } + + @java.lang.Override + @JsonValue + public String toString() { + return this.string; + } + + @java.lang.Override + public boolean equals(Object other) { + return (this == other) + || (other instanceof QuantityTransformationReason + && this.string.equals(((QuantityTransformationReason) other).string)); + } + + @java.lang.Override + public int hashCode() { + return this.string.hashCode(); + } + + public T visit(Visitor visitor) { + switch (value) { + case UNSUPPORTED_UNIT: + return visitor.visitUnsupportedUnit(); + case UNSUPPORTED_FRACTION: + return visitor.visitUnsupportedFraction(); + case DRUG_UNRESOLVED: + return visitor.visitDrugUnresolved(); + case INVALID_RESULT: + return visitor.visitInvalidResult(); + case UNSUPPORTED_QUANTITY: + return visitor.visitUnsupportedQuantity(); + case UNSUPPORTED_DOSE_FORM: + return visitor.visitUnsupportedDoseForm(); + case UNSUPPORTED_STRENGTH: + return visitor.visitUnsupportedStrength(); + case UNIT_MISMATCH: + return visitor.visitUnitMismatch(); + case UNSUPPORTED_DRUG_CLASS: + return visitor.visitUnsupportedDrugClass(); + case UNSUPPORTED_COMPARATOR: + return visitor.visitUnsupportedComparator(); + case MISSING_DRUG_EVIDENCE: + return visitor.visitMissingDrugEvidence(); + case MISSING_CLASSIFICATION: + return visitor.visitMissingClassification(); + case INCOMPLETE_DRUG_EVIDENCE: + return visitor.visitIncompleteDrugEvidence(); + case AMBIGUOUS_DRUG_EVIDENCE: + return visitor.visitAmbiguousDrugEvidence(); + case UNKNOWN: + default: + return visitor.visitUnknown(string); + } + } + + @JsonCreator(mode = JsonCreator.Mode.DELEGATING) + public static QuantityTransformationReason valueOf(String value) { + switch (value) { + case "unsupported_unit": + return UNSUPPORTED_UNIT; + case "unsupported_fraction": + return UNSUPPORTED_FRACTION; + case "drug_unresolved": + return DRUG_UNRESOLVED; + case "invalid_result": + return INVALID_RESULT; + case "unsupported_quantity": + return UNSUPPORTED_QUANTITY; + case "unsupported_dose_form": + return UNSUPPORTED_DOSE_FORM; + case "unsupported_strength": + return UNSUPPORTED_STRENGTH; + case "unit_mismatch": + return UNIT_MISMATCH; + case "unsupported_drug_class": + return UNSUPPORTED_DRUG_CLASS; + case "unsupported_comparator": + return UNSUPPORTED_COMPARATOR; + case "missing_drug_evidence": + return MISSING_DRUG_EVIDENCE; + case "missing_classification": + return MISSING_CLASSIFICATION; + case "incomplete_drug_evidence": + return INCOMPLETE_DRUG_EVIDENCE; + case "ambiguous_drug_evidence": + return AMBIGUOUS_DRUG_EVIDENCE; + default: + return new QuantityTransformationReason(Value.UNKNOWN, value); + } + } + + public enum Value { + UNSUPPORTED_QUANTITY, + + UNSUPPORTED_COMPARATOR, + + UNSUPPORTED_UNIT, + + DRUG_UNRESOLVED, + + MISSING_DRUG_EVIDENCE, + + INCOMPLETE_DRUG_EVIDENCE, + + AMBIGUOUS_DRUG_EVIDENCE, + + MISSING_CLASSIFICATION, + + UNSUPPORTED_DRUG_CLASS, + + UNSUPPORTED_DOSE_FORM, + + UNSUPPORTED_STRENGTH, + + UNIT_MISMATCH, + + UNSUPPORTED_FRACTION, + + INVALID_RESULT, + + UNKNOWN + } + + public interface Visitor { + T visitUnsupportedQuantity(); + + T visitUnsupportedComparator(); + + T visitUnsupportedUnit(); + + T visitDrugUnresolved(); + + T visitMissingDrugEvidence(); + + T visitIncompleteDrugEvidence(); + + T visitAmbiguousDrugEvidence(); + + T visitMissingClassification(); + + T visitUnsupportedDrugClass(); + + T visitUnsupportedDoseForm(); + + T visitUnsupportedStrength(); + + T visitUnitMismatch(); + + T visitUnsupportedFraction(); + + T visitInvalidResult(); + + T visitUnknown(String unknownType); + } +} diff --git a/src/main/java/com/phenoml/api/resources/fhir2omop/types/QuantityTransformationResourceType.java b/src/main/java/com/phenoml/api/resources/fhir2omop/types/QuantityTransformationResourceType.java new file mode 100644 index 00000000..fae86434 --- /dev/null +++ b/src/main/java/com/phenoml/api/resources/fhir2omop/types/QuantityTransformationResourceType.java @@ -0,0 +1,108 @@ +/** + * This file was auto-generated by Fern from our API Definition. + */ +package com.phenoml.api.resources.fhir2omop.types; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; + +public final class QuantityTransformationResourceType { + public static final QuantityTransformationResourceType IMMUNIZATION = + new QuantityTransformationResourceType(Value.IMMUNIZATION, "Immunization"); + + public static final QuantityTransformationResourceType MEDICATION_DISPENSE = + new QuantityTransformationResourceType(Value.MEDICATION_DISPENSE, "MedicationDispense"); + + public static final QuantityTransformationResourceType MEDICATION_ADMINISTRATION = + new QuantityTransformationResourceType(Value.MEDICATION_ADMINISTRATION, "MedicationAdministration"); + + public static final QuantityTransformationResourceType MEDICATION_REQUEST = + new QuantityTransformationResourceType(Value.MEDICATION_REQUEST, "MedicationRequest"); + + private final Value value; + + private final String string; + + QuantityTransformationResourceType(Value value, String string) { + this.value = value; + this.string = string; + } + + public Value getEnumValue() { + return value; + } + + @java.lang.Override + @JsonValue + public String toString() { + return this.string; + } + + @java.lang.Override + public boolean equals(Object other) { + return (this == other) + || (other instanceof QuantityTransformationResourceType + && this.string.equals(((QuantityTransformationResourceType) other).string)); + } + + @java.lang.Override + public int hashCode() { + return this.string.hashCode(); + } + + public T visit(Visitor visitor) { + switch (value) { + case IMMUNIZATION: + return visitor.visitImmunization(); + case MEDICATION_DISPENSE: + return visitor.visitMedicationDispense(); + case MEDICATION_ADMINISTRATION: + return visitor.visitMedicationAdministration(); + case MEDICATION_REQUEST: + return visitor.visitMedicationRequest(); + case UNKNOWN: + default: + return visitor.visitUnknown(string); + } + } + + @JsonCreator(mode = JsonCreator.Mode.DELEGATING) + public static QuantityTransformationResourceType valueOf(String value) { + switch (value) { + case "Immunization": + return IMMUNIZATION; + case "MedicationDispense": + return MEDICATION_DISPENSE; + case "MedicationAdministration": + return MEDICATION_ADMINISTRATION; + case "MedicationRequest": + return MEDICATION_REQUEST; + default: + return new QuantityTransformationResourceType(Value.UNKNOWN, value); + } + } + + public enum Value { + MEDICATION_REQUEST, + + MEDICATION_DISPENSE, + + MEDICATION_ADMINISTRATION, + + IMMUNIZATION, + + UNKNOWN + } + + public interface Visitor { + T visitMedicationRequest(); + + T visitMedicationDispense(); + + T visitMedicationAdministration(); + + T visitImmunization(); + + T visitUnknown(String unknownType); + } +} diff --git a/src/main/java/com/phenoml/api/resources/fhir2omop/types/QuantityTransformationSourcePath.java b/src/main/java/com/phenoml/api/resources/fhir2omop/types/QuantityTransformationSourcePath.java new file mode 100644 index 00000000..764338fc --- /dev/null +++ b/src/main/java/com/phenoml/api/resources/fhir2omop/types/QuantityTransformationSourcePath.java @@ -0,0 +1,108 @@ +/** + * This file was auto-generated by Fern from our API Definition. + */ +package com.phenoml.api.resources.fhir2omop.types; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; + +public final class QuantityTransformationSourcePath { + public static final QuantityTransformationSourcePath QUANTITY = + new QuantityTransformationSourcePath(Value.QUANTITY, "quantity"); + + public static final QuantityTransformationSourcePath DOSE_QUANTITY = + new QuantityTransformationSourcePath(Value.DOSE_QUANTITY, "doseQuantity"); + + public static final QuantityTransformationSourcePath DOSAGE_DOSE = + new QuantityTransformationSourcePath(Value.DOSAGE_DOSE, "dosage.dose"); + + public static final QuantityTransformationSourcePath DISPENSE_REQUEST_QUANTITY = + new QuantityTransformationSourcePath(Value.DISPENSE_REQUEST_QUANTITY, "dispenseRequest.quantity"); + + private final Value value; + + private final String string; + + QuantityTransformationSourcePath(Value value, String string) { + this.value = value; + this.string = string; + } + + public Value getEnumValue() { + return value; + } + + @java.lang.Override + @JsonValue + public String toString() { + return this.string; + } + + @java.lang.Override + public boolean equals(Object other) { + return (this == other) + || (other instanceof QuantityTransformationSourcePath + && this.string.equals(((QuantityTransformationSourcePath) other).string)); + } + + @java.lang.Override + public int hashCode() { + return this.string.hashCode(); + } + + public T visit(Visitor visitor) { + switch (value) { + case QUANTITY: + return visitor.visitQuantity(); + case DOSE_QUANTITY: + return visitor.visitDoseQuantity(); + case DOSAGE_DOSE: + return visitor.visitDosageDose(); + case DISPENSE_REQUEST_QUANTITY: + return visitor.visitDispenseRequestQuantity(); + case UNKNOWN: + default: + return visitor.visitUnknown(string); + } + } + + @JsonCreator(mode = JsonCreator.Mode.DELEGATING) + public static QuantityTransformationSourcePath valueOf(String value) { + switch (value) { + case "quantity": + return QUANTITY; + case "doseQuantity": + return DOSE_QUANTITY; + case "dosage.dose": + return DOSAGE_DOSE; + case "dispenseRequest.quantity": + return DISPENSE_REQUEST_QUANTITY; + default: + return new QuantityTransformationSourcePath(Value.UNKNOWN, value); + } + } + + public enum Value { + DISPENSE_REQUEST_QUANTITY, + + QUANTITY, + + DOSAGE_DOSE, + + DOSE_QUANTITY, + + UNKNOWN + } + + public interface Visitor { + T visitDispenseRequestQuantity(); + + T visitQuantity(); + + T visitDosageDose(); + + T visitDoseQuantity(); + + T visitUnknown(String unknownType); + } +} diff --git a/src/main/java/com/phenoml/api/resources/fhir2omop/types/QuantityUnit.java b/src/main/java/com/phenoml/api/resources/fhir2omop/types/QuantityUnit.java new file mode 100644 index 00000000..c7617187 --- /dev/null +++ b/src/main/java/com/phenoml/api/resources/fhir2omop/types/QuantityUnit.java @@ -0,0 +1,171 @@ +/** + * This file was auto-generated by Fern from our API Definition. + */ +package com.phenoml.api.resources.fhir2omop.types; + +import com.fasterxml.jackson.annotation.JsonAnyGetter; +import com.fasterxml.jackson.annotation.JsonAnySetter; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonSetter; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.phenoml.api.core.ObjectMappers; +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; +import org.jetbrains.annotations.NotNull; + +@JsonInclude(JsonInclude.Include.NON_ABSENT) +@JsonDeserialize(builder = QuantityUnit.Builder.class) +public final class QuantityUnit { + private final long conceptId; + + private final String code; + + private final String vocabularyId; + + private final Map additionalProperties; + + private QuantityUnit(long conceptId, String code, String vocabularyId, Map additionalProperties) { + this.conceptId = conceptId; + this.code = code; + this.vocabularyId = vocabularyId; + this.additionalProperties = additionalProperties; + } + + @JsonProperty("concept_id") + public long getConceptId() { + return conceptId; + } + + @JsonProperty("code") + public String getCode() { + return code; + } + + @JsonProperty("vocabulary_id") + public String getVocabularyId() { + return vocabularyId; + } + + @java.lang.Override + public boolean equals(Object other) { + if (this == other) return true; + return other instanceof QuantityUnit && equalTo((QuantityUnit) other); + } + + @JsonAnyGetter + public Map getAdditionalProperties() { + return this.additionalProperties; + } + + private boolean equalTo(QuantityUnit other) { + return conceptId == other.conceptId && code.equals(other.code) && vocabularyId.equals(other.vocabularyId); + } + + @java.lang.Override + public int hashCode() { + return Objects.hash(this.conceptId, this.code, this.vocabularyId); + } + + @java.lang.Override + public String toString() { + return ObjectMappers.stringify(this); + } + + public static ConceptIdStage builder() { + return new Builder(); + } + + public interface ConceptIdStage { + CodeStage conceptId(long conceptId); + + Builder from(QuantityUnit other); + } + + public interface CodeStage { + VocabularyIdStage code(@NotNull String code); + } + + public interface VocabularyIdStage { + _FinalStage vocabularyId(@NotNull String vocabularyId); + } + + public interface _FinalStage { + QuantityUnit build(); + + _FinalStage additionalProperty(String key, Object value); + + _FinalStage additionalProperties(Map additionalProperties); + } + + @JsonIgnoreProperties(ignoreUnknown = true) + public static final class Builder implements ConceptIdStage, CodeStage, VocabularyIdStage, _FinalStage { + private long conceptId; + + private String code; + + private String vocabularyId; + + private Map additionalProperties = new HashMap<>(); + + private Builder() {} + + @java.lang.Override + public Builder from(QuantityUnit other) { + conceptId(other.getConceptId()); + code(other.getCode()); + vocabularyId(other.getVocabularyId()); + return this; + } + + @java.lang.Override + @JsonSetter("concept_id") + public CodeStage conceptId(long conceptId) { + this.conceptId = conceptId; + return this; + } + + @java.lang.Override + @JsonSetter("code") + public VocabularyIdStage code(@NotNull String code) { + this.code = Objects.requireNonNull(code, "code must not be null"); + return this; + } + + @java.lang.Override + @JsonSetter("vocabulary_id") + public _FinalStage vocabularyId(@NotNull String vocabularyId) { + this.vocabularyId = Objects.requireNonNull(vocabularyId, "vocabularyId must not be null"); + return this; + } + + @java.lang.Override + public QuantityUnit build() { + return new QuantityUnit(conceptId, code, vocabularyId, additionalProperties); + } + + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("concept_id") + || key.equalsIgnoreCase("code") + || key.equalsIgnoreCase("vocabulary_id")) { + return; + } + this.additionalProperties.put(key, value); + } + + @java.lang.Override + public Builder additionalProperty(String key, Object value) { + this.additionalProperties.put(key, value); + return this; + } + + @java.lang.Override + public Builder additionalProperties(Map additionalProperties) { + this.additionalProperties.putAll(additionalProperties); + return this; + } + } +} diff --git a/src/main/java/com/phenoml/api/resources/fhir2omop/types/QuantityUnitConversion.java b/src/main/java/com/phenoml/api/resources/fhir2omop/types/QuantityUnitConversion.java new file mode 100644 index 00000000..daf224e1 --- /dev/null +++ b/src/main/java/com/phenoml/api/resources/fhir2omop/types/QuantityUnitConversion.java @@ -0,0 +1,192 @@ +/** + * This file was auto-generated by Fern from our API Definition. + */ +package com.phenoml.api.resources.fhir2omop.types; + +import com.fasterxml.jackson.annotation.JsonAnyGetter; +import com.fasterxml.jackson.annotation.JsonAnySetter; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonSetter; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.phenoml.api.core.ObjectMappers; +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; +import org.jetbrains.annotations.NotNull; + +@JsonInclude(JsonInclude.Include.NON_ABSENT) +@JsonDeserialize(builder = QuantityUnitConversion.Builder.class) +public final class QuantityUnitConversion { + private final String sourceCode; + + private final double result; + + private final QuantityUnit resultUnit; + + private final Map additionalProperties; + + private QuantityUnitConversion( + String sourceCode, double result, QuantityUnit resultUnit, Map additionalProperties) { + this.sourceCode = sourceCode; + this.result = result; + this.resultUnit = resultUnit; + this.additionalProperties = additionalProperties; + } + + /** + * @return Original UCUM code from source_quantity.code. + */ + @JsonProperty("source_code") + public String getSourceCode() { + return sourceCode; + } + + /** + * @return Original source magnitude converted to result_unit, before strength arithmetic. + */ + @JsonProperty("result") + public double getResult() { + return result; + } + + @JsonProperty("result_unit") + public QuantityUnit getResultUnit() { + return resultUnit; + } + + @java.lang.Override + public boolean equals(Object other) { + if (this == other) return true; + return other instanceof QuantityUnitConversion && equalTo((QuantityUnitConversion) other); + } + + @JsonAnyGetter + public Map getAdditionalProperties() { + return this.additionalProperties; + } + + private boolean equalTo(QuantityUnitConversion other) { + return sourceCode.equals(other.sourceCode) && result == other.result && resultUnit.equals(other.resultUnit); + } + + @java.lang.Override + public int hashCode() { + return Objects.hash(this.sourceCode, this.result, this.resultUnit); + } + + @java.lang.Override + public String toString() { + return ObjectMappers.stringify(this); + } + + public static SourceCodeStage builder() { + return new Builder(); + } + + public interface SourceCodeStage { + /** + *

            Original UCUM code from source_quantity.code.

            + */ + ResultStage sourceCode(@NotNull String sourceCode); + + Builder from(QuantityUnitConversion other); + } + + public interface ResultStage { + /** + *

            Original source magnitude converted to result_unit, before strength arithmetic.

            + */ + ResultUnitStage result(double result); + } + + public interface ResultUnitStage { + _FinalStage resultUnit(@NotNull QuantityUnit resultUnit); + } + + public interface _FinalStage { + QuantityUnitConversion build(); + + _FinalStage additionalProperty(String key, Object value); + + _FinalStage additionalProperties(Map additionalProperties); + } + + @JsonIgnoreProperties(ignoreUnknown = true) + public static final class Builder implements SourceCodeStage, ResultStage, ResultUnitStage, _FinalStage { + private String sourceCode; + + private double result; + + private QuantityUnit resultUnit; + + private Map additionalProperties = new HashMap<>(); + + private Builder() {} + + @java.lang.Override + public Builder from(QuantityUnitConversion other) { + sourceCode(other.getSourceCode()); + result(other.getResult()); + resultUnit(other.getResultUnit()); + return this; + } + + /** + *

            Original UCUM code from source_quantity.code.

            + * @return Reference to {@code this} so that method calls can be chained together. + */ + @java.lang.Override + @JsonSetter("source_code") + public ResultStage sourceCode(@NotNull String sourceCode) { + this.sourceCode = Objects.requireNonNull(sourceCode, "sourceCode must not be null"); + return this; + } + + /** + *

            Original source magnitude converted to result_unit, before strength arithmetic.

            + * @return Reference to {@code this} so that method calls can be chained together. + */ + @java.lang.Override + @JsonSetter("result") + public ResultUnitStage result(double result) { + this.result = result; + return this; + } + + @java.lang.Override + @JsonSetter("result_unit") + public _FinalStage resultUnit(@NotNull QuantityUnit resultUnit) { + this.resultUnit = Objects.requireNonNull(resultUnit, "resultUnit must not be null"); + return this; + } + + @java.lang.Override + public QuantityUnitConversion build() { + return new QuantityUnitConversion(sourceCode, result, resultUnit, additionalProperties); + } + + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("source_code") + || key.equalsIgnoreCase("result") + || key.equalsIgnoreCase("result_unit")) { + return; + } + this.additionalProperties.put(key, value); + } + + @java.lang.Override + public Builder additionalProperty(String key, Object value) { + this.additionalProperties.put(key, value); + return this; + } + + @java.lang.Override + public Builder additionalProperties(Map additionalProperties) { + this.additionalProperties.putAll(additionalProperties); + return this; + } + } +} diff --git a/src/main/java/com/phenoml/api/resources/fhir2omop/types/ReferenceDiagnostic.java b/src/main/java/com/phenoml/api/resources/fhir2omop/types/ReferenceDiagnostic.java index d1455e5c..8fddf462 100644 --- a/src/main/java/com/phenoml/api/resources/fhir2omop/types/ReferenceDiagnostic.java +++ b/src/main/java/com/phenoml/api/resources/fhir2omop/types/ReferenceDiagnostic.java @@ -86,7 +86,7 @@ public Optional getOmopId() { } /** - * @return FHIR element path on the source resource. + * @return FHIR element path on the source resource, or slash-separated related paths for a joint rule such as whenHandedOver/daysSupply. */ @JsonProperty("path") public Optional getPath() { @@ -101,11 +101,32 @@ public Optional getReference() { return reference; } + /** + * @return UNRESOLVED: a source value needed to populate a field is missing, + * such as specimen material or collection start, or a supported + * reference cannot resolve to a resource or emitted target supplied + * in this request. + * AMBIGUOUS: a reference or identifier matches multiple possible + * targets. Identifier-only PractitionerRole.practitioner references + * are unambiguous when all matches map to one canonical provider. + * Literal references matching multiple source resources remain + * ambiguous even if those Practitioners merge into one provider. + * CONFLICTING: supplied values or relationships cannot be combined + * under the mapping policy, such as differing mapped provider identity + * attributes, multiple distinct NPIs on one Practitioner, conflicting + * Patient demographic extensions, or an Encounter belonging to a + * different patient. + * UNSUPPORTED: a supplied reference form, resource type, or value + * shape is outside the supported mapping policy. + */ @JsonProperty("outcome") public Optional getOutcome() { return outcome; } + /** + * @return Explanation of the condition and, when applicable, its effect on mapping. + */ @JsonProperty("reason") public Optional getReason() { return reason; @@ -173,7 +194,6 @@ public static final class Builder { private Optional reason = Optional.empty(); - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -241,7 +261,7 @@ public Builder omopId(Long omopId) { } /** - *

            FHIR element path on the source resource.

            + *

            FHIR element path on the source resource, or slash-separated related paths for a joint rule such as whenHandedOver/daysSupply.

            */ @JsonSetter(value = "path", nulls = Nulls.SKIP) public Builder path(Optional path) { @@ -268,6 +288,24 @@ public Builder reference(String reference) { return this; } + /** + *

            UNRESOLVED: a source value needed to populate a field is missing, + * such as specimen material or collection start, or a supported + * reference cannot resolve to a resource or emitted target supplied + * in this request. + * AMBIGUOUS: a reference or identifier matches multiple possible + * targets. Identifier-only PractitionerRole.practitioner references + * are unambiguous when all matches map to one canonical provider. + * Literal references matching multiple source resources remain + * ambiguous even if those Practitioners merge into one provider. + * CONFLICTING: supplied values or relationships cannot be combined + * under the mapping policy, such as differing mapped provider identity + * attributes, multiple distinct NPIs on one Practitioner, conflicting + * Patient demographic extensions, or an Encounter belonging to a + * different patient. + * UNSUPPORTED: a supplied reference form, resource type, or value + * shape is outside the supported mapping policy.

            + */ @JsonSetter(value = "outcome", nulls = Nulls.SKIP) public Builder outcome(Optional outcome) { this.outcome = outcome; @@ -279,6 +317,9 @@ public Builder outcome(ReferenceDiagnosticOutcome outcome) { return this; } + /** + *

            Explanation of the condition and, when applicable, its effect on mapping.

            + */ @JsonSetter(value = "reason", nulls = Nulls.SKIP) public Builder reason(Optional reason) { this.reason = reason; @@ -303,6 +344,21 @@ public ReferenceDiagnostic build() { additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("resource_type") + || key.equalsIgnoreCase("resource_id") + || key.equalsIgnoreCase("omop_table") + || key.equalsIgnoreCase("omop_id") + || key.equalsIgnoreCase("path") + || key.equalsIgnoreCase("reference") + || key.equalsIgnoreCase("outcome") + || key.equalsIgnoreCase("reason")) { + return; + } + this.additionalProperties.put(key, value); + } + public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); return this; diff --git a/src/main/java/com/phenoml/api/resources/fhir2omop/types/RowForReview.java b/src/main/java/com/phenoml/api/resources/fhir2omop/types/RowForReview.java new file mode 100644 index 00000000..646333cb --- /dev/null +++ b/src/main/java/com/phenoml/api/resources/fhir2omop/types/RowForReview.java @@ -0,0 +1,362 @@ +/** + * This file was auto-generated by Fern from our API Definition. + */ +package com.phenoml.api.resources.fhir2omop.types; + +import com.fasterxml.jackson.annotation.JsonAnyGetter; +import com.fasterxml.jackson.annotation.JsonAnySetter; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonSetter; +import com.fasterxml.jackson.annotation.Nulls; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.phenoml.api.core.ObjectMappers; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import org.jetbrains.annotations.NotNull; + +@JsonInclude(JsonInclude.Include.NON_ABSENT) +@JsonDeserialize(builder = RowForReview.Builder.class) +public final class RowForReview { + private final String resourceType; + + private final String resourceId; + + private final Optional sourceFullUrl; + + private final String omopTable; + + private final long omopId; + + private final List reasons; + + private final Optional> sourceStatuses; + + private final Map additionalProperties; + + private RowForReview( + String resourceType, + String resourceId, + Optional sourceFullUrl, + String omopTable, + long omopId, + List reasons, + Optional> sourceStatuses, + Map additionalProperties) { + this.resourceType = resourceType; + this.resourceId = resourceId; + this.sourceFullUrl = sourceFullUrl; + this.omopTable = omopTable; + this.omopId = omopId; + this.reasons = reasons; + this.sourceStatuses = sourceStatuses; + this.additionalProperties = additionalProperties; + } + + @JsonProperty("resource_type") + public String getResourceType() { + return resourceType; + } + + @JsonProperty("resource_id") + public String getResourceId() { + return resourceId; + } + + /** + * @return Original Bundle entry fullUrl, when supplied. + */ + @JsonProperty("source_full_url") + public Optional getSourceFullUrl() { + return sourceFullUrl; + } + + @JsonProperty("omop_table") + public String getOmopTable() { + return omopTable; + } + + @JsonProperty("omop_id") + public long getOmopId() { + return omopId; + } + + @JsonProperty("reasons") + public List getReasons() { + return reasons; + } + + /** + * @return Supplied status, verificationStatus, and clinicalStatus fields copied + * verbatim from FHIR, including nulls or unreadable JSON values. Missing + * fields have no key. No inferred status is supplied. + */ + @JsonProperty("source_statuses") + public Optional> getSourceStatuses() { + return sourceStatuses; + } + + @java.lang.Override + public boolean equals(Object other) { + if (this == other) return true; + return other instanceof RowForReview && equalTo((RowForReview) other); + } + + @JsonAnyGetter + public Map getAdditionalProperties() { + return this.additionalProperties; + } + + private boolean equalTo(RowForReview other) { + return resourceType.equals(other.resourceType) + && resourceId.equals(other.resourceId) + && sourceFullUrl.equals(other.sourceFullUrl) + && omopTable.equals(other.omopTable) + && omopId == other.omopId + && reasons.equals(other.reasons) + && sourceStatuses.equals(other.sourceStatuses); + } + + @java.lang.Override + public int hashCode() { + return Objects.hash( + this.resourceType, + this.resourceId, + this.sourceFullUrl, + this.omopTable, + this.omopId, + this.reasons, + this.sourceStatuses); + } + + @java.lang.Override + public String toString() { + return ObjectMappers.stringify(this); + } + + public static ResourceTypeStage builder() { + return new Builder(); + } + + public interface ResourceTypeStage { + ResourceIdStage resourceType(@NotNull String resourceType); + + Builder from(RowForReview other); + } + + public interface ResourceIdStage { + OmopTableStage resourceId(@NotNull String resourceId); + } + + public interface OmopTableStage { + OmopIdStage omopTable(@NotNull String omopTable); + } + + public interface OmopIdStage { + _FinalStage omopId(long omopId); + } + + public interface _FinalStage { + RowForReview build(); + + _FinalStage additionalProperty(String key, Object value); + + _FinalStage additionalProperties(Map additionalProperties); + + /** + *

            Original Bundle entry fullUrl, when supplied.

            + */ + _FinalStage sourceFullUrl(Optional sourceFullUrl); + + _FinalStage sourceFullUrl(String sourceFullUrl); + + _FinalStage reasons(List reasons); + + _FinalStage addReasons(RowForReviewReasonsItem reasons); + + _FinalStage addAllReasons(List reasons); + + /** + *

            Supplied status, verificationStatus, and clinicalStatus fields copied + * verbatim from FHIR, including nulls or unreadable JSON values. Missing + * fields have no key. No inferred status is supplied.

            + */ + _FinalStage sourceStatuses(Optional> sourceStatuses); + + _FinalStage sourceStatuses(Map sourceStatuses); + } + + @JsonIgnoreProperties(ignoreUnknown = true) + public static final class Builder + implements ResourceTypeStage, ResourceIdStage, OmopTableStage, OmopIdStage, _FinalStage { + private String resourceType; + + private String resourceId; + + private String omopTable; + + private long omopId; + + private Optional> sourceStatuses = Optional.empty(); + + private List reasons = new ArrayList<>(); + + private Optional sourceFullUrl = Optional.empty(); + + private Map additionalProperties = new HashMap<>(); + + private Builder() {} + + @java.lang.Override + public Builder from(RowForReview other) { + resourceType(other.getResourceType()); + resourceId(other.getResourceId()); + sourceFullUrl(other.getSourceFullUrl()); + omopTable(other.getOmopTable()); + omopId(other.getOmopId()); + reasons(other.getReasons()); + sourceStatuses(other.getSourceStatuses()); + return this; + } + + @java.lang.Override + @JsonSetter("resource_type") + public ResourceIdStage resourceType(@NotNull String resourceType) { + this.resourceType = Objects.requireNonNull(resourceType, "resourceType must not be null"); + return this; + } + + @java.lang.Override + @JsonSetter("resource_id") + public OmopTableStage resourceId(@NotNull String resourceId) { + this.resourceId = Objects.requireNonNull(resourceId, "resourceId must not be null"); + return this; + } + + @java.lang.Override + @JsonSetter("omop_table") + public OmopIdStage omopTable(@NotNull String omopTable) { + this.omopTable = Objects.requireNonNull(omopTable, "omopTable must not be null"); + return this; + } + + @java.lang.Override + @JsonSetter("omop_id") + public _FinalStage omopId(long omopId) { + this.omopId = omopId; + return this; + } + + /** + *

            Supplied status, verificationStatus, and clinicalStatus fields copied + * verbatim from FHIR, including nulls or unreadable JSON values. Missing + * fields have no key. No inferred status is supplied.

            + * @return Reference to {@code this} so that method calls can be chained together. + */ + @java.lang.Override + public _FinalStage sourceStatuses(Map sourceStatuses) { + this.sourceStatuses = Optional.ofNullable(sourceStatuses); + return this; + } + + /** + *

            Supplied status, verificationStatus, and clinicalStatus fields copied + * verbatim from FHIR, including nulls or unreadable JSON values. Missing + * fields have no key. No inferred status is supplied.

            + */ + @java.lang.Override + @JsonSetter(value = "source_statuses", nulls = Nulls.SKIP) + public _FinalStage sourceStatuses(Optional> sourceStatuses) { + this.sourceStatuses = sourceStatuses; + return this; + } + + @java.lang.Override + public _FinalStage addAllReasons(List reasons) { + if (reasons != null) { + this.reasons.addAll(reasons); + } + return this; + } + + @java.lang.Override + public _FinalStage addReasons(RowForReviewReasonsItem reasons) { + this.reasons.add(reasons); + return this; + } + + @java.lang.Override + @JsonSetter(value = "reasons", nulls = Nulls.SKIP) + public _FinalStage reasons(List reasons) { + this.reasons.clear(); + if (reasons != null) { + this.reasons.addAll(reasons); + } + return this; + } + + /** + *

            Original Bundle entry fullUrl, when supplied.

            + * @return Reference to {@code this} so that method calls can be chained together. + */ + @java.lang.Override + public _FinalStage sourceFullUrl(String sourceFullUrl) { + this.sourceFullUrl = Optional.ofNullable(sourceFullUrl); + return this; + } + + /** + *

            Original Bundle entry fullUrl, when supplied.

            + */ + @java.lang.Override + @JsonSetter(value = "source_full_url", nulls = Nulls.SKIP) + public _FinalStage sourceFullUrl(Optional sourceFullUrl) { + this.sourceFullUrl = sourceFullUrl; + return this; + } + + @java.lang.Override + public RowForReview build() { + return new RowForReview( + resourceType, + resourceId, + sourceFullUrl, + omopTable, + omopId, + reasons, + sourceStatuses, + additionalProperties); + } + + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("resource_type") + || key.equalsIgnoreCase("resource_id") + || key.equalsIgnoreCase("source_full_url") + || key.equalsIgnoreCase("omop_table") + || key.equalsIgnoreCase("omop_id") + || key.equalsIgnoreCase("reasons") + || key.equalsIgnoreCase("source_statuses")) { + return; + } + this.additionalProperties.put(key, value); + } + + @java.lang.Override + public Builder additionalProperty(String key, Object value) { + this.additionalProperties.put(key, value); + return this; + } + + @java.lang.Override + public Builder additionalProperties(Map additionalProperties) { + this.additionalProperties.putAll(additionalProperties); + return this; + } + } +} diff --git a/src/main/java/com/phenoml/api/resources/fhir2omop/types/RowForReviewReasonsItem.java b/src/main/java/com/phenoml/api/resources/fhir2omop/types/RowForReviewReasonsItem.java new file mode 100644 index 00000000..d2645879 --- /dev/null +++ b/src/main/java/com/phenoml/api/resources/fhir2omop/types/RowForReviewReasonsItem.java @@ -0,0 +1,174 @@ +/** + * This file was auto-generated by Fern from our API Definition. + */ +package com.phenoml.api.resources.fhir2omop.types; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; + +public final class RowForReviewReasonsItem { + public static final RowForReviewReasonsItem CONDITION_VERIFICATION_UNCONFIRMED = + new RowForReviewReasonsItem(Value.CONDITION_VERIFICATION_UNCONFIRMED, "condition_verification_unconfirmed"); + + public static final RowForReviewReasonsItem CONDITION_VERIFICATION_DIFFERENTIAL = new RowForReviewReasonsItem( + Value.CONDITION_VERIFICATION_DIFFERENTIAL, "condition_verification_differential"); + + public static final RowForReviewReasonsItem OBSERVATION_STATUS_UNKNOWN = + new RowForReviewReasonsItem(Value.OBSERVATION_STATUS_UNKNOWN, "observation_status_unknown"); + + public static final RowForReviewReasonsItem PROCEDURE_STATUS_UNKNOWN = + new RowForReviewReasonsItem(Value.PROCEDURE_STATUS_UNKNOWN, "procedure_status_unknown"); + + public static final RowForReviewReasonsItem ENCOUNTER_STATUS_UNKNOWN = + new RowForReviewReasonsItem(Value.ENCOUNTER_STATUS_UNKNOWN, "encounter_status_unknown"); + + public static final RowForReviewReasonsItem MEDICATION_STATEMENT_STATUS_UNKNOWN = new RowForReviewReasonsItem( + Value.MEDICATION_STATEMENT_STATUS_UNKNOWN, "medication_statement_status_unknown"); + + public static final RowForReviewReasonsItem CONDITION_VERIFICATION_PROVISIONAL = + new RowForReviewReasonsItem(Value.CONDITION_VERIFICATION_PROVISIONAL, "condition_verification_provisional"); + + public static final RowForReviewReasonsItem CONDITION_VERIFICATION_UNRECOGNIZED = new RowForReviewReasonsItem( + Value.CONDITION_VERIFICATION_UNRECOGNIZED, "condition_verification_unrecognized"); + + public static final RowForReviewReasonsItem ENCOUNTER_STATUS_UNRECOGNIZED = + new RowForReviewReasonsItem(Value.ENCOUNTER_STATUS_UNRECOGNIZED, "encounter_status_unrecognized"); + + public static final RowForReviewReasonsItem PRIMARY_CONCEPT_UNRESOLVED = + new RowForReviewReasonsItem(Value.PRIMARY_CONCEPT_UNRESOLVED, "primary_concept_unresolved"); + + private final Value value; + + private final String string; + + RowForReviewReasonsItem(Value value, String string) { + this.value = value; + this.string = string; + } + + public Value getEnumValue() { + return value; + } + + @java.lang.Override + @JsonValue + public String toString() { + return this.string; + } + + @java.lang.Override + public boolean equals(Object other) { + return (this == other) + || (other instanceof RowForReviewReasonsItem + && this.string.equals(((RowForReviewReasonsItem) other).string)); + } + + @java.lang.Override + public int hashCode() { + return this.string.hashCode(); + } + + public T visit(Visitor visitor) { + switch (value) { + case CONDITION_VERIFICATION_UNCONFIRMED: + return visitor.visitConditionVerificationUnconfirmed(); + case CONDITION_VERIFICATION_DIFFERENTIAL: + return visitor.visitConditionVerificationDifferential(); + case OBSERVATION_STATUS_UNKNOWN: + return visitor.visitObservationStatusUnknown(); + case PROCEDURE_STATUS_UNKNOWN: + return visitor.visitProcedureStatusUnknown(); + case ENCOUNTER_STATUS_UNKNOWN: + return visitor.visitEncounterStatusUnknown(); + case MEDICATION_STATEMENT_STATUS_UNKNOWN: + return visitor.visitMedicationStatementStatusUnknown(); + case CONDITION_VERIFICATION_PROVISIONAL: + return visitor.visitConditionVerificationProvisional(); + case CONDITION_VERIFICATION_UNRECOGNIZED: + return visitor.visitConditionVerificationUnrecognized(); + case ENCOUNTER_STATUS_UNRECOGNIZED: + return visitor.visitEncounterStatusUnrecognized(); + case PRIMARY_CONCEPT_UNRESOLVED: + return visitor.visitPrimaryConceptUnresolved(); + case UNKNOWN: + default: + return visitor.visitUnknown(string); + } + } + + @JsonCreator(mode = JsonCreator.Mode.DELEGATING) + public static RowForReviewReasonsItem valueOf(String value) { + switch (value) { + case "condition_verification_unconfirmed": + return CONDITION_VERIFICATION_UNCONFIRMED; + case "condition_verification_differential": + return CONDITION_VERIFICATION_DIFFERENTIAL; + case "observation_status_unknown": + return OBSERVATION_STATUS_UNKNOWN; + case "procedure_status_unknown": + return PROCEDURE_STATUS_UNKNOWN; + case "encounter_status_unknown": + return ENCOUNTER_STATUS_UNKNOWN; + case "medication_statement_status_unknown": + return MEDICATION_STATEMENT_STATUS_UNKNOWN; + case "condition_verification_provisional": + return CONDITION_VERIFICATION_PROVISIONAL; + case "condition_verification_unrecognized": + return CONDITION_VERIFICATION_UNRECOGNIZED; + case "encounter_status_unrecognized": + return ENCOUNTER_STATUS_UNRECOGNIZED; + case "primary_concept_unresolved": + return PRIMARY_CONCEPT_UNRESOLVED; + default: + return new RowForReviewReasonsItem(Value.UNKNOWN, value); + } + } + + public enum Value { + CONDITION_VERIFICATION_UNCONFIRMED, + + CONDITION_VERIFICATION_PROVISIONAL, + + CONDITION_VERIFICATION_DIFFERENTIAL, + + CONDITION_VERIFICATION_UNRECOGNIZED, + + ENCOUNTER_STATUS_UNKNOWN, + + ENCOUNTER_STATUS_UNRECOGNIZED, + + MEDICATION_STATEMENT_STATUS_UNKNOWN, + + OBSERVATION_STATUS_UNKNOWN, + + PROCEDURE_STATUS_UNKNOWN, + + PRIMARY_CONCEPT_UNRESOLVED, + + UNKNOWN + } + + public interface Visitor { + T visitConditionVerificationUnconfirmed(); + + T visitConditionVerificationProvisional(); + + T visitConditionVerificationDifferential(); + + T visitConditionVerificationUnrecognized(); + + T visitEncounterStatusUnknown(); + + T visitEncounterStatusUnrecognized(); + + T visitMedicationStatementStatusUnknown(); + + T visitObservationStatusUnknown(); + + T visitProcedureStatusUnknown(); + + T visitPrimaryConceptUnresolved(); + + T visitUnknown(String unknownType); + } +} diff --git a/src/main/java/com/phenoml/api/resources/fhir2omop/types/SemanticMappingResult.java b/src/main/java/com/phenoml/api/resources/fhir2omop/types/SemanticMappingResult.java new file mode 100644 index 00000000..f60d33ea --- /dev/null +++ b/src/main/java/com/phenoml/api/resources/fhir2omop/types/SemanticMappingResult.java @@ -0,0 +1,254 @@ +/** + * This file was auto-generated by Fern from our API Definition. + */ +package com.phenoml.api.resources.fhir2omop.types; + +import com.fasterxml.jackson.annotation.JsonAnyGetter; +import com.fasterxml.jackson.annotation.JsonAnySetter; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonSetter; +import com.fasterxml.jackson.annotation.Nulls; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.phenoml.api.core.ObjectMappers; +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import org.jetbrains.annotations.NotNull; + +@JsonInclude(JsonInclude.Include.NON_ABSENT) +@JsonDeserialize(builder = SemanticMappingResult.Builder.class) +public final class SemanticMappingResult { + private final SemanticMappingResultStatus status; + + private final String vocabVersion; + + private final Optional reasonCode; + + private final Map additionalProperties; + + private SemanticMappingResult( + SemanticMappingResultStatus status, + String vocabVersion, + Optional reasonCode, + Map additionalProperties) { + this.status = status; + this.vocabVersion = vocabVersion; + this.reasonCode = reasonCode; + this.additionalProperties = additionalProperties; + } + + /** + * @return MAPPED when semantic mapping selected a validated standard OMOP concept; UNMAPPED when no standard concept was selected. + */ + @JsonProperty("status") + public SemanticMappingResultStatus getStatus() { + return status; + } + + /** + * @return OMOP vocabulary release selected for this mapping. + */ + @JsonProperty("vocab_version") + public String getVocabVersion() { + return vocabVersion; + } + + /** + * @return Why semantic mapping did not select a standard concept. Present when + * status is UNMAPPED; omitted when status is MAPPED. + *
              + *
            • MISSING_DESCRIPTION: no usable source text or coding display was supplied.
            • + *
            • NO_VALID_SUGGESTION: no candidate was accepted as having the same clinical meaning as the source description.
            • + *
            • AMBIGUOUS: a unique standard concept could not be selected from the possible matches.
            • + *
            • INVALID_TARGET: the suggested code is not an active standard OMOP concept valid for the destination field.
            • + *
            + *

            The linked concept field retains ID 0. An unresolved result-value field + * does not unset the row's primary concept.

            + */ + @JsonProperty("reason_code") + public Optional getReasonCode() { + return reasonCode; + } + + @java.lang.Override + public boolean equals(Object other) { + if (this == other) return true; + return other instanceof SemanticMappingResult && equalTo((SemanticMappingResult) other); + } + + @JsonAnyGetter + public Map getAdditionalProperties() { + return this.additionalProperties; + } + + private boolean equalTo(SemanticMappingResult other) { + return status.equals(other.status) + && vocabVersion.equals(other.vocabVersion) + && reasonCode.equals(other.reasonCode); + } + + @java.lang.Override + public int hashCode() { + return Objects.hash(this.status, this.vocabVersion, this.reasonCode); + } + + @java.lang.Override + public String toString() { + return ObjectMappers.stringify(this); + } + + public static StatusStage builder() { + return new Builder(); + } + + public interface StatusStage { + /** + *

            MAPPED when semantic mapping selected a validated standard OMOP concept; UNMAPPED when no standard concept was selected.

            + */ + VocabVersionStage status(@NotNull SemanticMappingResultStatus status); + + Builder from(SemanticMappingResult other); + } + + public interface VocabVersionStage { + /** + *

            OMOP vocabulary release selected for this mapping.

            + */ + _FinalStage vocabVersion(@NotNull String vocabVersion); + } + + public interface _FinalStage { + SemanticMappingResult build(); + + _FinalStage additionalProperty(String key, Object value); + + _FinalStage additionalProperties(Map additionalProperties); + + /** + *

            Why semantic mapping did not select a standard concept. Present when + * status is UNMAPPED; omitted when status is MAPPED.

            + *
              + *
            • MISSING_DESCRIPTION: no usable source text or coding display was supplied.
            • + *
            • NO_VALID_SUGGESTION: no candidate was accepted as having the same clinical meaning as the source description.
            • + *
            • AMBIGUOUS: a unique standard concept could not be selected from the possible matches.
            • + *
            • INVALID_TARGET: the suggested code is not an active standard OMOP concept valid for the destination field.
            • + *
            + *

            The linked concept field retains ID 0. An unresolved result-value field + * does not unset the row's primary concept.

            + */ + _FinalStage reasonCode(Optional reasonCode); + + _FinalStage reasonCode(SemanticMappingResultReasonCode reasonCode); + } + + @JsonIgnoreProperties(ignoreUnknown = true) + public static final class Builder implements StatusStage, VocabVersionStage, _FinalStage { + private SemanticMappingResultStatus status; + + private String vocabVersion; + + private Optional reasonCode = Optional.empty(); + + private Map additionalProperties = new HashMap<>(); + + private Builder() {} + + @java.lang.Override + public Builder from(SemanticMappingResult other) { + status(other.getStatus()); + vocabVersion(other.getVocabVersion()); + reasonCode(other.getReasonCode()); + return this; + } + + /** + *

            MAPPED when semantic mapping selected a validated standard OMOP concept; UNMAPPED when no standard concept was selected.

            + * @return Reference to {@code this} so that method calls can be chained together. + */ + @java.lang.Override + @JsonSetter("status") + public VocabVersionStage status(@NotNull SemanticMappingResultStatus status) { + this.status = Objects.requireNonNull(status, "status must not be null"); + return this; + } + + /** + *

            OMOP vocabulary release selected for this mapping.

            + * @return Reference to {@code this} so that method calls can be chained together. + */ + @java.lang.Override + @JsonSetter("vocab_version") + public _FinalStage vocabVersion(@NotNull String vocabVersion) { + this.vocabVersion = Objects.requireNonNull(vocabVersion, "vocabVersion must not be null"); + return this; + } + + /** + *

            Why semantic mapping did not select a standard concept. Present when + * status is UNMAPPED; omitted when status is MAPPED.

            + *
              + *
            • MISSING_DESCRIPTION: no usable source text or coding display was supplied.
            • + *
            • NO_VALID_SUGGESTION: no candidate was accepted as having the same clinical meaning as the source description.
            • + *
            • AMBIGUOUS: a unique standard concept could not be selected from the possible matches.
            • + *
            • INVALID_TARGET: the suggested code is not an active standard OMOP concept valid for the destination field.
            • + *
            + *

            The linked concept field retains ID 0. An unresolved result-value field + * does not unset the row's primary concept.

            + * @return Reference to {@code this} so that method calls can be chained together. + */ + @java.lang.Override + public _FinalStage reasonCode(SemanticMappingResultReasonCode reasonCode) { + this.reasonCode = Optional.ofNullable(reasonCode); + return this; + } + + /** + *

            Why semantic mapping did not select a standard concept. Present when + * status is UNMAPPED; omitted when status is MAPPED.

            + *
              + *
            • MISSING_DESCRIPTION: no usable source text or coding display was supplied.
            • + *
            • NO_VALID_SUGGESTION: no candidate was accepted as having the same clinical meaning as the source description.
            • + *
            • AMBIGUOUS: a unique standard concept could not be selected from the possible matches.
            • + *
            • INVALID_TARGET: the suggested code is not an active standard OMOP concept valid for the destination field.
            • + *
            + *

            The linked concept field retains ID 0. An unresolved result-value field + * does not unset the row's primary concept.

            + */ + @java.lang.Override + @JsonSetter(value = "reason_code", nulls = Nulls.SKIP) + public _FinalStage reasonCode(Optional reasonCode) { + this.reasonCode = reasonCode; + return this; + } + + @java.lang.Override + public SemanticMappingResult build() { + return new SemanticMappingResult(status, vocabVersion, reasonCode, additionalProperties); + } + + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("status") + || key.equalsIgnoreCase("vocab_version") + || key.equalsIgnoreCase("reason_code")) { + return; + } + this.additionalProperties.put(key, value); + } + + @java.lang.Override + public Builder additionalProperty(String key, Object value) { + this.additionalProperties.put(key, value); + return this; + } + + @java.lang.Override + public Builder additionalProperties(Map additionalProperties) { + this.additionalProperties.putAll(additionalProperties); + return this; + } + } +} diff --git a/src/main/java/com/phenoml/api/resources/fhir2omop/types/SemanticMappingResultReasonCode.java b/src/main/java/com/phenoml/api/resources/fhir2omop/types/SemanticMappingResultReasonCode.java new file mode 100644 index 00000000..b18db64b --- /dev/null +++ b/src/main/java/com/phenoml/api/resources/fhir2omop/types/SemanticMappingResultReasonCode.java @@ -0,0 +1,108 @@ +/** + * This file was auto-generated by Fern from our API Definition. + */ +package com.phenoml.api.resources.fhir2omop.types; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; + +public final class SemanticMappingResultReasonCode { + public static final SemanticMappingResultReasonCode MISSING_DESCRIPTION = + new SemanticMappingResultReasonCode(Value.MISSING_DESCRIPTION, "MISSING_DESCRIPTION"); + + public static final SemanticMappingResultReasonCode NO_VALID_SUGGESTION = + new SemanticMappingResultReasonCode(Value.NO_VALID_SUGGESTION, "NO_VALID_SUGGESTION"); + + public static final SemanticMappingResultReasonCode INVALID_TARGET = + new SemanticMappingResultReasonCode(Value.INVALID_TARGET, "INVALID_TARGET"); + + public static final SemanticMappingResultReasonCode AMBIGUOUS = + new SemanticMappingResultReasonCode(Value.AMBIGUOUS, "AMBIGUOUS"); + + private final Value value; + + private final String string; + + SemanticMappingResultReasonCode(Value value, String string) { + this.value = value; + this.string = string; + } + + public Value getEnumValue() { + return value; + } + + @java.lang.Override + @JsonValue + public String toString() { + return this.string; + } + + @java.lang.Override + public boolean equals(Object other) { + return (this == other) + || (other instanceof SemanticMappingResultReasonCode + && this.string.equals(((SemanticMappingResultReasonCode) other).string)); + } + + @java.lang.Override + public int hashCode() { + return this.string.hashCode(); + } + + public T visit(Visitor visitor) { + switch (value) { + case MISSING_DESCRIPTION: + return visitor.visitMissingDescription(); + case NO_VALID_SUGGESTION: + return visitor.visitNoValidSuggestion(); + case INVALID_TARGET: + return visitor.visitInvalidTarget(); + case AMBIGUOUS: + return visitor.visitAmbiguous(); + case UNKNOWN: + default: + return visitor.visitUnknown(string); + } + } + + @JsonCreator(mode = JsonCreator.Mode.DELEGATING) + public static SemanticMappingResultReasonCode valueOf(String value) { + switch (value) { + case "MISSING_DESCRIPTION": + return MISSING_DESCRIPTION; + case "NO_VALID_SUGGESTION": + return NO_VALID_SUGGESTION; + case "INVALID_TARGET": + return INVALID_TARGET; + case "AMBIGUOUS": + return AMBIGUOUS; + default: + return new SemanticMappingResultReasonCode(Value.UNKNOWN, value); + } + } + + public enum Value { + MISSING_DESCRIPTION, + + NO_VALID_SUGGESTION, + + AMBIGUOUS, + + INVALID_TARGET, + + UNKNOWN + } + + public interface Visitor { + T visitMissingDescription(); + + T visitNoValidSuggestion(); + + T visitAmbiguous(); + + T visitInvalidTarget(); + + T visitUnknown(String unknownType); + } +} diff --git a/src/main/java/com/phenoml/api/resources/fhir2omop/types/SemanticMappingResultStatus.java b/src/main/java/com/phenoml/api/resources/fhir2omop/types/SemanticMappingResultStatus.java new file mode 100644 index 00000000..37affa45 --- /dev/null +++ b/src/main/java/com/phenoml/api/resources/fhir2omop/types/SemanticMappingResultStatus.java @@ -0,0 +1,85 @@ +/** + * This file was auto-generated by Fern from our API Definition. + */ +package com.phenoml.api.resources.fhir2omop.types; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; + +public final class SemanticMappingResultStatus { + public static final SemanticMappingResultStatus MAPPED = new SemanticMappingResultStatus(Value.MAPPED, "MAPPED"); + + public static final SemanticMappingResultStatus UNMAPPED = + new SemanticMappingResultStatus(Value.UNMAPPED, "UNMAPPED"); + + private final Value value; + + private final String string; + + SemanticMappingResultStatus(Value value, String string) { + this.value = value; + this.string = string; + } + + public Value getEnumValue() { + return value; + } + + @java.lang.Override + @JsonValue + public String toString() { + return this.string; + } + + @java.lang.Override + public boolean equals(Object other) { + return (this == other) + || (other instanceof SemanticMappingResultStatus + && this.string.equals(((SemanticMappingResultStatus) other).string)); + } + + @java.lang.Override + public int hashCode() { + return this.string.hashCode(); + } + + public T visit(Visitor visitor) { + switch (value) { + case MAPPED: + return visitor.visitMapped(); + case UNMAPPED: + return visitor.visitUnmapped(); + case UNKNOWN: + default: + return visitor.visitUnknown(string); + } + } + + @JsonCreator(mode = JsonCreator.Mode.DELEGATING) + public static SemanticMappingResultStatus valueOf(String value) { + switch (value) { + case "MAPPED": + return MAPPED; + case "UNMAPPED": + return UNMAPPED; + default: + return new SemanticMappingResultStatus(Value.UNKNOWN, value); + } + } + + public enum Value { + MAPPED, + + UNMAPPED, + + UNKNOWN + } + + public interface Visitor { + T visitMapped(); + + T visitUnmapped(); + + T visitUnknown(String unknownType); + } +} diff --git a/src/main/java/com/phenoml/api/resources/fhir2omop/types/SpecialtyCodingResult.java b/src/main/java/com/phenoml/api/resources/fhir2omop/types/SpecialtyCodingResult.java new file mode 100644 index 00000000..ad7af891 --- /dev/null +++ b/src/main/java/com/phenoml/api/resources/fhir2omop/types/SpecialtyCodingResult.java @@ -0,0 +1,253 @@ +/** + * This file was auto-generated by Fern from our API Definition. + */ +package com.phenoml.api.resources.fhir2omop.types; + +import com.fasterxml.jackson.annotation.JsonAnyGetter; +import com.fasterxml.jackson.annotation.JsonAnySetter; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonSetter; +import com.fasterxml.jackson.annotation.Nulls; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.phenoml.api.core.ObjectMappers; +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import org.jetbrains.annotations.NotNull; + +@JsonInclude(JsonInclude.Include.NON_ABSENT) +@JsonDeserialize(builder = SpecialtyCodingResult.Builder.class) +public final class SpecialtyCodingResult { + private final int codingIndex; + + private final SpecialtyCodingResultSystemSupport systemSupport; + + private final SpecialtyCodingResultStatus status; + + private final Optional reasonCode; + + private final Map additionalProperties; + + private SpecialtyCodingResult( + int codingIndex, + SpecialtyCodingResultSystemSupport systemSupport, + SpecialtyCodingResultStatus status, + Optional reasonCode, + Map additionalProperties) { + this.codingIndex = codingIndex; + this.systemSupport = systemSupport; + this.status = status; + this.reasonCode = reasonCode; + this.additionalProperties = additionalProperties; + } + + /** + * @return Zero-based index in the originating specialty entry's coding array. + */ + @JsonProperty("coding_index") + public int getCodingIndex() { + return codingIndex; + } + + /** + * @return Whether the coding system is supported for resolution, unsupported, or missing/malformed. A missing code can still have a supported or unsupported system. + */ + @JsonProperty("system_support") + public SpecialtyCodingResultSystemSupport getSystemSupport() { + return systemSupport; + } + + /** + * @return This coding's independent outcome before combining the entry's evidence. + */ + @JsonProperty("status") + public SpecialtyCodingResultStatus getStatus() { + return status; + } + + /** + * @return Machine-readable explanation when available; omitted for successful codings and unknown reasons. + */ + @JsonProperty("reason_code") + public Optional getReasonCode() { + return reasonCode; + } + + @java.lang.Override + public boolean equals(Object other) { + if (this == other) return true; + return other instanceof SpecialtyCodingResult && equalTo((SpecialtyCodingResult) other); + } + + @JsonAnyGetter + public Map getAdditionalProperties() { + return this.additionalProperties; + } + + private boolean equalTo(SpecialtyCodingResult other) { + return codingIndex == other.codingIndex + && systemSupport.equals(other.systemSupport) + && status.equals(other.status) + && reasonCode.equals(other.reasonCode); + } + + @java.lang.Override + public int hashCode() { + return Objects.hash(this.codingIndex, this.systemSupport, this.status, this.reasonCode); + } + + @java.lang.Override + public String toString() { + return ObjectMappers.stringify(this); + } + + public static CodingIndexStage builder() { + return new Builder(); + } + + public interface CodingIndexStage { + /** + *

            Zero-based index in the originating specialty entry's coding array.

            + */ + SystemSupportStage codingIndex(int codingIndex); + + Builder from(SpecialtyCodingResult other); + } + + public interface SystemSupportStage { + /** + *

            Whether the coding system is supported for resolution, unsupported, or missing/malformed. A missing code can still have a supported or unsupported system.

            + */ + StatusStage systemSupport(@NotNull SpecialtyCodingResultSystemSupport systemSupport); + } + + public interface StatusStage { + /** + *

            This coding's independent outcome before combining the entry's evidence.

            + */ + _FinalStage status(@NotNull SpecialtyCodingResultStatus status); + } + + public interface _FinalStage { + SpecialtyCodingResult build(); + + _FinalStage additionalProperty(String key, Object value); + + _FinalStage additionalProperties(Map additionalProperties); + + /** + *

            Machine-readable explanation when available; omitted for successful codings and unknown reasons.

            + */ + _FinalStage reasonCode(Optional reasonCode); + + _FinalStage reasonCode(SpecialtyCodingResultReasonCode reasonCode); + } + + @JsonIgnoreProperties(ignoreUnknown = true) + public static final class Builder implements CodingIndexStage, SystemSupportStage, StatusStage, _FinalStage { + private int codingIndex; + + private SpecialtyCodingResultSystemSupport systemSupport; + + private SpecialtyCodingResultStatus status; + + private Optional reasonCode = Optional.empty(); + + private Map additionalProperties = new HashMap<>(); + + private Builder() {} + + @java.lang.Override + public Builder from(SpecialtyCodingResult other) { + codingIndex(other.getCodingIndex()); + systemSupport(other.getSystemSupport()); + status(other.getStatus()); + reasonCode(other.getReasonCode()); + return this; + } + + /** + *

            Zero-based index in the originating specialty entry's coding array.

            + * @return Reference to {@code this} so that method calls can be chained together. + */ + @java.lang.Override + @JsonSetter("coding_index") + public SystemSupportStage codingIndex(int codingIndex) { + this.codingIndex = codingIndex; + return this; + } + + /** + *

            Whether the coding system is supported for resolution, unsupported, or missing/malformed. A missing code can still have a supported or unsupported system.

            + * @return Reference to {@code this} so that method calls can be chained together. + */ + @java.lang.Override + @JsonSetter("system_support") + public StatusStage systemSupport(@NotNull SpecialtyCodingResultSystemSupport systemSupport) { + this.systemSupport = Objects.requireNonNull(systemSupport, "systemSupport must not be null"); + return this; + } + + /** + *

            This coding's independent outcome before combining the entry's evidence.

            + * @return Reference to {@code this} so that method calls can be chained together. + */ + @java.lang.Override + @JsonSetter("status") + public _FinalStage status(@NotNull SpecialtyCodingResultStatus status) { + this.status = Objects.requireNonNull(status, "status must not be null"); + return this; + } + + /** + *

            Machine-readable explanation when available; omitted for successful codings and unknown reasons.

            + * @return Reference to {@code this} so that method calls can be chained together. + */ + @java.lang.Override + public _FinalStage reasonCode(SpecialtyCodingResultReasonCode reasonCode) { + this.reasonCode = Optional.ofNullable(reasonCode); + return this; + } + + /** + *

            Machine-readable explanation when available; omitted for successful codings and unknown reasons.

            + */ + @java.lang.Override + @JsonSetter(value = "reason_code", nulls = Nulls.SKIP) + public _FinalStage reasonCode(Optional reasonCode) { + this.reasonCode = reasonCode; + return this; + } + + @java.lang.Override + public SpecialtyCodingResult build() { + return new SpecialtyCodingResult(codingIndex, systemSupport, status, reasonCode, additionalProperties); + } + + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("coding_index") + || key.equalsIgnoreCase("system_support") + || key.equalsIgnoreCase("status") + || key.equalsIgnoreCase("reason_code")) { + return; + } + this.additionalProperties.put(key, value); + } + + @java.lang.Override + public Builder additionalProperty(String key, Object value) { + this.additionalProperties.put(key, value); + return this; + } + + @java.lang.Override + public Builder additionalProperties(Map additionalProperties) { + this.additionalProperties.putAll(additionalProperties); + return this; + } + } +} diff --git a/src/main/java/com/phenoml/api/resources/fhir2omop/types/SpecialtyCodingResultReasonCode.java b/src/main/java/com/phenoml/api/resources/fhir2omop/types/SpecialtyCodingResultReasonCode.java new file mode 100644 index 00000000..42b7604a --- /dev/null +++ b/src/main/java/com/phenoml/api/resources/fhir2omop/types/SpecialtyCodingResultReasonCode.java @@ -0,0 +1,141 @@ +/** + * This file was auto-generated by Fern from our API Definition. + */ +package com.phenoml.api.resources.fhir2omop.types; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; + +public final class SpecialtyCodingResultReasonCode { + public static final SpecialtyCodingResultReasonCode DOMAIN_MISMATCH = + new SpecialtyCodingResultReasonCode(Value.DOMAIN_MISMATCH, "DOMAIN_MISMATCH"); + + public static final SpecialtyCodingResultReasonCode NO_OMOP_SOURCE_CONCEPT = + new SpecialtyCodingResultReasonCode(Value.NO_OMOP_SOURCE_CONCEPT, "NO_OMOP_SOURCE_CONCEPT"); + + public static final SpecialtyCodingResultReasonCode AMBIGUOUS = + new SpecialtyCodingResultReasonCode(Value.AMBIGUOUS, "AMBIGUOUS"); + + public static final SpecialtyCodingResultReasonCode UNSUPPORTED_SYSTEM = + new SpecialtyCodingResultReasonCode(Value.UNSUPPORTED_SYSTEM, "UNSUPPORTED_SYSTEM"); + + public static final SpecialtyCodingResultReasonCode NO_STANDARD_MAP = + new SpecialtyCodingResultReasonCode(Value.NO_STANDARD_MAP, "NO_STANDARD_MAP"); + + public static final SpecialtyCodingResultReasonCode UNSUPPORTED_CODE = + new SpecialtyCodingResultReasonCode(Value.UNSUPPORTED_CODE, "UNSUPPORTED_CODE"); + + public static final SpecialtyCodingResultReasonCode NO_SOURCE_CODE = + new SpecialtyCodingResultReasonCode(Value.NO_SOURCE_CODE, "NO_SOURCE_CODE"); + + private final Value value; + + private final String string; + + SpecialtyCodingResultReasonCode(Value value, String string) { + this.value = value; + this.string = string; + } + + public Value getEnumValue() { + return value; + } + + @java.lang.Override + @JsonValue + public String toString() { + return this.string; + } + + @java.lang.Override + public boolean equals(Object other) { + return (this == other) + || (other instanceof SpecialtyCodingResultReasonCode + && this.string.equals(((SpecialtyCodingResultReasonCode) other).string)); + } + + @java.lang.Override + public int hashCode() { + return this.string.hashCode(); + } + + public T visit(Visitor visitor) { + switch (value) { + case DOMAIN_MISMATCH: + return visitor.visitDomainMismatch(); + case NO_OMOP_SOURCE_CONCEPT: + return visitor.visitNoOmopSourceConcept(); + case AMBIGUOUS: + return visitor.visitAmbiguous(); + case UNSUPPORTED_SYSTEM: + return visitor.visitUnsupportedSystem(); + case NO_STANDARD_MAP: + return visitor.visitNoStandardMap(); + case UNSUPPORTED_CODE: + return visitor.visitUnsupportedCode(); + case NO_SOURCE_CODE: + return visitor.visitNoSourceCode(); + case UNKNOWN: + default: + return visitor.visitUnknown(string); + } + } + + @JsonCreator(mode = JsonCreator.Mode.DELEGATING) + public static SpecialtyCodingResultReasonCode valueOf(String value) { + switch (value) { + case "DOMAIN_MISMATCH": + return DOMAIN_MISMATCH; + case "NO_OMOP_SOURCE_CONCEPT": + return NO_OMOP_SOURCE_CONCEPT; + case "AMBIGUOUS": + return AMBIGUOUS; + case "UNSUPPORTED_SYSTEM": + return UNSUPPORTED_SYSTEM; + case "NO_STANDARD_MAP": + return NO_STANDARD_MAP; + case "UNSUPPORTED_CODE": + return UNSUPPORTED_CODE; + case "NO_SOURCE_CODE": + return NO_SOURCE_CODE; + default: + return new SpecialtyCodingResultReasonCode(Value.UNKNOWN, value); + } + } + + public enum Value { + NO_SOURCE_CODE, + + NO_OMOP_SOURCE_CONCEPT, + + NO_STANDARD_MAP, + + AMBIGUOUS, + + DOMAIN_MISMATCH, + + UNSUPPORTED_SYSTEM, + + UNSUPPORTED_CODE, + + UNKNOWN + } + + public interface Visitor { + T visitNoSourceCode(); + + T visitNoOmopSourceConcept(); + + T visitNoStandardMap(); + + T visitAmbiguous(); + + T visitDomainMismatch(); + + T visitUnsupportedSystem(); + + T visitUnsupportedCode(); + + T visitUnknown(String unknownType); + } +} diff --git a/src/main/java/com/phenoml/api/resources/fhir2omop/types/SpecialtyCodingResultStatus.java b/src/main/java/com/phenoml/api/resources/fhir2omop/types/SpecialtyCodingResultStatus.java new file mode 100644 index 00000000..475c3df1 --- /dev/null +++ b/src/main/java/com/phenoml/api/resources/fhir2omop/types/SpecialtyCodingResultStatus.java @@ -0,0 +1,108 @@ +/** + * This file was auto-generated by Fern from our API Definition. + */ +package com.phenoml.api.resources.fhir2omop.types; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; + +public final class SpecialtyCodingResultStatus { + public static final SpecialtyCodingResultStatus RESOLVED = + new SpecialtyCodingResultStatus(Value.RESOLVED, "RESOLVED"); + + public static final SpecialtyCodingResultStatus UNRESOLVED = + new SpecialtyCodingResultStatus(Value.UNRESOLVED, "UNRESOLVED"); + + public static final SpecialtyCodingResultStatus DOMAIN_MISMATCH = + new SpecialtyCodingResultStatus(Value.DOMAIN_MISMATCH, "DOMAIN_MISMATCH"); + + public static final SpecialtyCodingResultStatus AMBIGUOUS = + new SpecialtyCodingResultStatus(Value.AMBIGUOUS, "AMBIGUOUS"); + + private final Value value; + + private final String string; + + SpecialtyCodingResultStatus(Value value, String string) { + this.value = value; + this.string = string; + } + + public Value getEnumValue() { + return value; + } + + @java.lang.Override + @JsonValue + public String toString() { + return this.string; + } + + @java.lang.Override + public boolean equals(Object other) { + return (this == other) + || (other instanceof SpecialtyCodingResultStatus + && this.string.equals(((SpecialtyCodingResultStatus) other).string)); + } + + @java.lang.Override + public int hashCode() { + return this.string.hashCode(); + } + + public T visit(Visitor visitor) { + switch (value) { + case RESOLVED: + return visitor.visitResolved(); + case UNRESOLVED: + return visitor.visitUnresolved(); + case DOMAIN_MISMATCH: + return visitor.visitDomainMismatch(); + case AMBIGUOUS: + return visitor.visitAmbiguous(); + case UNKNOWN: + default: + return visitor.visitUnknown(string); + } + } + + @JsonCreator(mode = JsonCreator.Mode.DELEGATING) + public static SpecialtyCodingResultStatus valueOf(String value) { + switch (value) { + case "RESOLVED": + return RESOLVED; + case "UNRESOLVED": + return UNRESOLVED; + case "DOMAIN_MISMATCH": + return DOMAIN_MISMATCH; + case "AMBIGUOUS": + return AMBIGUOUS; + default: + return new SpecialtyCodingResultStatus(Value.UNKNOWN, value); + } + } + + public enum Value { + RESOLVED, + + UNRESOLVED, + + DOMAIN_MISMATCH, + + AMBIGUOUS, + + UNKNOWN + } + + public interface Visitor { + T visitResolved(); + + T visitUnresolved(); + + T visitDomainMismatch(); + + T visitAmbiguous(); + + T visitUnknown(String unknownType); + } +} diff --git a/src/main/java/com/phenoml/api/resources/fhir2omop/types/SpecialtyCodingResultSystemSupport.java b/src/main/java/com/phenoml/api/resources/fhir2omop/types/SpecialtyCodingResultSystemSupport.java new file mode 100644 index 00000000..d8e1d42a --- /dev/null +++ b/src/main/java/com/phenoml/api/resources/fhir2omop/types/SpecialtyCodingResultSystemSupport.java @@ -0,0 +1,97 @@ +/** + * This file was auto-generated by Fern from our API Definition. + */ +package com.phenoml.api.resources.fhir2omop.types; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; + +public final class SpecialtyCodingResultSystemSupport { + public static final SpecialtyCodingResultSystemSupport INVALID = + new SpecialtyCodingResultSystemSupport(Value.INVALID, "INVALID"); + + public static final SpecialtyCodingResultSystemSupport UNSUPPORTED = + new SpecialtyCodingResultSystemSupport(Value.UNSUPPORTED, "UNSUPPORTED"); + + public static final SpecialtyCodingResultSystemSupport SUPPORTED = + new SpecialtyCodingResultSystemSupport(Value.SUPPORTED, "SUPPORTED"); + + private final Value value; + + private final String string; + + SpecialtyCodingResultSystemSupport(Value value, String string) { + this.value = value; + this.string = string; + } + + public Value getEnumValue() { + return value; + } + + @java.lang.Override + @JsonValue + public String toString() { + return this.string; + } + + @java.lang.Override + public boolean equals(Object other) { + return (this == other) + || (other instanceof SpecialtyCodingResultSystemSupport + && this.string.equals(((SpecialtyCodingResultSystemSupport) other).string)); + } + + @java.lang.Override + public int hashCode() { + return this.string.hashCode(); + } + + public T visit(Visitor visitor) { + switch (value) { + case INVALID: + return visitor.visitInvalid(); + case UNSUPPORTED: + return visitor.visitUnsupported(); + case SUPPORTED: + return visitor.visitSupported(); + case UNKNOWN: + default: + return visitor.visitUnknown(string); + } + } + + @JsonCreator(mode = JsonCreator.Mode.DELEGATING) + public static SpecialtyCodingResultSystemSupport valueOf(String value) { + switch (value) { + case "INVALID": + return INVALID; + case "UNSUPPORTED": + return UNSUPPORTED; + case "SUPPORTED": + return SUPPORTED; + default: + return new SpecialtyCodingResultSystemSupport(Value.UNKNOWN, value); + } + } + + public enum Value { + SUPPORTED, + + UNSUPPORTED, + + INVALID, + + UNKNOWN + } + + public interface Visitor { + T visitSupported(); + + T visitUnsupported(); + + T visitInvalid(); + + T visitUnknown(String unknownType); + } +} diff --git a/src/main/java/com/phenoml/api/resources/fhir2omop/types/SpecialtyResolution.java b/src/main/java/com/phenoml/api/resources/fhir2omop/types/SpecialtyResolution.java new file mode 100644 index 00000000..36e84106 --- /dev/null +++ b/src/main/java/com/phenoml/api/resources/fhir2omop/types/SpecialtyResolution.java @@ -0,0 +1,217 @@ +/** + * This file was auto-generated by Fern from our API Definition. + */ +package com.phenoml.api.resources.fhir2omop.types; + +import com.fasterxml.jackson.annotation.JsonAnyGetter; +import com.fasterxml.jackson.annotation.JsonAnySetter; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonSetter; +import com.fasterxml.jackson.annotation.Nulls; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.phenoml.api.core.ObjectMappers; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import org.jetbrains.annotations.NotNull; + +@JsonInclude(JsonInclude.Include.NON_ABSENT) +@JsonDeserialize(builder = SpecialtyResolution.Builder.class) +public final class SpecialtyResolution { + private final SpecialtyResolutionTextFallbackReason textFallbackReason; + + private final List codingResults; + + private final Map additionalProperties; + + private SpecialtyResolution( + SpecialtyResolutionTextFallbackReason textFallbackReason, + List codingResults, + Map additionalProperties) { + this.textFallbackReason = textFallbackReason; + this.codingResults = codingResults; + this.additionalProperties = additionalProperties; + } + + /** + * @return NO_CODINGS identifies ordinary text-only resolution. INVALID_CODING + * means a missing code or missing/malformed system blocks fallback. + * SUPPORTED_SYSTEM_PRESENT blocks fallback even when a supported code + * does not resolve. NO_SPECIALTY_TEXT means complete unsupported + * codings lack explicit nonblank text. UNSUPPORTED_SYSTEMS_ONLY means + * complete unsupported codings permit a text attempt; the attempt may + * still be unresolved or ambiguous. + */ + @JsonProperty("text_fallback_reason") + public SpecialtyResolutionTextFallbackReason getTextFallbackReason() { + return textFallbackReason; + } + + /** + * @return Independent outcome of every supplied coding in its original order, including empty or incomplete objects. Empty for text-only specialties. Each mapping's source_coding_index identifies its corresponding outcome. + */ + @JsonProperty("coding_results") + public List getCodingResults() { + return codingResults; + } + + @java.lang.Override + public boolean equals(Object other) { + if (this == other) return true; + return other instanceof SpecialtyResolution && equalTo((SpecialtyResolution) other); + } + + @JsonAnyGetter + public Map getAdditionalProperties() { + return this.additionalProperties; + } + + private boolean equalTo(SpecialtyResolution other) { + return textFallbackReason.equals(other.textFallbackReason) && codingResults.equals(other.codingResults); + } + + @java.lang.Override + public int hashCode() { + return Objects.hash(this.textFallbackReason, this.codingResults); + } + + @java.lang.Override + public String toString() { + return ObjectMappers.stringify(this); + } + + public static TextFallbackReasonStage builder() { + return new Builder(); + } + + public interface TextFallbackReasonStage { + /** + *

            NO_CODINGS identifies ordinary text-only resolution. INVALID_CODING + * means a missing code or missing/malformed system blocks fallback. + * SUPPORTED_SYSTEM_PRESENT blocks fallback even when a supported code + * does not resolve. NO_SPECIALTY_TEXT means complete unsupported + * codings lack explicit nonblank text. UNSUPPORTED_SYSTEMS_ONLY means + * complete unsupported codings permit a text attempt; the attempt may + * still be unresolved or ambiguous.

            + */ + _FinalStage textFallbackReason(@NotNull SpecialtyResolutionTextFallbackReason textFallbackReason); + + Builder from(SpecialtyResolution other); + } + + public interface _FinalStage { + SpecialtyResolution build(); + + _FinalStage additionalProperty(String key, Object value); + + _FinalStage additionalProperties(Map additionalProperties); + + /** + *

            Independent outcome of every supplied coding in its original order, including empty or incomplete objects. Empty for text-only specialties. Each mapping's source_coding_index identifies its corresponding outcome.

            + */ + _FinalStage codingResults(List codingResults); + + _FinalStage addCodingResults(SpecialtyCodingResult codingResults); + + _FinalStage addAllCodingResults(List codingResults); + } + + @JsonIgnoreProperties(ignoreUnknown = true) + public static final class Builder implements TextFallbackReasonStage, _FinalStage { + private SpecialtyResolutionTextFallbackReason textFallbackReason; + + private List codingResults = new ArrayList<>(); + + private Map additionalProperties = new HashMap<>(); + + private Builder() {} + + @java.lang.Override + public Builder from(SpecialtyResolution other) { + textFallbackReason(other.getTextFallbackReason()); + codingResults(other.getCodingResults()); + return this; + } + + /** + *

            NO_CODINGS identifies ordinary text-only resolution. INVALID_CODING + * means a missing code or missing/malformed system blocks fallback. + * SUPPORTED_SYSTEM_PRESENT blocks fallback even when a supported code + * does not resolve. NO_SPECIALTY_TEXT means complete unsupported + * codings lack explicit nonblank text. UNSUPPORTED_SYSTEMS_ONLY means + * complete unsupported codings permit a text attempt; the attempt may + * still be unresolved or ambiguous.

            + * @return Reference to {@code this} so that method calls can be chained together. + */ + @java.lang.Override + @JsonSetter("text_fallback_reason") + public _FinalStage textFallbackReason(@NotNull SpecialtyResolutionTextFallbackReason textFallbackReason) { + this.textFallbackReason = Objects.requireNonNull(textFallbackReason, "textFallbackReason must not be null"); + return this; + } + + /** + *

            Independent outcome of every supplied coding in its original order, including empty or incomplete objects. Empty for text-only specialties. Each mapping's source_coding_index identifies its corresponding outcome.

            + * @return Reference to {@code this} so that method calls can be chained together. + */ + @java.lang.Override + public _FinalStage addAllCodingResults(List codingResults) { + if (codingResults != null) { + this.codingResults.addAll(codingResults); + } + return this; + } + + /** + *

            Independent outcome of every supplied coding in its original order, including empty or incomplete objects. Empty for text-only specialties. Each mapping's source_coding_index identifies its corresponding outcome.

            + * @return Reference to {@code this} so that method calls can be chained together. + */ + @java.lang.Override + public _FinalStage addCodingResults(SpecialtyCodingResult codingResults) { + this.codingResults.add(codingResults); + return this; + } + + /** + *

            Independent outcome of every supplied coding in its original order, including empty or incomplete objects. Empty for text-only specialties. Each mapping's source_coding_index identifies its corresponding outcome.

            + */ + @java.lang.Override + @JsonSetter(value = "coding_results", nulls = Nulls.SKIP) + public _FinalStage codingResults(List codingResults) { + this.codingResults.clear(); + if (codingResults != null) { + this.codingResults.addAll(codingResults); + } + return this; + } + + @java.lang.Override + public SpecialtyResolution build() { + return new SpecialtyResolution(textFallbackReason, codingResults, additionalProperties); + } + + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("text_fallback_reason") || key.equalsIgnoreCase("coding_results")) { + return; + } + this.additionalProperties.put(key, value); + } + + @java.lang.Override + public Builder additionalProperty(String key, Object value) { + this.additionalProperties.put(key, value); + return this; + } + + @java.lang.Override + public Builder additionalProperties(Map additionalProperties) { + this.additionalProperties.putAll(additionalProperties); + return this; + } + } +} diff --git a/src/main/java/com/phenoml/api/resources/fhir2omop/types/SpecialtyResolutionTextFallbackReason.java b/src/main/java/com/phenoml/api/resources/fhir2omop/types/SpecialtyResolutionTextFallbackReason.java new file mode 100644 index 00000000..4fb3a8e8 --- /dev/null +++ b/src/main/java/com/phenoml/api/resources/fhir2omop/types/SpecialtyResolutionTextFallbackReason.java @@ -0,0 +1,119 @@ +/** + * This file was auto-generated by Fern from our API Definition. + */ +package com.phenoml.api.resources.fhir2omop.types; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; + +public final class SpecialtyResolutionTextFallbackReason { + public static final SpecialtyResolutionTextFallbackReason UNSUPPORTED_SYSTEMS_ONLY = + new SpecialtyResolutionTextFallbackReason(Value.UNSUPPORTED_SYSTEMS_ONLY, "UNSUPPORTED_SYSTEMS_ONLY"); + + public static final SpecialtyResolutionTextFallbackReason NO_CODINGS = + new SpecialtyResolutionTextFallbackReason(Value.NO_CODINGS, "NO_CODINGS"); + + public static final SpecialtyResolutionTextFallbackReason NO_SPECIALTY_TEXT = + new SpecialtyResolutionTextFallbackReason(Value.NO_SPECIALTY_TEXT, "NO_SPECIALTY_TEXT"); + + public static final SpecialtyResolutionTextFallbackReason INVALID_CODING = + new SpecialtyResolutionTextFallbackReason(Value.INVALID_CODING, "INVALID_CODING"); + + public static final SpecialtyResolutionTextFallbackReason SUPPORTED_SYSTEM_PRESENT = + new SpecialtyResolutionTextFallbackReason(Value.SUPPORTED_SYSTEM_PRESENT, "SUPPORTED_SYSTEM_PRESENT"); + + private final Value value; + + private final String string; + + SpecialtyResolutionTextFallbackReason(Value value, String string) { + this.value = value; + this.string = string; + } + + public Value getEnumValue() { + return value; + } + + @java.lang.Override + @JsonValue + public String toString() { + return this.string; + } + + @java.lang.Override + public boolean equals(Object other) { + return (this == other) + || (other instanceof SpecialtyResolutionTextFallbackReason + && this.string.equals(((SpecialtyResolutionTextFallbackReason) other).string)); + } + + @java.lang.Override + public int hashCode() { + return this.string.hashCode(); + } + + public T visit(Visitor visitor) { + switch (value) { + case UNSUPPORTED_SYSTEMS_ONLY: + return visitor.visitUnsupportedSystemsOnly(); + case NO_CODINGS: + return visitor.visitNoCodings(); + case NO_SPECIALTY_TEXT: + return visitor.visitNoSpecialtyText(); + case INVALID_CODING: + return visitor.visitInvalidCoding(); + case SUPPORTED_SYSTEM_PRESENT: + return visitor.visitSupportedSystemPresent(); + case UNKNOWN: + default: + return visitor.visitUnknown(string); + } + } + + @JsonCreator(mode = JsonCreator.Mode.DELEGATING) + public static SpecialtyResolutionTextFallbackReason valueOf(String value) { + switch (value) { + case "UNSUPPORTED_SYSTEMS_ONLY": + return UNSUPPORTED_SYSTEMS_ONLY; + case "NO_CODINGS": + return NO_CODINGS; + case "NO_SPECIALTY_TEXT": + return NO_SPECIALTY_TEXT; + case "INVALID_CODING": + return INVALID_CODING; + case "SUPPORTED_SYSTEM_PRESENT": + return SUPPORTED_SYSTEM_PRESENT; + default: + return new SpecialtyResolutionTextFallbackReason(Value.UNKNOWN, value); + } + } + + public enum Value { + NO_CODINGS, + + INVALID_CODING, + + SUPPORTED_SYSTEM_PRESENT, + + NO_SPECIALTY_TEXT, + + UNSUPPORTED_SYSTEMS_ONLY, + + UNKNOWN + } + + public interface Visitor { + T visitNoCodings(); + + T visitInvalidCoding(); + + T visitSupportedSystemPresent(); + + T visitNoSpecialtyText(); + + T visitUnsupportedSystemsOnly(); + + T visitUnknown(String unknownType); + } +} diff --git a/src/main/java/com/phenoml/api/resources/fhir2omop/types/SpecimenCollectionPeriod.java b/src/main/java/com/phenoml/api/resources/fhir2omop/types/SpecimenCollectionPeriod.java new file mode 100644 index 00000000..7558c56b --- /dev/null +++ b/src/main/java/com/phenoml/api/resources/fhir2omop/types/SpecimenCollectionPeriod.java @@ -0,0 +1,147 @@ +/** + * This file was auto-generated by Fern from our API Definition. + */ +package com.phenoml.api.resources.fhir2omop.types; + +import com.fasterxml.jackson.annotation.JsonAnyGetter; +import com.fasterxml.jackson.annotation.JsonAnySetter; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonSetter; +import com.fasterxml.jackson.annotation.Nulls; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.phenoml.api.core.ObjectMappers; +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; + +@JsonInclude(JsonInclude.Include.NON_ABSENT) +@JsonDeserialize(builder = SpecimenCollectionPeriod.Builder.class) +public final class SpecimenCollectionPeriod { + private final Optional start; + + private final Optional end; + + private final Map additionalProperties; + + private SpecimenCollectionPeriod( + Optional start, Optional end, Map additionalProperties) { + this.start = start; + this.end = end; + this.additionalProperties = additionalProperties; + } + + /** + * @return Original collectedPeriod.start, without precision completion. + */ + @JsonProperty("start") + public Optional getStart() { + return start; + } + + /** + * @return Original collectedPeriod.end, retained without using it as collection start. + */ + @JsonProperty("end") + public Optional getEnd() { + return end; + } + + @java.lang.Override + public boolean equals(Object other) { + if (this == other) return true; + return other instanceof SpecimenCollectionPeriod && equalTo((SpecimenCollectionPeriod) other); + } + + @JsonAnyGetter + public Map getAdditionalProperties() { + return this.additionalProperties; + } + + private boolean equalTo(SpecimenCollectionPeriod other) { + return start.equals(other.start) && end.equals(other.end); + } + + @java.lang.Override + public int hashCode() { + return Objects.hash(this.start, this.end); + } + + @java.lang.Override + public String toString() { + return ObjectMappers.stringify(this); + } + + public static Builder builder() { + return new Builder(); + } + + @JsonIgnoreProperties(ignoreUnknown = true) + public static final class Builder { + private Optional start = Optional.empty(); + + private Optional end = Optional.empty(); + + private Map additionalProperties = new HashMap<>(); + + private Builder() {} + + public Builder from(SpecimenCollectionPeriod other) { + start(other.getStart()); + end(other.getEnd()); + return this; + } + + /** + *

            Original collectedPeriod.start, without precision completion.

            + */ + @JsonSetter(value = "start", nulls = Nulls.SKIP) + public Builder start(Optional start) { + this.start = start; + return this; + } + + public Builder start(String start) { + this.start = Optional.ofNullable(start); + return this; + } + + /** + *

            Original collectedPeriod.end, retained without using it as collection start.

            + */ + @JsonSetter(value = "end", nulls = Nulls.SKIP) + public Builder end(Optional end) { + this.end = end; + return this; + } + + public Builder end(String end) { + this.end = Optional.ofNullable(end); + return this; + } + + public SpecimenCollectionPeriod build() { + return new SpecimenCollectionPeriod(start, end, additionalProperties); + } + + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("start") || key.equalsIgnoreCase("end")) { + return; + } + this.additionalProperties.put(key, value); + } + + public Builder additionalProperty(String key, Object value) { + this.additionalProperties.put(key, value); + return this; + } + + public Builder additionalProperties(Map additionalProperties) { + this.additionalProperties.putAll(additionalProperties); + return this; + } + } +} diff --git a/src/main/java/com/phenoml/api/resources/fhir2omop/types/SpecimenCollectionTiming.java b/src/main/java/com/phenoml/api/resources/fhir2omop/types/SpecimenCollectionTiming.java new file mode 100644 index 00000000..cabad1f2 --- /dev/null +++ b/src/main/java/com/phenoml/api/resources/fhir2omop/types/SpecimenCollectionTiming.java @@ -0,0 +1,208 @@ +/** + * This file was auto-generated by Fern from our API Definition. + */ +package com.phenoml.api.resources.fhir2omop.types; + +import com.fasterxml.jackson.annotation.JsonAnyGetter; +import com.fasterxml.jackson.annotation.JsonAnySetter; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonSetter; +import com.fasterxml.jackson.annotation.Nulls; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.phenoml.api.core.ObjectMappers; +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; + +@JsonInclude(JsonInclude.Include.NON_ABSENT) +@JsonDeserialize(builder = SpecimenCollectionTiming.Builder.class) +public final class SpecimenCollectionTiming { + private final Optional collectedDateTime; + + private final Optional collectedPeriod; + + private final Optional receivedTime; + + private final Optional selectedPath; + + private final Map additionalProperties; + + private SpecimenCollectionTiming( + Optional collectedDateTime, + Optional collectedPeriod, + Optional receivedTime, + Optional selectedPath, + Map additionalProperties) { + this.collectedDateTime = collectedDateTime; + this.collectedPeriod = collectedPeriod; + this.receivedTime = receivedTime; + this.selectedPath = selectedPath; + this.additionalProperties = additionalProperties; + } + + /** + * @return Original collection.collectedDateTime, including partial precision, invalid text, or timezone offset. + */ + @JsonProperty("collected_date_time") + public Optional getCollectedDateTime() { + return collectedDateTime; + } + + @JsonProperty("collected_period") + public Optional getCollectedPeriod() { + return collectedPeriod; + } + + /** + * @return Original receivedTime, never substituted for collection timing. + */ + @JsonProperty("received_time") + public Optional getReceivedTime() { + return receivedTime; + } + + /** + * @return collection.collectedDateTime or collection.collectedPeriod.start when specimen_date was populated; omitted when no date could safely be selected. + */ + @JsonProperty("selected_path") + public Optional getSelectedPath() { + return selectedPath; + } + + @java.lang.Override + public boolean equals(Object other) { + if (this == other) return true; + return other instanceof SpecimenCollectionTiming && equalTo((SpecimenCollectionTiming) other); + } + + @JsonAnyGetter + public Map getAdditionalProperties() { + return this.additionalProperties; + } + + private boolean equalTo(SpecimenCollectionTiming other) { + return collectedDateTime.equals(other.collectedDateTime) + && collectedPeriod.equals(other.collectedPeriod) + && receivedTime.equals(other.receivedTime) + && selectedPath.equals(other.selectedPath); + } + + @java.lang.Override + public int hashCode() { + return Objects.hash(this.collectedDateTime, this.collectedPeriod, this.receivedTime, this.selectedPath); + } + + @java.lang.Override + public String toString() { + return ObjectMappers.stringify(this); + } + + public static Builder builder() { + return new Builder(); + } + + @JsonIgnoreProperties(ignoreUnknown = true) + public static final class Builder { + private Optional collectedDateTime = Optional.empty(); + + private Optional collectedPeriod = Optional.empty(); + + private Optional receivedTime = Optional.empty(); + + private Optional selectedPath = Optional.empty(); + + private Map additionalProperties = new HashMap<>(); + + private Builder() {} + + public Builder from(SpecimenCollectionTiming other) { + collectedDateTime(other.getCollectedDateTime()); + collectedPeriod(other.getCollectedPeriod()); + receivedTime(other.getReceivedTime()); + selectedPath(other.getSelectedPath()); + return this; + } + + /** + *

            Original collection.collectedDateTime, including partial precision, invalid text, or timezone offset.

            + */ + @JsonSetter(value = "collected_date_time", nulls = Nulls.SKIP) + public Builder collectedDateTime(Optional collectedDateTime) { + this.collectedDateTime = collectedDateTime; + return this; + } + + public Builder collectedDateTime(String collectedDateTime) { + this.collectedDateTime = Optional.ofNullable(collectedDateTime); + return this; + } + + @JsonSetter(value = "collected_period", nulls = Nulls.SKIP) + public Builder collectedPeriod(Optional collectedPeriod) { + this.collectedPeriod = collectedPeriod; + return this; + } + + public Builder collectedPeriod(SpecimenCollectionPeriod collectedPeriod) { + this.collectedPeriod = Optional.ofNullable(collectedPeriod); + return this; + } + + /** + *

            Original receivedTime, never substituted for collection timing.

            + */ + @JsonSetter(value = "received_time", nulls = Nulls.SKIP) + public Builder receivedTime(Optional receivedTime) { + this.receivedTime = receivedTime; + return this; + } + + public Builder receivedTime(String receivedTime) { + this.receivedTime = Optional.ofNullable(receivedTime); + return this; + } + + /** + *

            collection.collectedDateTime or collection.collectedPeriod.start when specimen_date was populated; omitted when no date could safely be selected.

            + */ + @JsonSetter(value = "selected_path", nulls = Nulls.SKIP) + public Builder selectedPath(Optional selectedPath) { + this.selectedPath = selectedPath; + return this; + } + + public Builder selectedPath(String selectedPath) { + this.selectedPath = Optional.ofNullable(selectedPath); + return this; + } + + public SpecimenCollectionTiming build() { + return new SpecimenCollectionTiming( + collectedDateTime, collectedPeriod, receivedTime, selectedPath, additionalProperties); + } + + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("collected_date_time") + || key.equalsIgnoreCase("collected_period") + || key.equalsIgnoreCase("received_time") + || key.equalsIgnoreCase("selected_path")) { + return; + } + this.additionalProperties.put(key, value); + } + + public Builder additionalProperty(String key, Object value) { + this.additionalProperties.put(key, value); + return this; + } + + public Builder additionalProperties(Map additionalProperties) { + this.additionalProperties.putAll(additionalProperties); + return this; + } + } +} diff --git a/src/main/java/com/phenoml/api/resources/fhir2omop/types/SpecimenIdentifier.java b/src/main/java/com/phenoml/api/resources/fhir2omop/types/SpecimenIdentifier.java new file mode 100644 index 00000000..edb010d0 --- /dev/null +++ b/src/main/java/com/phenoml/api/resources/fhir2omop/types/SpecimenIdentifier.java @@ -0,0 +1,155 @@ +/** + * This file was auto-generated by Fern from our API Definition. + */ +package com.phenoml.api.resources.fhir2omop.types; + +import com.fasterxml.jackson.annotation.JsonAnyGetter; +import com.fasterxml.jackson.annotation.JsonAnySetter; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonSetter; +import com.fasterxml.jackson.annotation.Nulls; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.phenoml.api.core.ObjectMappers; +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import org.jetbrains.annotations.NotNull; + +@JsonInclude(JsonInclude.Include.NON_ABSENT) +@JsonDeserialize(builder = SpecimenIdentifier.Builder.class) +public final class SpecimenIdentifier { + private final Optional system; + + private final String value; + + private final Map additionalProperties; + + private SpecimenIdentifier(Optional system, String value, Map additionalProperties) { + this.system = system; + this.value = value; + this.additionalProperties = additionalProperties; + } + + @JsonProperty("system") + public Optional getSystem() { + return system; + } + + @JsonProperty("value") + public String getValue() { + return value; + } + + @java.lang.Override + public boolean equals(Object other) { + if (this == other) return true; + return other instanceof SpecimenIdentifier && equalTo((SpecimenIdentifier) other); + } + + @JsonAnyGetter + public Map getAdditionalProperties() { + return this.additionalProperties; + } + + private boolean equalTo(SpecimenIdentifier other) { + return system.equals(other.system) && value.equals(other.value); + } + + @java.lang.Override + public int hashCode() { + return Objects.hash(this.system, this.value); + } + + @java.lang.Override + public String toString() { + return ObjectMappers.stringify(this); + } + + public static ValueStage builder() { + return new Builder(); + } + + public interface ValueStage { + _FinalStage value(@NotNull String value); + + Builder from(SpecimenIdentifier other); + } + + public interface _FinalStage { + SpecimenIdentifier build(); + + _FinalStage additionalProperty(String key, Object value); + + _FinalStage additionalProperties(Map additionalProperties); + + _FinalStage system(Optional system); + + _FinalStage system(String system); + } + + @JsonIgnoreProperties(ignoreUnknown = true) + public static final class Builder implements ValueStage, _FinalStage { + private String value; + + private Optional system = Optional.empty(); + + private Map additionalProperties = new HashMap<>(); + + private Builder() {} + + @java.lang.Override + public Builder from(SpecimenIdentifier other) { + system(other.getSystem()); + value(other.getValue()); + return this; + } + + @java.lang.Override + @JsonSetter("value") + public _FinalStage value(@NotNull String value) { + this.value = Objects.requireNonNull(value, "value must not be null"); + return this; + } + + @java.lang.Override + public _FinalStage system(String system) { + this.system = Optional.ofNullable(system); + return this; + } + + @java.lang.Override + @JsonSetter(value = "system", nulls = Nulls.SKIP) + public _FinalStage system(Optional system) { + this.system = system; + return this; + } + + @java.lang.Override + public SpecimenIdentifier build() { + return new SpecimenIdentifier(system, value, additionalProperties); + } + + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("system") || key.equalsIgnoreCase("value")) { + return; + } + this.additionalProperties.put(key, value); + } + + @java.lang.Override + public Builder additionalProperty(String key, Object value) { + this.additionalProperties.put(key, value); + return this; + } + + @java.lang.Override + public Builder additionalProperties(Map additionalProperties) { + this.additionalProperties.putAll(additionalProperties); + return this; + } + } +} diff --git a/src/main/java/com/phenoml/api/resources/fhir2omop/types/SpecimenRow.java b/src/main/java/com/phenoml/api/resources/fhir2omop/types/SpecimenRow.java new file mode 100644 index 00000000..276b231e --- /dev/null +++ b/src/main/java/com/phenoml/api/resources/fhir2omop/types/SpecimenRow.java @@ -0,0 +1,667 @@ +/** + * This file was auto-generated by Fern from our API Definition. + */ +package com.phenoml.api.resources.fhir2omop.types; + +import com.fasterxml.jackson.annotation.JsonAnyGetter; +import com.fasterxml.jackson.annotation.JsonAnySetter; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonSetter; +import com.fasterxml.jackson.annotation.Nulls; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.phenoml.api.core.ObjectMappers; +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; + +@JsonInclude(JsonInclude.Include.NON_ABSENT) +@JsonDeserialize(builder = SpecimenRow.Builder.class) +public final class SpecimenRow { + private final long specimenId; + + private final long personId; + + private final long specimenConceptId; + + private final long specimenTypeConceptId; + + private final Optional specimenDate; + + private final Optional specimenDatetime; + + private final Optional quantity; + + private final Optional unitConceptId; + + private final Optional anatomicSiteConceptId; + + private final Optional specimenSourceId; + + private final Optional specimenSourceValue; + + private final Optional unitSourceValue; + + private final Optional anatomicSiteSourceValue; + + private final Map additionalProperties; + + private SpecimenRow( + long specimenId, + long personId, + long specimenConceptId, + long specimenTypeConceptId, + Optional specimenDate, + Optional specimenDatetime, + Optional quantity, + Optional unitConceptId, + Optional anatomicSiteConceptId, + Optional specimenSourceId, + Optional specimenSourceValue, + Optional unitSourceValue, + Optional anatomicSiteSourceValue, + Map additionalProperties) { + this.specimenId = specimenId; + this.personId = personId; + this.specimenConceptId = specimenConceptId; + this.specimenTypeConceptId = specimenTypeConceptId; + this.specimenDate = specimenDate; + this.specimenDatetime = specimenDatetime; + this.quantity = quantity; + this.unitConceptId = unitConceptId; + this.anatomicSiteConceptId = anatomicSiteConceptId; + this.specimenSourceId = specimenSourceId; + this.specimenSourceValue = specimenSourceValue; + this.unitSourceValue = unitSourceValue; + this.anatomicSiteSourceValue = anatomicSiteSourceValue; + this.additionalProperties = additionalProperties; + } + + /** + * @return Request-local generated row identity; distinct from specimen_source_id. + */ + @JsonProperty("specimen_id") + public long getSpecimenId() { + return specimenId; + } + + /** + * @return Explicitly resolved top-level Patient in this response. + */ + @JsonProperty("person_id") + public long getPersonId() { + return personId; + } + + /** + * @return Material validated against Specimen; 0 for supplied unmapped material or absent material. Absence has a linked type diagnostic and no mapping/summary count; supplied unmapped material has a vocabulary mapping outcome. + */ + @JsonProperty("specimen_concept_id") + public long getSpecimenConceptId() { + return specimenConceptId; + } + + /** + * @return 32817 (EHR record provenance), independent of material and availability. + */ + @JsonProperty("specimen_type_concept_id") + public long getSpecimenTypeConceptId() { + return specimenTypeConceptId; + } + + /** + * @return Collection date from collectedDateTime or collectedPeriod.start when day precision exists. Omitted for missing, partial, invalid, or conflicting timing, with a linked diagnostic. Required by CDM 5.4 for loading, but not required for partial API output; downstream loaders own completion. + */ + @JsonProperty("specimen_date") + public Optional getSpecimenDate() { + return specimenDate; + } + + /** + * @return Collection local wall-clock time, only when supplied; no fabricated time or offset conversion. + */ + @JsonProperty("specimen_datetime") + public Optional getSpecimenDatetime() { + return specimenDatetime; + } + + /** + * @return Exact nonnegative collection.quantity.value, without comparator or numeric conversion. + */ + @JsonProperty("quantity") + public Optional getQuantity() { + return quantity; + } + + /** + * @return Valid standard Unit concept for coded UCUM; 0 for supplied unmapped units, null for absence. + */ + @JsonProperty("unit_concept_id") + public Optional getUnitConceptId() { + return unitConceptId; + } + + /** + * @return Valid standard Spec Anatomic Site concept for a coded site; 0 for a supplied unmapped or text-only site, null for absence. Text-only sites retain source text without a concept suggestion. + */ + @JsonProperty("anatomic_site_concept_id") + public Optional getAnatomicSiteConceptId() { + return anatomicSiteConceptId; + } + + /** + * @return Resource id, first valued identifier, accession value, then fullUrl. All source identities and selection path are in specimen_source_contexts. Kept verbatim; downstream loaders must handle CDM varchar(50) limits. + */ + @JsonProperty("specimen_source_id") + public Optional getSpecimenSourceId() { + return specimenSourceId; + } + + /** + * @return Selected bare material code or source text. + */ + @JsonProperty("specimen_source_value") + public Optional getSpecimenSourceValue() { + return specimenSourceValue; + } + + /** + * @return Exact Quantity.code when supplied, otherwise Quantity.unit. + */ + @JsonProperty("unit_source_value") + public Optional getUnitSourceValue() { + return unitSourceValue; + } + + /** + * @return Selected bare bodySite code or source text. + */ + @JsonProperty("anatomic_site_source_value") + public Optional getAnatomicSiteSourceValue() { + return anatomicSiteSourceValue; + } + + @java.lang.Override + public boolean equals(Object other) { + if (this == other) return true; + return other instanceof SpecimenRow && equalTo((SpecimenRow) other); + } + + @JsonAnyGetter + public Map getAdditionalProperties() { + return this.additionalProperties; + } + + private boolean equalTo(SpecimenRow other) { + return specimenId == other.specimenId + && personId == other.personId + && specimenConceptId == other.specimenConceptId + && specimenTypeConceptId == other.specimenTypeConceptId + && specimenDate.equals(other.specimenDate) + && specimenDatetime.equals(other.specimenDatetime) + && quantity.equals(other.quantity) + && unitConceptId.equals(other.unitConceptId) + && anatomicSiteConceptId.equals(other.anatomicSiteConceptId) + && specimenSourceId.equals(other.specimenSourceId) + && specimenSourceValue.equals(other.specimenSourceValue) + && unitSourceValue.equals(other.unitSourceValue) + && anatomicSiteSourceValue.equals(other.anatomicSiteSourceValue); + } + + @java.lang.Override + public int hashCode() { + return Objects.hash( + this.specimenId, + this.personId, + this.specimenConceptId, + this.specimenTypeConceptId, + this.specimenDate, + this.specimenDatetime, + this.quantity, + this.unitConceptId, + this.anatomicSiteConceptId, + this.specimenSourceId, + this.specimenSourceValue, + this.unitSourceValue, + this.anatomicSiteSourceValue); + } + + @java.lang.Override + public String toString() { + return ObjectMappers.stringify(this); + } + + public static SpecimenIdStage builder() { + return new Builder(); + } + + public interface SpecimenIdStage { + /** + *

            Request-local generated row identity; distinct from specimen_source_id.

            + */ + PersonIdStage specimenId(long specimenId); + + Builder from(SpecimenRow other); + } + + public interface PersonIdStage { + /** + *

            Explicitly resolved top-level Patient in this response.

            + */ + SpecimenConceptIdStage personId(long personId); + } + + public interface SpecimenConceptIdStage { + /** + *

            Material validated against Specimen; 0 for supplied unmapped material or absent material. Absence has a linked type diagnostic and no mapping/summary count; supplied unmapped material has a vocabulary mapping outcome.

            + */ + SpecimenTypeConceptIdStage specimenConceptId(long specimenConceptId); + } + + public interface SpecimenTypeConceptIdStage { + /** + *

            32817 (EHR record provenance), independent of material and availability.

            + */ + _FinalStage specimenTypeConceptId(long specimenTypeConceptId); + } + + public interface _FinalStage { + SpecimenRow build(); + + _FinalStage additionalProperty(String key, Object value); + + _FinalStage additionalProperties(Map additionalProperties); + + /** + *

            Collection date from collectedDateTime or collectedPeriod.start when day precision exists. Omitted for missing, partial, invalid, or conflicting timing, with a linked diagnostic. Required by CDM 5.4 for loading, but not required for partial API output; downstream loaders own completion.

            + */ + _FinalStage specimenDate(Optional specimenDate); + + _FinalStage specimenDate(String specimenDate); + + /** + *

            Collection local wall-clock time, only when supplied; no fabricated time or offset conversion.

            + */ + _FinalStage specimenDatetime(Optional specimenDatetime); + + _FinalStage specimenDatetime(String specimenDatetime); + + /** + *

            Exact nonnegative collection.quantity.value, without comparator or numeric conversion.

            + */ + _FinalStage quantity(Optional quantity); + + _FinalStage quantity(Double quantity); + + /** + *

            Valid standard Unit concept for coded UCUM; 0 for supplied unmapped units, null for absence.

            + */ + _FinalStage unitConceptId(Optional unitConceptId); + + _FinalStage unitConceptId(Long unitConceptId); + + /** + *

            Valid standard Spec Anatomic Site concept for a coded site; 0 for a supplied unmapped or text-only site, null for absence. Text-only sites retain source text without a concept suggestion.

            + */ + _FinalStage anatomicSiteConceptId(Optional anatomicSiteConceptId); + + _FinalStage anatomicSiteConceptId(Long anatomicSiteConceptId); + + /** + *

            Resource id, first valued identifier, accession value, then fullUrl. All source identities and selection path are in specimen_source_contexts. Kept verbatim; downstream loaders must handle CDM varchar(50) limits.

            + */ + _FinalStage specimenSourceId(Optional specimenSourceId); + + _FinalStage specimenSourceId(String specimenSourceId); + + /** + *

            Selected bare material code or source text.

            + */ + _FinalStage specimenSourceValue(Optional specimenSourceValue); + + _FinalStage specimenSourceValue(String specimenSourceValue); + + /** + *

            Exact Quantity.code when supplied, otherwise Quantity.unit.

            + */ + _FinalStage unitSourceValue(Optional unitSourceValue); + + _FinalStage unitSourceValue(String unitSourceValue); + + /** + *

            Selected bare bodySite code or source text.

            + */ + _FinalStage anatomicSiteSourceValue(Optional anatomicSiteSourceValue); + + _FinalStage anatomicSiteSourceValue(String anatomicSiteSourceValue); + } + + @JsonIgnoreProperties(ignoreUnknown = true) + public static final class Builder + implements SpecimenIdStage, PersonIdStage, SpecimenConceptIdStage, SpecimenTypeConceptIdStage, _FinalStage { + private long specimenId; + + private long personId; + + private long specimenConceptId; + + private long specimenTypeConceptId; + + private Optional anatomicSiteSourceValue = Optional.empty(); + + private Optional unitSourceValue = Optional.empty(); + + private Optional specimenSourceValue = Optional.empty(); + + private Optional specimenSourceId = Optional.empty(); + + private Optional anatomicSiteConceptId = Optional.empty(); + + private Optional unitConceptId = Optional.empty(); + + private Optional quantity = Optional.empty(); + + private Optional specimenDatetime = Optional.empty(); + + private Optional specimenDate = Optional.empty(); + + private Map additionalProperties = new HashMap<>(); + + private Builder() {} + + @java.lang.Override + public Builder from(SpecimenRow other) { + specimenId(other.getSpecimenId()); + personId(other.getPersonId()); + specimenConceptId(other.getSpecimenConceptId()); + specimenTypeConceptId(other.getSpecimenTypeConceptId()); + specimenDate(other.getSpecimenDate()); + specimenDatetime(other.getSpecimenDatetime()); + quantity(other.getQuantity()); + unitConceptId(other.getUnitConceptId()); + anatomicSiteConceptId(other.getAnatomicSiteConceptId()); + specimenSourceId(other.getSpecimenSourceId()); + specimenSourceValue(other.getSpecimenSourceValue()); + unitSourceValue(other.getUnitSourceValue()); + anatomicSiteSourceValue(other.getAnatomicSiteSourceValue()); + return this; + } + + /** + *

            Request-local generated row identity; distinct from specimen_source_id.

            + * @return Reference to {@code this} so that method calls can be chained together. + */ + @java.lang.Override + @JsonSetter("specimen_id") + public PersonIdStage specimenId(long specimenId) { + this.specimenId = specimenId; + return this; + } + + /** + *

            Explicitly resolved top-level Patient in this response.

            + * @return Reference to {@code this} so that method calls can be chained together. + */ + @java.lang.Override + @JsonSetter("person_id") + public SpecimenConceptIdStage personId(long personId) { + this.personId = personId; + return this; + } + + /** + *

            Material validated against Specimen; 0 for supplied unmapped material or absent material. Absence has a linked type diagnostic and no mapping/summary count; supplied unmapped material has a vocabulary mapping outcome.

            + * @return Reference to {@code this} so that method calls can be chained together. + */ + @java.lang.Override + @JsonSetter("specimen_concept_id") + public SpecimenTypeConceptIdStage specimenConceptId(long specimenConceptId) { + this.specimenConceptId = specimenConceptId; + return this; + } + + /** + *

            32817 (EHR record provenance), independent of material and availability.

            + * @return Reference to {@code this} so that method calls can be chained together. + */ + @java.lang.Override + @JsonSetter("specimen_type_concept_id") + public _FinalStage specimenTypeConceptId(long specimenTypeConceptId) { + this.specimenTypeConceptId = specimenTypeConceptId; + return this; + } + + /** + *

            Selected bare bodySite code or source text.

            + * @return Reference to {@code this} so that method calls can be chained together. + */ + @java.lang.Override + public _FinalStage anatomicSiteSourceValue(String anatomicSiteSourceValue) { + this.anatomicSiteSourceValue = Optional.ofNullable(anatomicSiteSourceValue); + return this; + } + + /** + *

            Selected bare bodySite code or source text.

            + */ + @java.lang.Override + @JsonSetter(value = "anatomic_site_source_value", nulls = Nulls.SKIP) + public _FinalStage anatomicSiteSourceValue(Optional anatomicSiteSourceValue) { + this.anatomicSiteSourceValue = anatomicSiteSourceValue; + return this; + } + + /** + *

            Exact Quantity.code when supplied, otherwise Quantity.unit.

            + * @return Reference to {@code this} so that method calls can be chained together. + */ + @java.lang.Override + public _FinalStage unitSourceValue(String unitSourceValue) { + this.unitSourceValue = Optional.ofNullable(unitSourceValue); + return this; + } + + /** + *

            Exact Quantity.code when supplied, otherwise Quantity.unit.

            + */ + @java.lang.Override + @JsonSetter(value = "unit_source_value", nulls = Nulls.SKIP) + public _FinalStage unitSourceValue(Optional unitSourceValue) { + this.unitSourceValue = unitSourceValue; + return this; + } + + /** + *

            Selected bare material code or source text.

            + * @return Reference to {@code this} so that method calls can be chained together. + */ + @java.lang.Override + public _FinalStage specimenSourceValue(String specimenSourceValue) { + this.specimenSourceValue = Optional.ofNullable(specimenSourceValue); + return this; + } + + /** + *

            Selected bare material code or source text.

            + */ + @java.lang.Override + @JsonSetter(value = "specimen_source_value", nulls = Nulls.SKIP) + public _FinalStage specimenSourceValue(Optional specimenSourceValue) { + this.specimenSourceValue = specimenSourceValue; + return this; + } + + /** + *

            Resource id, first valued identifier, accession value, then fullUrl. All source identities and selection path are in specimen_source_contexts. Kept verbatim; downstream loaders must handle CDM varchar(50) limits.

            + * @return Reference to {@code this} so that method calls can be chained together. + */ + @java.lang.Override + public _FinalStage specimenSourceId(String specimenSourceId) { + this.specimenSourceId = Optional.ofNullable(specimenSourceId); + return this; + } + + /** + *

            Resource id, first valued identifier, accession value, then fullUrl. All source identities and selection path are in specimen_source_contexts. Kept verbatim; downstream loaders must handle CDM varchar(50) limits.

            + */ + @java.lang.Override + @JsonSetter(value = "specimen_source_id", nulls = Nulls.SKIP) + public _FinalStage specimenSourceId(Optional specimenSourceId) { + this.specimenSourceId = specimenSourceId; + return this; + } + + /** + *

            Valid standard Spec Anatomic Site concept for a coded site; 0 for a supplied unmapped or text-only site, null for absence. Text-only sites retain source text without a concept suggestion.

            + * @return Reference to {@code this} so that method calls can be chained together. + */ + @java.lang.Override + public _FinalStage anatomicSiteConceptId(Long anatomicSiteConceptId) { + this.anatomicSiteConceptId = Optional.ofNullable(anatomicSiteConceptId); + return this; + } + + /** + *

            Valid standard Spec Anatomic Site concept for a coded site; 0 for a supplied unmapped or text-only site, null for absence. Text-only sites retain source text without a concept suggestion.

            + */ + @java.lang.Override + @JsonSetter(value = "anatomic_site_concept_id", nulls = Nulls.SKIP) + public _FinalStage anatomicSiteConceptId(Optional anatomicSiteConceptId) { + this.anatomicSiteConceptId = anatomicSiteConceptId; + return this; + } + + /** + *

            Valid standard Unit concept for coded UCUM; 0 for supplied unmapped units, null for absence.

            + * @return Reference to {@code this} so that method calls can be chained together. + */ + @java.lang.Override + public _FinalStage unitConceptId(Long unitConceptId) { + this.unitConceptId = Optional.ofNullable(unitConceptId); + return this; + } + + /** + *

            Valid standard Unit concept for coded UCUM; 0 for supplied unmapped units, null for absence.

            + */ + @java.lang.Override + @JsonSetter(value = "unit_concept_id", nulls = Nulls.SKIP) + public _FinalStage unitConceptId(Optional unitConceptId) { + this.unitConceptId = unitConceptId; + return this; + } + + /** + *

            Exact nonnegative collection.quantity.value, without comparator or numeric conversion.

            + * @return Reference to {@code this} so that method calls can be chained together. + */ + @java.lang.Override + public _FinalStage quantity(Double quantity) { + this.quantity = Optional.ofNullable(quantity); + return this; + } + + /** + *

            Exact nonnegative collection.quantity.value, without comparator or numeric conversion.

            + */ + @java.lang.Override + @JsonSetter(value = "quantity", nulls = Nulls.SKIP) + public _FinalStage quantity(Optional quantity) { + this.quantity = quantity; + return this; + } + + /** + *

            Collection local wall-clock time, only when supplied; no fabricated time or offset conversion.

            + * @return Reference to {@code this} so that method calls can be chained together. + */ + @java.lang.Override + public _FinalStage specimenDatetime(String specimenDatetime) { + this.specimenDatetime = Optional.ofNullable(specimenDatetime); + return this; + } + + /** + *

            Collection local wall-clock time, only when supplied; no fabricated time or offset conversion.

            + */ + @java.lang.Override + @JsonSetter(value = "specimen_datetime", nulls = Nulls.SKIP) + public _FinalStage specimenDatetime(Optional specimenDatetime) { + this.specimenDatetime = specimenDatetime; + return this; + } + + /** + *

            Collection date from collectedDateTime or collectedPeriod.start when day precision exists. Omitted for missing, partial, invalid, or conflicting timing, with a linked diagnostic. Required by CDM 5.4 for loading, but not required for partial API output; downstream loaders own completion.

            + * @return Reference to {@code this} so that method calls can be chained together. + */ + @java.lang.Override + public _FinalStage specimenDate(String specimenDate) { + this.specimenDate = Optional.ofNullable(specimenDate); + return this; + } + + /** + *

            Collection date from collectedDateTime or collectedPeriod.start when day precision exists. Omitted for missing, partial, invalid, or conflicting timing, with a linked diagnostic. Required by CDM 5.4 for loading, but not required for partial API output; downstream loaders own completion.

            + */ + @java.lang.Override + @JsonSetter(value = "specimen_date", nulls = Nulls.SKIP) + public _FinalStage specimenDate(Optional specimenDate) { + this.specimenDate = specimenDate; + return this; + } + + @java.lang.Override + public SpecimenRow build() { + return new SpecimenRow( + specimenId, + personId, + specimenConceptId, + specimenTypeConceptId, + specimenDate, + specimenDatetime, + quantity, + unitConceptId, + anatomicSiteConceptId, + specimenSourceId, + specimenSourceValue, + unitSourceValue, + anatomicSiteSourceValue, + additionalProperties); + } + + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("specimen_id") + || key.equalsIgnoreCase("person_id") + || key.equalsIgnoreCase("specimen_concept_id") + || key.equalsIgnoreCase("specimen_type_concept_id") + || key.equalsIgnoreCase("specimen_date") + || key.equalsIgnoreCase("specimen_datetime") + || key.equalsIgnoreCase("quantity") + || key.equalsIgnoreCase("unit_concept_id") + || key.equalsIgnoreCase("anatomic_site_concept_id") + || key.equalsIgnoreCase("specimen_source_id") + || key.equalsIgnoreCase("specimen_source_value") + || key.equalsIgnoreCase("unit_source_value") + || key.equalsIgnoreCase("anatomic_site_source_value")) { + return; + } + this.additionalProperties.put(key, value); + } + + @java.lang.Override + public Builder additionalProperty(String key, Object value) { + this.additionalProperties.put(key, value); + return this; + } + + @java.lang.Override + public Builder additionalProperties(Map additionalProperties) { + this.additionalProperties.putAll(additionalProperties); + return this; + } + } +} diff --git a/src/main/java/com/phenoml/api/resources/fhir2omop/types/SpecimenSourceContext.java b/src/main/java/com/phenoml/api/resources/fhir2omop/types/SpecimenSourceContext.java new file mode 100644 index 00000000..dd6ed3ff --- /dev/null +++ b/src/main/java/com/phenoml/api/resources/fhir2omop/types/SpecimenSourceContext.java @@ -0,0 +1,389 @@ +/** + * This file was auto-generated by Fern from our API Definition. + */ +package com.phenoml.api.resources.fhir2omop.types; + +import com.fasterxml.jackson.annotation.JsonAnyGetter; +import com.fasterxml.jackson.annotation.JsonAnySetter; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonSetter; +import com.fasterxml.jackson.annotation.Nulls; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.phenoml.api.core.ObjectMappers; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; + +@JsonInclude(JsonInclude.Include.NON_ABSENT) +@JsonDeserialize(builder = SpecimenSourceContext.Builder.class) +public final class SpecimenSourceContext { + private final long specimenId; + + private final Optional resourceId; + + private final Optional fullUrl; + + private final Optional status; + + private final Optional> identifiers; + + private final Optional accessionIdentifier; + + private final Optional sourceIdPath; + + private final Optional collectionTiming; + + private final Map additionalProperties; + + private SpecimenSourceContext( + long specimenId, + Optional resourceId, + Optional fullUrl, + Optional status, + Optional> identifiers, + Optional accessionIdentifier, + Optional sourceIdPath, + Optional collectionTiming, + Map additionalProperties) { + this.specimenId = specimenId; + this.resourceId = resourceId; + this.fullUrl = fullUrl; + this.status = status; + this.identifiers = identifiers; + this.accessionIdentifier = accessionIdentifier; + this.sourceIdPath = sourceIdPath; + this.collectionTiming = collectionTiming; + this.additionalProperties = additionalProperties; + } + + @JsonProperty("specimen_id") + public long getSpecimenId() { + return specimenId; + } + + @JsonProperty("resource_id") + public Optional getResourceId() { + return resourceId; + } + + @JsonProperty("full_url") + public Optional getFullUrl() { + return fullUrl; + } + + /** + * @return Original FHIR Specimen.status for this emitted row, describing sample availability or suitability. Omitted when no status was supplied; no availability is inferred. This is source metadata, not an OMOP disease-status concept. + */ + @JsonProperty("status") + public Optional getStatus() { + return status; + } + + @JsonProperty("identifiers") + public Optional> getIdentifiers() { + return identifiers; + } + + @JsonProperty("accession_identifier") + public Optional getAccessionIdentifier() { + return accessionIdentifier; + } + + /** + * @return Selected source path (id, identifier[index].value, accessionIdentifier.value, or fullUrl); absent if no source identity was supplied. + */ + @JsonProperty("source_id_path") + public Optional getSourceIdPath() { + return sourceIdPath; + } + + @JsonProperty("collection_timing") + public Optional getCollectionTiming() { + return collectionTiming; + } + + @java.lang.Override + public boolean equals(Object other) { + if (this == other) return true; + return other instanceof SpecimenSourceContext && equalTo((SpecimenSourceContext) other); + } + + @JsonAnyGetter + public Map getAdditionalProperties() { + return this.additionalProperties; + } + + private boolean equalTo(SpecimenSourceContext other) { + return specimenId == other.specimenId + && resourceId.equals(other.resourceId) + && fullUrl.equals(other.fullUrl) + && status.equals(other.status) + && identifiers.equals(other.identifiers) + && accessionIdentifier.equals(other.accessionIdentifier) + && sourceIdPath.equals(other.sourceIdPath) + && collectionTiming.equals(other.collectionTiming); + } + + @java.lang.Override + public int hashCode() { + return Objects.hash( + this.specimenId, + this.resourceId, + this.fullUrl, + this.status, + this.identifiers, + this.accessionIdentifier, + this.sourceIdPath, + this.collectionTiming); + } + + @java.lang.Override + public String toString() { + return ObjectMappers.stringify(this); + } + + public static SpecimenIdStage builder() { + return new Builder(); + } + + public interface SpecimenIdStage { + _FinalStage specimenId(long specimenId); + + Builder from(SpecimenSourceContext other); + } + + public interface _FinalStage { + SpecimenSourceContext build(); + + _FinalStage additionalProperty(String key, Object value); + + _FinalStage additionalProperties(Map additionalProperties); + + _FinalStage resourceId(Optional resourceId); + + _FinalStage resourceId(String resourceId); + + _FinalStage fullUrl(Optional fullUrl); + + _FinalStage fullUrl(String fullUrl); + + /** + *

            Original FHIR Specimen.status for this emitted row, describing sample availability or suitability. Omitted when no status was supplied; no availability is inferred. This is source metadata, not an OMOP disease-status concept.

            + */ + _FinalStage status(Optional status); + + _FinalStage status(SpecimenSourceContextStatus status); + + _FinalStage identifiers(Optional> identifiers); + + _FinalStage identifiers(List identifiers); + + _FinalStage accessionIdentifier(Optional accessionIdentifier); + + _FinalStage accessionIdentifier(SpecimenIdentifier accessionIdentifier); + + /** + *

            Selected source path (id, identifier[index].value, accessionIdentifier.value, or fullUrl); absent if no source identity was supplied.

            + */ + _FinalStage sourceIdPath(Optional sourceIdPath); + + _FinalStage sourceIdPath(String sourceIdPath); + + _FinalStage collectionTiming(Optional collectionTiming); + + _FinalStage collectionTiming(SpecimenCollectionTiming collectionTiming); + } + + @JsonIgnoreProperties(ignoreUnknown = true) + public static final class Builder implements SpecimenIdStage, _FinalStage { + private long specimenId; + + private Optional collectionTiming = Optional.empty(); + + private Optional sourceIdPath = Optional.empty(); + + private Optional accessionIdentifier = Optional.empty(); + + private Optional> identifiers = Optional.empty(); + + private Optional status = Optional.empty(); + + private Optional fullUrl = Optional.empty(); + + private Optional resourceId = Optional.empty(); + + private Map additionalProperties = new HashMap<>(); + + private Builder() {} + + @java.lang.Override + public Builder from(SpecimenSourceContext other) { + specimenId(other.getSpecimenId()); + resourceId(other.getResourceId()); + fullUrl(other.getFullUrl()); + status(other.getStatus()); + identifiers(other.getIdentifiers()); + accessionIdentifier(other.getAccessionIdentifier()); + sourceIdPath(other.getSourceIdPath()); + collectionTiming(other.getCollectionTiming()); + return this; + } + + @java.lang.Override + @JsonSetter("specimen_id") + public _FinalStage specimenId(long specimenId) { + this.specimenId = specimenId; + return this; + } + + @java.lang.Override + public _FinalStage collectionTiming(SpecimenCollectionTiming collectionTiming) { + this.collectionTiming = Optional.ofNullable(collectionTiming); + return this; + } + + @java.lang.Override + @JsonSetter(value = "collection_timing", nulls = Nulls.SKIP) + public _FinalStage collectionTiming(Optional collectionTiming) { + this.collectionTiming = collectionTiming; + return this; + } + + /** + *

            Selected source path (id, identifier[index].value, accessionIdentifier.value, or fullUrl); absent if no source identity was supplied.

            + * @return Reference to {@code this} so that method calls can be chained together. + */ + @java.lang.Override + public _FinalStage sourceIdPath(String sourceIdPath) { + this.sourceIdPath = Optional.ofNullable(sourceIdPath); + return this; + } + + /** + *

            Selected source path (id, identifier[index].value, accessionIdentifier.value, or fullUrl); absent if no source identity was supplied.

            + */ + @java.lang.Override + @JsonSetter(value = "source_id_path", nulls = Nulls.SKIP) + public _FinalStage sourceIdPath(Optional sourceIdPath) { + this.sourceIdPath = sourceIdPath; + return this; + } + + @java.lang.Override + public _FinalStage accessionIdentifier(SpecimenIdentifier accessionIdentifier) { + this.accessionIdentifier = Optional.ofNullable(accessionIdentifier); + return this; + } + + @java.lang.Override + @JsonSetter(value = "accession_identifier", nulls = Nulls.SKIP) + public _FinalStage accessionIdentifier(Optional accessionIdentifier) { + this.accessionIdentifier = accessionIdentifier; + return this; + } + + @java.lang.Override + public _FinalStage identifiers(List identifiers) { + this.identifiers = Optional.ofNullable(identifiers); + return this; + } + + @java.lang.Override + @JsonSetter(value = "identifiers", nulls = Nulls.SKIP) + public _FinalStage identifiers(Optional> identifiers) { + this.identifiers = identifiers; + return this; + } + + /** + *

            Original FHIR Specimen.status for this emitted row, describing sample availability or suitability. Omitted when no status was supplied; no availability is inferred. This is source metadata, not an OMOP disease-status concept.

            + * @return Reference to {@code this} so that method calls can be chained together. + */ + @java.lang.Override + public _FinalStage status(SpecimenSourceContextStatus status) { + this.status = Optional.ofNullable(status); + return this; + } + + /** + *

            Original FHIR Specimen.status for this emitted row, describing sample availability or suitability. Omitted when no status was supplied; no availability is inferred. This is source metadata, not an OMOP disease-status concept.

            + */ + @java.lang.Override + @JsonSetter(value = "status", nulls = Nulls.SKIP) + public _FinalStage status(Optional status) { + this.status = status; + return this; + } + + @java.lang.Override + public _FinalStage fullUrl(String fullUrl) { + this.fullUrl = Optional.ofNullable(fullUrl); + return this; + } + + @java.lang.Override + @JsonSetter(value = "full_url", nulls = Nulls.SKIP) + public _FinalStage fullUrl(Optional fullUrl) { + this.fullUrl = fullUrl; + return this; + } + + @java.lang.Override + public _FinalStage resourceId(String resourceId) { + this.resourceId = Optional.ofNullable(resourceId); + return this; + } + + @java.lang.Override + @JsonSetter(value = "resource_id", nulls = Nulls.SKIP) + public _FinalStage resourceId(Optional resourceId) { + this.resourceId = resourceId; + return this; + } + + @java.lang.Override + public SpecimenSourceContext build() { + return new SpecimenSourceContext( + specimenId, + resourceId, + fullUrl, + status, + identifiers, + accessionIdentifier, + sourceIdPath, + collectionTiming, + additionalProperties); + } + + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("specimen_id") + || key.equalsIgnoreCase("resource_id") + || key.equalsIgnoreCase("full_url") + || key.equalsIgnoreCase("status") + || key.equalsIgnoreCase("identifiers") + || key.equalsIgnoreCase("accession_identifier") + || key.equalsIgnoreCase("source_id_path") + || key.equalsIgnoreCase("collection_timing")) { + return; + } + this.additionalProperties.put(key, value); + } + + @java.lang.Override + public Builder additionalProperty(String key, Object value) { + this.additionalProperties.put(key, value); + return this; + } + + @java.lang.Override + public Builder additionalProperties(Map additionalProperties) { + this.additionalProperties.putAll(additionalProperties); + return this; + } + } +} diff --git a/src/main/java/com/phenoml/api/resources/fhir2omop/types/SpecimenSourceContextStatus.java b/src/main/java/com/phenoml/api/resources/fhir2omop/types/SpecimenSourceContextStatus.java new file mode 100644 index 00000000..8ffba70a --- /dev/null +++ b/src/main/java/com/phenoml/api/resources/fhir2omop/types/SpecimenSourceContextStatus.java @@ -0,0 +1,97 @@ +/** + * This file was auto-generated by Fern from our API Definition. + */ +package com.phenoml.api.resources.fhir2omop.types; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; + +public final class SpecimenSourceContextStatus { + public static final SpecimenSourceContextStatus UNSATISFACTORY = + new SpecimenSourceContextStatus(Value.UNSATISFACTORY, "unsatisfactory"); + + public static final SpecimenSourceContextStatus AVAILABLE = + new SpecimenSourceContextStatus(Value.AVAILABLE, "available"); + + public static final SpecimenSourceContextStatus UNAVAILABLE = + new SpecimenSourceContextStatus(Value.UNAVAILABLE, "unavailable"); + + private final Value value; + + private final String string; + + SpecimenSourceContextStatus(Value value, String string) { + this.value = value; + this.string = string; + } + + public Value getEnumValue() { + return value; + } + + @java.lang.Override + @JsonValue + public String toString() { + return this.string; + } + + @java.lang.Override + public boolean equals(Object other) { + return (this == other) + || (other instanceof SpecimenSourceContextStatus + && this.string.equals(((SpecimenSourceContextStatus) other).string)); + } + + @java.lang.Override + public int hashCode() { + return this.string.hashCode(); + } + + public T visit(Visitor visitor) { + switch (value) { + case UNSATISFACTORY: + return visitor.visitUnsatisfactory(); + case AVAILABLE: + return visitor.visitAvailable(); + case UNAVAILABLE: + return visitor.visitUnavailable(); + case UNKNOWN: + default: + return visitor.visitUnknown(string); + } + } + + @JsonCreator(mode = JsonCreator.Mode.DELEGATING) + public static SpecimenSourceContextStatus valueOf(String value) { + switch (value) { + case "unsatisfactory": + return UNSATISFACTORY; + case "available": + return AVAILABLE; + case "unavailable": + return UNAVAILABLE; + default: + return new SpecimenSourceContextStatus(Value.UNKNOWN, value); + } + } + + public enum Value { + AVAILABLE, + + UNAVAILABLE, + + UNSATISFACTORY, + + UNKNOWN + } + + public interface Visitor { + T visitAvailable(); + + T visitUnavailable(); + + T visitUnsatisfactory(); + + T visitUnknown(String unknownType); + } +} diff --git a/src/main/java/com/phenoml/api/resources/fhir2omop/types/Summary.java b/src/main/java/com/phenoml/api/resources/fhir2omop/types/Summary.java index 73f1875b..f35cdd09 100644 --- a/src/main/java/com/phenoml/api/resources/fhir2omop/types/Summary.java +++ b/src/main/java/com/phenoml/api/resources/fhir2omop/types/Summary.java @@ -26,6 +26,8 @@ public final class Summary { private final Optional codesUnmapped; + private final Optional rowsForReview; + private final Optional offVocabRate; private final Map additionalProperties; @@ -34,11 +36,13 @@ private Summary( Optional codesAlreadyStandard, Optional codesNormalized, Optional codesUnmapped, + Optional rowsForReview, Optional offVocabRate, Map additionalProperties) { this.codesAlreadyStandard = codesAlreadyStandard; this.codesNormalized = codesNormalized; this.codesUnmapped = codesUnmapped; + this.rowsForReview = rowsForReview; this.offVocabRate = offVocabRate; this.additionalProperties = additionalProperties; } @@ -60,13 +64,21 @@ public Optional getCodesNormalized() { } /** - * @return Resolution outcomes with no standard concept found (UNMAPPED). + * @return Resolution outcomes with no standard concept valid for the destination selected (UNMAPPED). */ @JsonProperty("codes_unmapped") public Optional getCodesUnmapped() { return codesUnmapped; } + /** + * @return Length of the rows_for_review index; each row counts once even with multiple retention reasons. codes_* and off_vocab_rate include all shaped rows; dropped resources do not count. + */ + @JsonProperty("rows_for_review") + public Optional getRowsForReview() { + return rowsForReview; + } + /** * @return Share of resolution outcomes not already standard ((normalized + unmapped) / total). */ @@ -90,12 +102,18 @@ private boolean equalTo(Summary other) { return codesAlreadyStandard.equals(other.codesAlreadyStandard) && codesNormalized.equals(other.codesNormalized) && codesUnmapped.equals(other.codesUnmapped) + && rowsForReview.equals(other.rowsForReview) && offVocabRate.equals(other.offVocabRate); } @java.lang.Override public int hashCode() { - return Objects.hash(this.codesAlreadyStandard, this.codesNormalized, this.codesUnmapped, this.offVocabRate); + return Objects.hash( + this.codesAlreadyStandard, + this.codesNormalized, + this.codesUnmapped, + this.rowsForReview, + this.offVocabRate); } @java.lang.Override @@ -115,9 +133,10 @@ public static final class Builder { private Optional codesUnmapped = Optional.empty(); + private Optional rowsForReview = Optional.empty(); + private Optional offVocabRate = Optional.empty(); - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -126,6 +145,7 @@ public Builder from(Summary other) { codesAlreadyStandard(other.getCodesAlreadyStandard()); codesNormalized(other.getCodesNormalized()); codesUnmapped(other.getCodesUnmapped()); + rowsForReview(other.getRowsForReview()); offVocabRate(other.getOffVocabRate()); return this; } @@ -159,7 +179,7 @@ public Builder codesNormalized(Integer codesNormalized) { } /** - *

            Resolution outcomes with no standard concept found (UNMAPPED).

            + *

            Resolution outcomes with no standard concept valid for the destination selected (UNMAPPED).

            */ @JsonSetter(value = "codes_unmapped", nulls = Nulls.SKIP) public Builder codesUnmapped(Optional codesUnmapped) { @@ -172,6 +192,20 @@ public Builder codesUnmapped(Integer codesUnmapped) { return this; } + /** + *

            Length of the rows_for_review index; each row counts once even with multiple retention reasons. codes_* and off_vocab_rate include all shaped rows; dropped resources do not count.

            + */ + @JsonSetter(value = "rows_for_review", nulls = Nulls.SKIP) + public Builder rowsForReview(Optional rowsForReview) { + this.rowsForReview = rowsForReview; + return this; + } + + public Builder rowsForReview(Integer rowsForReview) { + this.rowsForReview = Optional.ofNullable(rowsForReview); + return this; + } + /** *

            Share of resolution outcomes not already standard ((normalized + unmapped) / total).

            */ @@ -188,7 +222,24 @@ public Builder offVocabRate(Double offVocabRate) { public Summary build() { return new Summary( - codesAlreadyStandard, codesNormalized, codesUnmapped, offVocabRate, additionalProperties); + codesAlreadyStandard, + codesNormalized, + codesUnmapped, + rowsForReview, + offVocabRate, + additionalProperties); + } + + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("codes_already_standard") + || key.equalsIgnoreCase("codes_normalized") + || key.equalsIgnoreCase("codes_unmapped") + || key.equalsIgnoreCase("rows_for_review") + || key.equalsIgnoreCase("off_vocab_rate")) { + return; + } + this.additionalProperties.put(key, value); } public Builder additionalProperty(String key, Object value) { diff --git a/src/main/java/com/phenoml/api/resources/fhir2omop/types/VisitOccurrenceRow.java b/src/main/java/com/phenoml/api/resources/fhir2omop/types/VisitOccurrenceRow.java index 937be4a7..92a4b913 100644 --- a/src/main/java/com/phenoml/api/resources/fhir2omop/types/VisitOccurrenceRow.java +++ b/src/main/java/com/phenoml/api/resources/fhir2omop/types/VisitOccurrenceRow.java @@ -42,6 +42,8 @@ public final class VisitOccurrenceRow { private final Optional visitSourceValue; + private final Optional visitSourceConceptId; + private final Map additionalProperties; private VisitOccurrenceRow( @@ -56,6 +58,7 @@ private VisitOccurrenceRow( Optional providerId, Optional careSiteId, Optional visitSourceValue, + Optional visitSourceConceptId, Map additionalProperties) { this.visitOccurrenceId = visitOccurrenceId; this.personId = personId; @@ -68,6 +71,7 @@ private VisitOccurrenceRow( this.providerId = providerId; this.careSiteId = careSiteId; this.visitSourceValue = visitSourceValue; + this.visitSourceConceptId = visitSourceConceptId; this.additionalProperties = additionalProperties; } @@ -132,6 +136,14 @@ public Optional getVisitSourceValue() { return visitSourceValue; } + /** + * @return OMOP source concept for the selected Encounter.class or Encounter.type coding when available; 0 when the source coding has no OMOP source concept, including the HL7 encounter class codes listed above. + */ + @JsonProperty("visit_source_concept_id") + public Optional getVisitSourceConceptId() { + return visitSourceConceptId; + } + @java.lang.Override public boolean equals(Object other) { if (this == other) return true; @@ -154,7 +166,8 @@ private boolean equalTo(VisitOccurrenceRow other) { && visitTypeConceptId.equals(other.visitTypeConceptId) && providerId.equals(other.providerId) && careSiteId.equals(other.careSiteId) - && visitSourceValue.equals(other.visitSourceValue); + && visitSourceValue.equals(other.visitSourceValue) + && visitSourceConceptId.equals(other.visitSourceConceptId); } @java.lang.Override @@ -170,7 +183,8 @@ public int hashCode() { this.visitTypeConceptId, this.providerId, this.careSiteId, - this.visitSourceValue); + this.visitSourceValue, + this.visitSourceConceptId); } @java.lang.Override @@ -206,7 +220,8 @@ public static final class Builder { private Optional visitSourceValue = Optional.empty(); - @JsonAnySetter + private Optional visitSourceConceptId = Optional.empty(); + private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -223,6 +238,7 @@ public Builder from(VisitOccurrenceRow other) { providerId(other.getProviderId()); careSiteId(other.getCareSiteId()); visitSourceValue(other.getVisitSourceValue()); + visitSourceConceptId(other.getVisitSourceConceptId()); return this; } @@ -353,6 +369,20 @@ public Builder visitSourceValue(String visitSourceValue) { return this; } + /** + *

            OMOP source concept for the selected Encounter.class or Encounter.type coding when available; 0 when the source coding has no OMOP source concept, including the HL7 encounter class codes listed above.

            + */ + @JsonSetter(value = "visit_source_concept_id", nulls = Nulls.SKIP) + public Builder visitSourceConceptId(Optional visitSourceConceptId) { + this.visitSourceConceptId = visitSourceConceptId; + return this; + } + + public Builder visitSourceConceptId(Long visitSourceConceptId) { + this.visitSourceConceptId = Optional.ofNullable(visitSourceConceptId); + return this; + } + public VisitOccurrenceRow build() { return new VisitOccurrenceRow( visitOccurrenceId, @@ -366,9 +396,29 @@ public VisitOccurrenceRow build() { providerId, careSiteId, visitSourceValue, + visitSourceConceptId, additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("visit_occurrence_id") + || key.equalsIgnoreCase("person_id") + || key.equalsIgnoreCase("visit_concept_id") + || key.equalsIgnoreCase("visit_start_date") + || key.equalsIgnoreCase("visit_start_datetime") + || key.equalsIgnoreCase("visit_end_date") + || key.equalsIgnoreCase("visit_end_datetime") + || key.equalsIgnoreCase("visit_type_concept_id") + || key.equalsIgnoreCase("provider_id") + || key.equalsIgnoreCase("care_site_id") + || key.equalsIgnoreCase("visit_source_value") + || key.equalsIgnoreCase("visit_source_concept_id")) { + return; + } + this.additionalProperties.put(key, value); + } + public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); return this; diff --git a/src/main/java/com/phenoml/api/resources/fhirprovider/AsyncFhirProviderClient.java b/src/main/java/com/phenoml/api/resources/fhirprovider/AsyncFhirProviderClient.java index be5f47ab..60358c59 100644 --- a/src/main/java/com/phenoml/api/resources/fhirprovider/AsyncFhirProviderClient.java +++ b/src/main/java/com/phenoml/api/resources/fhirprovider/AsyncFhirProviderClient.java @@ -4,6 +4,7 @@ package com.phenoml.api.resources.fhirprovider; import com.phenoml.api.core.ClientOptions; +import com.phenoml.api.core.PhenomlClientHttpResponse; import com.phenoml.api.core.RequestOptions; import com.phenoml.api.core.Suppliers; import com.phenoml.api.resources.fhirprovider.authconfig.AsyncAuthConfigClient; @@ -39,7 +40,14 @@ public AsyncRawFhirProviderClient withRawResponse() { *

            Note: The "sandbox" provider type cannot be created via this API - it is managed internally.

            */ public CompletableFuture create(FhirProviderCreateRequest request) { - return this.rawClient.create(request).thenApply(response -> response.body()); + CompletableFuture> rawFuture = this.rawClient.create(request); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** @@ -48,7 +56,15 @@ public CompletableFuture create(FhirProviderCreateRequest */ public CompletableFuture create( FhirProviderCreateRequest request, RequestOptions requestOptions) { - return this.rawClient.create(request, requestOptions).thenApply(response -> response.body()); + CompletableFuture> rawFuture = + this.rawClient.create(request, requestOptions); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** @@ -57,7 +73,14 @@ public CompletableFuture create( * Sandbox providers return FhirProviderSandboxInfo.

            */ public CompletableFuture list() { - return this.rawClient.list().thenApply(response -> response.body()); + CompletableFuture> rawFuture = this.rawClient.list(); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** @@ -66,7 +89,15 @@ public CompletableFuture list() { * Sandbox providers return FhirProviderSandboxInfo.

            */ public CompletableFuture list(RequestOptions requestOptions) { - return this.rawClient.list(requestOptions).thenApply(response -> response.body()); + CompletableFuture> rawFuture = + this.rawClient.list(requestOptions); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** @@ -75,7 +106,15 @@ public CompletableFuture list(RequestOptions requestOp * On shared instances, only sandbox providers can be accessed.

            */ public CompletableFuture get(String fhirProviderId) { - return this.rawClient.get(fhirProviderId).thenApply(response -> response.body()); + CompletableFuture> rawFuture = + this.rawClient.get(fhirProviderId); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** @@ -84,7 +123,15 @@ public CompletableFuture get(String fhirProviderId) { * On shared instances, only sandbox providers can be accessed.

            */ public CompletableFuture get(String fhirProviderId, RequestOptions requestOptions) { - return this.rawClient.get(fhirProviderId, requestOptions).thenApply(response -> response.body()); + CompletableFuture> rawFuture = + this.rawClient.get(fhirProviderId, requestOptions); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** @@ -92,7 +139,14 @@ public CompletableFuture get(String fhirProviderId, Reques *

            Note: Sandbox providers cannot be deleted.

            */ public CompletableFuture delete(String fhirProviderId) { - return this.rawClient.delete(fhirProviderId).thenApply(response -> response.body()); + CompletableFuture> rawFuture = this.rawClient.delete(fhirProviderId); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** @@ -100,7 +154,15 @@ public CompletableFuture delete(String fhirProviderId) { *

            Note: Sandbox providers cannot be deleted.

            */ public CompletableFuture delete(String fhirProviderId, RequestOptions requestOptions) { - return this.rawClient.delete(fhirProviderId, requestOptions).thenApply(response -> response.body()); + CompletableFuture> rawFuture = + this.rawClient.delete(fhirProviderId, requestOptions); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } public AsyncAuthConfigClient authConfig() { diff --git a/src/main/java/com/phenoml/api/resources/fhirprovider/AsyncRawFhirProviderClient.java b/src/main/java/com/phenoml/api/resources/fhirprovider/AsyncRawFhirProviderClient.java index 75640792..9759294c 100644 --- a/src/main/java/com/phenoml/api/resources/fhirprovider/AsyncRawFhirProviderClient.java +++ b/src/main/java/com/phenoml/api/resources/fhirprovider/AsyncRawFhirProviderClient.java @@ -4,9 +4,11 @@ package com.phenoml.api.resources.fhirprovider; import com.fasterxml.jackson.core.JsonProcessingException; +import com.phenoml.api.core.BodyProperties; import com.phenoml.api.core.ClientOptions; import com.phenoml.api.core.MediaTypes; import com.phenoml.api.core.ObjectMappers; +import com.phenoml.api.core.PathSegments; import com.phenoml.api.core.PhenomlClientApiException; import com.phenoml.api.core.PhenomlClientException; import com.phenoml.api.core.PhenomlClientHttpResponse; @@ -67,7 +69,9 @@ public CompletableFuture> create RequestBody body; try { body = RequestBody.create( - ObjectMappers.JSON_MAPPER.writeValueAsBytes(request), MediaTypes.APPLICATION_JSON); + ObjectMappers.JSON_MAPPER.writeValueAsBytes(BodyProperties.merge( + request, requestOptions != null ? requestOptions.getBodyProperties() : null)), + MediaTypes.APPLICATION_JSON); } catch (JsonProcessingException e) { throw new PhenomlClientException("Failed to serialize request", e); } @@ -92,7 +96,8 @@ public CompletableFuture> create .build(); } CompletableFuture> future = new CompletableFuture<>(); - client.newCall(okhttpRequest).enqueue(new Callback() { + RetryInterceptor.AsyncCall okhttpCall = RetryInterceptor.newAsyncCall(client, okhttpRequest); + okhttpCall.enqueue(new Callback() { @Override public void onResponse(@NotNull Call call, @NotNull Response response) throws IOException { try (ResponseBody responseBody = response.body()) { @@ -146,6 +151,11 @@ public void onFailure(@NotNull Call call, @NotNull IOException e) { future.completeExceptionally(new PhenomlClientException("Network error executing HTTP request", e)); } }); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + okhttpCall.cancel(); + } + }); return future; } @@ -192,7 +202,8 @@ public CompletableFuture> li .build(); } CompletableFuture> future = new CompletableFuture<>(); - client.newCall(okhttpRequest).enqueue(new Callback() { + RetryInterceptor.AsyncCall okhttpCall = RetryInterceptor.newAsyncCall(client, okhttpRequest); + okhttpCall.enqueue(new Callback() { @Override public void onResponse(@NotNull Call call, @NotNull Response response) throws IOException { try (ResponseBody responseBody = response.body()) { @@ -236,6 +247,11 @@ public void onFailure(@NotNull Call call, @NotNull IOException e) { future.completeExceptionally(new PhenomlClientException("Network error executing HTTP request", e)); } }); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + okhttpCall.cancel(); + } + }); return future; } @@ -258,7 +274,7 @@ public CompletableFuture> get( HttpUrl.Builder httpUrl = HttpUrl.parse(this.clientOptions.environment().getUrl()) .newBuilder() .addPathSegments("fhir-provider") - .addPathSegment(fhirProviderId); + .addPathSegment(PathSegments.validate(fhirProviderId)); if (requestOptions != null) { requestOptions.getQueryParameters().forEach((_key, _value) -> { httpUrl.addQueryParameter(_key, _value); @@ -284,7 +300,8 @@ public CompletableFuture> get( .build(); } CompletableFuture> future = new CompletableFuture<>(); - client.newCall(okhttpRequest).enqueue(new Callback() { + RetryInterceptor.AsyncCall okhttpCall = RetryInterceptor.newAsyncCall(client, okhttpRequest); + okhttpCall.enqueue(new Callback() { @Override public void onResponse(@NotNull Call call, @NotNull Response response) throws IOException { try (ResponseBody responseBody = response.body()) { @@ -333,6 +350,11 @@ public void onFailure(@NotNull Call call, @NotNull IOException e) { future.completeExceptionally(new PhenomlClientException("Network error executing HTTP request", e)); } }); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + okhttpCall.cancel(); + } + }); return future; } @@ -353,7 +375,7 @@ public CompletableFuture> delete( HttpUrl.Builder httpUrl = HttpUrl.parse(this.clientOptions.environment().getUrl()) .newBuilder() .addPathSegments("fhir-provider") - .addPathSegment(fhirProviderId); + .addPathSegment(PathSegments.validate(fhirProviderId)); if (requestOptions != null) { requestOptions.getQueryParameters().forEach((_key, _value) -> { httpUrl.addQueryParameter(_key, _value); @@ -361,7 +383,10 @@ public CompletableFuture> delete( } Request okhttpRequest = new Request.Builder() .url(httpUrl.build()) - .method("DELETE", null) + .method( + "DELETE", + BodyProperties.toRequestBody( + requestOptions != null ? requestOptions.getBodyProperties() : null, null)) .headers(Headers.of(clientOptions.headers(requestOptions))) .addHeader("Accept", "application/json") .build(); @@ -379,7 +404,8 @@ public CompletableFuture> delete( .build(); } CompletableFuture> future = new CompletableFuture<>(); - client.newCall(okhttpRequest).enqueue(new Callback() { + RetryInterceptor.AsyncCall okhttpCall = RetryInterceptor.newAsyncCall(client, okhttpRequest); + okhttpCall.enqueue(new Callback() { @Override public void onResponse(@NotNull Call call, @NotNull Response response) throws IOException { try (ResponseBody responseBody = response.body()) { @@ -433,6 +459,11 @@ public void onFailure(@NotNull Call call, @NotNull IOException e) { future.completeExceptionally(new PhenomlClientException("Network error executing HTTP request", e)); } }); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + okhttpCall.cancel(); + } + }); return future; } } diff --git a/src/main/java/com/phenoml/api/resources/fhirprovider/RawFhirProviderClient.java b/src/main/java/com/phenoml/api/resources/fhirprovider/RawFhirProviderClient.java index 93a7a594..0c85ab05 100644 --- a/src/main/java/com/phenoml/api/resources/fhirprovider/RawFhirProviderClient.java +++ b/src/main/java/com/phenoml/api/resources/fhirprovider/RawFhirProviderClient.java @@ -4,9 +4,11 @@ package com.phenoml.api.resources.fhirprovider; import com.fasterxml.jackson.core.JsonProcessingException; +import com.phenoml.api.core.BodyProperties; import com.phenoml.api.core.ClientOptions; import com.phenoml.api.core.MediaTypes; import com.phenoml.api.core.ObjectMappers; +import com.phenoml.api.core.PathSegments; import com.phenoml.api.core.PhenomlClientApiException; import com.phenoml.api.core.PhenomlClientException; import com.phenoml.api.core.PhenomlClientHttpResponse; @@ -62,7 +64,9 @@ public PhenomlClientHttpResponse create( RequestBody body; try { body = RequestBody.create( - ObjectMappers.JSON_MAPPER.writeValueAsBytes(request), MediaTypes.APPLICATION_JSON); + ObjectMappers.JSON_MAPPER.writeValueAsBytes(BodyProperties.merge( + request, requestOptions != null ? requestOptions.getBodyProperties() : null)), + MediaTypes.APPLICATION_JSON); } catch (JsonProcessingException e) { throw new PhenomlClientException("Failed to serialize request", e); } @@ -211,7 +215,7 @@ public PhenomlClientHttpResponse get(String fhirProviderId HttpUrl.Builder httpUrl = HttpUrl.parse(this.clientOptions.environment().getUrl()) .newBuilder() .addPathSegments("fhir-provider") - .addPathSegment(fhirProviderId); + .addPathSegment(PathSegments.validate(fhirProviderId)); if (requestOptions != null) { requestOptions.getQueryParameters().forEach((_key, _value) -> { httpUrl.addQueryParameter(_key, _value); @@ -284,7 +288,7 @@ public PhenomlClientHttpResponse delete(String fhirProviderId, R HttpUrl.Builder httpUrl = HttpUrl.parse(this.clientOptions.environment().getUrl()) .newBuilder() .addPathSegments("fhir-provider") - .addPathSegment(fhirProviderId); + .addPathSegment(PathSegments.validate(fhirProviderId)); if (requestOptions != null) { requestOptions.getQueryParameters().forEach((_key, _value) -> { httpUrl.addQueryParameter(_key, _value); @@ -292,7 +296,10 @@ public PhenomlClientHttpResponse delete(String fhirProviderId, R } Request okhttpRequest = new Request.Builder() .url(httpUrl.build()) - .method("DELETE", null) + .method( + "DELETE", + BodyProperties.toRequestBody( + requestOptions != null ? requestOptions.getBodyProperties() : null, null)) .headers(Headers.of(clientOptions.headers(requestOptions))) .addHeader("Accept", "application/json") .build(); diff --git a/src/main/java/com/phenoml/api/resources/fhirprovider/authconfig/AsyncAuthConfigClient.java b/src/main/java/com/phenoml/api/resources/fhirprovider/authconfig/AsyncAuthConfigClient.java index e308af6d..22a67eb9 100644 --- a/src/main/java/com/phenoml/api/resources/fhirprovider/authconfig/AsyncAuthConfigClient.java +++ b/src/main/java/com/phenoml/api/resources/fhirprovider/authconfig/AsyncAuthConfigClient.java @@ -4,6 +4,7 @@ package com.phenoml.api.resources.fhirprovider.authconfig; import com.phenoml.api.core.ClientOptions; +import com.phenoml.api.core.PhenomlClientHttpResponse; import com.phenoml.api.core.RequestOptions; import com.phenoml.api.resources.fhirprovider.authconfig.requests.FhirProviderRemoveAuthConfigRequest; import com.phenoml.api.resources.fhirprovider.authconfig.requests.FhirProviderSetActiveAuthConfigRequest; @@ -36,7 +37,15 @@ public AsyncRawAuthConfigClient withRawResponse() { */ public CompletableFuture add( String fhirProviderId, FhirProviderAddAuthConfigRequest request) { - return this.rawClient.add(fhirProviderId, request).thenApply(response -> response.body()); + CompletableFuture> rawFuture = + this.rawClient.add(fhirProviderId, request); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** @@ -46,7 +55,15 @@ public CompletableFuture add( */ public CompletableFuture add( String fhirProviderId, FhirProviderAddAuthConfigRequest request, RequestOptions requestOptions) { - return this.rawClient.add(fhirProviderId, request, requestOptions).thenApply(response -> response.body()); + CompletableFuture> rawFuture = + this.rawClient.add(fhirProviderId, request, requestOptions); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** @@ -58,7 +75,15 @@ public CompletableFuture add( */ public CompletableFuture setActive( String fhirProviderId, FhirProviderSetActiveAuthConfigRequest request) { - return this.rawClient.setActive(fhirProviderId, request).thenApply(response -> response.body()); + CompletableFuture> rawFuture = + this.rawClient.setActive(fhirProviderId, request); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** @@ -70,7 +95,15 @@ public CompletableFuture setActive( */ public CompletableFuture setActive( String fhirProviderId, FhirProviderSetActiveAuthConfigRequest request, RequestOptions requestOptions) { - return this.rawClient.setActive(fhirProviderId, request, requestOptions).thenApply(response -> response.body()); + CompletableFuture> rawFuture = + this.rawClient.setActive(fhirProviderId, request, requestOptions); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** @@ -80,7 +113,15 @@ public CompletableFuture setActive( */ public CompletableFuture remove( String fhirProviderId, FhirProviderRemoveAuthConfigRequest request) { - return this.rawClient.remove(fhirProviderId, request).thenApply(response -> response.body()); + CompletableFuture> rawFuture = + this.rawClient.remove(fhirProviderId, request); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** @@ -90,6 +131,14 @@ public CompletableFuture remove( */ public CompletableFuture remove( String fhirProviderId, FhirProviderRemoveAuthConfigRequest request, RequestOptions requestOptions) { - return this.rawClient.remove(fhirProviderId, request, requestOptions).thenApply(response -> response.body()); + CompletableFuture> rawFuture = + this.rawClient.remove(fhirProviderId, request, requestOptions); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } } diff --git a/src/main/java/com/phenoml/api/resources/fhirprovider/authconfig/AsyncRawAuthConfigClient.java b/src/main/java/com/phenoml/api/resources/fhirprovider/authconfig/AsyncRawAuthConfigClient.java index 967ae50e..28ddc706 100644 --- a/src/main/java/com/phenoml/api/resources/fhirprovider/authconfig/AsyncRawAuthConfigClient.java +++ b/src/main/java/com/phenoml/api/resources/fhirprovider/authconfig/AsyncRawAuthConfigClient.java @@ -4,9 +4,11 @@ package com.phenoml.api.resources.fhirprovider.authconfig; import com.fasterxml.jackson.core.JsonProcessingException; +import com.phenoml.api.core.BodyProperties; import com.phenoml.api.core.ClientOptions; import com.phenoml.api.core.MediaTypes; import com.phenoml.api.core.ObjectMappers; +import com.phenoml.api.core.PathSegments; import com.phenoml.api.core.PhenomlClientApiException; import com.phenoml.api.core.PhenomlClientException; import com.phenoml.api.core.PhenomlClientHttpResponse; @@ -62,7 +64,7 @@ public CompletableFuture> add( HttpUrl.Builder httpUrl = HttpUrl.parse(this.clientOptions.environment().getUrl()) .newBuilder() .addPathSegments("fhir-provider") - .addPathSegment(fhirProviderId) + .addPathSegment(PathSegments.validate(fhirProviderId)) .addPathSegments("add-auth-config"); if (requestOptions != null) { requestOptions.getQueryParameters().forEach((_key, _value) -> { @@ -72,7 +74,9 @@ public CompletableFuture> add( RequestBody body; try { body = RequestBody.create( - ObjectMappers.JSON_MAPPER.writeValueAsBytes(request), MediaTypes.APPLICATION_JSON); + ObjectMappers.JSON_MAPPER.writeValueAsBytes(BodyProperties.merge( + request, requestOptions != null ? requestOptions.getBodyProperties() : null)), + MediaTypes.APPLICATION_JSON); } catch (JsonProcessingException e) { throw new PhenomlClientException("Failed to serialize request", e); } @@ -97,7 +101,8 @@ public CompletableFuture> add( .build(); } CompletableFuture> future = new CompletableFuture<>(); - client.newCall(okhttpRequest).enqueue(new Callback() { + RetryInterceptor.AsyncCall okhttpCall = RetryInterceptor.newAsyncCall(client, okhttpRequest); + okhttpCall.enqueue(new Callback() { @Override public void onResponse(@NotNull Call call, @NotNull Response response) throws IOException { try (ResponseBody responseBody = response.body()) { @@ -156,6 +161,11 @@ public void onFailure(@NotNull Call call, @NotNull IOException e) { future.completeExceptionally(new PhenomlClientException("Network error executing HTTP request", e)); } }); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + okhttpCall.cancel(); + } + }); return future; } @@ -183,7 +193,7 @@ public CompletableFuture> setAct HttpUrl.Builder httpUrl = HttpUrl.parse(this.clientOptions.environment().getUrl()) .newBuilder() .addPathSegments("fhir-provider") - .addPathSegment(fhirProviderId) + .addPathSegment(PathSegments.validate(fhirProviderId)) .addPathSegments("set-active-auth-config"); if (requestOptions != null) { requestOptions.getQueryParameters().forEach((_key, _value) -> { @@ -193,7 +203,9 @@ public CompletableFuture> setAct RequestBody body; try { body = RequestBody.create( - ObjectMappers.JSON_MAPPER.writeValueAsBytes(request), MediaTypes.APPLICATION_JSON); + ObjectMappers.JSON_MAPPER.writeValueAsBytes(BodyProperties.merge( + request, requestOptions != null ? requestOptions.getBodyProperties() : null)), + MediaTypes.APPLICATION_JSON); } catch (JsonProcessingException e) { throw new PhenomlClientException("Failed to serialize request", e); } @@ -218,7 +230,8 @@ public CompletableFuture> setAct .build(); } CompletableFuture> future = new CompletableFuture<>(); - client.newCall(okhttpRequest).enqueue(new Callback() { + RetryInterceptor.AsyncCall okhttpCall = RetryInterceptor.newAsyncCall(client, okhttpRequest); + okhttpCall.enqueue(new Callback() { @Override public void onResponse(@NotNull Call call, @NotNull Response response) throws IOException { try (ResponseBody responseBody = response.body()) { @@ -277,6 +290,11 @@ public void onFailure(@NotNull Call call, @NotNull IOException e) { future.completeExceptionally(new PhenomlClientException("Network error executing HTTP request", e)); } }); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + okhttpCall.cancel(); + } + }); return future; } @@ -300,7 +318,7 @@ public CompletableFuture> remove( HttpUrl.Builder httpUrl = HttpUrl.parse(this.clientOptions.environment().getUrl()) .newBuilder() .addPathSegments("fhir-provider") - .addPathSegment(fhirProviderId) + .addPathSegment(PathSegments.validate(fhirProviderId)) .addPathSegments("remove-auth-config"); if (requestOptions != null) { requestOptions.getQueryParameters().forEach((_key, _value) -> { @@ -310,7 +328,9 @@ public CompletableFuture> remove( RequestBody body; try { body = RequestBody.create( - ObjectMappers.JSON_MAPPER.writeValueAsBytes(request), MediaTypes.APPLICATION_JSON); + ObjectMappers.JSON_MAPPER.writeValueAsBytes(BodyProperties.merge( + request, requestOptions != null ? requestOptions.getBodyProperties() : null)), + MediaTypes.APPLICATION_JSON); } catch (JsonProcessingException e) { throw new PhenomlClientException("Failed to serialize request", e); } @@ -335,7 +355,8 @@ public CompletableFuture> remove( .build(); } CompletableFuture> future = new CompletableFuture<>(); - client.newCall(okhttpRequest).enqueue(new Callback() { + RetryInterceptor.AsyncCall okhttpCall = RetryInterceptor.newAsyncCall(client, okhttpRequest); + okhttpCall.enqueue(new Callback() { @Override public void onResponse(@NotNull Call call, @NotNull Response response) throws IOException { try (ResponseBody responseBody = response.body()) { @@ -394,6 +415,11 @@ public void onFailure(@NotNull Call call, @NotNull IOException e) { future.completeExceptionally(new PhenomlClientException("Network error executing HTTP request", e)); } }); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + okhttpCall.cancel(); + } + }); return future; } } diff --git a/src/main/java/com/phenoml/api/resources/fhirprovider/authconfig/RawAuthConfigClient.java b/src/main/java/com/phenoml/api/resources/fhirprovider/authconfig/RawAuthConfigClient.java index 00a995d0..ca377cd3 100644 --- a/src/main/java/com/phenoml/api/resources/fhirprovider/authconfig/RawAuthConfigClient.java +++ b/src/main/java/com/phenoml/api/resources/fhirprovider/authconfig/RawAuthConfigClient.java @@ -4,9 +4,11 @@ package com.phenoml.api.resources.fhirprovider.authconfig; import com.fasterxml.jackson.core.JsonProcessingException; +import com.phenoml.api.core.BodyProperties; import com.phenoml.api.core.ClientOptions; import com.phenoml.api.core.MediaTypes; import com.phenoml.api.core.ObjectMappers; +import com.phenoml.api.core.PathSegments; import com.phenoml.api.core.PhenomlClientApiException; import com.phenoml.api.core.PhenomlClientException; import com.phenoml.api.core.PhenomlClientHttpResponse; @@ -58,7 +60,7 @@ public PhenomlClientHttpResponse add( HttpUrl.Builder httpUrl = HttpUrl.parse(this.clientOptions.environment().getUrl()) .newBuilder() .addPathSegments("fhir-provider") - .addPathSegment(fhirProviderId) + .addPathSegment(PathSegments.validate(fhirProviderId)) .addPathSegments("add-auth-config"); if (requestOptions != null) { requestOptions.getQueryParameters().forEach((_key, _value) -> { @@ -68,7 +70,9 @@ public PhenomlClientHttpResponse add( RequestBody body; try { body = RequestBody.create( - ObjectMappers.JSON_MAPPER.writeValueAsBytes(request), MediaTypes.APPLICATION_JSON); + ObjectMappers.JSON_MAPPER.writeValueAsBytes(BodyProperties.merge( + request, requestOptions != null ? requestOptions.getBodyProperties() : null)), + MediaTypes.APPLICATION_JSON); } catch (JsonProcessingException e) { throw new PhenomlClientException("Failed to serialize request", e); } @@ -154,7 +158,7 @@ public PhenomlClientHttpResponse setActive( HttpUrl.Builder httpUrl = HttpUrl.parse(this.clientOptions.environment().getUrl()) .newBuilder() .addPathSegments("fhir-provider") - .addPathSegment(fhirProviderId) + .addPathSegment(PathSegments.validate(fhirProviderId)) .addPathSegments("set-active-auth-config"); if (requestOptions != null) { requestOptions.getQueryParameters().forEach((_key, _value) -> { @@ -164,7 +168,9 @@ public PhenomlClientHttpResponse setActive( RequestBody body; try { body = RequestBody.create( - ObjectMappers.JSON_MAPPER.writeValueAsBytes(request), MediaTypes.APPLICATION_JSON); + ObjectMappers.JSON_MAPPER.writeValueAsBytes(BodyProperties.merge( + request, requestOptions != null ? requestOptions.getBodyProperties() : null)), + MediaTypes.APPLICATION_JSON); } catch (JsonProcessingException e) { throw new PhenomlClientException("Failed to serialize request", e); } @@ -246,7 +252,7 @@ public PhenomlClientHttpResponse remove( HttpUrl.Builder httpUrl = HttpUrl.parse(this.clientOptions.environment().getUrl()) .newBuilder() .addPathSegments("fhir-provider") - .addPathSegment(fhirProviderId) + .addPathSegment(PathSegments.validate(fhirProviderId)) .addPathSegments("remove-auth-config"); if (requestOptions != null) { requestOptions.getQueryParameters().forEach((_key, _value) -> { @@ -256,7 +262,9 @@ public PhenomlClientHttpResponse remove( RequestBody body; try { body = RequestBody.create( - ObjectMappers.JSON_MAPPER.writeValueAsBytes(request), MediaTypes.APPLICATION_JSON); + ObjectMappers.JSON_MAPPER.writeValueAsBytes(BodyProperties.merge( + request, requestOptions != null ? requestOptions.getBodyProperties() : null)), + MediaTypes.APPLICATION_JSON); } catch (JsonProcessingException e) { throw new PhenomlClientException("Failed to serialize request", e); } diff --git a/src/main/java/com/phenoml/api/resources/fhirprovider/authconfig/requests/FhirProviderRemoveAuthConfigRequest.java b/src/main/java/com/phenoml/api/resources/fhirprovider/authconfig/requests/FhirProviderRemoveAuthConfigRequest.java index 4abe2de2..2ec02550 100644 --- a/src/main/java/com/phenoml/api/resources/fhirprovider/authconfig/requests/FhirProviderRemoveAuthConfigRequest.java +++ b/src/main/java/com/phenoml/api/resources/fhirprovider/authconfig/requests/FhirProviderRemoveAuthConfigRequest.java @@ -87,7 +87,6 @@ public interface _FinalStage { public static final class Builder implements AuthConfigIdStage, _FinalStage { private String authConfigId; - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -114,6 +113,14 @@ public FhirProviderRemoveAuthConfigRequest build() { return new FhirProviderRemoveAuthConfigRequest(authConfigId, additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("auth_config_id")) { + return; + } + this.additionalProperties.put(key, value); + } + @java.lang.Override public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); diff --git a/src/main/java/com/phenoml/api/resources/fhirprovider/authconfig/requests/FhirProviderSetActiveAuthConfigRequest.java b/src/main/java/com/phenoml/api/resources/fhirprovider/authconfig/requests/FhirProviderSetActiveAuthConfigRequest.java index 40e3ffc7..f3b96f27 100644 --- a/src/main/java/com/phenoml/api/resources/fhirprovider/authconfig/requests/FhirProviderSetActiveAuthConfigRequest.java +++ b/src/main/java/com/phenoml/api/resources/fhirprovider/authconfig/requests/FhirProviderSetActiveAuthConfigRequest.java @@ -87,7 +87,6 @@ public interface _FinalStage { public static final class Builder implements AuthConfigIdStage, _FinalStage { private String authConfigId; - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -114,6 +113,14 @@ public FhirProviderSetActiveAuthConfigRequest build() { return new FhirProviderSetActiveAuthConfigRequest(authConfigId, additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("auth_config_id")) { + return; + } + this.additionalProperties.put(key, value); + } + @java.lang.Override public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); diff --git a/src/main/java/com/phenoml/api/resources/fhirprovider/authconfig/types/RemoveResponse.java b/src/main/java/com/phenoml/api/resources/fhirprovider/authconfig/types/RemoveResponse.java index 67b86279..bd6d4731 100644 --- a/src/main/java/com/phenoml/api/resources/fhirprovider/authconfig/types/RemoveResponse.java +++ b/src/main/java/com/phenoml/api/resources/fhirprovider/authconfig/types/RemoveResponse.java @@ -92,7 +92,6 @@ public static final class Builder { private Optional data = Optional.empty(); - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -141,6 +140,14 @@ public RemoveResponse build() { return new RemoveResponse(success, message, data, additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("success") || key.equalsIgnoreCase("message") || key.equalsIgnoreCase("data")) { + return; + } + this.additionalProperties.put(key, value); + } + public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); return this; diff --git a/src/main/java/com/phenoml/api/resources/fhirprovider/requests/FhirProviderCreateRequest.java b/src/main/java/com/phenoml/api/resources/fhirprovider/requests/FhirProviderCreateRequest.java index 7f295085..4dbf7ed8 100644 --- a/src/main/java/com/phenoml/api/resources/fhirprovider/requests/FhirProviderCreateRequest.java +++ b/src/main/java/com/phenoml/api/resources/fhirprovider/requests/FhirProviderCreateRequest.java @@ -168,7 +168,6 @@ public static final class Builder implements NameStage, ProviderStage, BaseUrlSt private Optional description = Optional.empty(); - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -244,6 +243,18 @@ public FhirProviderCreateRequest build() { return new FhirProviderCreateRequest(name, description, provider, baseUrl, auth, additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("name") + || key.equalsIgnoreCase("description") + || key.equalsIgnoreCase("provider") + || key.equalsIgnoreCase("base_url") + || key.equalsIgnoreCase("auth")) { + return; + } + this.additionalProperties.put(key, value); + } + @java.lang.Override public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); diff --git a/src/main/java/com/phenoml/api/resources/fhirprovider/types/ClientSecretAuth.java b/src/main/java/com/phenoml/api/resources/fhirprovider/types/ClientSecretAuth.java index d1bfbf50..15fa66c2 100644 --- a/src/main/java/com/phenoml/api/resources/fhirprovider/types/ClientSecretAuth.java +++ b/src/main/java/com/phenoml/api/resources/fhirprovider/types/ClientSecretAuth.java @@ -150,7 +150,6 @@ public static final class Builder implements ClientIdStage, ClientSecretStage, _ private Optional role = Optional.empty(); - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -224,6 +223,17 @@ public ClientSecretAuth build() { return new ClientSecretAuth(clientId, clientSecret, role, scopes, additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("client_id") + || key.equalsIgnoreCase("client_secret") + || key.equalsIgnoreCase("role") + || key.equalsIgnoreCase("scopes")) { + return; + } + this.additionalProperties.put(key, value); + } + @java.lang.Override public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); diff --git a/src/main/java/com/phenoml/api/resources/fhirprovider/types/DeleteResponse.java b/src/main/java/com/phenoml/api/resources/fhirprovider/types/DeleteResponse.java index ae23cdc7..ceb86410 100644 --- a/src/main/java/com/phenoml/api/resources/fhirprovider/types/DeleteResponse.java +++ b/src/main/java/com/phenoml/api/resources/fhirprovider/types/DeleteResponse.java @@ -78,7 +78,6 @@ public static final class Builder { private Optional message = Optional.empty(); - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -115,6 +114,14 @@ public DeleteResponse build() { return new DeleteResponse(success, message, additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("success") || key.equalsIgnoreCase("message")) { + return; + } + this.additionalProperties.put(key, value); + } + public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); return this; diff --git a/src/main/java/com/phenoml/api/resources/fhirprovider/types/FhirProviderAuthConfig.java b/src/main/java/com/phenoml/api/resources/fhirprovider/types/FhirProviderAuthConfig.java index 1b400ba1..37cfa881 100644 --- a/src/main/java/com/phenoml/api/resources/fhirprovider/types/FhirProviderAuthConfig.java +++ b/src/main/java/com/phenoml/api/resources/fhirprovider/types/FhirProviderAuthConfig.java @@ -238,7 +238,6 @@ public static final class Builder { private Optional clientId = Optional.empty(); - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -432,6 +431,25 @@ public FhirProviderAuthConfig build() { additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("auth_config_id") + || key.equalsIgnoreCase("auth_method") + || key.equalsIgnoreCase("is_active_auth_config") + || key.equalsIgnoreCase("created_at") + || key.equalsIgnoreCase("updated_at") + || key.equalsIgnoreCase("public_key_cert_pem") + || key.equalsIgnoreCase("json_web_key") + || key.equalsIgnoreCase("credential_expiry") + || key.equalsIgnoreCase("smart_configuration") + || key.equalsIgnoreCase("service_account_metadata") + || key.equalsIgnoreCase("scopes") + || key.equalsIgnoreCase("client_id")) { + return; + } + this.additionalProperties.put(key, value); + } + public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); return this; diff --git a/src/main/java/com/phenoml/api/resources/fhirprovider/types/FhirProviderListResponse.java b/src/main/java/com/phenoml/api/resources/fhirprovider/types/FhirProviderListResponse.java index 1160e0e9..2d13541d 100644 --- a/src/main/java/com/phenoml/api/resources/fhirprovider/types/FhirProviderListResponse.java +++ b/src/main/java/com/phenoml/api/resources/fhirprovider/types/FhirProviderListResponse.java @@ -98,7 +98,6 @@ public static final class Builder { private Optional> fhirProviders = Optional.empty(); - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -151,6 +150,16 @@ public FhirProviderListResponse build() { return new FhirProviderListResponse(success, message, fhirProviders, additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("success") + || key.equalsIgnoreCase("message") + || key.equalsIgnoreCase("fhir_providers")) { + return; + } + this.additionalProperties.put(key, value); + } + public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); return this; diff --git a/src/main/java/com/phenoml/api/resources/fhirprovider/types/FhirProviderResponse.java b/src/main/java/com/phenoml/api/resources/fhirprovider/types/FhirProviderResponse.java index 66873e84..87fd946a 100644 --- a/src/main/java/com/phenoml/api/resources/fhirprovider/types/FhirProviderResponse.java +++ b/src/main/java/com/phenoml/api/resources/fhirprovider/types/FhirProviderResponse.java @@ -94,7 +94,6 @@ public static final class Builder { private Optional data = Optional.empty(); - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -146,6 +145,14 @@ public FhirProviderResponse build() { return new FhirProviderResponse(success, message, data, additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("success") || key.equalsIgnoreCase("message") || key.equalsIgnoreCase("data")) { + return; + } + this.additionalProperties.put(key, value); + } + public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); return this; diff --git a/src/main/java/com/phenoml/api/resources/fhirprovider/types/FhirProviderSandboxInfo.java b/src/main/java/com/phenoml/api/resources/fhirprovider/types/FhirProviderSandboxInfo.java index a012be1f..85e92a0c 100644 --- a/src/main/java/com/phenoml/api/resources/fhirprovider/types/FhirProviderSandboxInfo.java +++ b/src/main/java/com/phenoml/api/resources/fhirprovider/types/FhirProviderSandboxInfo.java @@ -137,7 +137,6 @@ public static final class Builder implements IdStage, NameStage, _FinalStage { private Optional description = Optional.empty(); - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -197,6 +196,17 @@ public FhirProviderSandboxInfo build() { return new FhirProviderSandboxInfo(id, name, description, additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("id") + || key.equalsIgnoreCase("name") + || key.equalsIgnoreCase("description") + || key.equalsIgnoreCase("provider")) { + return; + } + this.additionalProperties.put(key, value); + } + @java.lang.Override public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); diff --git a/src/main/java/com/phenoml/api/resources/fhirprovider/types/FhirProviderTemplate.java b/src/main/java/com/phenoml/api/resources/fhirprovider/types/FhirProviderTemplate.java index 5e0d733a..b0193708 100644 --- a/src/main/java/com/phenoml/api/resources/fhirprovider/types/FhirProviderTemplate.java +++ b/src/main/java/com/phenoml/api/resources/fhirprovider/types/FhirProviderTemplate.java @@ -250,7 +250,6 @@ public static final class Builder implements IdStage, NameStage, ProviderStage, private Optional description = Optional.empty(); - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -409,6 +408,21 @@ public FhirProviderTemplate build() { id, name, description, provider, baseUrl, clientId, authConfigs, lastUpdated, additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("id") + || key.equalsIgnoreCase("name") + || key.equalsIgnoreCase("description") + || key.equalsIgnoreCase("provider") + || key.equalsIgnoreCase("base_url") + || key.equalsIgnoreCase("client_id") + || key.equalsIgnoreCase("auth_configs") + || key.equalsIgnoreCase("last_updated")) { + return; + } + this.additionalProperties.put(key, value); + } + @java.lang.Override public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); diff --git a/src/main/java/com/phenoml/api/resources/fhirprovider/types/FhirQueryResponse.java b/src/main/java/com/phenoml/api/resources/fhirprovider/types/FhirQueryResponse.java index 29af4ae6..7e80004d 100644 --- a/src/main/java/com/phenoml/api/resources/fhirprovider/types/FhirQueryResponse.java +++ b/src/main/java/com/phenoml/api/resources/fhirprovider/types/FhirQueryResponse.java @@ -108,7 +108,6 @@ public static final class Builder { private Optional data = Optional.empty(); - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -172,6 +171,17 @@ public FhirQueryResponse build() { return new FhirQueryResponse(success, status, message, data, additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("success") + || key.equalsIgnoreCase("status") + || key.equalsIgnoreCase("message") + || key.equalsIgnoreCase("data")) { + return; + } + this.additionalProperties.put(key, value); + } + public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); return this; diff --git a/src/main/java/com/phenoml/api/resources/fhirprovider/types/GoogleHealthcareAuth.java b/src/main/java/com/phenoml/api/resources/fhirprovider/types/GoogleHealthcareAuth.java index 12cceac5..ac50f7b9 100644 --- a/src/main/java/com/phenoml/api/resources/fhirprovider/types/GoogleHealthcareAuth.java +++ b/src/main/java/com/phenoml/api/resources/fhirprovider/types/GoogleHealthcareAuth.java @@ -80,7 +80,6 @@ public interface _FinalStage { public static final class Builder implements ServiceAccountKeyStage, _FinalStage { private ServiceAccountKey serviceAccountKey; - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -103,6 +102,14 @@ public GoogleHealthcareAuth build() { return new GoogleHealthcareAuth(serviceAccountKey, additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("service_account_key")) { + return; + } + this.additionalProperties.put(key, value); + } + @java.lang.Override public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); diff --git a/src/main/java/com/phenoml/api/resources/fhirprovider/types/JsonWebKey.java b/src/main/java/com/phenoml/api/resources/fhirprovider/types/JsonWebKey.java index 9ecb0f33..27a1e0f7 100644 --- a/src/main/java/com/phenoml/api/resources/fhirprovider/types/JsonWebKey.java +++ b/src/main/java/com/phenoml/api/resources/fhirprovider/types/JsonWebKey.java @@ -147,7 +147,6 @@ public static final class Builder { private Optional e = Optional.empty(); - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -250,6 +249,19 @@ public JsonWebKey build() { return new JsonWebKey(kty, use, kid, alg, n, e, additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("kty") + || key.equalsIgnoreCase("use") + || key.equalsIgnoreCase("kid") + || key.equalsIgnoreCase("alg") + || key.equalsIgnoreCase("n") + || key.equalsIgnoreCase("e")) { + return; + } + this.additionalProperties.put(key, value); + } + public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); return this; diff --git a/src/main/java/com/phenoml/api/resources/fhirprovider/types/JwtAuth.java b/src/main/java/com/phenoml/api/resources/fhirprovider/types/JwtAuth.java index 407bb08b..9a9fa104 100644 --- a/src/main/java/com/phenoml/api/resources/fhirprovider/types/JwtAuth.java +++ b/src/main/java/com/phenoml/api/resources/fhirprovider/types/JwtAuth.java @@ -151,7 +151,6 @@ public static final class Builder implements ClientIdStage, _FinalStage { private Optional credentialExpiry = Optional.empty(); - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -234,6 +233,17 @@ public JwtAuth build() { return new JwtAuth(clientId, credentialExpiry, role, scopes, additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("client_id") + || key.equalsIgnoreCase("credential_expiry") + || key.equalsIgnoreCase("role") + || key.equalsIgnoreCase("scopes")) { + return; + } + this.additionalProperties.put(key, value); + } + @java.lang.Override public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); diff --git a/src/main/java/com/phenoml/api/resources/fhirprovider/types/NoAuth.java b/src/main/java/com/phenoml/api/resources/fhirprovider/types/NoAuth.java index 40287f36..d212ea42 100644 --- a/src/main/java/com/phenoml/api/resources/fhirprovider/types/NoAuth.java +++ b/src/main/java/com/phenoml/api/resources/fhirprovider/types/NoAuth.java @@ -43,7 +43,6 @@ public static Builder builder() { @JsonIgnoreProperties(ignoreUnknown = true) public static final class Builder { - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -56,6 +55,11 @@ public NoAuth build() { return new NoAuth(additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + this.additionalProperties.put(key, value); + } + public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); return this; diff --git a/src/main/java/com/phenoml/api/resources/fhirprovider/types/OnBehalfOfAuth.java b/src/main/java/com/phenoml/api/resources/fhirprovider/types/OnBehalfOfAuth.java index cd221875..ac0afe3e 100644 --- a/src/main/java/com/phenoml/api/resources/fhirprovider/types/OnBehalfOfAuth.java +++ b/src/main/java/com/phenoml/api/resources/fhirprovider/types/OnBehalfOfAuth.java @@ -150,7 +150,6 @@ public static final class Builder implements ClientIdStage, ClientSecretStage, _ private Optional role = Optional.empty(); - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -224,6 +223,17 @@ public OnBehalfOfAuth build() { return new OnBehalfOfAuth(clientId, clientSecret, role, scopes, additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("client_id") + || key.equalsIgnoreCase("client_secret") + || key.equalsIgnoreCase("role") + || key.equalsIgnoreCase("scopes")) { + return; + } + this.additionalProperties.put(key, value); + } + @java.lang.Override public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); diff --git a/src/main/java/com/phenoml/api/resources/fhirprovider/types/ServiceAccountKey.java b/src/main/java/com/phenoml/api/resources/fhirprovider/types/ServiceAccountKey.java index 06a328c2..078db5bf 100644 --- a/src/main/java/com/phenoml/api/resources/fhirprovider/types/ServiceAccountKey.java +++ b/src/main/java/com/phenoml/api/resources/fhirprovider/types/ServiceAccountKey.java @@ -265,7 +265,6 @@ public static final class Builder private String universeDomain; - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -381,6 +380,24 @@ public ServiceAccountKey build() { additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("type") + || key.equalsIgnoreCase("project_id") + || key.equalsIgnoreCase("private_key_id") + || key.equalsIgnoreCase("private_key") + || key.equalsIgnoreCase("client_email") + || key.equalsIgnoreCase("client_id") + || key.equalsIgnoreCase("auth_uri") + || key.equalsIgnoreCase("token_uri") + || key.equalsIgnoreCase("auth_provider_x509_cert_url") + || key.equalsIgnoreCase("client_x509_cert_url") + || key.equalsIgnoreCase("universe_domain")) { + return; + } + this.additionalProperties.put(key, value); + } + @java.lang.Override public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); diff --git a/src/main/java/com/phenoml/api/resources/fhirprovider/types/ServiceAccountMetadata.java b/src/main/java/com/phenoml/api/resources/fhirprovider/types/ServiceAccountMetadata.java index c0fa885e..da09cbfe 100644 --- a/src/main/java/com/phenoml/api/resources/fhirprovider/types/ServiceAccountMetadata.java +++ b/src/main/java/com/phenoml/api/resources/fhirprovider/types/ServiceAccountMetadata.java @@ -117,7 +117,6 @@ public static final class Builder { private Optional clientId = Optional.empty(); - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -190,6 +189,17 @@ public ServiceAccountMetadata build() { return new ServiceAccountMetadata(type, projectId, clientEmail, clientId, additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("type") + || key.equalsIgnoreCase("project_id") + || key.equalsIgnoreCase("client_email") + || key.equalsIgnoreCase("client_id")) { + return; + } + this.additionalProperties.put(key, value); + } + public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); return this; diff --git a/src/main/java/com/phenoml/api/resources/fhirprovider/types/SmartConfiguration.java b/src/main/java/com/phenoml/api/resources/fhirprovider/types/SmartConfiguration.java index 9f1195c9..55587f62 100644 --- a/src/main/java/com/phenoml/api/resources/fhirprovider/types/SmartConfiguration.java +++ b/src/main/java/com/phenoml/api/resources/fhirprovider/types/SmartConfiguration.java @@ -134,7 +134,6 @@ public static final class Builder { private Optional> scopesSupported = Optional.empty(); - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -223,6 +222,18 @@ public SmartConfiguration build() { authorizationEndpoint, tokenEndpoint, issuer, jwksUri, scopesSupported, additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("authorization_endpoint") + || key.equalsIgnoreCase("token_endpoint") + || key.equalsIgnoreCase("issuer") + || key.equalsIgnoreCase("jwks_uri") + || key.equalsIgnoreCase("scopes_supported")) { + return; + } + this.additionalProperties.put(key, value); + } + public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); return this; diff --git a/src/main/java/com/phenoml/api/resources/fhirprovider/types/TokenPassthroughAuth.java b/src/main/java/com/phenoml/api/resources/fhirprovider/types/TokenPassthroughAuth.java index dc30bbcc..f913ef06 100644 --- a/src/main/java/com/phenoml/api/resources/fhirprovider/types/TokenPassthroughAuth.java +++ b/src/main/java/com/phenoml/api/resources/fhirprovider/types/TokenPassthroughAuth.java @@ -43,7 +43,6 @@ public static Builder builder() { @JsonIgnoreProperties(ignoreUnknown = true) public static final class Builder { - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -56,6 +55,11 @@ public TokenPassthroughAuth build() { return new TokenPassthroughAuth(additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + this.additionalProperties.put(key, value); + } + public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); return this; diff --git a/src/main/java/com/phenoml/api/resources/implementationguides/implementationguides/AsyncImplementationGuidesClient.java b/src/main/java/com/phenoml/api/resources/implementationguides/implementationguides/AsyncImplementationGuidesClient.java index c4489f5e..ef63a979 100644 --- a/src/main/java/com/phenoml/api/resources/implementationguides/implementationguides/AsyncImplementationGuidesClient.java +++ b/src/main/java/com/phenoml/api/resources/implementationguides/implementationguides/AsyncImplementationGuidesClient.java @@ -4,6 +4,7 @@ package com.phenoml.api.resources.implementationguides.implementationguides; import com.phenoml.api.core.ClientOptions; +import com.phenoml.api.core.PhenomlClientHttpResponse; import com.phenoml.api.core.RequestOptions; import com.phenoml.api.resources.implementationguides.implementationguides.requests.CreateCanonicalImplementationGuideRequest; import com.phenoml.api.resources.implementationguides.implementationguides.requests.UpdateImplementationGuideRequest; @@ -36,7 +37,14 @@ public AsyncRawImplementationGuidesClient withRawResponse() { * least one custom profile — with the number of profiles in each. */ public CompletableFuture list() { - return this.rawClient.list().thenApply(response -> response.body()); + CompletableFuture> rawFuture = this.rawClient.list(); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** @@ -45,7 +53,15 @@ public CompletableFuture list() { * least one custom profile — with the number of profiles in each. */ public CompletableFuture list(RequestOptions requestOptions) { - return this.rawClient.list(requestOptions).thenApply(response -> response.body()); + CompletableFuture> rawFuture = + this.rawClient.list(requestOptions); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** @@ -53,7 +69,14 @@ public CompletableFuture list(RequestOptions re * the ids of the profiles that belong to it. */ public CompletableFuture get(String name) { - return this.rawClient.get(name).thenApply(response -> response.body()); + CompletableFuture> rawFuture = this.rawClient.get(name); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** @@ -61,7 +84,15 @@ public CompletableFuture get(String name) { * the ids of the profiles that belong to it. */ public CompletableFuture get(String name, RequestOptions requestOptions) { - return this.rawClient.get(name, requestOptions).thenApply(response -> response.body()); + CompletableFuture> rawFuture = + this.rawClient.get(name, requestOptions); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** @@ -71,7 +102,15 @@ public CompletableFuture get(String name, RequestOpti * It applies to every profile in the guide. */ public CompletableFuture update(String name) { - return this.rawClient.update(name).thenApply(response -> response.body()); + CompletableFuture> rawFuture = + this.rawClient.update(name); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** @@ -81,7 +120,15 @@ public CompletableFuture update(String name) { * It applies to every profile in the guide. */ public CompletableFuture update(String name, RequestOptions requestOptions) { - return this.rawClient.update(name, requestOptions).thenApply(response -> response.body()); + CompletableFuture> rawFuture = + this.rawClient.update(name, requestOptions); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** @@ -91,7 +138,15 @@ public CompletableFuture update(String name, Request * It applies to every profile in the guide. */ public CompletableFuture update(String name, UpdateImplementationGuideRequest request) { - return this.rawClient.update(name, request).thenApply(response -> response.body()); + CompletableFuture> rawFuture = + this.rawClient.update(name, request); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** @@ -102,7 +157,15 @@ public CompletableFuture update(String name, UpdateI */ public CompletableFuture update( String name, UpdateImplementationGuideRequest request, RequestOptions requestOptions) { - return this.rawClient.update(name, request, requestOptions).thenApply(response -> response.body()); + CompletableFuture> rawFuture = + this.rawClient.update(name, request, requestOptions); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** @@ -110,7 +173,14 @@ public CompletableFuture update( * Custom profiles and their implementation-guide assignments are preserved. */ public CompletableFuture delete(String name) { - return this.rawClient.delete(name).thenApply(response -> response.body()); + CompletableFuture> rawFuture = this.rawClient.delete(name); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** @@ -118,7 +188,14 @@ public CompletableFuture delete(String name) { * Custom profiles and their implementation-guide assignments are preserved. */ public CompletableFuture delete(String name, RequestOptions requestOptions) { - return this.rawClient.delete(name, requestOptions).thenApply(response -> response.body()); + CompletableFuture> rawFuture = this.rawClient.delete(name, requestOptions); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** @@ -128,7 +205,15 @@ public CompletableFuture delete(String name, RequestOptions requestOptions */ public CompletableFuture createVersion( String name, CreateCanonicalImplementationGuideRequest request) { - return this.rawClient.createVersion(name, request).thenApply(response -> response.body()); + CompletableFuture> rawFuture = + this.rawClient.createVersion(name, request); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** @@ -138,15 +223,39 @@ public CompletableFuture createVersion( */ public CompletableFuture createVersion( String name, CreateCanonicalImplementationGuideRequest request, RequestOptions requestOptions) { - return this.rawClient.createVersion(name, request, requestOptions).thenApply(response -> response.body()); + CompletableFuture> rawFuture = + this.rawClient.createVersion(name, request, requestOptions); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } public CompletableFuture getVersion(String name, String version) { - return this.rawClient.getVersion(name, version).thenApply(response -> response.body()); + CompletableFuture> rawFuture = + this.rawClient.getVersion(name, version); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } public CompletableFuture getVersion( String name, String version, RequestOptions requestOptions) { - return this.rawClient.getVersion(name, version, requestOptions).thenApply(response -> response.body()); + CompletableFuture> rawFuture = + this.rawClient.getVersion(name, version, requestOptions); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } } diff --git a/src/main/java/com/phenoml/api/resources/implementationguides/implementationguides/AsyncRawImplementationGuidesClient.java b/src/main/java/com/phenoml/api/resources/implementationguides/implementationguides/AsyncRawImplementationGuidesClient.java index e4c9fab0..1a522639 100644 --- a/src/main/java/com/phenoml/api/resources/implementationguides/implementationguides/AsyncRawImplementationGuidesClient.java +++ b/src/main/java/com/phenoml/api/resources/implementationguides/implementationguides/AsyncRawImplementationGuidesClient.java @@ -4,9 +4,11 @@ package com.phenoml.api.resources.implementationguides.implementationguides; import com.fasterxml.jackson.core.JsonProcessingException; +import com.phenoml.api.core.BodyProperties; import com.phenoml.api.core.ClientOptions; import com.phenoml.api.core.MediaTypes; import com.phenoml.api.core.ObjectMappers; +import com.phenoml.api.core.PathSegments; import com.phenoml.api.core.PhenomlClientApiException; import com.phenoml.api.core.PhenomlClientException; import com.phenoml.api.core.PhenomlClientHttpResponse; @@ -89,7 +91,8 @@ public CompletableFuture> future = new CompletableFuture<>(); - client.newCall(okhttpRequest).enqueue(new Callback() { + RetryInterceptor.AsyncCall okhttpCall = RetryInterceptor.newAsyncCall(client, okhttpRequest); + okhttpCall.enqueue(new Callback() { @Override public void onResponse(@NotNull Call call, @NotNull Response response) throws IOException { try (ResponseBody responseBody = response.body()) { @@ -139,6 +142,11 @@ public void onFailure(@NotNull Call call, @NotNull IOException e) { future.completeExceptionally(new PhenomlClientException("Network error executing HTTP request", e)); } }); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + okhttpCall.cancel(); + } + }); return future; } @@ -159,7 +167,7 @@ public CompletableFuture> g HttpUrl.Builder httpUrl = HttpUrl.parse(this.clientOptions.environment().getUrl()) .newBuilder() .addPathSegments("fhir/implementation-guides") - .addPathSegment(name); + .addPathSegment(PathSegments.validate(name)); if (requestOptions != null) { requestOptions.getQueryParameters().forEach((_key, _value) -> { httpUrl.addQueryParameter(_key, _value); @@ -185,7 +193,8 @@ public CompletableFuture> g .build(); } CompletableFuture> future = new CompletableFuture<>(); - client.newCall(okhttpRequest).enqueue(new Callback() { + RetryInterceptor.AsyncCall okhttpCall = RetryInterceptor.newAsyncCall(client, okhttpRequest); + okhttpCall.enqueue(new Callback() { @Override public void onResponse(@NotNull Call call, @NotNull Response response) throws IOException { try (ResponseBody responseBody = response.body()) { @@ -245,6 +254,11 @@ public void onFailure(@NotNull Call call, @NotNull IOException e) { future.completeExceptionally(new PhenomlClientException("Network error executing HTTP request", e)); } }); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + okhttpCall.cancel(); + } + }); return future; } @@ -291,7 +305,7 @@ public CompletableFuture> HttpUrl.Builder httpUrl = HttpUrl.parse(this.clientOptions.environment().getUrl()) .newBuilder() .addPathSegments("fhir/implementation-guides") - .addPathSegment(name); + .addPathSegment(PathSegments.validate(name)); if (requestOptions != null) { requestOptions.getQueryParameters().forEach((_key, _value) -> { httpUrl.addQueryParameter(_key, _value); @@ -300,7 +314,9 @@ public CompletableFuture> RequestBody body; try { body = RequestBody.create( - ObjectMappers.JSON_MAPPER.writeValueAsBytes(request), MediaTypes.APPLICATION_JSON); + ObjectMappers.JSON_MAPPER.writeValueAsBytes(BodyProperties.merge( + request, requestOptions != null ? requestOptions.getBodyProperties() : null)), + MediaTypes.APPLICATION_JSON); } catch (JsonProcessingException e) { throw new PhenomlClientException("Failed to serialize request", e); } @@ -325,7 +341,8 @@ public CompletableFuture> .build(); } CompletableFuture> future = new CompletableFuture<>(); - client.newCall(okhttpRequest).enqueue(new Callback() { + RetryInterceptor.AsyncCall okhttpCall = RetryInterceptor.newAsyncCall(client, okhttpRequest); + okhttpCall.enqueue(new Callback() { @Override public void onResponse(@NotNull Call call, @NotNull Response response) throws IOException { try (ResponseBody responseBody = response.body()) { @@ -380,6 +397,11 @@ public void onFailure(@NotNull Call call, @NotNull IOException e) { future.completeExceptionally(new PhenomlClientException("Network error executing HTTP request", e)); } }); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + okhttpCall.cancel(); + } + }); return future; } @@ -399,7 +421,7 @@ public CompletableFuture> delete(String name, Re HttpUrl.Builder httpUrl = HttpUrl.parse(this.clientOptions.environment().getUrl()) .newBuilder() .addPathSegments("fhir/implementation-guides") - .addPathSegment(name); + .addPathSegment(PathSegments.validate(name)); if (requestOptions != null) { requestOptions.getQueryParameters().forEach((_key, _value) -> { httpUrl.addQueryParameter(_key, _value); @@ -407,7 +429,10 @@ public CompletableFuture> delete(String name, Re } Request okhttpRequest = new Request.Builder() .url(httpUrl.build()) - .method("DELETE", null) + .method( + "DELETE", + BodyProperties.toRequestBody( + requestOptions != null ? requestOptions.getBodyProperties() : null, null)) .headers(Headers.of(clientOptions.headers(requestOptions))) .addHeader("Accept", "application/json") .build(); @@ -425,7 +450,8 @@ public CompletableFuture> delete(String name, Re .build(); } CompletableFuture> future = new CompletableFuture<>(); - client.newCall(okhttpRequest).enqueue(new Callback() { + RetryInterceptor.AsyncCall okhttpCall = RetryInterceptor.newAsyncCall(client, okhttpRequest); + okhttpCall.enqueue(new Callback() { @Override public void onResponse(@NotNull Call call, @NotNull Response response) throws IOException { try (ResponseBody responseBody = response.body()) { @@ -482,6 +508,11 @@ public void onFailure(@NotNull Call call, @NotNull IOException e) { future.completeExceptionally(new PhenomlClientException("Network error executing HTTP request", e)); } }); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + okhttpCall.cancel(); + } + }); return future; } @@ -505,7 +536,7 @@ public CompletableFuture { @@ -515,7 +546,9 @@ public CompletableFuture> future = new CompletableFuture<>(); - client.newCall(okhttpRequest).enqueue(new Callback() { + RetryInterceptor.AsyncCall okhttpCall = RetryInterceptor.newAsyncCall(client, okhttpRequest); + okhttpCall.enqueue(new Callback() { @Override public void onResponse(@NotNull Call call, @NotNull Response response) throws IOException { try (ResponseBody responseBody = response.body()) { @@ -591,6 +625,11 @@ public void onFailure(@NotNull Call call, @NotNull IOException e) { future.completeExceptionally(new PhenomlClientException("Network error executing HTTP request", e)); } }); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + okhttpCall.cancel(); + } + }); return future; } @@ -604,9 +643,9 @@ public CompletableFuture { httpUrl.addQueryParameter(_key, _value); @@ -633,7 +672,8 @@ public CompletableFuture> future = new CompletableFuture<>(); - client.newCall(okhttpRequest).enqueue(new Callback() { + RetryInterceptor.AsyncCall okhttpCall = RetryInterceptor.newAsyncCall(client, okhttpRequest); + okhttpCall.enqueue(new Callback() { @Override public void onResponse(@NotNull Call call, @NotNull Response response) throws IOException { try (ResponseBody responseBody = response.body()) { @@ -678,6 +718,11 @@ public void onFailure(@NotNull Call call, @NotNull IOException e) { future.completeExceptionally(new PhenomlClientException("Network error executing HTTP request", e)); } }); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + okhttpCall.cancel(); + } + }); return future; } } diff --git a/src/main/java/com/phenoml/api/resources/implementationguides/implementationguides/RawImplementationGuidesClient.java b/src/main/java/com/phenoml/api/resources/implementationguides/implementationguides/RawImplementationGuidesClient.java index b5cbfefa..1bb4a678 100644 --- a/src/main/java/com/phenoml/api/resources/implementationguides/implementationguides/RawImplementationGuidesClient.java +++ b/src/main/java/com/phenoml/api/resources/implementationguides/implementationguides/RawImplementationGuidesClient.java @@ -4,9 +4,11 @@ package com.phenoml.api.resources.implementationguides.implementationguides; import com.fasterxml.jackson.core.JsonProcessingException; +import com.phenoml.api.core.BodyProperties; import com.phenoml.api.core.ClientOptions; import com.phenoml.api.core.MediaTypes; import com.phenoml.api.core.ObjectMappers; +import com.phenoml.api.core.PathSegments; import com.phenoml.api.core.PhenomlClientApiException; import com.phenoml.api.core.PhenomlClientException; import com.phenoml.api.core.PhenomlClientHttpResponse; @@ -131,7 +133,7 @@ public PhenomlClientHttpResponse get(String name, Req HttpUrl.Builder httpUrl = HttpUrl.parse(this.clientOptions.environment().getUrl()) .newBuilder() .addPathSegments("fhir/implementation-guides") - .addPathSegment(name); + .addPathSegment(PathSegments.validate(name)); if (requestOptions != null) { requestOptions.getQueryParameters().forEach((_key, _value) -> { httpUrl.addQueryParameter(_key, _value); @@ -237,7 +239,7 @@ public PhenomlClientHttpResponse update( HttpUrl.Builder httpUrl = HttpUrl.parse(this.clientOptions.environment().getUrl()) .newBuilder() .addPathSegments("fhir/implementation-guides") - .addPathSegment(name); + .addPathSegment(PathSegments.validate(name)); if (requestOptions != null) { requestOptions.getQueryParameters().forEach((_key, _value) -> { httpUrl.addQueryParameter(_key, _value); @@ -246,7 +248,9 @@ public PhenomlClientHttpResponse update( RequestBody body; try { body = RequestBody.create( - ObjectMappers.JSON_MAPPER.writeValueAsBytes(request), MediaTypes.APPLICATION_JSON); + ObjectMappers.JSON_MAPPER.writeValueAsBytes(BodyProperties.merge( + request, requestOptions != null ? requestOptions.getBodyProperties() : null)), + MediaTypes.APPLICATION_JSON); } catch (JsonProcessingException e) { throw new PhenomlClientException("Failed to serialize request", e); } @@ -322,7 +326,7 @@ public PhenomlClientHttpResponse delete(String name, RequestOptions reques HttpUrl.Builder httpUrl = HttpUrl.parse(this.clientOptions.environment().getUrl()) .newBuilder() .addPathSegments("fhir/implementation-guides") - .addPathSegment(name); + .addPathSegment(PathSegments.validate(name)); if (requestOptions != null) { requestOptions.getQueryParameters().forEach((_key, _value) -> { httpUrl.addQueryParameter(_key, _value); @@ -330,7 +334,10 @@ public PhenomlClientHttpResponse delete(String name, RequestOptions reques } Request okhttpRequest = new Request.Builder() .url(httpUrl.build()) - .method("DELETE", null) + .method( + "DELETE", + BodyProperties.toRequestBody( + requestOptions != null ? requestOptions.getBodyProperties() : null, null)) .headers(Headers.of(clientOptions.headers(requestOptions))) .addHeader("Accept", "application/json") .build(); @@ -404,7 +411,7 @@ public PhenomlClientHttpResponse createVersion HttpUrl.Builder httpUrl = HttpUrl.parse(this.clientOptions.environment().getUrl()) .newBuilder() .addPathSegments("fhir/implementation-guides") - .addPathSegment(name) + .addPathSegment(PathSegments.validate(name)) .addPathSegments("versions"); if (requestOptions != null) { requestOptions.getQueryParameters().forEach((_key, _value) -> { @@ -414,7 +421,9 @@ public PhenomlClientHttpResponse createVersion RequestBody body; try { body = RequestBody.create( - ObjectMappers.JSON_MAPPER.writeValueAsBytes(request), MediaTypes.APPLICATION_JSON); + ObjectMappers.JSON_MAPPER.writeValueAsBytes(BodyProperties.merge( + request, requestOptions != null ? requestOptions.getBodyProperties() : null)), + MediaTypes.APPLICATION_JSON); } catch (JsonProcessingException e) { throw new PhenomlClientException("Failed to serialize request", e); } @@ -480,9 +489,9 @@ public PhenomlClientHttpResponse getVersion( HttpUrl.Builder httpUrl = HttpUrl.parse(this.clientOptions.environment().getUrl()) .newBuilder() .addPathSegments("fhir/implementation-guides") - .addPathSegment(name) + .addPathSegment(PathSegments.validate(name)) .addPathSegments("versions") - .addPathSegment(version); + .addPathSegment(PathSegments.validate(version)); if (requestOptions != null) { requestOptions.getQueryParameters().forEach((_key, _value) -> { httpUrl.addQueryParameter(_key, _value); diff --git a/src/main/java/com/phenoml/api/resources/implementationguides/implementationguides/requests/CreateCanonicalImplementationGuideRequest.java b/src/main/java/com/phenoml/api/resources/implementationguides/implementationguides/requests/CreateCanonicalImplementationGuideRequest.java index 2a516fbd..09f18f0d 100644 --- a/src/main/java/com/phenoml/api/resources/implementationguides/implementationguides/requests/CreateCanonicalImplementationGuideRequest.java +++ b/src/main/java/com/phenoml/api/resources/implementationguides/implementationguides/requests/CreateCanonicalImplementationGuideRequest.java @@ -134,7 +134,6 @@ public static final class Builder implements ImplementationGuideStage, _FinalSta private List profileRefs = new ArrayList<>(); - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -216,6 +215,16 @@ public CreateCanonicalImplementationGuideRequest build() { implementationGuide, profileRefs, profileContext, additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("implementation_guide") + || key.equalsIgnoreCase("profile_refs") + || key.equalsIgnoreCase("profile_context")) { + return; + } + this.additionalProperties.put(key, value); + } + @java.lang.Override public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); diff --git a/src/main/java/com/phenoml/api/resources/implementationguides/implementationguides/requests/UpdateImplementationGuideRequest.java b/src/main/java/com/phenoml/api/resources/implementationguides/implementationguides/requests/UpdateImplementationGuideRequest.java index 2b05dd11..0c7fcf87 100644 --- a/src/main/java/com/phenoml/api/resources/implementationguides/implementationguides/requests/UpdateImplementationGuideRequest.java +++ b/src/main/java/com/phenoml/api/resources/implementationguides/implementationguides/requests/UpdateImplementationGuideRequest.java @@ -71,7 +71,6 @@ public static Builder builder() { public static final class Builder { private Optional profileContext = Optional.empty(); - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -99,6 +98,14 @@ public UpdateImplementationGuideRequest build() { return new UpdateImplementationGuideRequest(profileContext, additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("profile_context")) { + return; + } + this.additionalProperties.put(key, value); + } + public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); return this; diff --git a/src/main/java/com/phenoml/api/resources/implementationguides/types/FhirImplementationGuide.java b/src/main/java/com/phenoml/api/resources/implementationguides/types/FhirImplementationGuide.java index d2da8a87..0cb084fa 100644 --- a/src/main/java/com/phenoml/api/resources/implementationguides/types/FhirImplementationGuide.java +++ b/src/main/java/com/phenoml/api/resources/implementationguides/types/FhirImplementationGuide.java @@ -186,7 +186,6 @@ public static final class Builder implements UrlStage, VersionStage, _FinalStage private Optional id = Optional.empty(); - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -288,6 +287,21 @@ public FhirImplementationGuide build() { id, url, version, name, status, packageId, fhirVersion, additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("resourceType") + || key.equalsIgnoreCase("id") + || key.equalsIgnoreCase("url") + || key.equalsIgnoreCase("version") + || key.equalsIgnoreCase("name") + || key.equalsIgnoreCase("status") + || key.equalsIgnoreCase("packageId") + || key.equalsIgnoreCase("fhirVersion")) { + return; + } + this.additionalProperties.put(key, value); + } + @java.lang.Override public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); diff --git a/src/main/java/com/phenoml/api/resources/implementationguides/types/ImplementationGuideDetail.java b/src/main/java/com/phenoml/api/resources/implementationguides/types/ImplementationGuideDetail.java index 1dccf8cf..1d1d5611 100644 --- a/src/main/java/com/phenoml/api/resources/implementationguides/types/ImplementationGuideDetail.java +++ b/src/main/java/com/phenoml/api/resources/implementationguides/types/ImplementationGuideDetail.java @@ -194,7 +194,6 @@ public static final class Builder { private Optional> profiles = Optional.empty(); - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -336,6 +335,21 @@ public ImplementationGuideDetail build() { additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("name") + || key.equalsIgnoreCase("profile_context") + || key.equalsIgnoreCase("profile_count") + || key.equalsIgnoreCase("canonical_url") + || key.equalsIgnoreCase("version_count") + || key.equalsIgnoreCase("created_at") + || key.equalsIgnoreCase("updated_at") + || key.equalsIgnoreCase("profiles")) { + return; + } + this.additionalProperties.put(key, value); + } + public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); return this; diff --git a/src/main/java/com/phenoml/api/resources/implementationguides/types/ImplementationGuideListResponse.java b/src/main/java/com/phenoml/api/resources/implementationguides/types/ImplementationGuideListResponse.java index f95a7d21..0069b88b 100644 --- a/src/main/java/com/phenoml/api/resources/implementationguides/types/ImplementationGuideListResponse.java +++ b/src/main/java/com/phenoml/api/resources/implementationguides/types/ImplementationGuideListResponse.java @@ -69,7 +69,6 @@ public static Builder builder() { public static final class Builder { private Optional> implementationGuides = Optional.empty(); - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -94,6 +93,14 @@ public ImplementationGuideListResponse build() { return new ImplementationGuideListResponse(implementationGuides, additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("implementation_guides")) { + return; + } + this.additionalProperties.put(key, value); + } + public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); return this; diff --git a/src/main/java/com/phenoml/api/resources/implementationguides/types/ImplementationGuideSummary.java b/src/main/java/com/phenoml/api/resources/implementationguides/types/ImplementationGuideSummary.java index 38652ac1..8a1951df 100644 --- a/src/main/java/com/phenoml/api/resources/implementationguides/types/ImplementationGuideSummary.java +++ b/src/main/java/com/phenoml/api/resources/implementationguides/types/ImplementationGuideSummary.java @@ -177,7 +177,6 @@ public static final class Builder { private Optional updatedAt = Optional.empty(); - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -303,6 +302,20 @@ public ImplementationGuideSummary build() { additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("name") + || key.equalsIgnoreCase("profile_context") + || key.equalsIgnoreCase("profile_count") + || key.equalsIgnoreCase("canonical_url") + || key.equalsIgnoreCase("version_count") + || key.equalsIgnoreCase("created_at") + || key.equalsIgnoreCase("updated_at")) { + return; + } + this.additionalProperties.put(key, value); + } + public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); return this; diff --git a/src/main/java/com/phenoml/api/resources/implementationguides/types/ImplementationGuideVersionDetail.java b/src/main/java/com/phenoml/api/resources/implementationguides/types/ImplementationGuideVersionDetail.java index 28c37db5..e2570066 100644 --- a/src/main/java/com/phenoml/api/resources/implementationguides/types/ImplementationGuideVersionDetail.java +++ b/src/main/java/com/phenoml/api/resources/implementationguides/types/ImplementationGuideVersionDetail.java @@ -235,7 +235,6 @@ public static final class Builder private List profiles = new ArrayList<>(); - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -367,6 +366,22 @@ public ImplementationGuideVersionDetail build() { additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("name") + || key.equalsIgnoreCase("url") + || key.equalsIgnoreCase("version") + || key.equalsIgnoreCase("profile_context") + || key.equalsIgnoreCase("profiles") + || key.equalsIgnoreCase("profile_refs") + || key.equalsIgnoreCase("implementation_guide") + || key.equalsIgnoreCase("created_at") + || key.equalsIgnoreCase("updated_at")) { + return; + } + this.additionalProperties.put(key, value); + } + @java.lang.Override public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); diff --git a/src/main/java/com/phenoml/api/resources/lang2fhir/AsyncLang2FhirClient.java b/src/main/java/com/phenoml/api/resources/lang2fhir/AsyncLang2FhirClient.java index 5d3db41c..2046fa2d 100644 --- a/src/main/java/com/phenoml/api/resources/lang2fhir/AsyncLang2FhirClient.java +++ b/src/main/java/com/phenoml/api/resources/lang2fhir/AsyncLang2FhirClient.java @@ -4,6 +4,7 @@ package com.phenoml.api.resources.lang2fhir; import com.phenoml.api.core.ClientOptions; +import com.phenoml.api.core.PhenomlClientHttpResponse; import com.phenoml.api.core.RequestOptions; import com.phenoml.api.resources.lang2fhir.requests.CreateMultiRequest; import com.phenoml.api.resources.lang2fhir.requests.CreateRequest; @@ -41,7 +42,14 @@ public AsyncRawLang2FhirClient withRawResponse() { *

            Patient identifier handling. When generating a patient (or patient-canvas) resource, US Core requires Patient.identifier (a business identifier such as an MRN). When the source text contains an identifier, it is extracted with an appropriate URI system. When the source text does not contain a detectable identifier, a synthetic one is generated with system: "urn:phenoml:lang2fhir-generated-id" and a UUID value so the resource remains FHIR-valid and US Core conformant. Callers who need a tenant-specific namespace should rewrite the synthetic system after extraction.

            */ public CompletableFuture> create(CreateRequest request) { - return this.rawClient.create(request).thenApply(response -> response.body()); + CompletableFuture>> rawFuture = this.rawClient.create(request); + CompletableFuture> future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** @@ -50,7 +58,15 @@ public CompletableFuture> create(CreateRequest request) { *

            Patient identifier handling. When generating a patient (or patient-canvas) resource, US Core requires Patient.identifier (a business identifier such as an MRN). When the source text contains an identifier, it is extracted with an appropriate URI system. When the source text does not contain a detectable identifier, a synthetic one is generated with system: "urn:phenoml:lang2fhir-generated-id" and a UUID value so the resource remains FHIR-valid and US Core conformant. Callers who need a tenant-specific namespace should rewrite the synthetic system after extraction.

            */ public CompletableFuture> create(CreateRequest request, RequestOptions requestOptions) { - return this.rawClient.create(request, requestOptions).thenApply(response -> response.body()); + CompletableFuture>> rawFuture = + this.rawClient.create(request, requestOptions); + CompletableFuture> future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** @@ -58,10 +74,19 @@ public CompletableFuture> create(CreateRequest request, Requ * Automatically detects Patient, Condition, MedicationRequest, Observation, and other resource types from the text. * Resources are linked with proper references (e.g., Conditions reference the Patient). *

            The complete JSON request body is limited to 32 MiB (33,554,432 bytes). The application enforces this whole-envelope limit.

            - *

            Patient identifier handling. US Core requires Patient.identifier (a business identifier such as an MRN). When the source text contains an identifier, it is extracted with an appropriate URI system. When the source text does not contain a detectable identifier, a synthetic one is generated with system: "urn:phenoml:lang2fhir-generated-id" and a UUID value so the bundle remains FHIR-valid and US Core conformant. Callers who need a tenant-specific namespace should rewrite the synthetic system after extraction.

            + *

            Patient handling. Every bundle contains exactly one Patient, and every clinical resource references it. When the text names its patient, the Patient is built from the text. When it names several patients, the Patient is the one primary_patient names, otherwise the one most of the text is about; the others are not represented as Patients. Otherwise it is built from primary_patient when supplied, or is an unidentified Patient carrying only a synthetic identifier. When the text names its patient, the primary_patient identifier is added to that Patient unless its name, birth date, or gender contradicts the text's; the response message says whether it was added or did not match.

            + *

            Patient identifier handling. US Core requires Patient.identifier (a business identifier such as an MRN). Every Patient carries a synthetic identifier with system: "urn:phenoml:lang2fhir-generated-id" and a UUID value, so the bundle remains FHIR-valid and US Core conformant. A medical record number stated in the text is added with system: "urn:phenoml:lang2fhir-document-mrn", and the primary_patient identifier is added as described above. Callers who need a tenant-specific namespace should rewrite these systems after extraction.

            */ public CompletableFuture createMulti(CreateMultiRequest request) { - return this.rawClient.createMulti(request).thenApply(response -> response.body()); + CompletableFuture> rawFuture = + this.rawClient.createMulti(request); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** @@ -69,11 +94,20 @@ public CompletableFuture createMulti(CreateMultiRequest req * Automatically detects Patient, Condition, MedicationRequest, Observation, and other resource types from the text. * Resources are linked with proper references (e.g., Conditions reference the Patient). *

            The complete JSON request body is limited to 32 MiB (33,554,432 bytes). The application enforces this whole-envelope limit.

            - *

            Patient identifier handling. US Core requires Patient.identifier (a business identifier such as an MRN). When the source text contains an identifier, it is extracted with an appropriate URI system. When the source text does not contain a detectable identifier, a synthetic one is generated with system: "urn:phenoml:lang2fhir-generated-id" and a UUID value so the bundle remains FHIR-valid and US Core conformant. Callers who need a tenant-specific namespace should rewrite the synthetic system after extraction.

            + *

            Patient handling. Every bundle contains exactly one Patient, and every clinical resource references it. When the text names its patient, the Patient is built from the text. When it names several patients, the Patient is the one primary_patient names, otherwise the one most of the text is about; the others are not represented as Patients. Otherwise it is built from primary_patient when supplied, or is an unidentified Patient carrying only a synthetic identifier. When the text names its patient, the primary_patient identifier is added to that Patient unless its name, birth date, or gender contradicts the text's; the response message says whether it was added or did not match.

            + *

            Patient identifier handling. US Core requires Patient.identifier (a business identifier such as an MRN). Every Patient carries a synthetic identifier with system: "urn:phenoml:lang2fhir-generated-id" and a UUID value, so the bundle remains FHIR-valid and US Core conformant. A medical record number stated in the text is added with system: "urn:phenoml:lang2fhir-document-mrn", and the primary_patient identifier is added as described above. Callers who need a tenant-specific namespace should rewrite these systems after extraction.

            */ public CompletableFuture createMulti( CreateMultiRequest request, RequestOptions requestOptions) { - return this.rawClient.createMulti(request, requestOptions).thenApply(response -> response.body()); + CompletableFuture> rawFuture = + this.rawClient.createMulti(request, requestOptions); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** @@ -86,7 +120,14 @@ public CompletableFuture createMulti( * Schedule, ServiceRequest, Slot, and Specimen.

            */ public CompletableFuture search(SearchRequest request) { - return this.rawClient.search(request).thenApply(response -> response.body()); + CompletableFuture> rawFuture = this.rawClient.search(request); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** @@ -99,7 +140,15 @@ public CompletableFuture search(SearchRequest request) { * Schedule, ServiceRequest, Slot, and Specimen.

            */ public CompletableFuture search(SearchRequest request, RequestOptions requestOptions) { - return this.rawClient.search(request, requestOptions).thenApply(response -> response.body()); + CompletableFuture> rawFuture = + this.rawClient.search(request, requestOptions); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** @@ -114,13 +163,21 @@ public CompletableFuture search(SearchRequest request, RequestOp * To use the uploaded profile with /lang2fhir/create, pass this id as the resource parameter.

            *

            Uploads will be rejected if:

            *
              - *
            • A built-in US Core or R4 base profile already exists with the same id
            • + *
            • A built-in profile (US Core, PhenoML HPO, or R4 base) already exists with the same id, or the id starts with a reserved prefix (us-core-, phenoml-hpo-)
            • *
            • A custom profile with the same id has already been uploaded
            • *
            • A custom profile with the same url has already been uploaded
            • *
            */ public CompletableFuture uploadProfile(ProfileUploadRequest request) { - return this.rawClient.uploadProfile(request).thenApply(response -> response.body()); + CompletableFuture> rawFuture = + this.rawClient.uploadProfile(request); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** @@ -135,14 +192,22 @@ public CompletableFuture uploadProfile(ProfileUploadReque * To use the uploaded profile with /lang2fhir/create, pass this id as the resource parameter.

            *

            Uploads will be rejected if:

            *
              - *
            • A built-in US Core or R4 base profile already exists with the same id
            • + *
            • A built-in profile (US Core, PhenoML HPO, or R4 base) already exists with the same id, or the id starts with a reserved prefix (us-core-, phenoml-hpo-)
            • *
            • A custom profile with the same id has already been uploaded
            • *
            • A custom profile with the same url has already been uploaded
            • *
            */ public CompletableFuture uploadProfile( ProfileUploadRequest request, RequestOptions requestOptions) { - return this.rawClient.uploadProfile(request, requestOptions).thenApply(response -> response.body()); + CompletableFuture> rawFuture = + this.rawClient.uploadProfile(request, requestOptions); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** @@ -151,7 +216,14 @@ public CompletableFuture uploadProfile( *

            Patient identifier handling. When generating a patient (or patient-canvas) resource, US Core requires Patient.identifier (a business identifier such as an MRN). When the source text contains an identifier, it is extracted with an appropriate URI system. When the source text does not contain a detectable identifier, a synthetic one is generated with system: "urn:phenoml:lang2fhir-generated-id" and a UUID value so the resource remains FHIR-valid and US Core conformant. Callers who need a tenant-specific namespace should rewrite the synthetic system after extraction.

            */ public CompletableFuture> document(DocumentRequest request) { - return this.rawClient.document(request).thenApply(response -> response.body()); + CompletableFuture>> rawFuture = this.rawClient.document(request); + CompletableFuture> future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** @@ -160,7 +232,15 @@ public CompletableFuture> document(DocumentRequest request) *

            Patient identifier handling. When generating a patient (or patient-canvas) resource, US Core requires Patient.identifier (a business identifier such as an MRN). When the source text contains an identifier, it is extracted with an appropriate URI system. When the source text does not contain a detectable identifier, a synthetic one is generated with system: "urn:phenoml:lang2fhir-generated-id" and a UUID value so the resource remains FHIR-valid and US Core conformant. Callers who need a tenant-specific namespace should rewrite the synthetic system after extraction.

            */ public CompletableFuture> document(DocumentRequest request, RequestOptions requestOptions) { - return this.rawClient.document(request, requestOptions).thenApply(response -> response.body()); + CompletableFuture>> rawFuture = + this.rawClient.document(request, requestOptions); + CompletableFuture> future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** @@ -169,11 +249,21 @@ public CompletableFuture> document(DocumentRequest request, * Automatically detects Patient, Condition, MedicationRequest, Observation, and other resource types. * Resources are linked with proper references (e.g., Conditions reference the Patient). *

            The complete JSON request body is limited to 32 MiB (33,554,432 bytes). The application enforces this whole-envelope limit, including base64-encoded document content.

            - *

            Patient identifier handling. US Core requires Patient.identifier (a business identifier such as an MRN). When the source text contains an identifier, it is extracted with an appropriate URI system. When the source text does not contain a detectable identifier, a synthetic one is generated with system: "urn:phenoml:lang2fhir-generated-id" and a UUID value so the bundle remains FHIR-valid and US Core conformant. Callers who need a tenant-specific namespace should rewrite the synthetic system after extraction.

            + *

            Patient handling. Every bundle contains exactly one Patient, and every clinical resource references it. When the document names its patient, the Patient is built from the document. When it names several patients, the Patient is the one primary_patient names, otherwise the one most of the document is about; the others are not represented as Patients. Otherwise it is built from primary_patient when supplied, or is an unidentified Patient carrying only a synthetic identifier. When the document names its patient, the primary_patient identifier is added to that Patient unless its name, birth date, or gender contradicts the document's; the response message says whether it was added or did not match.

            + *

            Patient identifier handling. US Core requires Patient.identifier (a business identifier such as an MRN). Every Patient carries a synthetic identifier with system: "urn:phenoml:lang2fhir-generated-id" and a UUID value, so the bundle remains FHIR-valid and US Core conformant. A medical record number stated in the document is added with system: "urn:phenoml:lang2fhir-document-mrn", and the primary_patient identifier is added as described above. Callers who need a tenant-specific namespace should rewrite these systems after extraction.

            + *

            Page classification. When config sets neither split_classifications nor page_filter, pages classified as test requisition forms (lab or diagnostic order forms) are dropped before extraction and reported in page_classifications. Supplying either setting replaces this default.

            *

            Split classifications (optional). config.split_classifications is a caller-defined list, not a fixed taxonomy. Choose each classification id and write a natural-language description for the per-page classifier. For each page, the classifier assigns the best-matching classification or leaves the page ungrouped. Classifications with operation: "group" keep matching pages and label resources extracted from those pages; classifications with operation: "drop" remove matching pages before extraction. The clinical and admin ids in the example are illustrative, not a fixed set.

            */ public CompletableFuture documentMulti(DocumentMultiRequest request) { - return this.rawClient.documentMulti(request).thenApply(response -> response.body()); + CompletableFuture> rawFuture = + this.rawClient.documentMulti(request); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** @@ -182,11 +272,21 @@ public CompletableFuture documentMulti(DocumentMultiReque * Automatically detects Patient, Condition, MedicationRequest, Observation, and other resource types. * Resources are linked with proper references (e.g., Conditions reference the Patient). *

            The complete JSON request body is limited to 32 MiB (33,554,432 bytes). The application enforces this whole-envelope limit, including base64-encoded document content.

            - *

            Patient identifier handling. US Core requires Patient.identifier (a business identifier such as an MRN). When the source text contains an identifier, it is extracted with an appropriate URI system. When the source text does not contain a detectable identifier, a synthetic one is generated with system: "urn:phenoml:lang2fhir-generated-id" and a UUID value so the bundle remains FHIR-valid and US Core conformant. Callers who need a tenant-specific namespace should rewrite the synthetic system after extraction.

            + *

            Patient handling. Every bundle contains exactly one Patient, and every clinical resource references it. When the document names its patient, the Patient is built from the document. When it names several patients, the Patient is the one primary_patient names, otherwise the one most of the document is about; the others are not represented as Patients. Otherwise it is built from primary_patient when supplied, or is an unidentified Patient carrying only a synthetic identifier. When the document names its patient, the primary_patient identifier is added to that Patient unless its name, birth date, or gender contradicts the document's; the response message says whether it was added or did not match.

            + *

            Patient identifier handling. US Core requires Patient.identifier (a business identifier such as an MRN). Every Patient carries a synthetic identifier with system: "urn:phenoml:lang2fhir-generated-id" and a UUID value, so the bundle remains FHIR-valid and US Core conformant. A medical record number stated in the document is added with system: "urn:phenoml:lang2fhir-document-mrn", and the primary_patient identifier is added as described above. Callers who need a tenant-specific namespace should rewrite these systems after extraction.

            + *

            Page classification. When config sets neither split_classifications nor page_filter, pages classified as test requisition forms (lab or diagnostic order forms) are dropped before extraction and reported in page_classifications. Supplying either setting replaces this default.

            *

            Split classifications (optional). config.split_classifications is a caller-defined list, not a fixed taxonomy. Choose each classification id and write a natural-language description for the per-page classifier. For each page, the classifier assigns the best-matching classification or leaves the page ungrouped. Classifications with operation: "group" keep matching pages and label resources extracted from those pages; classifications with operation: "drop" remove matching pages before extraction. The clinical and admin ids in the example are illustrative, not a fixed set.

            */ public CompletableFuture documentMulti( DocumentMultiRequest request, RequestOptions requestOptions) { - return this.rawClient.documentMulti(request, requestOptions).thenApply(response -> response.body()); + CompletableFuture> rawFuture = + this.rawClient.documentMulti(request, requestOptions); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } } diff --git a/src/main/java/com/phenoml/api/resources/lang2fhir/AsyncRawLang2FhirClient.java b/src/main/java/com/phenoml/api/resources/lang2fhir/AsyncRawLang2FhirClient.java index cf592d35..8647019e 100644 --- a/src/main/java/com/phenoml/api/resources/lang2fhir/AsyncRawLang2FhirClient.java +++ b/src/main/java/com/phenoml/api/resources/lang2fhir/AsyncRawLang2FhirClient.java @@ -5,6 +5,7 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.type.TypeReference; +import com.phenoml.api.core.BodyProperties; import com.phenoml.api.core.ClientOptions; import com.phenoml.api.core.MediaTypes; import com.phenoml.api.core.ObjectMappers; @@ -79,7 +80,9 @@ public CompletableFuture>> create( RequestBody body; try { body = RequestBody.create( - ObjectMappers.JSON_MAPPER.writeValueAsBytes(request), MediaTypes.APPLICATION_JSON); + ObjectMappers.JSON_MAPPER.writeValueAsBytes(BodyProperties.merge( + request, requestOptions != null ? requestOptions.getBodyProperties() : null)), + MediaTypes.APPLICATION_JSON); } catch (JsonProcessingException e) { throw new PhenomlClientException("Failed to serialize request", e); } @@ -104,7 +107,8 @@ public CompletableFuture>> create( .build(); } CompletableFuture>> future = new CompletableFuture<>(); - client.newCall(okhttpRequest).enqueue(new Callback() { + RetryInterceptor.AsyncCall okhttpCall = RetryInterceptor.newAsyncCall(client, okhttpRequest); + okhttpCall.enqueue(new Callback() { @Override public void onResponse(@NotNull Call call, @NotNull Response response) throws IOException { try (ResponseBody responseBody = response.body()) { @@ -164,6 +168,11 @@ public void onFailure(@NotNull Call call, @NotNull IOException e) { future.completeExceptionally(new PhenomlClientException("Network error executing HTTP request", e)); } }); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + okhttpCall.cancel(); + } + }); return future; } @@ -172,7 +181,8 @@ public void onFailure(@NotNull Call call, @NotNull IOException e) { * Automatically detects Patient, Condition, MedicationRequest, Observation, and other resource types from the text. * Resources are linked with proper references (e.g., Conditions reference the Patient). *

            The complete JSON request body is limited to 32 MiB (33,554,432 bytes). The application enforces this whole-envelope limit.

            - *

            Patient identifier handling. US Core requires Patient.identifier (a business identifier such as an MRN). When the source text contains an identifier, it is extracted with an appropriate URI system. When the source text does not contain a detectable identifier, a synthetic one is generated with system: "urn:phenoml:lang2fhir-generated-id" and a UUID value so the bundle remains FHIR-valid and US Core conformant. Callers who need a tenant-specific namespace should rewrite the synthetic system after extraction.

            + *

            Patient handling. Every bundle contains exactly one Patient, and every clinical resource references it. When the text names its patient, the Patient is built from the text. When it names several patients, the Patient is the one primary_patient names, otherwise the one most of the text is about; the others are not represented as Patients. Otherwise it is built from primary_patient when supplied, or is an unidentified Patient carrying only a synthetic identifier. When the text names its patient, the primary_patient identifier is added to that Patient unless its name, birth date, or gender contradicts the text's; the response message says whether it was added or did not match.

            + *

            Patient identifier handling. US Core requires Patient.identifier (a business identifier such as an MRN). Every Patient carries a synthetic identifier with system: "urn:phenoml:lang2fhir-generated-id" and a UUID value, so the bundle remains FHIR-valid and US Core conformant. A medical record number stated in the text is added with system: "urn:phenoml:lang2fhir-document-mrn", and the primary_patient identifier is added as described above. Callers who need a tenant-specific namespace should rewrite these systems after extraction.

            */ public CompletableFuture> createMulti(CreateMultiRequest request) { return createMulti(request, null); @@ -183,7 +193,8 @@ public CompletableFuture> createM * Automatically detects Patient, Condition, MedicationRequest, Observation, and other resource types from the text. * Resources are linked with proper references (e.g., Conditions reference the Patient). *

            The complete JSON request body is limited to 32 MiB (33,554,432 bytes). The application enforces this whole-envelope limit.

            - *

            Patient identifier handling. US Core requires Patient.identifier (a business identifier such as an MRN). When the source text contains an identifier, it is extracted with an appropriate URI system. When the source text does not contain a detectable identifier, a synthetic one is generated with system: "urn:phenoml:lang2fhir-generated-id" and a UUID value so the bundle remains FHIR-valid and US Core conformant. Callers who need a tenant-specific namespace should rewrite the synthetic system after extraction.

            + *

            Patient handling. Every bundle contains exactly one Patient, and every clinical resource references it. When the text names its patient, the Patient is built from the text. When it names several patients, the Patient is the one primary_patient names, otherwise the one most of the text is about; the others are not represented as Patients. Otherwise it is built from primary_patient when supplied, or is an unidentified Patient carrying only a synthetic identifier. When the text names its patient, the primary_patient identifier is added to that Patient unless its name, birth date, or gender contradicts the text's; the response message says whether it was added or did not match.

            + *

            Patient identifier handling. US Core requires Patient.identifier (a business identifier such as an MRN). Every Patient carries a synthetic identifier with system: "urn:phenoml:lang2fhir-generated-id" and a UUID value, so the bundle remains FHIR-valid and US Core conformant. A medical record number stated in the text is added with system: "urn:phenoml:lang2fhir-document-mrn", and the primary_patient identifier is added as described above. Callers who need a tenant-specific namespace should rewrite these systems after extraction.

            */ public CompletableFuture> createMulti( CreateMultiRequest request, RequestOptions requestOptions) { @@ -198,7 +209,9 @@ public CompletableFuture> createM RequestBody body; try { body = RequestBody.create( - ObjectMappers.JSON_MAPPER.writeValueAsBytes(request), MediaTypes.APPLICATION_JSON); + ObjectMappers.JSON_MAPPER.writeValueAsBytes(BodyProperties.merge( + request, requestOptions != null ? requestOptions.getBodyProperties() : null)), + MediaTypes.APPLICATION_JSON); } catch (JsonProcessingException e) { throw new PhenomlClientException("Failed to serialize request", e); } @@ -223,7 +236,8 @@ public CompletableFuture> createM .build(); } CompletableFuture> future = new CompletableFuture<>(); - client.newCall(okhttpRequest).enqueue(new Callback() { + RetryInterceptor.AsyncCall okhttpCall = RetryInterceptor.newAsyncCall(client, okhttpRequest); + okhttpCall.enqueue(new Callback() { @Override public void onResponse(@NotNull Call call, @NotNull Response response) throws IOException { try (ResponseBody responseBody = response.body()) { @@ -282,6 +296,11 @@ public void onFailure(@NotNull Call call, @NotNull IOException e) { future.completeExceptionally(new PhenomlClientException("Network error executing HTTP request", e)); } }); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + okhttpCall.cancel(); + } + }); return future; } @@ -320,7 +339,9 @@ public CompletableFuture> search( RequestBody body; try { body = RequestBody.create( - ObjectMappers.JSON_MAPPER.writeValueAsBytes(request), MediaTypes.APPLICATION_JSON); + ObjectMappers.JSON_MAPPER.writeValueAsBytes(BodyProperties.merge( + request, requestOptions != null ? requestOptions.getBodyProperties() : null)), + MediaTypes.APPLICATION_JSON); } catch (JsonProcessingException e) { throw new PhenomlClientException("Failed to serialize request", e); } @@ -345,7 +366,8 @@ public CompletableFuture> search( .build(); } CompletableFuture> future = new CompletableFuture<>(); - client.newCall(okhttpRequest).enqueue(new Callback() { + RetryInterceptor.AsyncCall okhttpCall = RetryInterceptor.newAsyncCall(client, okhttpRequest); + okhttpCall.enqueue(new Callback() { @Override public void onResponse(@NotNull Call call, @NotNull Response response) throws IOException { try (ResponseBody responseBody = response.body()) { @@ -394,6 +416,11 @@ public void onFailure(@NotNull Call call, @NotNull IOException e) { future.completeExceptionally(new PhenomlClientException("Network error executing HTTP request", e)); } }); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + okhttpCall.cancel(); + } + }); return future; } @@ -409,7 +436,7 @@ public void onFailure(@NotNull Call call, @NotNull IOException e) { * To use the uploaded profile with /lang2fhir/create, pass this id as the resource parameter.

            *

            Uploads will be rejected if:

            *
              - *
            • A built-in US Core or R4 base profile already exists with the same id
            • + *
            • A built-in profile (US Core, PhenoML HPO, or R4 base) already exists with the same id, or the id starts with a reserved prefix (us-core-, phenoml-hpo-)
            • *
            • A custom profile with the same id has already been uploaded
            • *
            • A custom profile with the same url has already been uploaded
            • *
            @@ -431,7 +458,7 @@ public CompletableFuture> uploa * To use the uploaded profile with /lang2fhir/create, pass this id as the resource parameter.

            *

            Uploads will be rejected if:

            *
              - *
            • A built-in US Core or R4 base profile already exists with the same id
            • + *
            • A built-in profile (US Core, PhenoML HPO, or R4 base) already exists with the same id, or the id starts with a reserved prefix (us-core-, phenoml-hpo-)
            • *
            • A custom profile with the same id has already been uploaded
            • *
            • A custom profile with the same url has already been uploaded
            • *
            @@ -449,7 +476,9 @@ public CompletableFuture> uploa RequestBody body; try { body = RequestBody.create( - ObjectMappers.JSON_MAPPER.writeValueAsBytes(request), MediaTypes.APPLICATION_JSON); + ObjectMappers.JSON_MAPPER.writeValueAsBytes(BodyProperties.merge( + request, requestOptions != null ? requestOptions.getBodyProperties() : null)), + MediaTypes.APPLICATION_JSON); } catch (JsonProcessingException e) { throw new PhenomlClientException("Failed to serialize request", e); } @@ -474,7 +503,8 @@ public CompletableFuture> uploa .build(); } CompletableFuture> future = new CompletableFuture<>(); - client.newCall(okhttpRequest).enqueue(new Callback() { + RetryInterceptor.AsyncCall okhttpCall = RetryInterceptor.newAsyncCall(client, okhttpRequest); + okhttpCall.enqueue(new Callback() { @Override public void onResponse(@NotNull Call call, @NotNull Response response) throws IOException { try (ResponseBody responseBody = response.body()) { @@ -528,6 +558,11 @@ public void onFailure(@NotNull Call call, @NotNull IOException e) { future.completeExceptionally(new PhenomlClientException("Network error executing HTTP request", e)); } }); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + okhttpCall.cancel(); + } + }); return future; } @@ -558,7 +593,9 @@ public CompletableFuture>> documen RequestBody body; try { body = RequestBody.create( - ObjectMappers.JSON_MAPPER.writeValueAsBytes(request), MediaTypes.APPLICATION_JSON); + ObjectMappers.JSON_MAPPER.writeValueAsBytes(BodyProperties.merge( + request, requestOptions != null ? requestOptions.getBodyProperties() : null)), + MediaTypes.APPLICATION_JSON); } catch (JsonProcessingException e) { throw new PhenomlClientException("Failed to serialize request", e); } @@ -583,7 +620,8 @@ public CompletableFuture>> documen .build(); } CompletableFuture>> future = new CompletableFuture<>(); - client.newCall(okhttpRequest).enqueue(new Callback() { + RetryInterceptor.AsyncCall okhttpCall = RetryInterceptor.newAsyncCall(client, okhttpRequest); + okhttpCall.enqueue(new Callback() { @Override public void onResponse(@NotNull Call call, @NotNull Response response) throws IOException { try (ResponseBody responseBody = response.body()) { @@ -658,6 +696,11 @@ public void onFailure(@NotNull Call call, @NotNull IOException e) { future.completeExceptionally(new PhenomlClientException("Network error executing HTTP request", e)); } }); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + okhttpCall.cancel(); + } + }); return future; } @@ -667,7 +710,9 @@ public void onFailure(@NotNull Call call, @NotNull IOException e) { * Automatically detects Patient, Condition, MedicationRequest, Observation, and other resource types. * Resources are linked with proper references (e.g., Conditions reference the Patient). *

            The complete JSON request body is limited to 32 MiB (33,554,432 bytes). The application enforces this whole-envelope limit, including base64-encoded document content.

            - *

            Patient identifier handling. US Core requires Patient.identifier (a business identifier such as an MRN). When the source text contains an identifier, it is extracted with an appropriate URI system. When the source text does not contain a detectable identifier, a synthetic one is generated with system: "urn:phenoml:lang2fhir-generated-id" and a UUID value so the bundle remains FHIR-valid and US Core conformant. Callers who need a tenant-specific namespace should rewrite the synthetic system after extraction.

            + *

            Patient handling. Every bundle contains exactly one Patient, and every clinical resource references it. When the document names its patient, the Patient is built from the document. When it names several patients, the Patient is the one primary_patient names, otherwise the one most of the document is about; the others are not represented as Patients. Otherwise it is built from primary_patient when supplied, or is an unidentified Patient carrying only a synthetic identifier. When the document names its patient, the primary_patient identifier is added to that Patient unless its name, birth date, or gender contradicts the document's; the response message says whether it was added or did not match.

            + *

            Patient identifier handling. US Core requires Patient.identifier (a business identifier such as an MRN). Every Patient carries a synthetic identifier with system: "urn:phenoml:lang2fhir-generated-id" and a UUID value, so the bundle remains FHIR-valid and US Core conformant. A medical record number stated in the document is added with system: "urn:phenoml:lang2fhir-document-mrn", and the primary_patient identifier is added as described above. Callers who need a tenant-specific namespace should rewrite these systems after extraction.

            + *

            Page classification. When config sets neither split_classifications nor page_filter, pages classified as test requisition forms (lab or diagnostic order forms) are dropped before extraction and reported in page_classifications. Supplying either setting replaces this default.

            *

            Split classifications (optional). config.split_classifications is a caller-defined list, not a fixed taxonomy. Choose each classification id and write a natural-language description for the per-page classifier. For each page, the classifier assigns the best-matching classification or leaves the page ungrouped. Classifications with operation: "group" keep matching pages and label resources extracted from those pages; classifications with operation: "drop" remove matching pages before extraction. The clinical and admin ids in the example are illustrative, not a fixed set.

            */ public CompletableFuture> documentMulti( @@ -681,7 +726,9 @@ public CompletableFuture> docum * Automatically detects Patient, Condition, MedicationRequest, Observation, and other resource types. * Resources are linked with proper references (e.g., Conditions reference the Patient). *

            The complete JSON request body is limited to 32 MiB (33,554,432 bytes). The application enforces this whole-envelope limit, including base64-encoded document content.

            - *

            Patient identifier handling. US Core requires Patient.identifier (a business identifier such as an MRN). When the source text contains an identifier, it is extracted with an appropriate URI system. When the source text does not contain a detectable identifier, a synthetic one is generated with system: "urn:phenoml:lang2fhir-generated-id" and a UUID value so the bundle remains FHIR-valid and US Core conformant. Callers who need a tenant-specific namespace should rewrite the synthetic system after extraction.

            + *

            Patient handling. Every bundle contains exactly one Patient, and every clinical resource references it. When the document names its patient, the Patient is built from the document. When it names several patients, the Patient is the one primary_patient names, otherwise the one most of the document is about; the others are not represented as Patients. Otherwise it is built from primary_patient when supplied, or is an unidentified Patient carrying only a synthetic identifier. When the document names its patient, the primary_patient identifier is added to that Patient unless its name, birth date, or gender contradicts the document's; the response message says whether it was added or did not match.

            + *

            Patient identifier handling. US Core requires Patient.identifier (a business identifier such as an MRN). Every Patient carries a synthetic identifier with system: "urn:phenoml:lang2fhir-generated-id" and a UUID value, so the bundle remains FHIR-valid and US Core conformant. A medical record number stated in the document is added with system: "urn:phenoml:lang2fhir-document-mrn", and the primary_patient identifier is added as described above. Callers who need a tenant-specific namespace should rewrite these systems after extraction.

            + *

            Page classification. When config sets neither split_classifications nor page_filter, pages classified as test requisition forms (lab or diagnostic order forms) are dropped before extraction and reported in page_classifications. Supplying either setting replaces this default.

            *

            Split classifications (optional). config.split_classifications is a caller-defined list, not a fixed taxonomy. Choose each classification id and write a natural-language description for the per-page classifier. For each page, the classifier assigns the best-matching classification or leaves the page ungrouped. Classifications with operation: "group" keep matching pages and label resources extracted from those pages; classifications with operation: "drop" remove matching pages before extraction. The clinical and admin ids in the example are illustrative, not a fixed set.

            */ public CompletableFuture> documentMulti( @@ -697,7 +744,9 @@ public CompletableFuture> docum RequestBody body; try { body = RequestBody.create( - ObjectMappers.JSON_MAPPER.writeValueAsBytes(request), MediaTypes.APPLICATION_JSON); + ObjectMappers.JSON_MAPPER.writeValueAsBytes(BodyProperties.merge( + request, requestOptions != null ? requestOptions.getBodyProperties() : null)), + MediaTypes.APPLICATION_JSON); } catch (JsonProcessingException e) { throw new PhenomlClientException("Failed to serialize request", e); } @@ -722,7 +771,8 @@ public CompletableFuture> docum .build(); } CompletableFuture> future = new CompletableFuture<>(); - client.newCall(okhttpRequest).enqueue(new Callback() { + RetryInterceptor.AsyncCall okhttpCall = RetryInterceptor.newAsyncCall(client, okhttpRequest); + okhttpCall.enqueue(new Callback() { @Override public void onResponse(@NotNull Call call, @NotNull Response response) throws IOException { try (ResponseBody responseBody = response.body()) { @@ -796,6 +846,11 @@ public void onFailure(@NotNull Call call, @NotNull IOException e) { future.completeExceptionally(new PhenomlClientException("Network error executing HTTP request", e)); } }); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + okhttpCall.cancel(); + } + }); return future; } } diff --git a/src/main/java/com/phenoml/api/resources/lang2fhir/Lang2FhirClient.java b/src/main/java/com/phenoml/api/resources/lang2fhir/Lang2FhirClient.java index 8f3f7c93..07c2e50b 100644 --- a/src/main/java/com/phenoml/api/resources/lang2fhir/Lang2FhirClient.java +++ b/src/main/java/com/phenoml/api/resources/lang2fhir/Lang2FhirClient.java @@ -57,7 +57,8 @@ public Map create(CreateRequest request, RequestOptions requestO * Automatically detects Patient, Condition, MedicationRequest, Observation, and other resource types from the text. * Resources are linked with proper references (e.g., Conditions reference the Patient). *

            The complete JSON request body is limited to 32 MiB (33,554,432 bytes). The application enforces this whole-envelope limit.

            - *

            Patient identifier handling. US Core requires Patient.identifier (a business identifier such as an MRN). When the source text contains an identifier, it is extracted with an appropriate URI system. When the source text does not contain a detectable identifier, a synthetic one is generated with system: "urn:phenoml:lang2fhir-generated-id" and a UUID value so the bundle remains FHIR-valid and US Core conformant. Callers who need a tenant-specific namespace should rewrite the synthetic system after extraction.

            + *

            Patient handling. Every bundle contains exactly one Patient, and every clinical resource references it. When the text names its patient, the Patient is built from the text. When it names several patients, the Patient is the one primary_patient names, otherwise the one most of the text is about; the others are not represented as Patients. Otherwise it is built from primary_patient when supplied, or is an unidentified Patient carrying only a synthetic identifier. When the text names its patient, the primary_patient identifier is added to that Patient unless its name, birth date, or gender contradicts the text's; the response message says whether it was added or did not match.

            + *

            Patient identifier handling. US Core requires Patient.identifier (a business identifier such as an MRN). Every Patient carries a synthetic identifier with system: "urn:phenoml:lang2fhir-generated-id" and a UUID value, so the bundle remains FHIR-valid and US Core conformant. A medical record number stated in the text is added with system: "urn:phenoml:lang2fhir-document-mrn", and the primary_patient identifier is added as described above. Callers who need a tenant-specific namespace should rewrite these systems after extraction.

            */ public CreateMultiResponse createMulti(CreateMultiRequest request) { return this.rawClient.createMulti(request).body(); @@ -68,7 +69,8 @@ public CreateMultiResponse createMulti(CreateMultiRequest request) { * Automatically detects Patient, Condition, MedicationRequest, Observation, and other resource types from the text. * Resources are linked with proper references (e.g., Conditions reference the Patient). *

            The complete JSON request body is limited to 32 MiB (33,554,432 bytes). The application enforces this whole-envelope limit.

            - *

            Patient identifier handling. US Core requires Patient.identifier (a business identifier such as an MRN). When the source text contains an identifier, it is extracted with an appropriate URI system. When the source text does not contain a detectable identifier, a synthetic one is generated with system: "urn:phenoml:lang2fhir-generated-id" and a UUID value so the bundle remains FHIR-valid and US Core conformant. Callers who need a tenant-specific namespace should rewrite the synthetic system after extraction.

            + *

            Patient handling. Every bundle contains exactly one Patient, and every clinical resource references it. When the text names its patient, the Patient is built from the text. When it names several patients, the Patient is the one primary_patient names, otherwise the one most of the text is about; the others are not represented as Patients. Otherwise it is built from primary_patient when supplied, or is an unidentified Patient carrying only a synthetic identifier. When the text names its patient, the primary_patient identifier is added to that Patient unless its name, birth date, or gender contradicts the text's; the response message says whether it was added or did not match.

            + *

            Patient identifier handling. US Core requires Patient.identifier (a business identifier such as an MRN). Every Patient carries a synthetic identifier with system: "urn:phenoml:lang2fhir-generated-id" and a UUID value, so the bundle remains FHIR-valid and US Core conformant. A medical record number stated in the text is added with system: "urn:phenoml:lang2fhir-document-mrn", and the primary_patient identifier is added as described above. Callers who need a tenant-specific namespace should rewrite these systems after extraction.

            */ public CreateMultiResponse createMulti(CreateMultiRequest request, RequestOptions requestOptions) { return this.rawClient.createMulti(request, requestOptions).body(); @@ -112,7 +114,7 @@ public SearchResponse search(SearchRequest request, RequestOptions requestOption * To use the uploaded profile with /lang2fhir/create, pass this id as the resource parameter.

            *

            Uploads will be rejected if:

            *
              - *
            • A built-in US Core or R4 base profile already exists with the same id
            • + *
            • A built-in profile (US Core, PhenoML HPO, or R4 base) already exists with the same id, or the id starts with a reserved prefix (us-core-, phenoml-hpo-)
            • *
            • A custom profile with the same id has already been uploaded
            • *
            • A custom profile with the same url has already been uploaded
            • *
            @@ -133,7 +135,7 @@ public UploadProfileResponse uploadProfile(ProfileUploadRequest request) { * To use the uploaded profile with /lang2fhir/create, pass this id as the resource parameter.

            *

            Uploads will be rejected if:

            *
              - *
            • A built-in US Core or R4 base profile already exists with the same id
            • + *
            • A built-in profile (US Core, PhenoML HPO, or R4 base) already exists with the same id, or the id starts with a reserved prefix (us-core-, phenoml-hpo-)
            • *
            • A custom profile with the same id has already been uploaded
            • *
            • A custom profile with the same url has already been uploaded
            • *
            @@ -166,7 +168,9 @@ public Map document(DocumentRequest request, RequestOptions requ * Automatically detects Patient, Condition, MedicationRequest, Observation, and other resource types. * Resources are linked with proper references (e.g., Conditions reference the Patient). *

            The complete JSON request body is limited to 32 MiB (33,554,432 bytes). The application enforces this whole-envelope limit, including base64-encoded document content.

            - *

            Patient identifier handling. US Core requires Patient.identifier (a business identifier such as an MRN). When the source text contains an identifier, it is extracted with an appropriate URI system. When the source text does not contain a detectable identifier, a synthetic one is generated with system: "urn:phenoml:lang2fhir-generated-id" and a UUID value so the bundle remains FHIR-valid and US Core conformant. Callers who need a tenant-specific namespace should rewrite the synthetic system after extraction.

            + *

            Patient handling. Every bundle contains exactly one Patient, and every clinical resource references it. When the document names its patient, the Patient is built from the document. When it names several patients, the Patient is the one primary_patient names, otherwise the one most of the document is about; the others are not represented as Patients. Otherwise it is built from primary_patient when supplied, or is an unidentified Patient carrying only a synthetic identifier. When the document names its patient, the primary_patient identifier is added to that Patient unless its name, birth date, or gender contradicts the document's; the response message says whether it was added or did not match.

            + *

            Patient identifier handling. US Core requires Patient.identifier (a business identifier such as an MRN). Every Patient carries a synthetic identifier with system: "urn:phenoml:lang2fhir-generated-id" and a UUID value, so the bundle remains FHIR-valid and US Core conformant. A medical record number stated in the document is added with system: "urn:phenoml:lang2fhir-document-mrn", and the primary_patient identifier is added as described above. Callers who need a tenant-specific namespace should rewrite these systems after extraction.

            + *

            Page classification. When config sets neither split_classifications nor page_filter, pages classified as test requisition forms (lab or diagnostic order forms) are dropped before extraction and reported in page_classifications. Supplying either setting replaces this default.

            *

            Split classifications (optional). config.split_classifications is a caller-defined list, not a fixed taxonomy. Choose each classification id and write a natural-language description for the per-page classifier. For each page, the classifier assigns the best-matching classification or leaves the page ungrouped. Classifications with operation: "group" keep matching pages and label resources extracted from those pages; classifications with operation: "drop" remove matching pages before extraction. The clinical and admin ids in the example are illustrative, not a fixed set.

            */ public DocumentMultiResponse documentMulti(DocumentMultiRequest request) { @@ -179,7 +183,9 @@ public DocumentMultiResponse documentMulti(DocumentMultiRequest request) { * Automatically detects Patient, Condition, MedicationRequest, Observation, and other resource types. * Resources are linked with proper references (e.g., Conditions reference the Patient). *

            The complete JSON request body is limited to 32 MiB (33,554,432 bytes). The application enforces this whole-envelope limit, including base64-encoded document content.

            - *

            Patient identifier handling. US Core requires Patient.identifier (a business identifier such as an MRN). When the source text contains an identifier, it is extracted with an appropriate URI system. When the source text does not contain a detectable identifier, a synthetic one is generated with system: "urn:phenoml:lang2fhir-generated-id" and a UUID value so the bundle remains FHIR-valid and US Core conformant. Callers who need a tenant-specific namespace should rewrite the synthetic system after extraction.

            + *

            Patient handling. Every bundle contains exactly one Patient, and every clinical resource references it. When the document names its patient, the Patient is built from the document. When it names several patients, the Patient is the one primary_patient names, otherwise the one most of the document is about; the others are not represented as Patients. Otherwise it is built from primary_patient when supplied, or is an unidentified Patient carrying only a synthetic identifier. When the document names its patient, the primary_patient identifier is added to that Patient unless its name, birth date, or gender contradicts the document's; the response message says whether it was added or did not match.

            + *

            Patient identifier handling. US Core requires Patient.identifier (a business identifier such as an MRN). Every Patient carries a synthetic identifier with system: "urn:phenoml:lang2fhir-generated-id" and a UUID value, so the bundle remains FHIR-valid and US Core conformant. A medical record number stated in the document is added with system: "urn:phenoml:lang2fhir-document-mrn", and the primary_patient identifier is added as described above. Callers who need a tenant-specific namespace should rewrite these systems after extraction.

            + *

            Page classification. When config sets neither split_classifications nor page_filter, pages classified as test requisition forms (lab or diagnostic order forms) are dropped before extraction and reported in page_classifications. Supplying either setting replaces this default.

            *

            Split classifications (optional). config.split_classifications is a caller-defined list, not a fixed taxonomy. Choose each classification id and write a natural-language description for the per-page classifier. For each page, the classifier assigns the best-matching classification or leaves the page ungrouped. Classifications with operation: "group" keep matching pages and label resources extracted from those pages; classifications with operation: "drop" remove matching pages before extraction. The clinical and admin ids in the example are illustrative, not a fixed set.

            */ public DocumentMultiResponse documentMulti(DocumentMultiRequest request, RequestOptions requestOptions) { diff --git a/src/main/java/com/phenoml/api/resources/lang2fhir/RawLang2FhirClient.java b/src/main/java/com/phenoml/api/resources/lang2fhir/RawLang2FhirClient.java index 2fd27ea0..59bd8298 100644 --- a/src/main/java/com/phenoml/api/resources/lang2fhir/RawLang2FhirClient.java +++ b/src/main/java/com/phenoml/api/resources/lang2fhir/RawLang2FhirClient.java @@ -5,6 +5,7 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.type.TypeReference; +import com.phenoml.api.core.BodyProperties; import com.phenoml.api.core.ClientOptions; import com.phenoml.api.core.MediaTypes; import com.phenoml.api.core.ObjectMappers; @@ -74,7 +75,9 @@ public PhenomlClientHttpResponse> create(CreateRequest reque RequestBody body; try { body = RequestBody.create( - ObjectMappers.JSON_MAPPER.writeValueAsBytes(request), MediaTypes.APPLICATION_JSON); + ObjectMappers.JSON_MAPPER.writeValueAsBytes(BodyProperties.merge( + request, requestOptions != null ? requestOptions.getBodyProperties() : null)), + MediaTypes.APPLICATION_JSON); } catch (JsonProcessingException e) { throw new PhenomlClientException("Failed to serialize request", e); } @@ -143,7 +146,8 @@ public PhenomlClientHttpResponse> create(CreateRequest reque * Automatically detects Patient, Condition, MedicationRequest, Observation, and other resource types from the text. * Resources are linked with proper references (e.g., Conditions reference the Patient). *

            The complete JSON request body is limited to 32 MiB (33,554,432 bytes). The application enforces this whole-envelope limit.

            - *

            Patient identifier handling. US Core requires Patient.identifier (a business identifier such as an MRN). When the source text contains an identifier, it is extracted with an appropriate URI system. When the source text does not contain a detectable identifier, a synthetic one is generated with system: "urn:phenoml:lang2fhir-generated-id" and a UUID value so the bundle remains FHIR-valid and US Core conformant. Callers who need a tenant-specific namespace should rewrite the synthetic system after extraction.

            + *

            Patient handling. Every bundle contains exactly one Patient, and every clinical resource references it. When the text names its patient, the Patient is built from the text. When it names several patients, the Patient is the one primary_patient names, otherwise the one most of the text is about; the others are not represented as Patients. Otherwise it is built from primary_patient when supplied, or is an unidentified Patient carrying only a synthetic identifier. When the text names its patient, the primary_patient identifier is added to that Patient unless its name, birth date, or gender contradicts the text's; the response message says whether it was added or did not match.

            + *

            Patient identifier handling. US Core requires Patient.identifier (a business identifier such as an MRN). Every Patient carries a synthetic identifier with system: "urn:phenoml:lang2fhir-generated-id" and a UUID value, so the bundle remains FHIR-valid and US Core conformant. A medical record number stated in the text is added with system: "urn:phenoml:lang2fhir-document-mrn", and the primary_patient identifier is added as described above. Callers who need a tenant-specific namespace should rewrite these systems after extraction.

            */ public PhenomlClientHttpResponse createMulti(CreateMultiRequest request) { return createMulti(request, null); @@ -154,7 +158,8 @@ public PhenomlClientHttpResponse createMulti(CreateMultiReq * Automatically detects Patient, Condition, MedicationRequest, Observation, and other resource types from the text. * Resources are linked with proper references (e.g., Conditions reference the Patient). *

            The complete JSON request body is limited to 32 MiB (33,554,432 bytes). The application enforces this whole-envelope limit.

            - *

            Patient identifier handling. US Core requires Patient.identifier (a business identifier such as an MRN). When the source text contains an identifier, it is extracted with an appropriate URI system. When the source text does not contain a detectable identifier, a synthetic one is generated with system: "urn:phenoml:lang2fhir-generated-id" and a UUID value so the bundle remains FHIR-valid and US Core conformant. Callers who need a tenant-specific namespace should rewrite the synthetic system after extraction.

            + *

            Patient handling. Every bundle contains exactly one Patient, and every clinical resource references it. When the text names its patient, the Patient is built from the text. When it names several patients, the Patient is the one primary_patient names, otherwise the one most of the text is about; the others are not represented as Patients. Otherwise it is built from primary_patient when supplied, or is an unidentified Patient carrying only a synthetic identifier. When the text names its patient, the primary_patient identifier is added to that Patient unless its name, birth date, or gender contradicts the text's; the response message says whether it was added or did not match.

            + *

            Patient identifier handling. US Core requires Patient.identifier (a business identifier such as an MRN). Every Patient carries a synthetic identifier with system: "urn:phenoml:lang2fhir-generated-id" and a UUID value, so the bundle remains FHIR-valid and US Core conformant. A medical record number stated in the text is added with system: "urn:phenoml:lang2fhir-document-mrn", and the primary_patient identifier is added as described above. Callers who need a tenant-specific namespace should rewrite these systems after extraction.

            */ public PhenomlClientHttpResponse createMulti( CreateMultiRequest request, RequestOptions requestOptions) { @@ -169,7 +174,9 @@ public PhenomlClientHttpResponse createMulti( RequestBody body; try { body = RequestBody.create( - ObjectMappers.JSON_MAPPER.writeValueAsBytes(request), MediaTypes.APPLICATION_JSON); + ObjectMappers.JSON_MAPPER.writeValueAsBytes(BodyProperties.merge( + request, requestOptions != null ? requestOptions.getBodyProperties() : null)), + MediaTypes.APPLICATION_JSON); } catch (JsonProcessingException e) { throw new PhenomlClientException("Failed to serialize request", e); } @@ -265,7 +272,9 @@ public PhenomlClientHttpResponse search(SearchRequest request, R RequestBody body; try { body = RequestBody.create( - ObjectMappers.JSON_MAPPER.writeValueAsBytes(request), MediaTypes.APPLICATION_JSON); + ObjectMappers.JSON_MAPPER.writeValueAsBytes(BodyProperties.merge( + request, requestOptions != null ? requestOptions.getBodyProperties() : null)), + MediaTypes.APPLICATION_JSON); } catch (JsonProcessingException e) { throw new PhenomlClientException("Failed to serialize request", e); } @@ -333,7 +342,7 @@ public PhenomlClientHttpResponse search(SearchRequest request, R * To use the uploaded profile with /lang2fhir/create, pass this id as the resource parameter.

            *

            Uploads will be rejected if:

            *
              - *
            • A built-in US Core or R4 base profile already exists with the same id
            • + *
            • A built-in profile (US Core, PhenoML HPO, or R4 base) already exists with the same id, or the id starts with a reserved prefix (us-core-, phenoml-hpo-)
            • *
            • A custom profile with the same id has already been uploaded
            • *
            • A custom profile with the same url has already been uploaded
            • *
            @@ -354,7 +363,7 @@ public PhenomlClientHttpResponse uploadProfile(ProfileUpl * To use the uploaded profile with /lang2fhir/create, pass this id as the resource parameter.

            *

            Uploads will be rejected if:

            *
              - *
            • A built-in US Core or R4 base profile already exists with the same id
            • + *
            • A built-in profile (US Core, PhenoML HPO, or R4 base) already exists with the same id, or the id starts with a reserved prefix (us-core-, phenoml-hpo-)
            • *
            • A custom profile with the same id has already been uploaded
            • *
            • A custom profile with the same url has already been uploaded
            • *
            @@ -372,7 +381,9 @@ public PhenomlClientHttpResponse uploadProfile( RequestBody body; try { body = RequestBody.create( - ObjectMappers.JSON_MAPPER.writeValueAsBytes(request), MediaTypes.APPLICATION_JSON); + ObjectMappers.JSON_MAPPER.writeValueAsBytes(BodyProperties.merge( + request, requestOptions != null ? requestOptions.getBodyProperties() : null)), + MediaTypes.APPLICATION_JSON); } catch (JsonProcessingException e) { throw new PhenomlClientException("Failed to serialize request", e); } @@ -458,7 +469,9 @@ public PhenomlClientHttpResponse> document( RequestBody body; try { body = RequestBody.create( - ObjectMappers.JSON_MAPPER.writeValueAsBytes(request), MediaTypes.APPLICATION_JSON); + ObjectMappers.JSON_MAPPER.writeValueAsBytes(BodyProperties.merge( + request, requestOptions != null ? requestOptions.getBodyProperties() : null)), + MediaTypes.APPLICATION_JSON); } catch (JsonProcessingException e) { throw new PhenomlClientException("Failed to serialize request", e); } @@ -537,7 +550,9 @@ public PhenomlClientHttpResponse> document( * Automatically detects Patient, Condition, MedicationRequest, Observation, and other resource types. * Resources are linked with proper references (e.g., Conditions reference the Patient). *

            The complete JSON request body is limited to 32 MiB (33,554,432 bytes). The application enforces this whole-envelope limit, including base64-encoded document content.

            - *

            Patient identifier handling. US Core requires Patient.identifier (a business identifier such as an MRN). When the source text contains an identifier, it is extracted with an appropriate URI system. When the source text does not contain a detectable identifier, a synthetic one is generated with system: "urn:phenoml:lang2fhir-generated-id" and a UUID value so the bundle remains FHIR-valid and US Core conformant. Callers who need a tenant-specific namespace should rewrite the synthetic system after extraction.

            + *

            Patient handling. Every bundle contains exactly one Patient, and every clinical resource references it. When the document names its patient, the Patient is built from the document. When it names several patients, the Patient is the one primary_patient names, otherwise the one most of the document is about; the others are not represented as Patients. Otherwise it is built from primary_patient when supplied, or is an unidentified Patient carrying only a synthetic identifier. When the document names its patient, the primary_patient identifier is added to that Patient unless its name, birth date, or gender contradicts the document's; the response message says whether it was added or did not match.

            + *

            Patient identifier handling. US Core requires Patient.identifier (a business identifier such as an MRN). Every Patient carries a synthetic identifier with system: "urn:phenoml:lang2fhir-generated-id" and a UUID value, so the bundle remains FHIR-valid and US Core conformant. A medical record number stated in the document is added with system: "urn:phenoml:lang2fhir-document-mrn", and the primary_patient identifier is added as described above. Callers who need a tenant-specific namespace should rewrite these systems after extraction.

            + *

            Page classification. When config sets neither split_classifications nor page_filter, pages classified as test requisition forms (lab or diagnostic order forms) are dropped before extraction and reported in page_classifications. Supplying either setting replaces this default.

            *

            Split classifications (optional). config.split_classifications is a caller-defined list, not a fixed taxonomy. Choose each classification id and write a natural-language description for the per-page classifier. For each page, the classifier assigns the best-matching classification or leaves the page ungrouped. Classifications with operation: "group" keep matching pages and label resources extracted from those pages; classifications with operation: "drop" remove matching pages before extraction. The clinical and admin ids in the example are illustrative, not a fixed set.

            */ public PhenomlClientHttpResponse documentMulti(DocumentMultiRequest request) { @@ -550,7 +565,9 @@ public PhenomlClientHttpResponse documentMulti(DocumentMu * Automatically detects Patient, Condition, MedicationRequest, Observation, and other resource types. * Resources are linked with proper references (e.g., Conditions reference the Patient). *

            The complete JSON request body is limited to 32 MiB (33,554,432 bytes). The application enforces this whole-envelope limit, including base64-encoded document content.

            - *

            Patient identifier handling. US Core requires Patient.identifier (a business identifier such as an MRN). When the source text contains an identifier, it is extracted with an appropriate URI system. When the source text does not contain a detectable identifier, a synthetic one is generated with system: "urn:phenoml:lang2fhir-generated-id" and a UUID value so the bundle remains FHIR-valid and US Core conformant. Callers who need a tenant-specific namespace should rewrite the synthetic system after extraction.

            + *

            Patient handling. Every bundle contains exactly one Patient, and every clinical resource references it. When the document names its patient, the Patient is built from the document. When it names several patients, the Patient is the one primary_patient names, otherwise the one most of the document is about; the others are not represented as Patients. Otherwise it is built from primary_patient when supplied, or is an unidentified Patient carrying only a synthetic identifier. When the document names its patient, the primary_patient identifier is added to that Patient unless its name, birth date, or gender contradicts the document's; the response message says whether it was added or did not match.

            + *

            Patient identifier handling. US Core requires Patient.identifier (a business identifier such as an MRN). Every Patient carries a synthetic identifier with system: "urn:phenoml:lang2fhir-generated-id" and a UUID value, so the bundle remains FHIR-valid and US Core conformant. A medical record number stated in the document is added with system: "urn:phenoml:lang2fhir-document-mrn", and the primary_patient identifier is added as described above. Callers who need a tenant-specific namespace should rewrite these systems after extraction.

            + *

            Page classification. When config sets neither split_classifications nor page_filter, pages classified as test requisition forms (lab or diagnostic order forms) are dropped before extraction and reported in page_classifications. Supplying either setting replaces this default.

            *

            Split classifications (optional). config.split_classifications is a caller-defined list, not a fixed taxonomy. Choose each classification id and write a natural-language description for the per-page classifier. For each page, the classifier assigns the best-matching classification or leaves the page ungrouped. Classifications with operation: "group" keep matching pages and label resources extracted from those pages; classifications with operation: "drop" remove matching pages before extraction. The clinical and admin ids in the example are illustrative, not a fixed set.

            */ public PhenomlClientHttpResponse documentMulti( @@ -566,7 +583,9 @@ public PhenomlClientHttpResponse documentMulti( RequestBody body; try { body = RequestBody.create( - ObjectMappers.JSON_MAPPER.writeValueAsBytes(request), MediaTypes.APPLICATION_JSON); + ObjectMappers.JSON_MAPPER.writeValueAsBytes(BodyProperties.merge( + request, requestOptions != null ? requestOptions.getBodyProperties() : null)), + MediaTypes.APPLICATION_JSON); } catch (JsonProcessingException e) { throw new PhenomlClientException("Failed to serialize request", e); } diff --git a/src/main/java/com/phenoml/api/resources/lang2fhir/requests/CreateMultiRequest.java b/src/main/java/com/phenoml/api/resources/lang2fhir/requests/CreateMultiRequest.java index 55dc46ff..4ba4d5b7 100644 --- a/src/main/java/com/phenoml/api/resources/lang2fhir/requests/CreateMultiRequest.java +++ b/src/main/java/com/phenoml/api/resources/lang2fhir/requests/CreateMultiRequest.java @@ -44,6 +44,8 @@ public final class CreateMultiRequest { private final Optional resourceReview; + private final Optional smallNote; + private final Map additionalProperties; private CreateMultiRequest( @@ -56,6 +58,7 @@ private CreateMultiRequest( Optional detectionEffort, Optional validationMethod, Optional resourceReview, + Optional smallNote, Map additionalProperties) { this.text = text; this.version = version; @@ -66,6 +69,7 @@ private CreateMultiRequest( this.detectionEffort = detectionEffort; this.validationMethod = validationMethod; this.resourceReview = resourceReview; + this.smallNote = smallNote; this.additionalProperties = additionalProperties; } @@ -93,6 +97,9 @@ public Optional getProvider() { return provider; } + /** + * @return Context for the patient the text is primarily about. When the text does not name its patient, the bundle's Patient is built from these fields. When it does, the identifier is added to that Patient unless its name, birth date, or gender contradicts these fields. + */ @JsonProperty("primary_patient") public Optional getPrimaryPatient() { return primaryPatient; @@ -107,7 +114,7 @@ public Optional getPatientReference() { } /** - * @return Custom Implementation Guide name. When specified, profiles from this IG are included alongside the default profiles during resource detection. Default profiles are always the base layer; custom IG profiles are additive. + * @return Implementation Guide to generate resources under. us_core (the default) and phenoml_hpo select a built-in guide. Any other value is a custom Implementation Guide name: its profiles are included alongside the US Core profiles, which are always the base layer. A custom name that matches no uploaded Implementation Guide returns 404. */ @JsonProperty("implementation_guide") public Optional getImplementationGuide() { @@ -115,7 +122,7 @@ public Optional getImplementationGuide() { } /** - * @return Deprecated; use the default 'standard' value. This field will be removed in a future release. 'standard' runs detection once; 'deep' runs detection multiple times for higher recall. + * @return Deprecated and ignored: both values produce the same result. This field will be removed in a future release. */ @JsonProperty("detection_effort") public Optional getDetectionEffort() { @@ -135,6 +142,14 @@ public Optional getResourceReview() { return resourceReview; } + /** + * @return Bills the request at the lower small-note rate. When true, text must be at most 15,360 bytes (15 KiB) when UTF-8 encoded; longer text is rejected with a 400 error. Extraction behavior is the same as a standard request. + */ + @JsonProperty("small_note") + public Optional getSmallNote() { + return smallNote; + } + @java.lang.Override public boolean equals(Object other) { if (this == other) return true; @@ -155,7 +170,8 @@ private boolean equalTo(CreateMultiRequest other) { && implementationGuide.equals(other.implementationGuide) && detectionEffort.equals(other.detectionEffort) && validationMethod.equals(other.validationMethod) - && resourceReview.equals(other.resourceReview); + && resourceReview.equals(other.resourceReview) + && smallNote.equals(other.smallNote); } @java.lang.Override @@ -169,7 +185,8 @@ public int hashCode() { this.implementationGuide, this.detectionEffort, this.validationMethod, - this.resourceReview); + this.resourceReview, + this.smallNote); } @java.lang.Override @@ -211,6 +228,9 @@ public interface _FinalStage { _FinalStage provider(String provider); + /** + *

            Context for the patient the text is primarily about. When the text does not name its patient, the bundle's Patient is built from these fields. When it does, the identifier is added to that Patient unless its name, birth date, or gender contradicts these fields.

            + */ _FinalStage primaryPatient(Optional primaryPatient); _FinalStage primaryPatient(PrimaryPatient primaryPatient); @@ -223,14 +243,14 @@ public interface _FinalStage { _FinalStage patientReference(PatientReference patientReference); /** - *

            Custom Implementation Guide name. When specified, profiles from this IG are included alongside the default profiles during resource detection. Default profiles are always the base layer; custom IG profiles are additive.

            + *

            Implementation Guide to generate resources under. us_core (the default) and phenoml_hpo select a built-in guide. Any other value is a custom Implementation Guide name: its profiles are included alongside the US Core profiles, which are always the base layer. A custom name that matches no uploaded Implementation Guide returns 404.

            */ _FinalStage implementationGuide(Optional implementationGuide); _FinalStage implementationGuide(String implementationGuide); /** - *

            Deprecated; use the default 'standard' value. This field will be removed in a future release. 'standard' runs detection once; 'deep' runs detection multiple times for higher recall.

            + *

            Deprecated and ignored: both values produce the same result. This field will be removed in a future release.

            */ _FinalStage detectionEffort(Optional detectionEffort); @@ -246,12 +266,21 @@ public interface _FinalStage { _FinalStage resourceReview(Optional resourceReview); _FinalStage resourceReview(ResourceReview resourceReview); + + /** + *

            Bills the request at the lower small-note rate. When true, text must be at most 15,360 bytes (15 KiB) when UTF-8 encoded; longer text is rejected with a 400 error. Extraction behavior is the same as a standard request.

            + */ + _FinalStage smallNote(Optional smallNote); + + _FinalStage smallNote(Boolean smallNote); } @JsonIgnoreProperties(ignoreUnknown = true) public static final class Builder implements TextStage, _FinalStage { private String text; + private Optional smallNote = Optional.empty(); + private Optional resourceReview = Optional.empty(); private Optional validationMethod = Optional.empty(); @@ -268,7 +297,6 @@ public static final class Builder implements TextStage, _FinalStage { private Optional version = Optional.empty(); - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -284,6 +312,7 @@ public Builder from(CreateMultiRequest other) { detectionEffort(other.getDetectionEffort()); validationMethod(other.getValidationMethod()); resourceReview(other.getResourceReview()); + smallNote(other.getSmallNote()); return this; } @@ -298,6 +327,26 @@ public _FinalStage text(@NotNull String text) { return this; } + /** + *

            Bills the request at the lower small-note rate. When true, text must be at most 15,360 bytes (15 KiB) when UTF-8 encoded; longer text is rejected with a 400 error. Extraction behavior is the same as a standard request.

            + * @return Reference to {@code this} so that method calls can be chained together. + */ + @java.lang.Override + public _FinalStage smallNote(Boolean smallNote) { + this.smallNote = Optional.ofNullable(smallNote); + return this; + } + + /** + *

            Bills the request at the lower small-note rate. When true, text must be at most 15,360 bytes (15 KiB) when UTF-8 encoded; longer text is rejected with a 400 error. Extraction behavior is the same as a standard request.

            + */ + @java.lang.Override + @JsonSetter(value = "small_note", nulls = Nulls.SKIP) + public _FinalStage smallNote(Optional smallNote) { + this.smallNote = smallNote; + return this; + } + @java.lang.Override public _FinalStage resourceReview(ResourceReview resourceReview) { this.resourceReview = Optional.ofNullable(resourceReview); @@ -332,7 +381,7 @@ public _FinalStage validationMethod(Optional } /** - *

            Deprecated; use the default 'standard' value. This field will be removed in a future release. 'standard' runs detection once; 'deep' runs detection multiple times for higher recall.

            + *

            Deprecated and ignored: both values produce the same result. This field will be removed in a future release.

            * @return Reference to {@code this} so that method calls can be chained together. */ @java.lang.Override @@ -342,7 +391,7 @@ public _FinalStage detectionEffort(CreateMultiRequestDetectionEffort detectionEf } /** - *

            Deprecated; use the default 'standard' value. This field will be removed in a future release. 'standard' runs detection once; 'deep' runs detection multiple times for higher recall.

            + *

            Deprecated and ignored: both values produce the same result. This field will be removed in a future release.

            */ @java.lang.Override @JsonSetter(value = "detection_effort", nulls = Nulls.SKIP) @@ -352,7 +401,7 @@ public _FinalStage detectionEffort(Optional d } /** - *

            Custom Implementation Guide name. When specified, profiles from this IG are included alongside the default profiles during resource detection. Default profiles are always the base layer; custom IG profiles are additive.

            + *

            Implementation Guide to generate resources under. us_core (the default) and phenoml_hpo select a built-in guide. Any other value is a custom Implementation Guide name: its profiles are included alongside the US Core profiles, which are always the base layer. A custom name that matches no uploaded Implementation Guide returns 404.

            * @return Reference to {@code this} so that method calls can be chained together. */ @java.lang.Override @@ -362,7 +411,7 @@ public _FinalStage implementationGuide(String implementationGuide) { } /** - *

            Custom Implementation Guide name. When specified, profiles from this IG are included alongside the default profiles during resource detection. Default profiles are always the base layer; custom IG profiles are additive.

            + *

            Implementation Guide to generate resources under. us_core (the default) and phenoml_hpo select a built-in guide. Any other value is a custom Implementation Guide name: its profiles are included alongside the US Core profiles, which are always the base layer. A custom name that matches no uploaded Implementation Guide returns 404.

            */ @java.lang.Override @JsonSetter(value = "implementation_guide", nulls = Nulls.SKIP) @@ -391,12 +440,19 @@ public _FinalStage patientReference(Optional patientReference) return this; } + /** + *

            Context for the patient the text is primarily about. When the text does not name its patient, the bundle's Patient is built from these fields. When it does, the identifier is added to that Patient unless its name, birth date, or gender contradicts these fields.

            + * @return Reference to {@code this} so that method calls can be chained together. + */ @java.lang.Override public _FinalStage primaryPatient(PrimaryPatient primaryPatient) { this.primaryPatient = Optional.ofNullable(primaryPatient); return this; } + /** + *

            Context for the patient the text is primarily about. When the text does not name its patient, the bundle's Patient is built from these fields. When it does, the identifier is added to that Patient unless its name, birth date, or gender contradicts these fields.

            + */ @java.lang.Override @JsonSetter(value = "primary_patient", nulls = Nulls.SKIP) public _FinalStage primaryPatient(Optional primaryPatient) { @@ -456,9 +512,27 @@ public CreateMultiRequest build() { detectionEffort, validationMethod, resourceReview, + smallNote, additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("text") + || key.equalsIgnoreCase("version") + || key.equalsIgnoreCase("provider") + || key.equalsIgnoreCase("primary_patient") + || key.equalsIgnoreCase("patient_reference") + || key.equalsIgnoreCase("implementation_guide") + || key.equalsIgnoreCase("detection_effort") + || key.equalsIgnoreCase("validation_method") + || key.equalsIgnoreCase("resource_review") + || key.equalsIgnoreCase("small_note")) { + return; + } + this.additionalProperties.put(key, value); + } + @java.lang.Override public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); diff --git a/src/main/java/com/phenoml/api/resources/lang2fhir/requests/CreateRequest.java b/src/main/java/com/phenoml/api/resources/lang2fhir/requests/CreateRequest.java index 011acb69..11c32682 100644 --- a/src/main/java/com/phenoml/api/resources/lang2fhir/requests/CreateRequest.java +++ b/src/main/java/com/phenoml/api/resources/lang2fhir/requests/CreateRequest.java @@ -45,7 +45,7 @@ public String getVersion() { } /** - * @return Type of FHIR resource to create. Use 'auto' for automatic resource type detection, or specify a supported profile. The default profile set includes US Core profiles and selected base R4 resources; you can also use any custom profile you've uploaded (if you're a develop or launch customer). + * @return Type of FHIR resource to create. Use 'auto' for automatic resource type detection, or specify a supported profile. The default profile set includes US Core profiles and selected base R4 resources. The built-in PhenoML HPO profiles must be requested by id: phenoml-hpo-condition and phenoml-hpo-phenotypic-observation code findings with the Human Phenotype Ontology, and phenoml-hpo-head-circumference-tape-measure and phenoml-hpo-head-circumference-percentile record a head circumference measured as a length or stated as an age-adjusted percentile. You can also use any custom profile you've uploaded (if you're a develop or launch customer). */ @JsonProperty("resource") public CreateRequestResource getResource() { @@ -100,7 +100,7 @@ public interface VersionStage { public interface ResourceStage { /** - *

            Type of FHIR resource to create. Use 'auto' for automatic resource type detection, or specify a supported profile. The default profile set includes US Core profiles and selected base R4 resources; you can also use any custom profile you've uploaded (if you're a develop or launch customer).

            + *

            Type of FHIR resource to create. Use 'auto' for automatic resource type detection, or specify a supported profile. The default profile set includes US Core profiles and selected base R4 resources. The built-in PhenoML HPO profiles must be requested by id: phenoml-hpo-condition and phenoml-hpo-phenotypic-observation code findings with the Human Phenotype Ontology, and phenoml-hpo-head-circumference-tape-measure and phenoml-hpo-head-circumference-percentile record a head circumference measured as a length or stated as an age-adjusted percentile. You can also use any custom profile you've uploaded (if you're a develop or launch customer).

            */ TextStage resource(@NotNull CreateRequestResource resource); } @@ -128,7 +128,6 @@ public static final class Builder implements VersionStage, ResourceStage, TextSt private String text; - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -153,7 +152,7 @@ public ResourceStage version(@NotNull String version) { } /** - *

            Type of FHIR resource to create. Use 'auto' for automatic resource type detection, or specify a supported profile. The default profile set includes US Core profiles and selected base R4 resources; you can also use any custom profile you've uploaded (if you're a develop or launch customer).

            + *

            Type of FHIR resource to create. Use 'auto' for automatic resource type detection, or specify a supported profile. The default profile set includes US Core profiles and selected base R4 resources. The built-in PhenoML HPO profiles must be requested by id: phenoml-hpo-condition and phenoml-hpo-phenotypic-observation code findings with the Human Phenotype Ontology, and phenoml-hpo-head-circumference-tape-measure and phenoml-hpo-head-circumference-percentile record a head circumference measured as a length or stated as an age-adjusted percentile. You can also use any custom profile you've uploaded (if you're a develop or launch customer).

            * @return Reference to {@code this} so that method calls can be chained together. */ @java.lang.Override @@ -179,6 +178,14 @@ public CreateRequest build() { return new CreateRequest(version, resource, text, additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("version") || key.equalsIgnoreCase("resource") || key.equalsIgnoreCase("text")) { + return; + } + this.additionalProperties.put(key, value); + } + @java.lang.Override public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); diff --git a/src/main/java/com/phenoml/api/resources/lang2fhir/requests/DocumentMultiRequest.java b/src/main/java/com/phenoml/api/resources/lang2fhir/requests/DocumentMultiRequest.java index 2b2fb15e..ef27d2e4 100644 --- a/src/main/java/com/phenoml/api/resources/lang2fhir/requests/DocumentMultiRequest.java +++ b/src/main/java/com/phenoml/api/resources/lang2fhir/requests/DocumentMultiRequest.java @@ -98,6 +98,9 @@ public Optional getProvider() { return provider; } + /** + * @return Context for the patient the document is primarily about. When the document does not name its patient, the bundle's Patient is built from these fields. When it does, the identifier is added to that Patient unless its name, birth date, or gender contradicts these fields. + */ @JsonProperty("primary_patient") public Optional getPrimaryPatient() { return primaryPatient; @@ -112,7 +115,7 @@ public Optional getPatientReference() { } /** - * @return Custom Implementation Guide name. When specified, profiles from this IG are included alongside the default profiles during resource detection. Default profiles are always the base layer; custom IG profiles are additive. + * @return Implementation Guide to generate resources under. us_core (the default) and phenoml_hpo select a built-in guide. Any other value is a custom Implementation Guide name: its profiles are included alongside the US Core profiles, which are always the base layer. A custom name that matches no uploaded Implementation Guide returns 404. */ @JsonProperty("implementation_guide") public Optional getImplementationGuide() { @@ -120,7 +123,7 @@ public Optional getImplementationGuide() { } /** - * @return Deprecated; use the default 'standard' value. This field will be removed in a future release. 'standard' runs detection once; 'deep' runs detection multiple times for higher recall. + * @return Deprecated and ignored: both values produce the same result. This field will be removed in a future release. */ @JsonProperty("detection_effort") public Optional getDetectionEffort() { @@ -221,6 +224,9 @@ public interface _FinalStage { _FinalStage provider(String provider); + /** + *

            Context for the patient the document is primarily about. When the document does not name its patient, the bundle's Patient is built from these fields. When it does, the identifier is added to that Patient unless its name, birth date, or gender contradicts these fields.

            + */ _FinalStage primaryPatient(Optional primaryPatient); _FinalStage primaryPatient(PrimaryPatient primaryPatient); @@ -233,14 +239,14 @@ public interface _FinalStage { _FinalStage patientReference(PatientReference patientReference); /** - *

            Custom Implementation Guide name. When specified, profiles from this IG are included alongside the default profiles during resource detection. Default profiles are always the base layer; custom IG profiles are additive.

            + *

            Implementation Guide to generate resources under. us_core (the default) and phenoml_hpo select a built-in guide. Any other value is a custom Implementation Guide name: its profiles are included alongside the US Core profiles, which are always the base layer. A custom name that matches no uploaded Implementation Guide returns 404.

            */ _FinalStage implementationGuide(Optional implementationGuide); _FinalStage implementationGuide(String implementationGuide); /** - *

            Deprecated; use the default 'standard' value. This field will be removed in a future release. 'standard' runs detection once; 'deep' runs detection multiple times for higher recall.

            + *

            Deprecated and ignored: both values produce the same result. This field will be removed in a future release.

            */ _FinalStage detectionEffort(Optional detectionEffort); @@ -278,7 +284,6 @@ public static final class Builder implements VersionStage, ContentStage, _FinalS private Optional provider = Optional.empty(); - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -358,7 +363,7 @@ public _FinalStage validationMethod(OptionalDeprecated; use the default 'standard' value. This field will be removed in a future release. 'standard' runs detection once; 'deep' runs detection multiple times for higher recall.

            + *

            Deprecated and ignored: both values produce the same result. This field will be removed in a future release.

            * @return Reference to {@code this} so that method calls can be chained together. */ @java.lang.Override @@ -368,7 +373,7 @@ public _FinalStage detectionEffort(DocumentMultiRequestDetectionEffort detection } /** - *

            Deprecated; use the default 'standard' value. This field will be removed in a future release. 'standard' runs detection once; 'deep' runs detection multiple times for higher recall.

            + *

            Deprecated and ignored: both values produce the same result. This field will be removed in a future release.

            */ @java.lang.Override @JsonSetter(value = "detection_effort", nulls = Nulls.SKIP) @@ -378,7 +383,7 @@ public _FinalStage detectionEffort(Optional } /** - *

            Custom Implementation Guide name. When specified, profiles from this IG are included alongside the default profiles during resource detection. Default profiles are always the base layer; custom IG profiles are additive.

            + *

            Implementation Guide to generate resources under. us_core (the default) and phenoml_hpo select a built-in guide. Any other value is a custom Implementation Guide name: its profiles are included alongside the US Core profiles, which are always the base layer. A custom name that matches no uploaded Implementation Guide returns 404.

            * @return Reference to {@code this} so that method calls can be chained together. */ @java.lang.Override @@ -388,7 +393,7 @@ public _FinalStage implementationGuide(String implementationGuide) { } /** - *

            Custom Implementation Guide name. When specified, profiles from this IG are included alongside the default profiles during resource detection. Default profiles are always the base layer; custom IG profiles are additive.

            + *

            Implementation Guide to generate resources under. us_core (the default) and phenoml_hpo select a built-in guide. Any other value is a custom Implementation Guide name: its profiles are included alongside the US Core profiles, which are always the base layer. A custom name that matches no uploaded Implementation Guide returns 404.

            */ @java.lang.Override @JsonSetter(value = "implementation_guide", nulls = Nulls.SKIP) @@ -417,12 +422,19 @@ public _FinalStage patientReference(Optional patientReference) return this; } + /** + *

            Context for the patient the document is primarily about. When the document does not name its patient, the bundle's Patient is built from these fields. When it does, the identifier is added to that Patient unless its name, birth date, or gender contradicts these fields.

            + * @return Reference to {@code this} so that method calls can be chained together. + */ @java.lang.Override public _FinalStage primaryPatient(PrimaryPatient primaryPatient) { this.primaryPatient = Optional.ofNullable(primaryPatient); return this; } + /** + *

            Context for the patient the document is primarily about. When the document does not name its patient, the bundle's Patient is built from these fields. When it does, the identifier is added to that Patient unless its name, birth date, or gender contradicts these fields.

            + */ @java.lang.Override @JsonSetter(value = "primary_patient", nulls = Nulls.SKIP) public _FinalStage primaryPatient(Optional primaryPatient) { @@ -465,6 +477,22 @@ public DocumentMultiRequest build() { additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("version") + || key.equalsIgnoreCase("content") + || key.equalsIgnoreCase("provider") + || key.equalsIgnoreCase("primary_patient") + || key.equalsIgnoreCase("patient_reference") + || key.equalsIgnoreCase("implementation_guide") + || key.equalsIgnoreCase("detection_effort") + || key.equalsIgnoreCase("validation_method") + || key.equalsIgnoreCase("config")) { + return; + } + this.additionalProperties.put(key, value); + } + @java.lang.Override public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); diff --git a/src/main/java/com/phenoml/api/resources/lang2fhir/requests/DocumentRequest.java b/src/main/java/com/phenoml/api/resources/lang2fhir/requests/DocumentRequest.java index 36c7f8b3..6a6d04b4 100644 --- a/src/main/java/com/phenoml/api/resources/lang2fhir/requests/DocumentRequest.java +++ b/src/main/java/com/phenoml/api/resources/lang2fhir/requests/DocumentRequest.java @@ -161,7 +161,6 @@ public static final class Builder implements VersionStage, ResourceStage, Conten private Optional config = Optional.empty(); - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -231,6 +230,17 @@ public DocumentRequest build() { return new DocumentRequest(version, resource, content, config, additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("version") + || key.equalsIgnoreCase("resource") + || key.equalsIgnoreCase("content") + || key.equalsIgnoreCase("config")) { + return; + } + this.additionalProperties.put(key, value); + } + @java.lang.Override public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); diff --git a/src/main/java/com/phenoml/api/resources/lang2fhir/requests/SearchRequest.java b/src/main/java/com/phenoml/api/resources/lang2fhir/requests/SearchRequest.java index f20a36fa..06431cbd 100644 --- a/src/main/java/com/phenoml/api/resources/lang2fhir/requests/SearchRequest.java +++ b/src/main/java/com/phenoml/api/resources/lang2fhir/requests/SearchRequest.java @@ -104,7 +104,6 @@ public interface _FinalStage { public static final class Builder implements TextStage, _FinalStage { private String text; - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -140,6 +139,14 @@ public SearchRequest build() { return new SearchRequest(text, additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("text")) { + return; + } + this.additionalProperties.put(key, value); + } + @java.lang.Override public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); diff --git a/src/main/java/com/phenoml/api/resources/lang2fhir/types/CreateMultiResponse.java b/src/main/java/com/phenoml/api/resources/lang2fhir/types/CreateMultiResponse.java index 4d50fece..25566e44 100644 --- a/src/main/java/com/phenoml/api/resources/lang2fhir/types/CreateMultiResponse.java +++ b/src/main/java/com/phenoml/api/resources/lang2fhir/types/CreateMultiResponse.java @@ -149,7 +149,6 @@ public static final class Builder { private Optional resourceReview = Optional.empty(); - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -250,6 +249,19 @@ public CreateMultiResponse build() { success, message, bundle, resources, validation, resourceReview, additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("success") + || key.equalsIgnoreCase("message") + || key.equalsIgnoreCase("bundle") + || key.equalsIgnoreCase("resources") + || key.equalsIgnoreCase("validation") + || key.equalsIgnoreCase("resource_review")) { + return; + } + this.additionalProperties.put(key, value); + } + public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); return this; diff --git a/src/main/java/com/phenoml/api/resources/lang2fhir/types/CreateMultiResponseBundle.java b/src/main/java/com/phenoml/api/resources/lang2fhir/types/CreateMultiResponseBundle.java index a743aa44..28898956 100644 --- a/src/main/java/com/phenoml/api/resources/lang2fhir/types/CreateMultiResponseBundle.java +++ b/src/main/java/com/phenoml/api/resources/lang2fhir/types/CreateMultiResponseBundle.java @@ -92,7 +92,6 @@ public static final class Builder { private Optional> entry = Optional.empty(); - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -141,6 +140,14 @@ public CreateMultiResponseBundle build() { return new CreateMultiResponseBundle(resourceType, type, entry, additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("resourceType") || key.equalsIgnoreCase("type") || key.equalsIgnoreCase("entry")) { + return; + } + this.additionalProperties.put(key, value); + } + public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); return this; diff --git a/src/main/java/com/phenoml/api/resources/lang2fhir/types/CreateMultiResponseBundleEntryItem.java b/src/main/java/com/phenoml/api/resources/lang2fhir/types/CreateMultiResponseBundleEntryItem.java index c69fe562..69b0e182 100644 --- a/src/main/java/com/phenoml/api/resources/lang2fhir/types/CreateMultiResponseBundleEntryItem.java +++ b/src/main/java/com/phenoml/api/resources/lang2fhir/types/CreateMultiResponseBundleEntryItem.java @@ -92,7 +92,6 @@ public static final class Builder { private Optional request = Optional.empty(); - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -141,6 +140,16 @@ public CreateMultiResponseBundleEntryItem build() { return new CreateMultiResponseBundleEntryItem(fullUrl, resource, request, additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("fullUrl") + || key.equalsIgnoreCase("resource") + || key.equalsIgnoreCase("request")) { + return; + } + this.additionalProperties.put(key, value); + } + public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); return this; diff --git a/src/main/java/com/phenoml/api/resources/lang2fhir/types/CreateMultiResponseBundleEntryItemRequest.java b/src/main/java/com/phenoml/api/resources/lang2fhir/types/CreateMultiResponseBundleEntryItemRequest.java index 495bf9cb..1388243d 100644 --- a/src/main/java/com/phenoml/api/resources/lang2fhir/types/CreateMultiResponseBundleEntryItemRequest.java +++ b/src/main/java/com/phenoml/api/resources/lang2fhir/types/CreateMultiResponseBundleEntryItemRequest.java @@ -79,7 +79,6 @@ public static final class Builder { private Optional url = Optional.empty(); - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -116,6 +115,14 @@ public CreateMultiResponseBundleEntryItemRequest build() { return new CreateMultiResponseBundleEntryItemRequest(method, url, additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("method") || key.equalsIgnoreCase("url")) { + return; + } + this.additionalProperties.put(key, value); + } + public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); return this; diff --git a/src/main/java/com/phenoml/api/resources/lang2fhir/types/CreateMultiResponseResourcesItem.java b/src/main/java/com/phenoml/api/resources/lang2fhir/types/CreateMultiResponseResourcesItem.java index 556de769..1df14885 100644 --- a/src/main/java/com/phenoml/api/resources/lang2fhir/types/CreateMultiResponseResourcesItem.java +++ b/src/main/java/com/phenoml/api/resources/lang2fhir/types/CreateMultiResponseResourcesItem.java @@ -149,7 +149,6 @@ public static final class Builder { private Optional> sourcePages = Optional.empty(); - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -253,6 +252,19 @@ public CreateMultiResponseResourcesItem build() { tempId, resourceType, description, originalText, group, sourcePages, additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("tempId") + || key.equalsIgnoreCase("resourceType") + || key.equalsIgnoreCase("description") + || key.equalsIgnoreCase("originalText") + || key.equalsIgnoreCase("group") + || key.equalsIgnoreCase("sourcePages")) { + return; + } + this.additionalProperties.put(key, value); + } + public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); return this; diff --git a/src/main/java/com/phenoml/api/resources/lang2fhir/types/CreateMultiResponseValidation.java b/src/main/java/com/phenoml/api/resources/lang2fhir/types/CreateMultiResponseValidation.java index 1c65245b..13cdafe4 100644 --- a/src/main/java/com/phenoml/api/resources/lang2fhir/types/CreateMultiResponseValidation.java +++ b/src/main/java/com/phenoml/api/resources/lang2fhir/types/CreateMultiResponseValidation.java @@ -118,7 +118,6 @@ public static final class Builder { private Optional summary = Optional.empty(); - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -191,6 +190,17 @@ public CreateMultiResponseValidation build() { return new CreateMultiResponseValidation(passes, fixed, attempts, summary, additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("passes") + || key.equalsIgnoreCase("fixed") + || key.equalsIgnoreCase("attempts") + || key.equalsIgnoreCase("summary")) { + return; + } + this.additionalProperties.put(key, value); + } + public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); return this; diff --git a/src/main/java/com/phenoml/api/resources/lang2fhir/types/CreateMultiResponseValidationPassesItem.java b/src/main/java/com/phenoml/api/resources/lang2fhir/types/CreateMultiResponseValidationPassesItem.java index 3a0264ae..0bfacc2b 100644 --- a/src/main/java/com/phenoml/api/resources/lang2fhir/types/CreateMultiResponseValidationPassesItem.java +++ b/src/main/java/com/phenoml/api/resources/lang2fhir/types/CreateMultiResponseValidationPassesItem.java @@ -88,7 +88,6 @@ public static final class Builder { private Optional stats = Optional.empty(); - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -131,6 +130,14 @@ public CreateMultiResponseValidationPassesItem build() { return new CreateMultiResponseValidationPassesItem(issues, stats, additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("issues") || key.equalsIgnoreCase("stats")) { + return; + } + this.additionalProperties.put(key, value); + } + public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); return this; diff --git a/src/main/java/com/phenoml/api/resources/lang2fhir/types/CreateMultiResponseValidationPassesItemIssuesItem.java b/src/main/java/com/phenoml/api/resources/lang2fhir/types/CreateMultiResponseValidationPassesItemIssuesItem.java index bd9959ed..6b732a48 100644 --- a/src/main/java/com/phenoml/api/resources/lang2fhir/types/CreateMultiResponseValidationPassesItemIssuesItem.java +++ b/src/main/java/com/phenoml/api/resources/lang2fhir/types/CreateMultiResponseValidationPassesItemIssuesItem.java @@ -134,7 +134,6 @@ public static final class Builder { private Optional source = Optional.empty(); - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -223,6 +222,18 @@ public CreateMultiResponseValidationPassesItemIssuesItem build() { severity, code, diagnostics, expression, source, additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("severity") + || key.equalsIgnoreCase("code") + || key.equalsIgnoreCase("diagnostics") + || key.equalsIgnoreCase("expression") + || key.equalsIgnoreCase("source")) { + return; + } + this.additionalProperties.put(key, value); + } + public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); return this; diff --git a/src/main/java/com/phenoml/api/resources/lang2fhir/types/CreateMultiResponseValidationPassesItemStats.java b/src/main/java/com/phenoml/api/resources/lang2fhir/types/CreateMultiResponseValidationPassesItemStats.java index bba12f9f..1614dca8 100644 --- a/src/main/java/com/phenoml/api/resources/lang2fhir/types/CreateMultiResponseValidationPassesItemStats.java +++ b/src/main/java/com/phenoml/api/resources/lang2fhir/types/CreateMultiResponseValidationPassesItemStats.java @@ -109,7 +109,6 @@ public static final class Builder { private Optional durationMs = Optional.empty(); - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -174,6 +173,17 @@ public CreateMultiResponseValidationPassesItemStats build() { resourceType, profileUrl, isCustomProfile, durationMs, additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("resource_type") + || key.equalsIgnoreCase("profile_url") + || key.equalsIgnoreCase("is_custom_profile") + || key.equalsIgnoreCase("duration_ms")) { + return; + } + this.additionalProperties.put(key, value); + } + public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); return this; diff --git a/src/main/java/com/phenoml/api/resources/lang2fhir/types/CreateRequestResource.java b/src/main/java/com/phenoml/api/resources/lang2fhir/types/CreateRequestResource.java index e396abf1..d638e12f 100644 --- a/src/main/java/com/phenoml/api/resources/lang2fhir/types/CreateRequestResource.java +++ b/src/main/java/com/phenoml/api/resources/lang2fhir/types/CreateRequestResource.java @@ -15,6 +15,9 @@ public final class CreateRequestResource { public static final CreateRequestResource MEDICATIONSTATEMENT = new CreateRequestResource(Value.MEDICATIONSTATEMENT, "medicationstatement"); + public static final CreateRequestResource PHENOML_HPO_PHENOTYPIC_OBSERVATION = + new CreateRequestResource(Value.PHENOML_HPO_PHENOTYPIC_OBSERVATION, "phenoml-hpo-phenotypic-observation"); + public static final CreateRequestResource MEDICATIONADMINISTRATION = new CreateRequestResource(Value.MEDICATIONADMINISTRATION, "medicationadministration"); @@ -32,9 +35,18 @@ public final class CreateRequestResource { public static final CreateRequestResource ENCOUNTER = new CreateRequestResource(Value.ENCOUNTER, "encounter"); + public static final CreateRequestResource PHENOML_HPO_HEAD_CIRCUMFERENCE_TAPE_MEASURE = new CreateRequestResource( + Value.PHENOML_HPO_HEAD_CIRCUMFERENCE_TAPE_MEASURE, "phenoml-hpo-head-circumference-tape-measure"); + + public static final CreateRequestResource PHENOML_HPO_HEAD_CIRCUMFERENCE_PERCENTILE = new CreateRequestResource( + Value.PHENOML_HPO_HEAD_CIRCUMFERENCE_PERCENTILE, "phenoml-hpo-head-circumference-percentile"); + public static final CreateRequestResource OBSERVATION_CLINICAL_RESULT = new CreateRequestResource(Value.OBSERVATION_CLINICAL_RESULT, "observation-clinical-result"); + public static final CreateRequestResource PHENOML_HPO_CONDITION = + new CreateRequestResource(Value.PHENOML_HPO_CONDITION, "phenoml-hpo-condition"); + public static final CreateRequestResource CAREPLAN = new CreateRequestResource(Value.CAREPLAN, "careplan"); public static final CreateRequestResource FAMILYMEMBERHISTORY = @@ -98,6 +110,8 @@ public T visit(Visitor visitor) { return visitor.visitVitalSigns(); case MEDICATIONSTATEMENT: return visitor.visitMedicationstatement(); + case PHENOML_HPO_PHENOTYPIC_OBSERVATION: + return visitor.visitPhenomlHpoPhenotypicObservation(); case MEDICATIONADMINISTRATION: return visitor.visitMedicationadministration(); case SIMPLE_OBSERVATION: @@ -110,8 +124,14 @@ public T visit(Visitor visitor) { return visitor.visitQuestionnaireresponse(); case ENCOUNTER: return visitor.visitEncounter(); + case PHENOML_HPO_HEAD_CIRCUMFERENCE_TAPE_MEASURE: + return visitor.visitPhenomlHpoHeadCircumferenceTapeMeasure(); + case PHENOML_HPO_HEAD_CIRCUMFERENCE_PERCENTILE: + return visitor.visitPhenomlHpoHeadCircumferencePercentile(); case OBSERVATION_CLINICAL_RESULT: return visitor.visitObservationClinicalResult(); + case PHENOML_HPO_CONDITION: + return visitor.visitPhenomlHpoCondition(); case CAREPLAN: return visitor.visitCareplan(); case FAMILYMEMBERHISTORY: @@ -147,6 +167,8 @@ public static CreateRequestResource valueOf(String value) { return VITAL_SIGNS; case "medicationstatement": return MEDICATIONSTATEMENT; + case "phenoml-hpo-phenotypic-observation": + return PHENOML_HPO_PHENOTYPIC_OBSERVATION; case "medicationadministration": return MEDICATIONADMINISTRATION; case "simple-observation": @@ -159,8 +181,14 @@ public static CreateRequestResource valueOf(String value) { return QUESTIONNAIRERESPONSE; case "encounter": return ENCOUNTER; + case "phenoml-hpo-head-circumference-tape-measure": + return PHENOML_HPO_HEAD_CIRCUMFERENCE_TAPE_MEASURE; + case "phenoml-hpo-head-circumference-percentile": + return PHENOML_HPO_HEAD_CIRCUMFERENCE_PERCENTILE; case "observation-clinical-result": return OBSERVATION_CLINICAL_RESULT; + case "phenoml-hpo-condition": + return PHENOML_HPO_CONDITION; case "careplan": return CAREPLAN; case "familymemberhistory": @@ -215,6 +243,14 @@ public enum Value { PATIENT, + PHENOML_HPO_CONDITION, + + PHENOML_HPO_PHENOTYPIC_OBSERVATION, + + PHENOML_HPO_HEAD_CIRCUMFERENCE_TAPE_MEASURE, + + PHENOML_HPO_HEAD_CIRCUMFERENCE_PERCENTILE, + PROCEDURE, QUESTIONNAIRE, @@ -259,6 +295,14 @@ public interface Visitor { T visitPatient(); + T visitPhenomlHpoCondition(); + + T visitPhenomlHpoPhenotypicObservation(); + + T visitPhenomlHpoHeadCircumferenceTapeMeasure(); + + T visitPhenomlHpoHeadCircumferencePercentile(); + T visitProcedure(); T visitQuestionnaire(); diff --git a/src/main/java/com/phenoml/api/resources/lang2fhir/types/DocumentConfig.java b/src/main/java/com/phenoml/api/resources/lang2fhir/types/DocumentConfig.java index e2be50df..fe039a88 100644 --- a/src/main/java/com/phenoml/api/resources/lang2fhir/types/DocumentConfig.java +++ b/src/main/java/com/phenoml/api/resources/lang2fhir/types/DocumentConfig.java @@ -100,7 +100,6 @@ public static final class Builder { private Optional resourceReview = Optional.empty(); - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -155,6 +154,16 @@ public DocumentConfig build() { return new DocumentConfig(pageFilter, splitClassifications, resourceReview, additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("page_filter") + || key.equalsIgnoreCase("split_classifications") + || key.equalsIgnoreCase("resource_review")) { + return; + } + this.additionalProperties.put(key, value); + } + public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); return this; diff --git a/src/main/java/com/phenoml/api/resources/lang2fhir/types/DocumentMultiResponse.java b/src/main/java/com/phenoml/api/resources/lang2fhir/types/DocumentMultiResponse.java index 5726e34b..78b857c0 100644 --- a/src/main/java/com/phenoml/api/resources/lang2fhir/types/DocumentMultiResponse.java +++ b/src/main/java/com/phenoml/api/resources/lang2fhir/types/DocumentMultiResponse.java @@ -105,7 +105,7 @@ public Optional getResourceReview() { } /** - * @return Per-page classifier decisions. Populated when page_filter or split_classifications was supplied in the request. Contains one entry per input page, including both kept and dropped pages. + * @return Per-page classifier decisions, one entry per input page, kept and dropped. Returned on every request: from page_filter or split_classifications when set, otherwise from the default test-requisition-form filter (see the endpoint description). */ @JsonProperty("page_classifications") public Optional> getPageClassifications() { @@ -170,7 +170,6 @@ public static final class Builder { private Optional> pageClassifications = Optional.empty(); - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -268,7 +267,7 @@ public Builder resourceReview(ResourceReviewResult resourceReview) { } /** - *

            Per-page classifier decisions. Populated when page_filter or split_classifications was supplied in the request. Contains one entry per input page, including both kept and dropped pages.

            + *

            Per-page classifier decisions, one entry per input page, kept and dropped. Returned on every request: from page_filter or split_classifications when set, otherwise from the default test-requisition-form filter (see the endpoint description).

            */ @JsonSetter(value = "page_classifications", nulls = Nulls.SKIP) public Builder pageClassifications(Optional> pageClassifications) { @@ -293,6 +292,20 @@ public DocumentMultiResponse build() { additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("success") + || key.equalsIgnoreCase("message") + || key.equalsIgnoreCase("bundle") + || key.equalsIgnoreCase("resources") + || key.equalsIgnoreCase("validation") + || key.equalsIgnoreCase("resource_review") + || key.equalsIgnoreCase("page_classifications")) { + return; + } + this.additionalProperties.put(key, value); + } + public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); return this; diff --git a/src/main/java/com/phenoml/api/resources/lang2fhir/types/PageClassification.java b/src/main/java/com/phenoml/api/resources/lang2fhir/types/PageClassification.java index 398290da..06e042c1 100644 --- a/src/main/java/com/phenoml/api/resources/lang2fhir/types/PageClassification.java +++ b/src/main/java/com/phenoml/api/resources/lang2fhir/types/PageClassification.java @@ -117,7 +117,6 @@ public static final class Builder { private Optional reason = Optional.empty(); - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -190,6 +189,17 @@ public PageClassification build() { return new PageClassification(pageNumber, include, classificationId, reason, additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("page_number") + || key.equalsIgnoreCase("include") + || key.equalsIgnoreCase("classification_id") + || key.equalsIgnoreCase("reason")) { + return; + } + this.additionalProperties.put(key, value); + } + public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); return this; diff --git a/src/main/java/com/phenoml/api/resources/lang2fhir/types/PageFilter.java b/src/main/java/com/phenoml/api/resources/lang2fhir/types/PageFilter.java index 0fe286f1..9b56e1b1 100644 --- a/src/main/java/com/phenoml/api/resources/lang2fhir/types/PageFilter.java +++ b/src/main/java/com/phenoml/api/resources/lang2fhir/types/PageFilter.java @@ -86,7 +86,6 @@ public interface _FinalStage { public static final class Builder implements ContextStage, _FinalStage { private String context; - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -113,6 +112,14 @@ public PageFilter build() { return new PageFilter(context, additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("context")) { + return; + } + this.additionalProperties.put(key, value); + } + @java.lang.Override public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); diff --git a/src/main/java/com/phenoml/api/resources/lang2fhir/types/PatientReference.java b/src/main/java/com/phenoml/api/resources/lang2fhir/types/PatientReference.java index 6a5c6deb..b433699a 100644 --- a/src/main/java/com/phenoml/api/resources/lang2fhir/types/PatientReference.java +++ b/src/main/java/com/phenoml/api/resources/lang2fhir/types/PatientReference.java @@ -106,7 +106,6 @@ public static final class Builder implements SystemStage, ValueStage, _FinalStag private String value; - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -145,6 +144,14 @@ public PatientReference build() { return new PatientReference(system, value, additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("system") || key.equalsIgnoreCase("value")) { + return; + } + this.additionalProperties.put(key, value); + } + @java.lang.Override public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); diff --git a/src/main/java/com/phenoml/api/resources/lang2fhir/types/PrimaryPatient.java b/src/main/java/com/phenoml/api/resources/lang2fhir/types/PrimaryPatient.java index 6d6a08f1..dc954c13 100644 --- a/src/main/java/com/phenoml/api/resources/lang2fhir/types/PrimaryPatient.java +++ b/src/main/java/com/phenoml/api/resources/lang2fhir/types/PrimaryPatient.java @@ -62,7 +62,7 @@ public Optional getBirthDate() { } /** - * @return Administrative gender. This corroborates another match but does not identify a patient alone. + * @return Administrative gender. */ @JsonProperty("gender") public Optional getGender() { @@ -111,7 +111,6 @@ public static final class Builder { private Optional gender = Optional.empty(); - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -161,7 +160,7 @@ public Builder birthDate(String birthDate) { } /** - *

            Administrative gender. This corroborates another match but does not identify a patient alone.

            + *

            Administrative gender.

            */ @JsonSetter(value = "gender", nulls = Nulls.SKIP) public Builder gender(Optional gender) { @@ -178,6 +177,17 @@ public PrimaryPatient build() { return new PrimaryPatient(identifier, name, birthDate, gender, additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("identifier") + || key.equalsIgnoreCase("name") + || key.equalsIgnoreCase("birthDate") + || key.equalsIgnoreCase("gender")) { + return; + } + this.additionalProperties.put(key, value); + } + public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); return this; diff --git a/src/main/java/com/phenoml/api/resources/lang2fhir/types/PrimaryPatientName.java b/src/main/java/com/phenoml/api/resources/lang2fhir/types/PrimaryPatientName.java index 9fae91bf..09744c8f 100644 --- a/src/main/java/com/phenoml/api/resources/lang2fhir/types/PrimaryPatientName.java +++ b/src/main/java/com/phenoml/api/resources/lang2fhir/types/PrimaryPatientName.java @@ -43,7 +43,7 @@ public Optional getFamily() { } /** - * @return Given names. Matching succeeds when a generated name has a supplied given name. + * @return Given names. */ @JsonProperty("given") public Optional> getGiven() { @@ -85,7 +85,6 @@ public static final class Builder { private Optional> given = Optional.empty(); - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -111,7 +110,7 @@ public Builder family(String family) { } /** - *

            Given names. Matching succeeds when a generated name has a supplied given name.

            + *

            Given names.

            */ @JsonSetter(value = "given", nulls = Nulls.SKIP) public Builder given(Optional> given) { @@ -128,6 +127,14 @@ public PrimaryPatientName build() { return new PrimaryPatientName(family, given, additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("family") || key.equalsIgnoreCase("given")) { + return; + } + this.additionalProperties.put(key, value); + } + public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); return this; diff --git a/src/main/java/com/phenoml/api/resources/lang2fhir/types/ProfileUploadRequest.java b/src/main/java/com/phenoml/api/resources/lang2fhir/types/ProfileUploadRequest.java index a08aedc2..c57e801a 100644 --- a/src/main/java/com/phenoml/api/resources/lang2fhir/types/ProfileUploadRequest.java +++ b/src/main/java/com/phenoml/api/resources/lang2fhir/types/ProfileUploadRequest.java @@ -49,7 +49,7 @@ public String getProfile() { } /** - * @return Implementation Guide name to group this profile under. Defaults to "custom" if omitted. Cannot be "us_core" (reserved). Use this to organize custom profiles into named IGs that can be referenced when calling create/multi or document/multi endpoints. + * @return Implementation Guide name to group this profile under. Defaults to "custom" if omitted. Cannot be "us_core" or "phenoml_hpo" (reserved). Use this to organize custom profiles into named IGs that can be referenced when calling create/multi or document/multi endpoints. */ @JsonProperty("implementation_guide") public Optional getImplementationGuide() { @@ -112,7 +112,7 @@ public interface _FinalStage { _FinalStage additionalProperties(Map additionalProperties); /** - *

            Implementation Guide name to group this profile under. Defaults to "custom" if omitted. Cannot be "us_core" (reserved). Use this to organize custom profiles into named IGs that can be referenced when calling create/multi or document/multi endpoints.

            + *

            Implementation Guide name to group this profile under. Defaults to "custom" if omitted. Cannot be "us_core" or "phenoml_hpo" (reserved). Use this to organize custom profiles into named IGs that can be referenced when calling create/multi or document/multi endpoints.

            */ _FinalStage implementationGuide(Optional implementationGuide); @@ -134,7 +134,6 @@ public static final class Builder implements ProfileStage, _FinalStage { private Optional implementationGuide = Optional.empty(); - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -179,7 +178,7 @@ public _FinalStage profileContext(Optional profileContext) { } /** - *

            Implementation Guide name to group this profile under. Defaults to "custom" if omitted. Cannot be "us_core" (reserved). Use this to organize custom profiles into named IGs that can be referenced when calling create/multi or document/multi endpoints.

            + *

            Implementation Guide name to group this profile under. Defaults to "custom" if omitted. Cannot be "us_core" or "phenoml_hpo" (reserved). Use this to organize custom profiles into named IGs that can be referenced when calling create/multi or document/multi endpoints.

            * @return Reference to {@code this} so that method calls can be chained together. */ @java.lang.Override @@ -189,7 +188,7 @@ public _FinalStage implementationGuide(String implementationGuide) { } /** - *

            Implementation Guide name to group this profile under. Defaults to "custom" if omitted. Cannot be "us_core" (reserved). Use this to organize custom profiles into named IGs that can be referenced when calling create/multi or document/multi endpoints.

            + *

            Implementation Guide name to group this profile under. Defaults to "custom" if omitted. Cannot be "us_core" or "phenoml_hpo" (reserved). Use this to organize custom profiles into named IGs that can be referenced when calling create/multi or document/multi endpoints.

            */ @java.lang.Override @JsonSetter(value = "implementation_guide", nulls = Nulls.SKIP) @@ -203,6 +202,16 @@ public ProfileUploadRequest build() { return new ProfileUploadRequest(profile, implementationGuide, profileContext, additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("profile") + || key.equalsIgnoreCase("implementation_guide") + || key.equalsIgnoreCase("profile_context")) { + return; + } + this.additionalProperties.put(key, value); + } + @java.lang.Override public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); diff --git a/src/main/java/com/phenoml/api/resources/lang2fhir/types/ResourceReview.java b/src/main/java/com/phenoml/api/resources/lang2fhir/types/ResourceReview.java index 4065eaa7..b705db74 100644 --- a/src/main/java/com/phenoml/api/resources/lang2fhir/types/ResourceReview.java +++ b/src/main/java/com/phenoml/api/resources/lang2fhir/types/ResourceReview.java @@ -31,7 +31,7 @@ private ResourceReview(List targets, Map a } /** - * @return The resource types to audit and which date or clinical code field kinds to check. + * @return The resource types to audit and which date or clinical code field kinds to check. An empty list turns the audit off. */ @JsonProperty("targets") public List getTargets() { @@ -71,7 +71,6 @@ public static Builder builder() { public static final class Builder { private List targets = new ArrayList<>(); - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -82,7 +81,7 @@ public Builder from(ResourceReview other) { } /** - *

            The resource types to audit and which date or clinical code field kinds to check.

            + *

            The resource types to audit and which date or clinical code field kinds to check. An empty list turns the audit off.

            */ @JsonSetter(value = "targets", nulls = Nulls.SKIP) public Builder targets(List targets) { @@ -109,6 +108,14 @@ public ResourceReview build() { return new ResourceReview(targets, additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("targets")) { + return; + } + this.additionalProperties.put(key, value); + } + public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); return this; diff --git a/src/main/java/com/phenoml/api/resources/lang2fhir/types/ResourceReviewFinding.java b/src/main/java/com/phenoml/api/resources/lang2fhir/types/ResourceReviewFinding.java index ae840aef..9cdbed66 100644 --- a/src/main/java/com/phenoml/api/resources/lang2fhir/types/ResourceReviewFinding.java +++ b/src/main/java/com/phenoml/api/resources/lang2fhir/types/ResourceReviewFinding.java @@ -141,7 +141,6 @@ public static final class Builder { private Optional rationale = Optional.empty(); - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -239,6 +238,19 @@ public ResourceReviewFinding build() { fieldKind, fieldPath, value, supported, unaudited, rationale, additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("fieldKind") + || key.equalsIgnoreCase("fieldPath") + || key.equalsIgnoreCase("value") + || key.equalsIgnoreCase("supported") + || key.equalsIgnoreCase("unaudited") + || key.equalsIgnoreCase("rationale")) { + return; + } + this.additionalProperties.put(key, value); + } + public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); return this; diff --git a/src/main/java/com/phenoml/api/resources/lang2fhir/types/ResourceReviewFlagged.java b/src/main/java/com/phenoml/api/resources/lang2fhir/types/ResourceReviewFlagged.java index 1fc06296..57fc89cf 100644 --- a/src/main/java/com/phenoml/api/resources/lang2fhir/types/ResourceReviewFlagged.java +++ b/src/main/java/com/phenoml/api/resources/lang2fhir/types/ResourceReviewFlagged.java @@ -115,7 +115,6 @@ public static final class Builder { private Optional> findings = Optional.empty(); - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -185,6 +184,17 @@ public ResourceReviewFlagged build() { return new ResourceReviewFlagged(tempId, resourceType, resource, findings, additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("tempId") + || key.equalsIgnoreCase("resourceType") + || key.equalsIgnoreCase("resource") + || key.equalsIgnoreCase("findings")) { + return; + } + this.additionalProperties.put(key, value); + } + public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); return this; diff --git a/src/main/java/com/phenoml/api/resources/lang2fhir/types/ResourceReviewRemediated.java b/src/main/java/com/phenoml/api/resources/lang2fhir/types/ResourceReviewRemediated.java index baaf8d0f..bdb56fdc 100644 --- a/src/main/java/com/phenoml/api/resources/lang2fhir/types/ResourceReviewRemediated.java +++ b/src/main/java/com/phenoml/api/resources/lang2fhir/types/ResourceReviewRemediated.java @@ -25,7 +25,7 @@ public final class ResourceReviewRemediated { private final Optional resourceType; - private final Optional action; + private final Optional action; private final Optional> findings; @@ -34,7 +34,7 @@ public final class ResourceReviewRemediated { private ResourceReviewRemediated( Optional tempId, Optional resourceType, - Optional action, + Optional action, Optional> findings, Map additionalProperties) { this.tempId = tempId; @@ -58,10 +58,10 @@ public Optional getResourceType() { } /** - * @return The safe change applied to the resource in the returned bundle. + * @return What review did to the resource in the returned bundle: removed_codings removed unsupported codings while each affected concept kept one; removed_fields removed unsupported fields its profile does not require; flagged_required kept unsupported fields its profile requires, so the findings report values that remain in the bundle. */ @JsonProperty("action") - public Optional getAction() { + public Optional getAction() { return action; } @@ -111,11 +111,10 @@ public static final class Builder { private Optional resourceType = Optional.empty(); - private Optional action = Optional.empty(); + private Optional action = Optional.empty(); private Optional> findings = Optional.empty(); - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -154,15 +153,15 @@ public Builder resourceType(String resourceType) { } /** - *

            The safe change applied to the resource in the returned bundle.

            + *

            What review did to the resource in the returned bundle: removed_codings removed unsupported codings while each affected concept kept one; removed_fields removed unsupported fields its profile does not require; flagged_required kept unsupported fields its profile requires, so the findings report values that remain in the bundle.

            */ @JsonSetter(value = "action", nulls = Nulls.SKIP) - public Builder action(Optional action) { + public Builder action(Optional action) { this.action = action; return this; } - public Builder action(String action) { + public Builder action(ResourceReviewRemediatedAction action) { this.action = Optional.ofNullable(action); return this; } @@ -185,6 +184,17 @@ public ResourceReviewRemediated build() { return new ResourceReviewRemediated(tempId, resourceType, action, findings, additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("tempId") + || key.equalsIgnoreCase("resourceType") + || key.equalsIgnoreCase("action") + || key.equalsIgnoreCase("findings")) { + return; + } + this.additionalProperties.put(key, value); + } + public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); return this; diff --git a/src/main/java/com/phenoml/api/resources/lang2fhir/types/ResourceReviewRemediatedAction.java b/src/main/java/com/phenoml/api/resources/lang2fhir/types/ResourceReviewRemediatedAction.java new file mode 100644 index 00000000..79289c2c --- /dev/null +++ b/src/main/java/com/phenoml/api/resources/lang2fhir/types/ResourceReviewRemediatedAction.java @@ -0,0 +1,97 @@ +/** + * This file was auto-generated by Fern from our API Definition. + */ +package com.phenoml.api.resources.lang2fhir.types; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; + +public final class ResourceReviewRemediatedAction { + public static final ResourceReviewRemediatedAction REMOVED_CODINGS = + new ResourceReviewRemediatedAction(Value.REMOVED_CODINGS, "removed_codings"); + + public static final ResourceReviewRemediatedAction REMOVED_FIELDS = + new ResourceReviewRemediatedAction(Value.REMOVED_FIELDS, "removed_fields"); + + public static final ResourceReviewRemediatedAction FLAGGED_REQUIRED = + new ResourceReviewRemediatedAction(Value.FLAGGED_REQUIRED, "flagged_required"); + + private final Value value; + + private final String string; + + ResourceReviewRemediatedAction(Value value, String string) { + this.value = value; + this.string = string; + } + + public Value getEnumValue() { + return value; + } + + @java.lang.Override + @JsonValue + public String toString() { + return this.string; + } + + @java.lang.Override + public boolean equals(Object other) { + return (this == other) + || (other instanceof ResourceReviewRemediatedAction + && this.string.equals(((ResourceReviewRemediatedAction) other).string)); + } + + @java.lang.Override + public int hashCode() { + return this.string.hashCode(); + } + + public T visit(Visitor visitor) { + switch (value) { + case REMOVED_CODINGS: + return visitor.visitRemovedCodings(); + case REMOVED_FIELDS: + return visitor.visitRemovedFields(); + case FLAGGED_REQUIRED: + return visitor.visitFlaggedRequired(); + case UNKNOWN: + default: + return visitor.visitUnknown(string); + } + } + + @JsonCreator(mode = JsonCreator.Mode.DELEGATING) + public static ResourceReviewRemediatedAction valueOf(String value) { + switch (value) { + case "removed_codings": + return REMOVED_CODINGS; + case "removed_fields": + return REMOVED_FIELDS; + case "flagged_required": + return FLAGGED_REQUIRED; + default: + return new ResourceReviewRemediatedAction(Value.UNKNOWN, value); + } + } + + public enum Value { + REMOVED_CODINGS, + + REMOVED_FIELDS, + + FLAGGED_REQUIRED, + + UNKNOWN + } + + public interface Visitor { + T visitRemovedCodings(); + + T visitRemovedFields(); + + T visitFlaggedRequired(); + + T visitUnknown(String unknownType); + } +} diff --git a/src/main/java/com/phenoml/api/resources/lang2fhir/types/ResourceReviewResult.java b/src/main/java/com/phenoml/api/resources/lang2fhir/types/ResourceReviewResult.java index 316d528c..7d334151 100644 --- a/src/main/java/com/phenoml/api/resources/lang2fhir/types/ResourceReviewResult.java +++ b/src/main/java/com/phenoml/api/resources/lang2fhir/types/ResourceReviewResult.java @@ -45,7 +45,7 @@ public Optional> getFlagged() { } /** - * @return Resources retained in the bundle after unsupported codings were safely removed. + * @return Resources retained in the bundle after review: unsupported codings or fields were safely removed, or unsupported required fields were kept and are reported here (see action). */ @JsonProperty("remediated") public Optional> getRemediated() { @@ -87,7 +87,6 @@ public static final class Builder { private Optional> remediated = Optional.empty(); - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -113,7 +112,7 @@ public Builder flagged(List flagged) { } /** - *

            Resources retained in the bundle after unsupported codings were safely removed.

            + *

            Resources retained in the bundle after review: unsupported codings or fields were safely removed, or unsupported required fields were kept and are reported here (see action).

            */ @JsonSetter(value = "remediated", nulls = Nulls.SKIP) public Builder remediated(Optional> remediated) { @@ -130,6 +129,14 @@ public ResourceReviewResult build() { return new ResourceReviewResult(flagged, remediated, additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("flagged") || key.equalsIgnoreCase("remediated")) { + return; + } + this.additionalProperties.put(key, value); + } + public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); return this; diff --git a/src/main/java/com/phenoml/api/resources/lang2fhir/types/ResourceReviewTarget.java b/src/main/java/com/phenoml/api/resources/lang2fhir/types/ResourceReviewTarget.java index c7cd93cd..7ed28506 100644 --- a/src/main/java/com/phenoml/api/resources/lang2fhir/types/ResourceReviewTarget.java +++ b/src/main/java/com/phenoml/api/resources/lang2fhir/types/ResourceReviewTarget.java @@ -112,7 +112,6 @@ public static final class Builder implements ResourceTypeStage, _FinalStage { private Optional> fields = Optional.empty(); - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -160,6 +159,14 @@ public ResourceReviewTarget build() { return new ResourceReviewTarget(resourceType, fields, additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("resource_type") || key.equalsIgnoreCase("fields")) { + return; + } + this.additionalProperties.put(key, value); + } + @java.lang.Override public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); diff --git a/src/main/java/com/phenoml/api/resources/lang2fhir/types/SearchResponse.java b/src/main/java/com/phenoml/api/resources/lang2fhir/types/SearchResponse.java index 7248bf9f..321d0349 100644 --- a/src/main/java/com/phenoml/api/resources/lang2fhir/types/SearchResponse.java +++ b/src/main/java/com/phenoml/api/resources/lang2fhir/types/SearchResponse.java @@ -88,7 +88,6 @@ public static final class Builder { private Optional searchParams = Optional.empty(); - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -133,6 +132,14 @@ public SearchResponse build() { return new SearchResponse(resourceType, searchParams, additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("resource_type") || key.equalsIgnoreCase("search_params")) { + return; + } + this.additionalProperties.put(key, value); + } + public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); return this; diff --git a/src/main/java/com/phenoml/api/resources/lang2fhir/types/SplitClassification.java b/src/main/java/com/phenoml/api/resources/lang2fhir/types/SplitClassification.java index d0803d86..19022fb0 100644 --- a/src/main/java/com/phenoml/api/resources/lang2fhir/types/SplitClassification.java +++ b/src/main/java/com/phenoml/api/resources/lang2fhir/types/SplitClassification.java @@ -130,7 +130,6 @@ public static final class Builder implements IdStage, DescriptionStage, Operatio private SplitClassificationOperation operation; - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -181,6 +180,16 @@ public SplitClassification build() { return new SplitClassification(id, description, operation, additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("id") + || key.equalsIgnoreCase("description") + || key.equalsIgnoreCase("operation")) { + return; + } + this.additionalProperties.put(key, value); + } + @java.lang.Override public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); diff --git a/src/main/java/com/phenoml/api/resources/lang2fhir/types/UploadProfileResponse.java b/src/main/java/com/phenoml/api/resources/lang2fhir/types/UploadProfileResponse.java index 485527df..a181f3e2 100644 --- a/src/main/java/com/phenoml/api/resources/lang2fhir/types/UploadProfileResponse.java +++ b/src/main/java/com/phenoml/api/resources/lang2fhir/types/UploadProfileResponse.java @@ -111,7 +111,6 @@ public static final class Builder { private Optional url = Optional.empty(); - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -181,6 +180,17 @@ public UploadProfileResponse build() { return new UploadProfileResponse(message, id, type, url, additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("message") + || key.equalsIgnoreCase("id") + || key.equalsIgnoreCase("type") + || key.equalsIgnoreCase("url")) { + return; + } + this.additionalProperties.put(key, value); + } + public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); return this; diff --git a/src/main/java/com/phenoml/api/resources/lang2fhirbatch/AsyncLang2FhirBatchClient.java b/src/main/java/com/phenoml/api/resources/lang2fhirbatch/AsyncLang2FhirBatchClient.java index 1ca87e4b..e1c78415 100644 --- a/src/main/java/com/phenoml/api/resources/lang2fhirbatch/AsyncLang2FhirBatchClient.java +++ b/src/main/java/com/phenoml/api/resources/lang2fhirbatch/AsyncLang2FhirBatchClient.java @@ -4,6 +4,7 @@ package com.phenoml.api.resources.lang2fhirbatch; import com.phenoml.api.core.ClientOptions; +import com.phenoml.api.core.PhenomlClientHttpResponse; import com.phenoml.api.core.RequestOptions; import com.phenoml.api.resources.lang2fhirbatch.requests.CreateBatchRequest; import com.phenoml.api.resources.lang2fhirbatch.requests.GetRequest; @@ -46,7 +47,14 @@ public AsyncRawLang2FhirBatchClient withRawResponse() { * credential's. */ public CompletableFuture list() { - return this.rawClient.list().thenApply(response -> response.body()); + CompletableFuture> rawFuture = this.rawClient.list(); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** @@ -56,7 +64,14 @@ public CompletableFuture list() { * credential's. */ public CompletableFuture list(RequestOptions requestOptions) { - return this.rawClient.list(requestOptions).thenApply(response -> response.body()); + CompletableFuture> rawFuture = this.rawClient.list(requestOptions); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** @@ -66,7 +81,14 @@ public CompletableFuture list(RequestOptions requestOptions) { * credential's. */ public CompletableFuture list(ListRequest request) { - return this.rawClient.list(request).thenApply(response -> response.body()); + CompletableFuture> rawFuture = this.rawClient.list(request); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** @@ -76,7 +98,15 @@ public CompletableFuture list(ListRequest request) { * credential's. */ public CompletableFuture list(ListRequest request, RequestOptions requestOptions) { - return this.rawClient.list(request, requestOptions).thenApply(response -> response.body()); + CompletableFuture> rawFuture = + this.rawClient.list(request, requestOptions); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** @@ -86,7 +116,14 @@ public CompletableFuture list(ListRequest request, RequestOptio * finalization; otherwise it continues to resolve to that job.

            */ public CompletableFuture create() { - return this.rawClient.create().thenApply(response -> response.body()); + CompletableFuture> rawFuture = this.rawClient.create(); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** @@ -96,7 +133,14 @@ public CompletableFuture create() { * finalization; otherwise it continues to resolve to that job.

            */ public CompletableFuture create(RequestOptions requestOptions) { - return this.rawClient.create(requestOptions).thenApply(response -> response.body()); + CompletableFuture> rawFuture = this.rawClient.create(requestOptions); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** @@ -106,7 +150,14 @@ public CompletableFuture create(RequestOptions requestOptions) { * finalization; otherwise it continues to resolve to that job.

            */ public CompletableFuture create(CreateBatchRequest request) { - return this.rawClient.create(request).thenApply(response -> response.body()); + CompletableFuture> rawFuture = this.rawClient.create(request); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** @@ -116,7 +167,15 @@ public CompletableFuture create(CreateBatchRequest request) { * finalization; otherwise it continues to resolve to that job.

            */ public CompletableFuture create(CreateBatchRequest request, RequestOptions requestOptions) { - return this.rawClient.create(request, requestOptions).thenApply(response -> response.body()); + CompletableFuture> rawFuture = + this.rawClient.create(request, requestOptions); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** @@ -134,7 +193,15 @@ public CompletableFuture create(CreateBatchRequest request, RequestOpt * when the item is too large.

            */ public CompletableFuture uploadItem(String jobId, Optional file) { - return this.rawClient.uploadItem(jobId, file).thenApply(response -> response.body()); + CompletableFuture> rawFuture = + this.rawClient.uploadItem(jobId, file); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** @@ -153,7 +220,15 @@ public CompletableFuture uploadItem(String jobId, Optional uploadItem( String jobId, Optional file, RequestOptions requestOptions) { - return this.rawClient.uploadItem(jobId, file, requestOptions).thenApply(response -> response.body()); + CompletableFuture> rawFuture = + this.rawClient.uploadItem(jobId, file, requestOptions); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** @@ -172,7 +247,15 @@ public CompletableFuture uploadItem( */ public CompletableFuture uploadItem( String jobId, Optional file, UploadItemRequest request) { - return this.rawClient.uploadItem(jobId, file, request).thenApply(response -> response.body()); + CompletableFuture> rawFuture = + this.rawClient.uploadItem(jobId, file, request); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** @@ -191,19 +274,41 @@ public CompletableFuture uploadItem( */ public CompletableFuture uploadItem( String jobId, Optional file, UploadItemRequest request, RequestOptions requestOptions) { - return this.rawClient.uploadItem(jobId, file, request, requestOptions).thenApply(response -> response.body()); + CompletableFuture> rawFuture = + this.rawClient.uploadItem(jobId, file, request, requestOptions); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } public CompletableFuture uploadItem( String jobId, UploadItemRequest request, InputStream stream, String filename) { - return this.rawClient.uploadItem(jobId, request, stream, filename).thenApply(response -> response.body()); + CompletableFuture> rawFuture = + this.rawClient.uploadItem(jobId, request, stream, filename); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } public CompletableFuture uploadItem( String jobId, UploadItemRequest request, InputStream stream, String filename, MediaType mediaType) { - return this.rawClient - .uploadItem(jobId, request, stream, filename, mediaType) - .thenApply(response -> response.body()); + CompletableFuture> rawFuture = + this.rawClient.uploadItem(jobId, request, stream, filename, mediaType); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } public CompletableFuture uploadItem( @@ -212,9 +317,15 @@ public CompletableFuture uploadItem( InputStream stream, String filename, RequestOptions requestOptions) { - return this.rawClient - .uploadItem(jobId, request, stream, filename, requestOptions) - .thenApply(response -> response.body()); + CompletableFuture> rawFuture = + this.rawClient.uploadItem(jobId, request, stream, filename, requestOptions); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } public CompletableFuture uploadItem( @@ -224,9 +335,15 @@ public CompletableFuture uploadItem( String filename, MediaType mediaType, RequestOptions requestOptions) { - return this.rawClient - .uploadItem(jobId, request, stream, filename, mediaType, requestOptions) - .thenApply(response -> response.body()); + CompletableFuture> rawFuture = + this.rawClient.uploadItem(jobId, request, stream, filename, mediaType, requestOptions); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** @@ -237,7 +354,14 @@ public CompletableFuture uploadItem( * Finalizing a job with no items is a 400.

            */ public CompletableFuture finalize(String jobId) { - return this.rawClient.finalize(jobId).thenApply(response -> response.body()); + CompletableFuture> rawFuture = this.rawClient.finalize(jobId); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** @@ -248,7 +372,15 @@ public CompletableFuture finalize(String jobId) { * Finalizing a job with no items is a 400.

            */ public CompletableFuture finalize(String jobId, RequestOptions requestOptions) { - return this.rawClient.finalize(jobId, requestOptions).thenApply(response -> response.body()); + CompletableFuture> rawFuture = + this.rawClient.finalize(jobId, requestOptions); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** @@ -264,7 +396,14 @@ public CompletableFuture finalize(String jobId, RequestOptions request * a 409.

            */ public CompletableFuture cancel(String jobId) { - return this.rawClient.cancel(jobId).thenApply(response -> response.body()); + CompletableFuture> rawFuture = this.rawClient.cancel(jobId); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** @@ -280,7 +419,14 @@ public CompletableFuture cancel(String jobId) { * a 409.

            */ public CompletableFuture cancel(String jobId, RequestOptions requestOptions) { - return this.rawClient.cancel(jobId, requestOptions).thenApply(response -> response.body()); + CompletableFuture> rawFuture = this.rawClient.cancel(jobId, requestOptions); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** @@ -292,7 +438,14 @@ public CompletableFuture cancel(String jobId, RequestOptions requestOp * never by position.

            */ public CompletableFuture get(String jobId) { - return this.rawClient.get(jobId).thenApply(response -> response.body()); + CompletableFuture> rawFuture = this.rawClient.get(jobId); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** @@ -304,7 +457,15 @@ public CompletableFuture get(String jobId) { * never by position.

            */ public CompletableFuture get(String jobId, RequestOptions requestOptions) { - return this.rawClient.get(jobId, requestOptions).thenApply(response -> response.body()); + CompletableFuture> rawFuture = + this.rawClient.get(jobId, requestOptions); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** @@ -316,7 +477,14 @@ public CompletableFuture get(String jobId, RequestOptions req * never by position.

            */ public CompletableFuture get(String jobId, GetRequest request) { - return this.rawClient.get(jobId, request).thenApply(response -> response.body()); + CompletableFuture> rawFuture = this.rawClient.get(jobId, request); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** @@ -328,7 +496,15 @@ public CompletableFuture get(String jobId, GetRequest request * never by position.

            */ public CompletableFuture get(String jobId, GetRequest request, RequestOptions requestOptions) { - return this.rawClient.get(jobId, request, requestOptions).thenApply(response -> response.body()); + CompletableFuture> rawFuture = + this.rawClient.get(jobId, request, requestOptions); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** @@ -343,7 +519,14 @@ public CompletableFuture get(String jobId, GetRequest request * never by position.

            */ public CompletableFuture getResults(String jobId) { - return this.rawClient.getResults(jobId).thenApply(response -> response.body()); + CompletableFuture> rawFuture = this.rawClient.getResults(jobId); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** @@ -358,7 +541,15 @@ public CompletableFuture getResults(String jobId) { * never by position.

            */ public CompletableFuture getResults(String jobId, RequestOptions requestOptions) { - return this.rawClient.getResults(jobId, requestOptions).thenApply(response -> response.body()); + CompletableFuture> rawFuture = + this.rawClient.getResults(jobId, requestOptions); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** @@ -373,7 +564,15 @@ public CompletableFuture getResults(String jobId, RequestOp * never by position.

            */ public CompletableFuture getResults(String jobId, GetResultsRequest request) { - return this.rawClient.getResults(jobId, request).thenApply(response -> response.body()); + CompletableFuture> rawFuture = + this.rawClient.getResults(jobId, request); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** @@ -389,7 +588,15 @@ public CompletableFuture getResults(String jobId, GetResult */ public CompletableFuture getResults( String jobId, GetResultsRequest request, RequestOptions requestOptions) { - return this.rawClient.getResults(jobId, request, requestOptions).thenApply(response -> response.body()); + CompletableFuture> rawFuture = + this.rawClient.getResults(jobId, request, requestOptions); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** @@ -402,7 +609,15 @@ public CompletableFuture getResults( * expired is a 404.

            */ public CompletableFuture> getResult(String jobId, String itemId) { - return this.rawClient.getResult(jobId, itemId).thenApply(response -> response.body()); + CompletableFuture>> rawFuture = + this.rawClient.getResult(jobId, itemId); + CompletableFuture> future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** @@ -416,6 +631,14 @@ public CompletableFuture> getResult(String jobId, String ite */ public CompletableFuture> getResult( String jobId, String itemId, RequestOptions requestOptions) { - return this.rawClient.getResult(jobId, itemId, requestOptions).thenApply(response -> response.body()); + CompletableFuture>> rawFuture = + this.rawClient.getResult(jobId, itemId, requestOptions); + CompletableFuture> future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } } diff --git a/src/main/java/com/phenoml/api/resources/lang2fhirbatch/AsyncRawLang2FhirBatchClient.java b/src/main/java/com/phenoml/api/resources/lang2fhirbatch/AsyncRawLang2FhirBatchClient.java index 5b36cd45..dec30416 100644 --- a/src/main/java/com/phenoml/api/resources/lang2fhirbatch/AsyncRawLang2FhirBatchClient.java +++ b/src/main/java/com/phenoml/api/resources/lang2fhirbatch/AsyncRawLang2FhirBatchClient.java @@ -5,10 +5,12 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.type.TypeReference; +import com.phenoml.api.core.BodyProperties; import com.phenoml.api.core.ClientOptions; import com.phenoml.api.core.FileStream; import com.phenoml.api.core.MediaTypes; import com.phenoml.api.core.ObjectMappers; +import com.phenoml.api.core.PathSegments; import com.phenoml.api.core.PhenomlClientApiException; import com.phenoml.api.core.PhenomlClientException; import com.phenoml.api.core.PhenomlClientHttpResponse; @@ -133,7 +135,8 @@ public CompletableFuture> list( .build(); } CompletableFuture> future = new CompletableFuture<>(); - client.newCall(okhttpRequest).enqueue(new Callback() { + RetryInterceptor.AsyncCall okhttpCall = RetryInterceptor.newAsyncCall(client, okhttpRequest); + okhttpCall.enqueue(new Callback() { @Override public void onResponse(@NotNull Call call, @NotNull Response response) throws IOException { try (ResponseBody responseBody = response.body()) { @@ -192,6 +195,11 @@ public void onFailure(@NotNull Call call, @NotNull IOException e) { future.completeExceptionally(new PhenomlClientException("Network error executing HTTP request", e)); } }); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + okhttpCall.cancel(); + } + }); return future; } @@ -244,7 +252,9 @@ public CompletableFuture> create( RequestBody body; try { body = RequestBody.create( - ObjectMappers.JSON_MAPPER.writeValueAsBytes(request), MediaTypes.APPLICATION_JSON); + ObjectMappers.JSON_MAPPER.writeValueAsBytes(BodyProperties.merge( + request, requestOptions != null ? requestOptions.getBodyProperties() : null)), + MediaTypes.APPLICATION_JSON); } catch (JsonProcessingException e) { throw new PhenomlClientException("Failed to serialize request", e); } @@ -269,7 +279,8 @@ public CompletableFuture> create( .build(); } CompletableFuture> future = new CompletableFuture<>(); - client.newCall(okhttpRequest).enqueue(new Callback() { + RetryInterceptor.AsyncCall okhttpCall = RetryInterceptor.newAsyncCall(client, okhttpRequest); + okhttpCall.enqueue(new Callback() { @Override public void onResponse(@NotNull Call call, @NotNull Response response) throws IOException { try (ResponseBody responseBody = response.body()) { @@ -327,6 +338,11 @@ public void onFailure(@NotNull Call call, @NotNull IOException e) { future.completeExceptionally(new PhenomlClientException("Network error executing HTTP request", e)); } }); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + okhttpCall.cancel(); + } + }); return future; } @@ -406,7 +422,7 @@ public CompletableFuture> uploadIt HttpUrl.Builder httpUrl = HttpUrl.parse(this.clientOptions.environment().getUrl()) .newBuilder() .addPathSegments("lang2fhir/batch") - .addPathSegment(jobId) + .addPathSegment(PathSegments.validate(jobId)) .addPathSegments("items"); if (requestOptions != null) { requestOptions.getQueryParameters().forEach((_key, _value) -> { @@ -463,7 +479,8 @@ public CompletableFuture> uploadIt .build(); } CompletableFuture> future = new CompletableFuture<>(); - client.newCall(okhttpRequest).enqueue(new Callback() { + RetryInterceptor.AsyncCall okhttpCall = RetryInterceptor.newAsyncCall(client, okhttpRequest); + okhttpCall.enqueue(new Callback() { @Override public void onResponse(@NotNull Call call, @NotNull Response response) throws IOException { try (ResponseBody responseBody = response.body()) { @@ -532,6 +549,11 @@ public void onFailure(@NotNull Call call, @NotNull IOException e) { future.completeExceptionally(new PhenomlClientException("Network error executing HTTP request", e)); } }); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + okhttpCall.cancel(); + } + }); return future; } @@ -540,7 +562,7 @@ public CompletableFuture> uploadIt HttpUrl.Builder httpUrl = HttpUrl.parse(this.clientOptions.environment().getUrl()) .newBuilder() .addPathSegments("lang2fhir/batch") - .addPathSegment(jobId) + .addPathSegment(PathSegments.validate(jobId)) .addPathSegments("items"); FileStream fs = new FileStream(stream, filename, null); MultipartBody.Builder multipartBodyBuilder = new MultipartBody.Builder().setType(MultipartBody.FORM); @@ -576,7 +598,8 @@ public CompletableFuture> uploadIt Request okhttpRequest = _requestBuilder.build(); OkHttpClient client = clientOptions.httpClient(); CompletableFuture> future = new CompletableFuture<>(); - client.newCall(okhttpRequest).enqueue(new Callback() { + RetryInterceptor.AsyncCall okhttpCall = RetryInterceptor.newAsyncCall(client, okhttpRequest); + okhttpCall.enqueue(new Callback() { @Override public void onResponse(@NotNull Call call, @NotNull Response response) throws IOException { try (ResponseBody responseBody = response.body()) { @@ -645,6 +668,11 @@ public void onFailure(@NotNull Call call, @NotNull IOException e) { future.completeExceptionally(new PhenomlClientException("Network error executing HTTP request", e)); } }); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + okhttpCall.cancel(); + } + }); return future; } @@ -653,7 +681,7 @@ public CompletableFuture> uploadIt HttpUrl.Builder httpUrl = HttpUrl.parse(this.clientOptions.environment().getUrl()) .newBuilder() .addPathSegments("lang2fhir/batch") - .addPathSegment(jobId) + .addPathSegment(PathSegments.validate(jobId)) .addPathSegments("items"); FileStream fs = new FileStream(stream, filename, mediaType); MultipartBody.Builder multipartBodyBuilder = new MultipartBody.Builder().setType(MultipartBody.FORM); @@ -689,7 +717,8 @@ public CompletableFuture> uploadIt Request okhttpRequest = _requestBuilder.build(); OkHttpClient client = clientOptions.httpClient(); CompletableFuture> future = new CompletableFuture<>(); - client.newCall(okhttpRequest).enqueue(new Callback() { + RetryInterceptor.AsyncCall okhttpCall = RetryInterceptor.newAsyncCall(client, okhttpRequest); + okhttpCall.enqueue(new Callback() { @Override public void onResponse(@NotNull Call call, @NotNull Response response) throws IOException { try (ResponseBody responseBody = response.body()) { @@ -758,6 +787,11 @@ public void onFailure(@NotNull Call call, @NotNull IOException e) { future.completeExceptionally(new PhenomlClientException("Network error executing HTTP request", e)); } }); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + okhttpCall.cancel(); + } + }); return future; } @@ -770,7 +804,7 @@ public CompletableFuture> uploadIt HttpUrl.Builder httpUrl = HttpUrl.parse(this.clientOptions.environment().getUrl()) .newBuilder() .addPathSegments("lang2fhir/batch") - .addPathSegment(jobId) + .addPathSegment(PathSegments.validate(jobId)) .addPathSegments("items"); if (requestOptions != null) { requestOptions.getQueryParameters().forEach((_key, _value) -> { @@ -823,7 +857,8 @@ public CompletableFuture> uploadIt .build(); } CompletableFuture> future = new CompletableFuture<>(); - client.newCall(okhttpRequest).enqueue(new Callback() { + RetryInterceptor.AsyncCall okhttpCall = RetryInterceptor.newAsyncCall(client, okhttpRequest); + okhttpCall.enqueue(new Callback() { @Override public void onResponse(@NotNull Call call, @NotNull Response response) throws IOException { try (ResponseBody responseBody = response.body()) { @@ -892,6 +927,11 @@ public void onFailure(@NotNull Call call, @NotNull IOException e) { future.completeExceptionally(new PhenomlClientException("Network error executing HTTP request", e)); } }); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + okhttpCall.cancel(); + } + }); return future; } @@ -905,7 +945,7 @@ public CompletableFuture> uploadIt HttpUrl.Builder httpUrl = HttpUrl.parse(this.clientOptions.environment().getUrl()) .newBuilder() .addPathSegments("lang2fhir/batch") - .addPathSegment(jobId) + .addPathSegment(PathSegments.validate(jobId)) .addPathSegments("items"); if (requestOptions != null) { requestOptions.getQueryParameters().forEach((_key, _value) -> { @@ -958,7 +998,8 @@ public CompletableFuture> uploadIt .build(); } CompletableFuture> future = new CompletableFuture<>(); - client.newCall(okhttpRequest).enqueue(new Callback() { + RetryInterceptor.AsyncCall okhttpCall = RetryInterceptor.newAsyncCall(client, okhttpRequest); + okhttpCall.enqueue(new Callback() { @Override public void onResponse(@NotNull Call call, @NotNull Response response) throws IOException { try (ResponseBody responseBody = response.body()) { @@ -1027,6 +1068,11 @@ public void onFailure(@NotNull Call call, @NotNull IOException e) { future.completeExceptionally(new PhenomlClientException("Network error executing HTTP request", e)); } }); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + okhttpCall.cancel(); + } + }); return future; } @@ -1053,7 +1099,7 @@ public CompletableFuture> finalize( HttpUrl.Builder httpUrl = HttpUrl.parse(this.clientOptions.environment().getUrl()) .newBuilder() .addPathSegments("lang2fhir/batch") - .addPathSegment(jobId) + .addPathSegment(PathSegments.validate(jobId)) .addPathSegments("finalize"); if (requestOptions != null) { requestOptions.getQueryParameters().forEach((_key, _value) -> { @@ -1062,7 +1108,11 @@ public CompletableFuture> finalize( } Request okhttpRequest = new Request.Builder() .url(httpUrl.build()) - .method("POST", RequestBody.create("", null)) + .method( + "POST", + BodyProperties.toRequestBody( + requestOptions != null ? requestOptions.getBodyProperties() : null, + RequestBody.create("", null))) .headers(Headers.of(clientOptions.headers(requestOptions))) .addHeader("Accept", "application/json") .build(); @@ -1080,7 +1130,8 @@ public CompletableFuture> finalize( .build(); } CompletableFuture> future = new CompletableFuture<>(); - client.newCall(okhttpRequest).enqueue(new Callback() { + RetryInterceptor.AsyncCall okhttpCall = RetryInterceptor.newAsyncCall(client, okhttpRequest); + okhttpCall.enqueue(new Callback() { @Override public void onResponse(@NotNull Call call, @NotNull Response response) throws IOException { try (ResponseBody responseBody = response.body()) { @@ -1148,6 +1199,11 @@ public void onFailure(@NotNull Call call, @NotNull IOException e) { future.completeExceptionally(new PhenomlClientException("Network error executing HTTP request", e)); } }); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + okhttpCall.cancel(); + } + }); return future; } @@ -1183,7 +1239,7 @@ public CompletableFuture> cancel(String jobI HttpUrl.Builder httpUrl = HttpUrl.parse(this.clientOptions.environment().getUrl()) .newBuilder() .addPathSegments("lang2fhir/batch") - .addPathSegment(jobId) + .addPathSegment(PathSegments.validate(jobId)) .addPathSegments("cancel"); if (requestOptions != null) { requestOptions.getQueryParameters().forEach((_key, _value) -> { @@ -1192,7 +1248,11 @@ public CompletableFuture> cancel(String jobI } Request okhttpRequest = new Request.Builder() .url(httpUrl.build()) - .method("POST", RequestBody.create("", null)) + .method( + "POST", + BodyProperties.toRequestBody( + requestOptions != null ? requestOptions.getBodyProperties() : null, + RequestBody.create("", null))) .headers(Headers.of(clientOptions.headers(requestOptions))) .addHeader("Accept", "application/json") .build(); @@ -1210,7 +1270,8 @@ public CompletableFuture> cancel(String jobI .build(); } CompletableFuture> future = new CompletableFuture<>(); - client.newCall(okhttpRequest).enqueue(new Callback() { + RetryInterceptor.AsyncCall okhttpCall = RetryInterceptor.newAsyncCall(client, okhttpRequest); + okhttpCall.enqueue(new Callback() { @Override public void onResponse(@NotNull Call call, @NotNull Response response) throws IOException { try (ResponseBody responseBody = response.body()) { @@ -1273,6 +1334,11 @@ public void onFailure(@NotNull Call call, @NotNull IOException e) { future.completeExceptionally(new PhenomlClientException("Network error executing HTTP request", e)); } }); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + okhttpCall.cancel(); + } + }); return future; } @@ -1326,7 +1392,7 @@ public CompletableFuture> get( HttpUrl.Builder httpUrl = HttpUrl.parse(this.clientOptions.environment().getUrl()) .newBuilder() .addPathSegments("lang2fhir/batch") - .addPathSegment(jobId); + .addPathSegment(PathSegments.validate(jobId)); if (request.getCursor().isPresent()) { QueryStringMapper.addQueryParameter( httpUrl, "cursor", request.getCursor().get(), false); @@ -1360,7 +1426,8 @@ public CompletableFuture> get( .build(); } CompletableFuture> future = new CompletableFuture<>(); - client.newCall(okhttpRequest).enqueue(new Callback() { + RetryInterceptor.AsyncCall okhttpCall = RetryInterceptor.newAsyncCall(client, okhttpRequest); + okhttpCall.enqueue(new Callback() { @Override public void onResponse(@NotNull Call call, @NotNull Response response) throws IOException { try (ResponseBody responseBody = response.body()) { @@ -1424,6 +1491,11 @@ public void onFailure(@NotNull Call call, @NotNull IOException e) { future.completeExceptionally(new PhenomlClientException("Network error executing HTTP request", e)); } }); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + okhttpCall.cancel(); + } + }); return future; } @@ -1490,7 +1562,7 @@ public CompletableFuture> getResu HttpUrl.Builder httpUrl = HttpUrl.parse(this.clientOptions.environment().getUrl()) .newBuilder() .addPathSegments("lang2fhir/batch") - .addPathSegment(jobId) + .addPathSegment(PathSegments.validate(jobId)) .addPathSegments("results"); if (request.getCursor().isPresent()) { QueryStringMapper.addQueryParameter( @@ -1525,7 +1597,8 @@ public CompletableFuture> getResu .build(); } CompletableFuture> future = new CompletableFuture<>(); - client.newCall(okhttpRequest).enqueue(new Callback() { + RetryInterceptor.AsyncCall okhttpCall = RetryInterceptor.newAsyncCall(client, okhttpRequest); + okhttpCall.enqueue(new Callback() { @Override public void onResponse(@NotNull Call call, @NotNull Response response) throws IOException { try (ResponseBody responseBody = response.body()) { @@ -1589,6 +1662,11 @@ public void onFailure(@NotNull Call call, @NotNull IOException e) { future.completeExceptionally(new PhenomlClientException("Network error executing HTTP request", e)); } }); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + okhttpCall.cancel(); + } + }); return future; } @@ -1619,9 +1697,9 @@ public CompletableFuture>> getResu HttpUrl.Builder httpUrl = HttpUrl.parse(this.clientOptions.environment().getUrl()) .newBuilder() .addPathSegments("lang2fhir/batch") - .addPathSegment(jobId) + .addPathSegment(PathSegments.validate(jobId)) .addPathSegments("results") - .addPathSegment(itemId); + .addPathSegment(PathSegments.validate(itemId)); if (requestOptions != null) { requestOptions.getQueryParameters().forEach((_key, _value) -> { httpUrl.addQueryParameter(_key, _value); @@ -1647,7 +1725,8 @@ public CompletableFuture>> getResu .build(); } CompletableFuture>> future = new CompletableFuture<>(); - client.newCall(okhttpRequest).enqueue(new Callback() { + RetryInterceptor.AsyncCall okhttpCall = RetryInterceptor.newAsyncCall(client, okhttpRequest); + okhttpCall.enqueue(new Callback() { @Override public void onResponse(@NotNull Call call, @NotNull Response response) throws IOException { try (ResponseBody responseBody = response.body()) { @@ -1717,6 +1796,11 @@ public void onFailure(@NotNull Call call, @NotNull IOException e) { future.completeExceptionally(new PhenomlClientException("Network error executing HTTP request", e)); } }); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + okhttpCall.cancel(); + } + }); return future; } } diff --git a/src/main/java/com/phenoml/api/resources/lang2fhirbatch/RawLang2FhirBatchClient.java b/src/main/java/com/phenoml/api/resources/lang2fhirbatch/RawLang2FhirBatchClient.java index 5877e66e..2f7fd5b6 100644 --- a/src/main/java/com/phenoml/api/resources/lang2fhirbatch/RawLang2FhirBatchClient.java +++ b/src/main/java/com/phenoml/api/resources/lang2fhirbatch/RawLang2FhirBatchClient.java @@ -5,10 +5,12 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.type.TypeReference; +import com.phenoml.api.core.BodyProperties; import com.phenoml.api.core.ClientOptions; import com.phenoml.api.core.FileStream; import com.phenoml.api.core.MediaTypes; import com.phenoml.api.core.ObjectMappers; +import com.phenoml.api.core.PathSegments; import com.phenoml.api.core.PhenomlClientApiException; import com.phenoml.api.core.PhenomlClientException; import com.phenoml.api.core.PhenomlClientHttpResponse; @@ -213,7 +215,9 @@ public PhenomlClientHttpResponse create(CreateBatchRequest request, Re RequestBody body; try { body = RequestBody.create( - ObjectMappers.JSON_MAPPER.writeValueAsBytes(request), MediaTypes.APPLICATION_JSON); + ObjectMappers.JSON_MAPPER.writeValueAsBytes(BodyProperties.merge( + request, requestOptions != null ? requestOptions.getBodyProperties() : null)), + MediaTypes.APPLICATION_JSON); } catch (JsonProcessingException e) { throw new PhenomlClientException("Failed to serialize request", e); } @@ -350,7 +354,7 @@ public PhenomlClientHttpResponse uploadItem( HttpUrl.Builder httpUrl = HttpUrl.parse(this.clientOptions.environment().getUrl()) .newBuilder() .addPathSegments("lang2fhir/batch") - .addPathSegment(jobId) + .addPathSegment(PathSegments.validate(jobId)) .addPathSegments("items"); if (requestOptions != null) { requestOptions.getQueryParameters().forEach((_key, _value) -> { @@ -455,7 +459,7 @@ public PhenomlClientHttpResponse uploadItem( HttpUrl.Builder httpUrl = HttpUrl.parse(this.clientOptions.environment().getUrl()) .newBuilder() .addPathSegments("lang2fhir/batch") - .addPathSegment(jobId) + .addPathSegment(PathSegments.validate(jobId)) .addPathSegments("items"); FileStream fs = new FileStream(stream, filename, null); MultipartBody.Builder multipartBodyBuilder = new MultipartBody.Builder().setType(MultipartBody.FORM); @@ -539,7 +543,7 @@ public PhenomlClientHttpResponse uploadItem( HttpUrl.Builder httpUrl = HttpUrl.parse(this.clientOptions.environment().getUrl()) .newBuilder() .addPathSegments("lang2fhir/batch") - .addPathSegment(jobId) + .addPathSegment(PathSegments.validate(jobId)) .addPathSegments("items"); FileStream fs = new FileStream(stream, filename, mediaType); MultipartBody.Builder multipartBodyBuilder = new MultipartBody.Builder().setType(MultipartBody.FORM); @@ -627,7 +631,7 @@ public PhenomlClientHttpResponse uploadItem( HttpUrl.Builder httpUrl = HttpUrl.parse(this.clientOptions.environment().getUrl()) .newBuilder() .addPathSegments("lang2fhir/batch") - .addPathSegment(jobId) + .addPathSegment(PathSegments.validate(jobId)) .addPathSegments("items"); if (requestOptions != null) { requestOptions.getQueryParameters().forEach((_key, _value) -> { @@ -733,7 +737,7 @@ public PhenomlClientHttpResponse uploadItem( HttpUrl.Builder httpUrl = HttpUrl.parse(this.clientOptions.environment().getUrl()) .newBuilder() .addPathSegments("lang2fhir/batch") - .addPathSegment(jobId) + .addPathSegment(PathSegments.validate(jobId)) .addPathSegments("items"); if (requestOptions != null) { requestOptions.getQueryParameters().forEach((_key, _value) -> { @@ -851,7 +855,7 @@ public PhenomlClientHttpResponse finalize(String jobId, RequestOptions HttpUrl.Builder httpUrl = HttpUrl.parse(this.clientOptions.environment().getUrl()) .newBuilder() .addPathSegments("lang2fhir/batch") - .addPathSegment(jobId) + .addPathSegment(PathSegments.validate(jobId)) .addPathSegments("finalize"); if (requestOptions != null) { requestOptions.getQueryParameters().forEach((_key, _value) -> { @@ -860,7 +864,11 @@ public PhenomlClientHttpResponse finalize(String jobId, RequestOptions } Request okhttpRequest = new Request.Builder() .url(httpUrl.build()) - .method("POST", RequestBody.create("", null)) + .method( + "POST", + BodyProperties.toRequestBody( + requestOptions != null ? requestOptions.getBodyProperties() : null, + RequestBody.create("", null))) .headers(Headers.of(clientOptions.headers(requestOptions))) .addHeader("Accept", "application/json") .build(); @@ -953,7 +961,7 @@ public PhenomlClientHttpResponse cancel(String jobId, RequestOptions r HttpUrl.Builder httpUrl = HttpUrl.parse(this.clientOptions.environment().getUrl()) .newBuilder() .addPathSegments("lang2fhir/batch") - .addPathSegment(jobId) + .addPathSegment(PathSegments.validate(jobId)) .addPathSegments("cancel"); if (requestOptions != null) { requestOptions.getQueryParameters().forEach((_key, _value) -> { @@ -962,7 +970,11 @@ public PhenomlClientHttpResponse cancel(String jobId, RequestOptions r } Request okhttpRequest = new Request.Builder() .url(httpUrl.build()) - .method("POST", RequestBody.create("", null)) + .method( + "POST", + BodyProperties.toRequestBody( + requestOptions != null ? requestOptions.getBodyProperties() : null, + RequestBody.create("", null))) .headers(Headers.of(clientOptions.headers(requestOptions))) .addHeader("Accept", "application/json") .build(); @@ -1069,7 +1081,7 @@ public PhenomlClientHttpResponse get( HttpUrl.Builder httpUrl = HttpUrl.parse(this.clientOptions.environment().getUrl()) .newBuilder() .addPathSegments("lang2fhir/batch") - .addPathSegment(jobId); + .addPathSegment(PathSegments.validate(jobId)); if (request.getCursor().isPresent()) { QueryStringMapper.addQueryParameter( httpUrl, "cursor", request.getCursor().get(), false); @@ -1204,7 +1216,7 @@ public PhenomlClientHttpResponse getResults( HttpUrl.Builder httpUrl = HttpUrl.parse(this.clientOptions.environment().getUrl()) .newBuilder() .addPathSegments("lang2fhir/batch") - .addPathSegment(jobId) + .addPathSegment(PathSegments.validate(jobId)) .addPathSegments("results"); if (request.getCursor().isPresent()) { QueryStringMapper.addQueryParameter( @@ -1306,9 +1318,9 @@ public PhenomlClientHttpResponse> getResult( HttpUrl.Builder httpUrl = HttpUrl.parse(this.clientOptions.environment().getUrl()) .newBuilder() .addPathSegments("lang2fhir/batch") - .addPathSegment(jobId) + .addPathSegment(PathSegments.validate(jobId)) .addPathSegments("results") - .addPathSegment(itemId); + .addPathSegment(PathSegments.validate(itemId)); if (requestOptions != null) { requestOptions.getQueryParameters().forEach((_key, _value) -> { httpUrl.addQueryParameter(_key, _value); diff --git a/src/main/java/com/phenoml/api/resources/lang2fhirbatch/requests/CreateBatchRequest.java b/src/main/java/com/phenoml/api/resources/lang2fhirbatch/requests/CreateBatchRequest.java index db47ac7f..0d8a7295 100644 --- a/src/main/java/com/phenoml/api/resources/lang2fhirbatch/requests/CreateBatchRequest.java +++ b/src/main/java/com/phenoml/api/resources/lang2fhirbatch/requests/CreateBatchRequest.java @@ -72,7 +72,6 @@ public static Builder builder() { public static final class Builder { private Optional requestId = Optional.empty(); - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -102,6 +101,14 @@ public CreateBatchRequest build() { return new CreateBatchRequest(requestId, additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("request_id")) { + return; + } + this.additionalProperties.put(key, value); + } + public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); return this; diff --git a/src/main/java/com/phenoml/api/resources/lang2fhirbatch/requests/GetRequest.java b/src/main/java/com/phenoml/api/resources/lang2fhirbatch/requests/GetRequest.java index 77ddc1b7..f21b4e55 100644 --- a/src/main/java/com/phenoml/api/resources/lang2fhirbatch/requests/GetRequest.java +++ b/src/main/java/com/phenoml/api/resources/lang2fhirbatch/requests/GetRequest.java @@ -83,7 +83,6 @@ public static final class Builder { private Optional limit = Optional.empty(); - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -126,6 +125,14 @@ public GetRequest build() { return new GetRequest(cursor, limit, additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("cursor") || key.equalsIgnoreCase("limit")) { + return; + } + this.additionalProperties.put(key, value); + } + public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); return this; diff --git a/src/main/java/com/phenoml/api/resources/lang2fhirbatch/requests/GetResultsRequest.java b/src/main/java/com/phenoml/api/resources/lang2fhirbatch/requests/GetResultsRequest.java index fa55df76..0bf803dd 100644 --- a/src/main/java/com/phenoml/api/resources/lang2fhirbatch/requests/GetResultsRequest.java +++ b/src/main/java/com/phenoml/api/resources/lang2fhirbatch/requests/GetResultsRequest.java @@ -84,7 +84,6 @@ public static final class Builder { private Optional limit = Optional.empty(); - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -127,6 +126,14 @@ public GetResultsRequest build() { return new GetResultsRequest(cursor, limit, additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("cursor") || key.equalsIgnoreCase("limit")) { + return; + } + this.additionalProperties.put(key, value); + } + public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); return this; diff --git a/src/main/java/com/phenoml/api/resources/lang2fhirbatch/requests/ListRequest.java b/src/main/java/com/phenoml/api/resources/lang2fhirbatch/requests/ListRequest.java index 49e89c21..f2b0b218 100644 --- a/src/main/java/com/phenoml/api/resources/lang2fhirbatch/requests/ListRequest.java +++ b/src/main/java/com/phenoml/api/resources/lang2fhirbatch/requests/ListRequest.java @@ -83,7 +83,6 @@ public static final class Builder { private Optional limit = Optional.empty(); - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -126,6 +125,14 @@ public ListRequest build() { return new ListRequest(cursor, limit, additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("cursor") || key.equalsIgnoreCase("limit")) { + return; + } + this.additionalProperties.put(key, value); + } + public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); return this; diff --git a/src/main/java/com/phenoml/api/resources/lang2fhirbatch/requests/UploadItemRequest.java b/src/main/java/com/phenoml/api/resources/lang2fhirbatch/requests/UploadItemRequest.java index 4abccc20..6a6b0e33 100644 --- a/src/main/java/com/phenoml/api/resources/lang2fhirbatch/requests/UploadItemRequest.java +++ b/src/main/java/com/phenoml/api/resources/lang2fhirbatch/requests/UploadItemRequest.java @@ -63,7 +63,7 @@ public Optional> getDocument() { * @return The JSON body of POST /lang2fhir/create/multi. Accepts that * endpoint's fields (text, version, provider, * primary_patient, patient_reference (deprecated), implementation_guide, detection_effort (deprecated), - * validation_method, resource_review). This is the + * validation_method, resource_review, small_note). This is the * multi-resource body: it has no single-resource field, and * the item's result is a CreateMultiResponse (a Bundle of * resources). Mutually exclusive with document; must not be @@ -139,7 +139,6 @@ public static final class Builder { private Optional id = Optional.empty(); - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -178,7 +177,7 @@ public Builder document(Map document) { *

            The JSON body of POST /lang2fhir/create/multi. Accepts that * endpoint's fields (text, version, provider, * primary_patient, patient_reference (deprecated), implementation_guide, detection_effort (deprecated), - * validation_method, resource_review). This is the + * validation_method, resource_review, small_note). This is the * multi-resource body: it has no single-resource field, and * the item's result is a CreateMultiResponse (a Bundle of * resources). Mutually exclusive with document; must not be @@ -234,6 +233,17 @@ public UploadItemRequest build() { return new UploadItemRequest(document, create, requestId, id, additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("document") + || key.equalsIgnoreCase("create") + || key.equalsIgnoreCase("request_id") + || key.equalsIgnoreCase("id")) { + return; + } + this.additionalProperties.put(key, value); + } + public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); return this; diff --git a/src/main/java/com/phenoml/api/resources/lang2fhirbatch/types/BatchCounts.java b/src/main/java/com/phenoml/api/resources/lang2fhirbatch/types/BatchCounts.java index c376f56c..a4774be6 100644 --- a/src/main/java/com/phenoml/api/resources/lang2fhirbatch/types/BatchCounts.java +++ b/src/main/java/com/phenoml/api/resources/lang2fhirbatch/types/BatchCounts.java @@ -146,7 +146,6 @@ public static final class Builder private int failed; - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -201,6 +200,18 @@ public BatchCounts build() { return new BatchCounts(total, pending, processing, succeeded, failed, additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("total") + || key.equalsIgnoreCase("pending") + || key.equalsIgnoreCase("processing") + || key.equalsIgnoreCase("succeeded") + || key.equalsIgnoreCase("failed")) { + return; + } + this.additionalProperties.put(key, value); + } + @java.lang.Override public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); diff --git a/src/main/java/com/phenoml/api/resources/lang2fhirbatch/types/BatchError.java b/src/main/java/com/phenoml/api/resources/lang2fhirbatch/types/BatchError.java index 0a5e70aa..dbf6b528 100644 --- a/src/main/java/com/phenoml/api/resources/lang2fhirbatch/types/BatchError.java +++ b/src/main/java/com/phenoml/api/resources/lang2fhirbatch/types/BatchError.java @@ -120,7 +120,6 @@ public static final class Builder implements KindStage, MessageStage, _FinalStag private String message; - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -166,6 +165,14 @@ public BatchError build() { return new BatchError(kind, message, additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("kind") || key.equalsIgnoreCase("message")) { + return; + } + this.additionalProperties.put(key, value); + } + @java.lang.Override public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); diff --git a/src/main/java/com/phenoml/api/resources/lang2fhirbatch/types/BatchItemStatus.java b/src/main/java/com/phenoml/api/resources/lang2fhirbatch/types/BatchItemStatus.java index 74a2a75a..3eef8e7a 100644 --- a/src/main/java/com/phenoml/api/resources/lang2fhirbatch/types/BatchItemStatus.java +++ b/src/main/java/com/phenoml/api/resources/lang2fhirbatch/types/BatchItemStatus.java @@ -253,7 +253,6 @@ public static final class Builder implements ItemIdStage, StatusStage, AttemptsS private Optional id = Optional.empty(); - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -408,6 +407,21 @@ public BatchItemStatus build() { itemId, id, status, attempts, detectRetries, resultSize, error, completedAt, additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("item_id") + || key.equalsIgnoreCase("id") + || key.equalsIgnoreCase("status") + || key.equalsIgnoreCase("attempts") + || key.equalsIgnoreCase("detect_retries") + || key.equalsIgnoreCase("result_size") + || key.equalsIgnoreCase("error") + || key.equalsIgnoreCase("completed_at")) { + return; + } + this.additionalProperties.put(key, value); + } + @java.lang.Override public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); diff --git a/src/main/java/com/phenoml/api/resources/lang2fhirbatch/types/BatchJob.java b/src/main/java/com/phenoml/api/resources/lang2fhirbatch/types/BatchJob.java index a8d85810..de6d11cb 100644 --- a/src/main/java/com/phenoml/api/resources/lang2fhirbatch/types/BatchJob.java +++ b/src/main/java/com/phenoml/api/resources/lang2fhirbatch/types/BatchJob.java @@ -324,7 +324,6 @@ public static final class Builder private Optional requestId = Optional.empty(); - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -499,6 +498,23 @@ public BatchJob build() { additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("job_id") + || key.equalsIgnoreCase("request_id") + || key.equalsIgnoreCase("status") + || key.equalsIgnoreCase("finalized") + || key.equalsIgnoreCase("total_items") + || key.equalsIgnoreCase("error") + || key.equalsIgnoreCase("created_at") + || key.equalsIgnoreCase("updated_at") + || key.equalsIgnoreCase("completed_at") + || key.equalsIgnoreCase("expires_at")) { + return; + } + this.additionalProperties.put(key, value); + } + @java.lang.Override public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); diff --git a/src/main/java/com/phenoml/api/resources/lang2fhirbatch/types/JobDetailResponse.java b/src/main/java/com/phenoml/api/resources/lang2fhirbatch/types/JobDetailResponse.java index b8d65064..417f6afc 100644 --- a/src/main/java/com/phenoml/api/resources/lang2fhirbatch/types/JobDetailResponse.java +++ b/src/main/java/com/phenoml/api/resources/lang2fhirbatch/types/JobDetailResponse.java @@ -410,7 +410,6 @@ public static final class Builder private Optional requestId = Optional.empty(); - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -655,6 +654,27 @@ public JobDetailResponse build() { additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("job_id") + || key.equalsIgnoreCase("request_id") + || key.equalsIgnoreCase("status") + || key.equalsIgnoreCase("finalized") + || key.equalsIgnoreCase("total_items") + || key.equalsIgnoreCase("error") + || key.equalsIgnoreCase("created_at") + || key.equalsIgnoreCase("updated_at") + || key.equalsIgnoreCase("completed_at") + || key.equalsIgnoreCase("expires_at") + || key.equalsIgnoreCase("counts") + || key.equalsIgnoreCase("items") + || key.equalsIgnoreCase("next_cursor") + || key.equalsIgnoreCase("has_more")) { + return; + } + this.additionalProperties.put(key, value); + } + @java.lang.Override public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); diff --git a/src/main/java/com/phenoml/api/resources/lang2fhirbatch/types/JobListResponse.java b/src/main/java/com/phenoml/api/resources/lang2fhirbatch/types/JobListResponse.java index 6ee0915d..a04cc79c 100644 --- a/src/main/java/com/phenoml/api/resources/lang2fhirbatch/types/JobListResponse.java +++ b/src/main/java/com/phenoml/api/resources/lang2fhirbatch/types/JobListResponse.java @@ -129,7 +129,6 @@ public static final class Builder implements HasMoreStage, _FinalStage { private List jobs = new ArrayList<>(); - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -202,6 +201,16 @@ public JobListResponse build() { return new JobListResponse(jobs, nextCursor, hasMore, additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("jobs") + || key.equalsIgnoreCase("next_cursor") + || key.equalsIgnoreCase("has_more")) { + return; + } + this.additionalProperties.put(key, value); + } + @java.lang.Override public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); diff --git a/src/main/java/com/phenoml/api/resources/lang2fhirbatch/types/ResultsPageResponse.java b/src/main/java/com/phenoml/api/resources/lang2fhirbatch/types/ResultsPageResponse.java index 00a13736..2863682b 100644 --- a/src/main/java/com/phenoml/api/resources/lang2fhirbatch/types/ResultsPageResponse.java +++ b/src/main/java/com/phenoml/api/resources/lang2fhirbatch/types/ResultsPageResponse.java @@ -129,7 +129,6 @@ public static final class Builder implements HasMoreStage, _FinalStage { private List results = new ArrayList<>(); - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -202,6 +201,16 @@ public ResultsPageResponse build() { return new ResultsPageResponse(results, nextCursor, hasMore, additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("results") + || key.equalsIgnoreCase("next_cursor") + || key.equalsIgnoreCase("has_more")) { + return; + } + this.additionalProperties.put(key, value); + } + @java.lang.Override public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); diff --git a/src/main/java/com/phenoml/api/resources/lang2fhirbatch/types/UploadItemResponse.java b/src/main/java/com/phenoml/api/resources/lang2fhirbatch/types/UploadItemResponse.java index f8cb88ed..ab7b566c 100644 --- a/src/main/java/com/phenoml/api/resources/lang2fhirbatch/types/UploadItemResponse.java +++ b/src/main/java/com/phenoml/api/resources/lang2fhirbatch/types/UploadItemResponse.java @@ -399,7 +399,6 @@ public static final class Builder private Optional requestId = Optional.empty(); - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -624,6 +623,26 @@ public UploadItemResponse build() { additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("job_id") + || key.equalsIgnoreCase("request_id") + || key.equalsIgnoreCase("status") + || key.equalsIgnoreCase("finalized") + || key.equalsIgnoreCase("total_items") + || key.equalsIgnoreCase("error") + || key.equalsIgnoreCase("created_at") + || key.equalsIgnoreCase("updated_at") + || key.equalsIgnoreCase("completed_at") + || key.equalsIgnoreCase("expires_at") + || key.equalsIgnoreCase("item_id") + || key.equalsIgnoreCase("id") + || key.equalsIgnoreCase("deduplicated")) { + return; + } + this.additionalProperties.put(key, value); + } + @java.lang.Override public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); diff --git a/src/main/java/com/phenoml/api/resources/profiles/profiles/AsyncProfilesClient.java b/src/main/java/com/phenoml/api/resources/profiles/profiles/AsyncProfilesClient.java index 503fe9bd..0aa457ca 100644 --- a/src/main/java/com/phenoml/api/resources/profiles/profiles/AsyncProfilesClient.java +++ b/src/main/java/com/phenoml/api/resources/profiles/profiles/AsyncProfilesClient.java @@ -4,6 +4,7 @@ package com.phenoml.api.resources.profiles.profiles; import com.phenoml.api.core.ClientOptions; +import com.phenoml.api.core.PhenomlClientHttpResponse; import com.phenoml.api.core.RequestOptions; import com.phenoml.api.resources.profiles.profiles.requests.ListRequest; import com.phenoml.api.resources.profiles.types.ProfileGetResponse; @@ -44,7 +45,14 @@ public AsyncRawProfilesClient withRawResponse() { * empty list, not a 404.

            */ public CompletableFuture list() { - return this.rawClient.list().thenApply(response -> response.body()); + CompletableFuture> rawFuture = this.rawClient.list(); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** @@ -62,7 +70,15 @@ public CompletableFuture list() { * empty list, not a 404.

            */ public CompletableFuture list(RequestOptions requestOptions) { - return this.rawClient.list(requestOptions).thenApply(response -> response.body()); + CompletableFuture> rawFuture = + this.rawClient.list(requestOptions); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** @@ -80,7 +96,14 @@ public CompletableFuture list(RequestOptions requestOptions * empty list, not a 404.

            */ public CompletableFuture list(ListRequest request) { - return this.rawClient.list(request).thenApply(response -> response.body()); + CompletableFuture> rawFuture = this.rawClient.list(request); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** @@ -98,7 +121,15 @@ public CompletableFuture list(ListRequest request) { * empty list, not a 404.

            */ public CompletableFuture list(ListRequest request, RequestOptions requestOptions) { - return this.rawClient.list(request, requestOptions).thenApply(response -> response.body()); + CompletableFuture> rawFuture = + this.rawClient.list(request, requestOptions); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** @@ -109,7 +140,14 @@ public CompletableFuture list(ListRequest request, RequestO * Optionally group the profile under a named implementation guide. */ public CompletableFuture create(ProfileUploadRequest request) { - return this.rawClient.create(request).thenApply(response -> response.body()); + CompletableFuture> rawFuture = this.rawClient.create(request); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** @@ -120,7 +158,15 @@ public CompletableFuture create(ProfileUploadRequest request) { * Optionally group the profile under a named implementation guide. */ public CompletableFuture create(ProfileUploadRequest request, RequestOptions requestOptions) { - return this.rawClient.create(request, requestOptions).thenApply(response -> response.body()); + CompletableFuture> rawFuture = + this.rawClient.create(request, requestOptions); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** @@ -128,7 +174,14 @@ public CompletableFuture create(ProfileUploadRequest request, Re * JSON. */ public CompletableFuture get(String id) { - return this.rawClient.get(id).thenApply(response -> response.body()); + CompletableFuture> rawFuture = this.rawClient.get(id); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** @@ -136,7 +189,15 @@ public CompletableFuture get(String id) { * JSON. */ public CompletableFuture get(String id, RequestOptions requestOptions) { - return this.rawClient.get(id, requestOptions).thenApply(response -> response.body()); + CompletableFuture> rawFuture = + this.rawClient.get(id, requestOptions); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** @@ -152,7 +213,14 @@ public CompletableFuture get(String id, RequestOptions reque * canonical URL cannot be changed. */ public CompletableFuture update(String id, ProfileUploadRequest request) { - return this.rawClient.update(id, request).thenApply(response -> response.body()); + CompletableFuture> rawFuture = this.rawClient.update(id, request); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** @@ -169,7 +237,15 @@ public CompletableFuture update(String id, ProfileUploadRequest */ public CompletableFuture update( String id, ProfileUploadRequest request, RequestOptions requestOptions) { - return this.rawClient.update(id, request, requestOptions).thenApply(response -> response.body()); + CompletableFuture> rawFuture = + this.rawClient.update(id, request, requestOptions); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** @@ -178,7 +254,14 @@ public CompletableFuture update( * upload. */ public CompletableFuture delete(String id) { - return this.rawClient.delete(id).thenApply(response -> response.body()); + CompletableFuture> rawFuture = this.rawClient.delete(id); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** @@ -187,6 +270,13 @@ public CompletableFuture delete(String id) { * upload. */ public CompletableFuture delete(String id, RequestOptions requestOptions) { - return this.rawClient.delete(id, requestOptions).thenApply(response -> response.body()); + CompletableFuture> rawFuture = this.rawClient.delete(id, requestOptions); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } } diff --git a/src/main/java/com/phenoml/api/resources/profiles/profiles/AsyncRawProfilesClient.java b/src/main/java/com/phenoml/api/resources/profiles/profiles/AsyncRawProfilesClient.java index 18e98e37..c3398c46 100644 --- a/src/main/java/com/phenoml/api/resources/profiles/profiles/AsyncRawProfilesClient.java +++ b/src/main/java/com/phenoml/api/resources/profiles/profiles/AsyncRawProfilesClient.java @@ -4,9 +4,11 @@ package com.phenoml.api.resources.profiles.profiles; import com.fasterxml.jackson.core.JsonProcessingException; +import com.phenoml.api.core.BodyProperties; import com.phenoml.api.core.ClientOptions; import com.phenoml.api.core.MediaTypes; import com.phenoml.api.core.ObjectMappers; +import com.phenoml.api.core.PathSegments; import com.phenoml.api.core.PhenomlClientApiException; import com.phenoml.api.core.PhenomlClientException; import com.phenoml.api.core.PhenomlClientHttpResponse; @@ -145,7 +147,8 @@ public CompletableFuture> list( .build(); } CompletableFuture> future = new CompletableFuture<>(); - client.newCall(okhttpRequest).enqueue(new Callback() { + RetryInterceptor.AsyncCall okhttpCall = RetryInterceptor.newAsyncCall(client, okhttpRequest); + okhttpCall.enqueue(new Callback() { @Override public void onResponse(@NotNull Call call, @NotNull Response response) throws IOException { try (ResponseBody responseBody = response.body()) { @@ -194,6 +197,11 @@ public void onFailure(@NotNull Call call, @NotNull IOException e) { future.completeExceptionally(new PhenomlClientException("Network error executing HTTP request", e)); } }); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + okhttpCall.cancel(); + } + }); return future; } @@ -228,7 +236,9 @@ public CompletableFuture> create( RequestBody body; try { body = RequestBody.create( - ObjectMappers.JSON_MAPPER.writeValueAsBytes(request), MediaTypes.APPLICATION_JSON); + ObjectMappers.JSON_MAPPER.writeValueAsBytes(BodyProperties.merge( + request, requestOptions != null ? requestOptions.getBodyProperties() : null)), + MediaTypes.APPLICATION_JSON); } catch (JsonProcessingException e) { throw new PhenomlClientException("Failed to serialize request", e); } @@ -253,7 +263,8 @@ public CompletableFuture> create( .build(); } CompletableFuture> future = new CompletableFuture<>(); - client.newCall(okhttpRequest).enqueue(new Callback() { + RetryInterceptor.AsyncCall okhttpCall = RetryInterceptor.newAsyncCall(client, okhttpRequest); + okhttpCall.enqueue(new Callback() { @Override public void onResponse(@NotNull Call call, @NotNull Response response) throws IOException { try (ResponseBody responseBody = response.body()) { @@ -307,6 +318,11 @@ public void onFailure(@NotNull Call call, @NotNull IOException e) { future.completeExceptionally(new PhenomlClientException("Network error executing HTTP request", e)); } }); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + okhttpCall.cancel(); + } + }); return future; } @@ -327,7 +343,7 @@ public CompletableFuture> get( HttpUrl.Builder httpUrl = HttpUrl.parse(this.clientOptions.environment().getUrl()) .newBuilder() .addPathSegments("fhir/profiles") - .addPathSegment(id); + .addPathSegment(PathSegments.validate(id)); if (requestOptions != null) { requestOptions.getQueryParameters().forEach((_key, _value) -> { httpUrl.addQueryParameter(_key, _value); @@ -353,7 +369,8 @@ public CompletableFuture> get( .build(); } CompletableFuture> future = new CompletableFuture<>(); - client.newCall(okhttpRequest).enqueue(new Callback() { + RetryInterceptor.AsyncCall okhttpCall = RetryInterceptor.newAsyncCall(client, okhttpRequest); + okhttpCall.enqueue(new Callback() { @Override public void onResponse(@NotNull Call call, @NotNull Response response) throws IOException { try (ResponseBody responseBody = response.body()) { @@ -412,6 +429,11 @@ public void onFailure(@NotNull Call call, @NotNull IOException e) { future.completeExceptionally(new PhenomlClientException("Network error executing HTTP request", e)); } }); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + okhttpCall.cancel(); + } + }); return future; } @@ -449,7 +471,7 @@ public CompletableFuture> update( HttpUrl.Builder httpUrl = HttpUrl.parse(this.clientOptions.environment().getUrl()) .newBuilder() .addPathSegments("fhir/profiles") - .addPathSegment(id); + .addPathSegment(PathSegments.validate(id)); if (requestOptions != null) { requestOptions.getQueryParameters().forEach((_key, _value) -> { httpUrl.addQueryParameter(_key, _value); @@ -458,7 +480,9 @@ public CompletableFuture> update( RequestBody body; try { body = RequestBody.create( - ObjectMappers.JSON_MAPPER.writeValueAsBytes(request), MediaTypes.APPLICATION_JSON); + ObjectMappers.JSON_MAPPER.writeValueAsBytes(BodyProperties.merge( + request, requestOptions != null ? requestOptions.getBodyProperties() : null)), + MediaTypes.APPLICATION_JSON); } catch (JsonProcessingException e) { throw new PhenomlClientException("Failed to serialize request", e); } @@ -483,7 +507,8 @@ public CompletableFuture> update( .build(); } CompletableFuture> future = new CompletableFuture<>(); - client.newCall(okhttpRequest).enqueue(new Callback() { + RetryInterceptor.AsyncCall okhttpCall = RetryInterceptor.newAsyncCall(client, okhttpRequest); + okhttpCall.enqueue(new Callback() { @Override public void onResponse(@NotNull Call call, @NotNull Response response) throws IOException { try (ResponseBody responseBody = response.body()) { @@ -547,6 +572,11 @@ public void onFailure(@NotNull Call call, @NotNull IOException e) { future.completeExceptionally(new PhenomlClientException("Network error executing HTTP request", e)); } }); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + okhttpCall.cancel(); + } + }); return future; } @@ -568,7 +598,7 @@ public CompletableFuture> delete(String id, Requ HttpUrl.Builder httpUrl = HttpUrl.parse(this.clientOptions.environment().getUrl()) .newBuilder() .addPathSegments("fhir/profiles") - .addPathSegment(id); + .addPathSegment(PathSegments.validate(id)); if (requestOptions != null) { requestOptions.getQueryParameters().forEach((_key, _value) -> { httpUrl.addQueryParameter(_key, _value); @@ -576,7 +606,10 @@ public CompletableFuture> delete(String id, Requ } Request okhttpRequest = new Request.Builder() .url(httpUrl.build()) - .method("DELETE", null) + .method( + "DELETE", + BodyProperties.toRequestBody( + requestOptions != null ? requestOptions.getBodyProperties() : null, null)) .headers(Headers.of(clientOptions.headers(requestOptions))) .addHeader("Accept", "application/json") .build(); @@ -594,7 +627,8 @@ public CompletableFuture> delete(String id, Requ .build(); } CompletableFuture> future = new CompletableFuture<>(); - client.newCall(okhttpRequest).enqueue(new Callback() { + RetryInterceptor.AsyncCall okhttpCall = RetryInterceptor.newAsyncCall(client, okhttpRequest); + okhttpCall.enqueue(new Callback() { @Override public void onResponse(@NotNull Call call, @NotNull Response response) throws IOException { try (ResponseBody responseBody = response.body()) { @@ -656,6 +690,11 @@ public void onFailure(@NotNull Call call, @NotNull IOException e) { future.completeExceptionally(new PhenomlClientException("Network error executing HTTP request", e)); } }); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + okhttpCall.cancel(); + } + }); return future; } } diff --git a/src/main/java/com/phenoml/api/resources/profiles/profiles/RawProfilesClient.java b/src/main/java/com/phenoml/api/resources/profiles/profiles/RawProfilesClient.java index 182b9ac3..c00990d4 100644 --- a/src/main/java/com/phenoml/api/resources/profiles/profiles/RawProfilesClient.java +++ b/src/main/java/com/phenoml/api/resources/profiles/profiles/RawProfilesClient.java @@ -4,9 +4,11 @@ package com.phenoml.api.resources.profiles.profiles; import com.fasterxml.jackson.core.JsonProcessingException; +import com.phenoml.api.core.BodyProperties; import com.phenoml.api.core.ClientOptions; import com.phenoml.api.core.MediaTypes; import com.phenoml.api.core.ObjectMappers; +import com.phenoml.api.core.PathSegments; import com.phenoml.api.core.PhenomlClientApiException; import com.phenoml.api.core.PhenomlClientException; import com.phenoml.api.core.PhenomlClientHttpResponse; @@ -202,7 +204,9 @@ public PhenomlClientHttpResponse create( RequestBody body; try { body = RequestBody.create( - ObjectMappers.JSON_MAPPER.writeValueAsBytes(request), MediaTypes.APPLICATION_JSON); + ObjectMappers.JSON_MAPPER.writeValueAsBytes(BodyProperties.merge( + request, requestOptions != null ? requestOptions.getBodyProperties() : null)), + MediaTypes.APPLICATION_JSON); } catch (JsonProcessingException e) { throw new PhenomlClientException("Failed to serialize request", e); } @@ -277,7 +281,7 @@ public PhenomlClientHttpResponse get(String id, RequestOptio HttpUrl.Builder httpUrl = HttpUrl.parse(this.clientOptions.environment().getUrl()) .newBuilder() .addPathSegments("fhir/profiles") - .addPathSegment(id); + .addPathSegment(PathSegments.validate(id)); if (requestOptions != null) { requestOptions.getQueryParameters().forEach((_key, _value) -> { httpUrl.addQueryParameter(_key, _value); @@ -373,7 +377,7 @@ public PhenomlClientHttpResponse update( HttpUrl.Builder httpUrl = HttpUrl.parse(this.clientOptions.environment().getUrl()) .newBuilder() .addPathSegments("fhir/profiles") - .addPathSegment(id); + .addPathSegment(PathSegments.validate(id)); if (requestOptions != null) { requestOptions.getQueryParameters().forEach((_key, _value) -> { httpUrl.addQueryParameter(_key, _value); @@ -382,7 +386,9 @@ public PhenomlClientHttpResponse update( RequestBody body; try { body = RequestBody.create( - ObjectMappers.JSON_MAPPER.writeValueAsBytes(request), MediaTypes.APPLICATION_JSON); + ObjectMappers.JSON_MAPPER.writeValueAsBytes(BodyProperties.merge( + request, requestOptions != null ? requestOptions.getBodyProperties() : null)), + MediaTypes.APPLICATION_JSON); } catch (JsonProcessingException e) { throw new PhenomlClientException("Failed to serialize request", e); } @@ -465,7 +471,7 @@ public PhenomlClientHttpResponse delete(String id, RequestOptions requestO HttpUrl.Builder httpUrl = HttpUrl.parse(this.clientOptions.environment().getUrl()) .newBuilder() .addPathSegments("fhir/profiles") - .addPathSegment(id); + .addPathSegment(PathSegments.validate(id)); if (requestOptions != null) { requestOptions.getQueryParameters().forEach((_key, _value) -> { httpUrl.addQueryParameter(_key, _value); @@ -473,7 +479,10 @@ public PhenomlClientHttpResponse delete(String id, RequestOptions requestO } Request okhttpRequest = new Request.Builder() .url(httpUrl.build()) - .method("DELETE", null) + .method( + "DELETE", + BodyProperties.toRequestBody( + requestOptions != null ? requestOptions.getBodyProperties() : null, null)) .headers(Headers.of(clientOptions.headers(requestOptions))) .addHeader("Accept", "application/json") .build(); diff --git a/src/main/java/com/phenoml/api/resources/profiles/profiles/requests/ListRequest.java b/src/main/java/com/phenoml/api/resources/profiles/profiles/requests/ListRequest.java index 3ef9e578..4c63e96b 100644 --- a/src/main/java/com/phenoml/api/resources/profiles/profiles/requests/ListRequest.java +++ b/src/main/java/com/phenoml/api/resources/profiles/profiles/requests/ListRequest.java @@ -70,7 +70,6 @@ public static Builder builder() { public static final class Builder { private Optional url = Optional.empty(); - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -98,6 +97,14 @@ public ListRequest build() { return new ListRequest(url, additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("url")) { + return; + } + this.additionalProperties.put(key, value); + } + public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); return this; diff --git a/src/main/java/com/phenoml/api/resources/profiles/types/ProfileGetResponse.java b/src/main/java/com/phenoml/api/resources/profiles/types/ProfileGetResponse.java index 288a6d49..1e8885e3 100644 --- a/src/main/java/com/phenoml/api/resources/profiles/types/ProfileGetResponse.java +++ b/src/main/java/com/phenoml/api/resources/profiles/types/ProfileGetResponse.java @@ -387,7 +387,6 @@ public static final class Builder private Optional status = Optional.empty(); - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -611,6 +610,26 @@ public ProfileGetResponse build() { additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("id") + || key.equalsIgnoreCase("source") + || key.equalsIgnoreCase("resource_type") + || key.equalsIgnoreCase("url") + || key.equalsIgnoreCase("version") + || key.equalsIgnoreCase("status") + || key.equalsIgnoreCase("date") + || key.equalsIgnoreCase("canonical") + || key.equalsIgnoreCase("fhir_version") + || key.equalsIgnoreCase("implementation_guide") + || key.equalsIgnoreCase("created_at") + || key.equalsIgnoreCase("updated_at") + || key.equalsIgnoreCase("structure_definition")) { + return; + } + this.additionalProperties.put(key, value); + } + @java.lang.Override public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); diff --git a/src/main/java/com/phenoml/api/resources/profiles/types/ProfileListResponse.java b/src/main/java/com/phenoml/api/resources/profiles/types/ProfileListResponse.java index d888afd2..13b94fe7 100644 --- a/src/main/java/com/phenoml/api/resources/profiles/types/ProfileListResponse.java +++ b/src/main/java/com/phenoml/api/resources/profiles/types/ProfileListResponse.java @@ -68,7 +68,6 @@ public static Builder builder() { public static final class Builder { private List profiles = new ArrayList<>(); - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -103,6 +102,14 @@ public ProfileListResponse build() { return new ProfileListResponse(profiles, additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("profiles")) { + return; + } + this.additionalProperties.put(key, value); + } + public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); return this; diff --git a/src/main/java/com/phenoml/api/resources/profiles/types/ProfileSummary.java b/src/main/java/com/phenoml/api/resources/profiles/types/ProfileSummary.java index c4664be4..2a9f2651 100644 --- a/src/main/java/com/phenoml/api/resources/profiles/types/ProfileSummary.java +++ b/src/main/java/com/phenoml/api/resources/profiles/types/ProfileSummary.java @@ -361,7 +361,6 @@ public static final class Builder private Optional status = Optional.empty(); - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -548,6 +547,25 @@ public ProfileSummary build() { additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("id") + || key.equalsIgnoreCase("source") + || key.equalsIgnoreCase("resource_type") + || key.equalsIgnoreCase("url") + || key.equalsIgnoreCase("version") + || key.equalsIgnoreCase("status") + || key.equalsIgnoreCase("date") + || key.equalsIgnoreCase("canonical") + || key.equalsIgnoreCase("fhir_version") + || key.equalsIgnoreCase("implementation_guide") + || key.equalsIgnoreCase("created_at") + || key.equalsIgnoreCase("updated_at")) { + return; + } + this.additionalProperties.put(key, value); + } + @java.lang.Override public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); diff --git a/src/main/java/com/phenoml/api/resources/profiles/types/ProfileUploadRequest.java b/src/main/java/com/phenoml/api/resources/profiles/types/ProfileUploadRequest.java index cad6befe..d0d412d0 100644 --- a/src/main/java/com/phenoml/api/resources/profiles/types/ProfileUploadRequest.java +++ b/src/main/java/com/phenoml/api/resources/profiles/types/ProfileUploadRequest.java @@ -45,7 +45,7 @@ public Map getStructureDefinition() { } /** - * @return Implementation Guide name to group this profile under. On create, defaults to "custom" if omitted; on update, omitting it preserves the profile's current guide. Cannot be "us_core" (reserved). Use this to organize custom profiles into named IGs that can be referenced when calling create/multi or document/multi endpoints. + * @return Implementation Guide name to group this profile under. On create, defaults to "custom" if omitted; on update, omitting it preserves the profile's current guide. Cannot be "us_core" or "phenoml_hpo" (reserved). Use this to organize custom profiles into named IGs that can be referenced when calling create/multi or document/multi endpoints. */ @JsonProperty("implementation_guide") public Optional getImplementationGuide() { @@ -88,7 +88,6 @@ public static final class Builder { private Optional implementationGuide = Optional.empty(); - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -124,7 +123,7 @@ public Builder structureDefinition(String key, Object value) { } /** - *

            Implementation Guide name to group this profile under. On create, defaults to "custom" if omitted; on update, omitting it preserves the profile's current guide. Cannot be "us_core" (reserved). Use this to organize custom profiles into named IGs that can be referenced when calling create/multi or document/multi endpoints.

            + *

            Implementation Guide name to group this profile under. On create, defaults to "custom" if omitted; on update, omitting it preserves the profile's current guide. Cannot be "us_core" or "phenoml_hpo" (reserved). Use this to organize custom profiles into named IGs that can be referenced when calling create/multi or document/multi endpoints.

            */ @JsonSetter(value = "implementation_guide", nulls = Nulls.SKIP) public Builder implementationGuide(Optional implementationGuide) { @@ -141,6 +140,14 @@ public ProfileUploadRequest build() { return new ProfileUploadRequest(structureDefinition, implementationGuide, additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("structure_definition") || key.equalsIgnoreCase("implementation_guide")) { + return; + } + this.additionalProperties.put(key, value); + } + public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); return this; diff --git a/src/main/java/com/phenoml/api/resources/profiles/types/ProfileVersionListResponse.java b/src/main/java/com/phenoml/api/resources/profiles/types/ProfileVersionListResponse.java index 6572548a..871904a5 100644 --- a/src/main/java/com/phenoml/api/resources/profiles/types/ProfileVersionListResponse.java +++ b/src/main/java/com/phenoml/api/resources/profiles/types/ProfileVersionListResponse.java @@ -68,7 +68,6 @@ public static Builder builder() { public static final class Builder { private List versions = new ArrayList<>(); - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -103,6 +102,14 @@ public ProfileVersionListResponse build() { return new ProfileVersionListResponse(versions, additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("versions")) { + return; + } + this.additionalProperties.put(key, value); + } + public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); return this; diff --git a/src/main/java/com/phenoml/api/resources/profiles/versions/AsyncRawVersionsClient.java b/src/main/java/com/phenoml/api/resources/profiles/versions/AsyncRawVersionsClient.java index 20f981af..6e6896f6 100644 --- a/src/main/java/com/phenoml/api/resources/profiles/versions/AsyncRawVersionsClient.java +++ b/src/main/java/com/phenoml/api/resources/profiles/versions/AsyncRawVersionsClient.java @@ -4,9 +4,11 @@ package com.phenoml.api.resources.profiles.versions; import com.fasterxml.jackson.core.JsonProcessingException; +import com.phenoml.api.core.BodyProperties; import com.phenoml.api.core.ClientOptions; import com.phenoml.api.core.MediaTypes; import com.phenoml.api.core.ObjectMappers; +import com.phenoml.api.core.PathSegments; import com.phenoml.api.core.PhenomlClientApiException; import com.phenoml.api.core.PhenomlClientException; import com.phenoml.api.core.PhenomlClientHttpResponse; @@ -57,7 +59,7 @@ public CompletableFuture> HttpUrl.Builder httpUrl = HttpUrl.parse(this.clientOptions.environment().getUrl()) .newBuilder() .addPathSegments("fhir/profiles") - .addPathSegment(id) + .addPathSegment(PathSegments.validate(id)) .addPathSegments("versions"); if (requestOptions != null) { requestOptions.getQueryParameters().forEach((_key, _value) -> { @@ -84,7 +86,8 @@ public CompletableFuture> .build(); } CompletableFuture> future = new CompletableFuture<>(); - client.newCall(okhttpRequest).enqueue(new Callback() { + RetryInterceptor.AsyncCall okhttpCall = RetryInterceptor.newAsyncCall(client, okhttpRequest); + okhttpCall.enqueue(new Callback() { @Override public void onResponse(@NotNull Call call, @NotNull Response response) throws IOException { try (ResponseBody responseBody = response.body()) { @@ -144,6 +147,11 @@ public void onFailure(@NotNull Call call, @NotNull IOException e) { future.completeExceptionally(new PhenomlClientException("Network error executing HTTP request", e)); } }); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + okhttpCall.cancel(); + } + }); return future; } @@ -184,7 +192,7 @@ public CompletableFuture> create( HttpUrl.Builder httpUrl = HttpUrl.parse(this.clientOptions.environment().getUrl()) .newBuilder() .addPathSegments("fhir/profiles") - .addPathSegment(id) + .addPathSegment(PathSegments.validate(id)) .addPathSegments("versions"); if (requestOptions != null) { requestOptions.getQueryParameters().forEach((_key, _value) -> { @@ -194,7 +202,9 @@ public CompletableFuture> create( RequestBody body; try { body = RequestBody.create( - ObjectMappers.JSON_MAPPER.writeValueAsBytes(request), MediaTypes.APPLICATION_JSON); + ObjectMappers.JSON_MAPPER.writeValueAsBytes(BodyProperties.merge( + request, requestOptions != null ? requestOptions.getBodyProperties() : null)), + MediaTypes.APPLICATION_JSON); } catch (JsonProcessingException e) { throw new PhenomlClientException("Failed to serialize request", e); } @@ -219,7 +229,8 @@ public CompletableFuture> create( .build(); } CompletableFuture> future = new CompletableFuture<>(); - client.newCall(okhttpRequest).enqueue(new Callback() { + RetryInterceptor.AsyncCall okhttpCall = RetryInterceptor.newAsyncCall(client, okhttpRequest); + okhttpCall.enqueue(new Callback() { @Override public void onResponse(@NotNull Call call, @NotNull Response response) throws IOException { try (ResponseBody responseBody = response.body()) { @@ -283,6 +294,11 @@ public void onFailure(@NotNull Call call, @NotNull IOException e) { future.completeExceptionally(new PhenomlClientException("Network error executing HTTP request", e)); } }); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + okhttpCall.cancel(); + } + }); return future; } @@ -305,9 +321,9 @@ public CompletableFuture> get( HttpUrl.Builder httpUrl = HttpUrl.parse(this.clientOptions.environment().getUrl()) .newBuilder() .addPathSegments("fhir/profiles") - .addPathSegment(id) + .addPathSegment(PathSegments.validate(id)) .addPathSegments("versions") - .addPathSegment(version); + .addPathSegment(PathSegments.validate(version)); if (requestOptions != null) { requestOptions.getQueryParameters().forEach((_key, _value) -> { httpUrl.addQueryParameter(_key, _value); @@ -333,7 +349,8 @@ public CompletableFuture> get( .build(); } CompletableFuture> future = new CompletableFuture<>(); - client.newCall(okhttpRequest).enqueue(new Callback() { + RetryInterceptor.AsyncCall okhttpCall = RetryInterceptor.newAsyncCall(client, okhttpRequest); + okhttpCall.enqueue(new Callback() { @Override public void onResponse(@NotNull Call call, @NotNull Response response) throws IOException { try (ResponseBody responseBody = response.body()) { @@ -392,6 +409,11 @@ public void onFailure(@NotNull Call call, @NotNull IOException e) { future.completeExceptionally(new PhenomlClientException("Network error executing HTTP request", e)); } }); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + okhttpCall.cancel(); + } + }); return future; } @@ -412,9 +434,9 @@ public CompletableFuture> delete( HttpUrl.Builder httpUrl = HttpUrl.parse(this.clientOptions.environment().getUrl()) .newBuilder() .addPathSegments("fhir/profiles") - .addPathSegment(id) + .addPathSegment(PathSegments.validate(id)) .addPathSegments("versions") - .addPathSegment(version); + .addPathSegment(PathSegments.validate(version)); if (requestOptions != null) { requestOptions.getQueryParameters().forEach((_key, _value) -> { httpUrl.addQueryParameter(_key, _value); @@ -422,7 +444,10 @@ public CompletableFuture> delete( } Request okhttpRequest = new Request.Builder() .url(httpUrl.build()) - .method("DELETE", null) + .method( + "DELETE", + BodyProperties.toRequestBody( + requestOptions != null ? requestOptions.getBodyProperties() : null, null)) .headers(Headers.of(clientOptions.headers(requestOptions))) .addHeader("Accept", "application/json") .build(); @@ -440,7 +465,8 @@ public CompletableFuture> delete( .build(); } CompletableFuture> future = new CompletableFuture<>(); - client.newCall(okhttpRequest).enqueue(new Callback() { + RetryInterceptor.AsyncCall okhttpCall = RetryInterceptor.newAsyncCall(client, okhttpRequest); + okhttpCall.enqueue(new Callback() { @Override public void onResponse(@NotNull Call call, @NotNull Response response) throws IOException { try (ResponseBody responseBody = response.body()) { @@ -502,6 +528,11 @@ public void onFailure(@NotNull Call call, @NotNull IOException e) { future.completeExceptionally(new PhenomlClientException("Network error executing HTTP request", e)); } }); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + okhttpCall.cancel(); + } + }); return future; } } diff --git a/src/main/java/com/phenoml/api/resources/profiles/versions/AsyncVersionsClient.java b/src/main/java/com/phenoml/api/resources/profiles/versions/AsyncVersionsClient.java index 282171f4..21cbb6a1 100644 --- a/src/main/java/com/phenoml/api/resources/profiles/versions/AsyncVersionsClient.java +++ b/src/main/java/com/phenoml/api/resources/profiles/versions/AsyncVersionsClient.java @@ -4,6 +4,7 @@ package com.phenoml.api.resources.profiles.versions; import com.phenoml.api.core.ClientOptions; +import com.phenoml.api.core.PhenomlClientHttpResponse; import com.phenoml.api.core.RequestOptions; import com.phenoml.api.resources.profiles.types.ProfileGetResponse; import com.phenoml.api.resources.profiles.types.ProfileSummary; @@ -32,14 +33,29 @@ public AsyncRawVersionsClient withRawResponse() { * Returns retained versions for a custom profile. */ public CompletableFuture list(String id) { - return this.rawClient.list(id).thenApply(response -> response.body()); + CompletableFuture> rawFuture = this.rawClient.list(id); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** * Returns retained versions for a custom profile. */ public CompletableFuture list(String id, RequestOptions requestOptions) { - return this.rawClient.list(id, requestOptions).thenApply(response -> response.body()); + CompletableFuture> rawFuture = + this.rawClient.list(id, requestOptions); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** @@ -57,7 +73,14 @@ public CompletableFuture list(String id, RequestOpti * versions before adding more. */ public CompletableFuture create(String id, Map request) { - return this.rawClient.create(id, request).thenApply(response -> response.body()); + CompletableFuture> rawFuture = this.rawClient.create(id, request); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** @@ -76,7 +99,15 @@ public CompletableFuture create(String id, Map r */ public CompletableFuture create( String id, Map request, RequestOptions requestOptions) { - return this.rawClient.create(id, request, requestOptions).thenApply(response -> response.body()); + CompletableFuture> rawFuture = + this.rawClient.create(id, request, requestOptions); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** @@ -85,7 +116,14 @@ public CompletableFuture create( * path version is the authored StructureDefinition.version value. */ public CompletableFuture get(String id, String version) { - return this.rawClient.get(id, version).thenApply(response -> response.body()); + CompletableFuture> rawFuture = this.rawClient.get(id, version); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** @@ -94,7 +132,15 @@ public CompletableFuture get(String id, String version) { * path version is the authored StructureDefinition.version value. */ public CompletableFuture get(String id, String version, RequestOptions requestOptions) { - return this.rawClient.get(id, version, requestOptions).thenApply(response -> response.body()); + CompletableFuture> rawFuture = + this.rawClient.get(id, version, requestOptions); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** @@ -102,7 +148,14 @@ public CompletableFuture get(String id, String version, Requ * version is the authored StructureDefinition.version value. */ public CompletableFuture delete(String id, String version) { - return this.rawClient.delete(id, version).thenApply(response -> response.body()); + CompletableFuture> rawFuture = this.rawClient.delete(id, version); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** @@ -110,6 +163,14 @@ public CompletableFuture delete(String id, String version) { * version is the authored StructureDefinition.version value. */ public CompletableFuture delete(String id, String version, RequestOptions requestOptions) { - return this.rawClient.delete(id, version, requestOptions).thenApply(response -> response.body()); + CompletableFuture> rawFuture = + this.rawClient.delete(id, version, requestOptions); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } } diff --git a/src/main/java/com/phenoml/api/resources/profiles/versions/RawVersionsClient.java b/src/main/java/com/phenoml/api/resources/profiles/versions/RawVersionsClient.java index 6e9d311f..84070f27 100644 --- a/src/main/java/com/phenoml/api/resources/profiles/versions/RawVersionsClient.java +++ b/src/main/java/com/phenoml/api/resources/profiles/versions/RawVersionsClient.java @@ -4,9 +4,11 @@ package com.phenoml.api.resources.profiles.versions; import com.fasterxml.jackson.core.JsonProcessingException; +import com.phenoml.api.core.BodyProperties; import com.phenoml.api.core.ClientOptions; import com.phenoml.api.core.MediaTypes; import com.phenoml.api.core.ObjectMappers; +import com.phenoml.api.core.PathSegments; import com.phenoml.api.core.PhenomlClientApiException; import com.phenoml.api.core.PhenomlClientException; import com.phenoml.api.core.PhenomlClientHttpResponse; @@ -52,7 +54,7 @@ public PhenomlClientHttpResponse list(String id, Req HttpUrl.Builder httpUrl = HttpUrl.parse(this.clientOptions.environment().getUrl()) .newBuilder() .addPathSegments("fhir/profiles") - .addPathSegment(id) + .addPathSegment(PathSegments.validate(id)) .addPathSegments("versions"); if (requestOptions != null) { requestOptions.getQueryParameters().forEach((_key, _value) -> { @@ -154,7 +156,7 @@ public PhenomlClientHttpResponse create( HttpUrl.Builder httpUrl = HttpUrl.parse(this.clientOptions.environment().getUrl()) .newBuilder() .addPathSegments("fhir/profiles") - .addPathSegment(id) + .addPathSegment(PathSegments.validate(id)) .addPathSegments("versions"); if (requestOptions != null) { requestOptions.getQueryParameters().forEach((_key, _value) -> { @@ -164,7 +166,9 @@ public PhenomlClientHttpResponse create( RequestBody body; try { body = RequestBody.create( - ObjectMappers.JSON_MAPPER.writeValueAsBytes(request), MediaTypes.APPLICATION_JSON); + ObjectMappers.JSON_MAPPER.writeValueAsBytes(BodyProperties.merge( + request, requestOptions != null ? requestOptions.getBodyProperties() : null)), + MediaTypes.APPLICATION_JSON); } catch (JsonProcessingException e) { throw new PhenomlClientException("Failed to serialize request", e); } @@ -247,9 +251,9 @@ public PhenomlClientHttpResponse get(String id, String versi HttpUrl.Builder httpUrl = HttpUrl.parse(this.clientOptions.environment().getUrl()) .newBuilder() .addPathSegments("fhir/profiles") - .addPathSegment(id) + .addPathSegment(PathSegments.validate(id)) .addPathSegments("versions") - .addPathSegment(version); + .addPathSegment(PathSegments.validate(version)); if (requestOptions != null) { requestOptions.getQueryParameters().forEach((_key, _value) -> { httpUrl.addQueryParameter(_key, _value); @@ -328,9 +332,9 @@ public PhenomlClientHttpResponse delete(String id, String version, Request HttpUrl.Builder httpUrl = HttpUrl.parse(this.clientOptions.environment().getUrl()) .newBuilder() .addPathSegments("fhir/profiles") - .addPathSegment(id) + .addPathSegment(PathSegments.validate(id)) .addPathSegments("versions") - .addPathSegment(version); + .addPathSegment(PathSegments.validate(version)); if (requestOptions != null) { requestOptions.getQueryParameters().forEach((_key, _value) -> { httpUrl.addQueryParameter(_key, _value); @@ -338,7 +342,10 @@ public PhenomlClientHttpResponse delete(String id, String version, Request } Request okhttpRequest = new Request.Builder() .url(httpUrl.build()) - .method("DELETE", null) + .method( + "DELETE", + BodyProperties.toRequestBody( + requestOptions != null ? requestOptions.getBodyProperties() : null, null)) .headers(Headers.of(clientOptions.headers(requestOptions))) .addHeader("Accept", "application/json") .build(); diff --git a/src/main/java/com/phenoml/api/resources/summary/AsyncRawSummaryClient.java b/src/main/java/com/phenoml/api/resources/summary/AsyncRawSummaryClient.java index d8154010..d6232363 100644 --- a/src/main/java/com/phenoml/api/resources/summary/AsyncRawSummaryClient.java +++ b/src/main/java/com/phenoml/api/resources/summary/AsyncRawSummaryClient.java @@ -4,6 +4,7 @@ package com.phenoml.api.resources.summary; import com.fasterxml.jackson.core.JsonProcessingException; +import com.phenoml.api.core.BodyProperties; import com.phenoml.api.core.ClientOptions; import com.phenoml.api.core.MediaTypes; import com.phenoml.api.core.ObjectMappers; @@ -72,7 +73,9 @@ public CompletableFuture> creat RequestBody body; try { body = RequestBody.create( - ObjectMappers.JSON_MAPPER.writeValueAsBytes(request), MediaTypes.APPLICATION_JSON); + ObjectMappers.JSON_MAPPER.writeValueAsBytes(BodyProperties.merge( + request, requestOptions != null ? requestOptions.getBodyProperties() : null)), + MediaTypes.APPLICATION_JSON); } catch (JsonProcessingException e) { throw new PhenomlClientException("Failed to serialize request", e); } @@ -97,7 +100,8 @@ public CompletableFuture> creat .build(); } CompletableFuture> future = new CompletableFuture<>(); - client.newCall(okhttpRequest).enqueue(new Callback() { + RetryInterceptor.AsyncCall okhttpCall = RetryInterceptor.newAsyncCall(client, okhttpRequest); + okhttpCall.enqueue(new Callback() { @Override public void onResponse(@NotNull Call call, @NotNull Response response) throws IOException { try (ResponseBody responseBody = response.body()) { @@ -156,6 +160,11 @@ public void onFailure(@NotNull Call call, @NotNull IOException e) { future.completeExceptionally(new PhenomlClientException("Network error executing HTTP request", e)); } }); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + okhttpCall.cancel(); + } + }); return future; } } diff --git a/src/main/java/com/phenoml/api/resources/summary/AsyncSummaryClient.java b/src/main/java/com/phenoml/api/resources/summary/AsyncSummaryClient.java index 201a635f..3250d0a7 100644 --- a/src/main/java/com/phenoml/api/resources/summary/AsyncSummaryClient.java +++ b/src/main/java/com/phenoml/api/resources/summary/AsyncSummaryClient.java @@ -4,6 +4,7 @@ package com.phenoml.api.resources.summary; import com.phenoml.api.core.ClientOptions; +import com.phenoml.api.core.PhenomlClientHttpResponse; import com.phenoml.api.core.RequestOptions; import com.phenoml.api.core.Suppliers; import com.phenoml.api.resources.summary.requests.CreateSummaryRequest; @@ -41,7 +42,14 @@ public AsyncRawSummaryClient withRawResponse() { * */ public CompletableFuture create(CreateSummaryRequest request) { - return this.rawClient.create(request).thenApply(response -> response.body()); + CompletableFuture> rawFuture = this.rawClient.create(request); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** @@ -54,7 +62,15 @@ public CompletableFuture create(CreateSummaryRequest requ */ public CompletableFuture create( CreateSummaryRequest request, RequestOptions requestOptions) { - return this.rawClient.create(request, requestOptions).thenApply(response -> response.body()); + CompletableFuture> rawFuture = + this.rawClient.create(request, requestOptions); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } public AsyncTemplatesClient templates() { diff --git a/src/main/java/com/phenoml/api/resources/summary/RawSummaryClient.java b/src/main/java/com/phenoml/api/resources/summary/RawSummaryClient.java index 5393da31..76221738 100644 --- a/src/main/java/com/phenoml/api/resources/summary/RawSummaryClient.java +++ b/src/main/java/com/phenoml/api/resources/summary/RawSummaryClient.java @@ -4,6 +4,7 @@ package com.phenoml.api.resources.summary; import com.fasterxml.jackson.core.JsonProcessingException; +import com.phenoml.api.core.BodyProperties; import com.phenoml.api.core.ClientOptions; import com.phenoml.api.core.MediaTypes; import com.phenoml.api.core.ObjectMappers; @@ -68,7 +69,9 @@ public PhenomlClientHttpResponse create( RequestBody body; try { body = RequestBody.create( - ObjectMappers.JSON_MAPPER.writeValueAsBytes(request), MediaTypes.APPLICATION_JSON); + ObjectMappers.JSON_MAPPER.writeValueAsBytes(BodyProperties.merge( + request, requestOptions != null ? requestOptions.getBodyProperties() : null)), + MediaTypes.APPLICATION_JSON); } catch (JsonProcessingException e) { throw new PhenomlClientException("Failed to serialize request", e); } diff --git a/src/main/java/com/phenoml/api/resources/summary/requests/CreateSummaryRequest.java b/src/main/java/com/phenoml/api/resources/summary/requests/CreateSummaryRequest.java index 9e905754..df9a8485 100644 --- a/src/main/java/com/phenoml/api/resources/summary/requests/CreateSummaryRequest.java +++ b/src/main/java/com/phenoml/api/resources/summary/requests/CreateSummaryRequest.java @@ -113,7 +113,6 @@ public static final class Builder { private Map fhirResources = new LinkedHashMap<>(); - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -190,6 +189,16 @@ public CreateSummaryRequest build() { return new CreateSummaryRequest(mode, templateId, fhirResources, additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("mode") + || key.equalsIgnoreCase("template_id") + || key.equalsIgnoreCase("fhir_resources")) { + return; + } + this.additionalProperties.put(key, value); + } + public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); return this; diff --git a/src/main/java/com/phenoml/api/resources/summary/templates/AsyncRawTemplatesClient.java b/src/main/java/com/phenoml/api/resources/summary/templates/AsyncRawTemplatesClient.java index 886d49fd..3efd2e2c 100644 --- a/src/main/java/com/phenoml/api/resources/summary/templates/AsyncRawTemplatesClient.java +++ b/src/main/java/com/phenoml/api/resources/summary/templates/AsyncRawTemplatesClient.java @@ -4,9 +4,11 @@ package com.phenoml.api.resources.summary.templates; import com.fasterxml.jackson.core.JsonProcessingException; +import com.phenoml.api.core.BodyProperties; import com.phenoml.api.core.ClientOptions; import com.phenoml.api.core.MediaTypes; import com.phenoml.api.core.ObjectMappers; +import com.phenoml.api.core.PathSegments; import com.phenoml.api.core.PhenomlClientApiException; import com.phenoml.api.core.PhenomlClientException; import com.phenoml.api.core.PhenomlClientHttpResponse; @@ -83,7 +85,8 @@ public CompletableFuture> list(RequestOp .build(); } CompletableFuture> future = new CompletableFuture<>(); - client.newCall(okhttpRequest).enqueue(new Callback() { + RetryInterceptor.AsyncCall okhttpCall = RetryInterceptor.newAsyncCall(client, okhttpRequest); + okhttpCall.enqueue(new Callback() { @Override public void onResponse(@NotNull Call call, @NotNull Response response) throws IOException { try (ResponseBody responseBody = response.body()) { @@ -126,6 +129,11 @@ public void onFailure(@NotNull Call call, @NotNull IOException e) { future.completeExceptionally(new PhenomlClientException("Network error executing HTTP request", e)); } }); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + okhttpCall.cancel(); + } + }); return future; } @@ -153,7 +161,9 @@ public CompletableFuture> future = new CompletableFuture<>(); - client.newCall(okhttpRequest).enqueue(new Callback() { + RetryInterceptor.AsyncCall okhttpCall = RetryInterceptor.newAsyncCall(client, okhttpRequest); + okhttpCall.enqueue(new Callback() { @Override public void onResponse(@NotNull Call call, @NotNull Response response) throws IOException { try (ResponseBody responseBody = response.body()) { @@ -228,6 +239,11 @@ public void onFailure(@NotNull Call call, @NotNull IOException e) { future.completeExceptionally(new PhenomlClientException("Network error executing HTTP request", e)); } }); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + okhttpCall.cancel(); + } + }); return future; } @@ -245,7 +261,7 @@ public CompletableFuture> get(String id, HttpUrl.Builder httpUrl = HttpUrl.parse(this.clientOptions.environment().getUrl()) .newBuilder() .addPathSegments("fhir2summary/template") - .addPathSegment(id); + .addPathSegment(PathSegments.validate(id)); if (requestOptions != null) { requestOptions.getQueryParameters().forEach((_key, _value) -> { httpUrl.addQueryParameter(_key, _value); @@ -271,7 +287,8 @@ public CompletableFuture> get(String id, .build(); } CompletableFuture> future = new CompletableFuture<>(); - client.newCall(okhttpRequest).enqueue(new Callback() { + RetryInterceptor.AsyncCall okhttpCall = RetryInterceptor.newAsyncCall(client, okhttpRequest); + okhttpCall.enqueue(new Callback() { @Override public void onResponse(@NotNull Call call, @NotNull Response response) throws IOException { try (ResponseBody responseBody = response.body()) { @@ -324,6 +341,11 @@ public void onFailure(@NotNull Call call, @NotNull IOException e) { future.completeExceptionally(new PhenomlClientException("Network error executing HTTP request", e)); } }); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + okhttpCall.cancel(); + } + }); return future; } @@ -343,7 +365,7 @@ public CompletableFuture> update( HttpUrl.Builder httpUrl = HttpUrl.parse(this.clientOptions.environment().getUrl()) .newBuilder() .addPathSegments("fhir2summary/template") - .addPathSegment(id); + .addPathSegment(PathSegments.validate(id)); if (requestOptions != null) { requestOptions.getQueryParameters().forEach((_key, _value) -> { httpUrl.addQueryParameter(_key, _value); @@ -352,7 +374,9 @@ public CompletableFuture> update( RequestBody body; try { body = RequestBody.create( - ObjectMappers.JSON_MAPPER.writeValueAsBytes(request), MediaTypes.APPLICATION_JSON); + ObjectMappers.JSON_MAPPER.writeValueAsBytes(BodyProperties.merge( + request, requestOptions != null ? requestOptions.getBodyProperties() : null)), + MediaTypes.APPLICATION_JSON); } catch (JsonProcessingException e) { throw new PhenomlClientException("Failed to serialize request", e); } @@ -377,7 +401,8 @@ public CompletableFuture> update( .build(); } CompletableFuture> future = new CompletableFuture<>(); - client.newCall(okhttpRequest).enqueue(new Callback() { + RetryInterceptor.AsyncCall okhttpCall = RetryInterceptor.newAsyncCall(client, okhttpRequest); + okhttpCall.enqueue(new Callback() { @Override public void onResponse(@NotNull Call call, @NotNull Response response) throws IOException { try (ResponseBody responseBody = response.body()) { @@ -436,6 +461,11 @@ public void onFailure(@NotNull Call call, @NotNull IOException e) { future.completeExceptionally(new PhenomlClientException("Network error executing HTTP request", e)); } }); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + okhttpCall.cancel(); + } + }); return future; } @@ -454,7 +484,7 @@ public CompletableFuture> delete( HttpUrl.Builder httpUrl = HttpUrl.parse(this.clientOptions.environment().getUrl()) .newBuilder() .addPathSegments("fhir2summary/template") - .addPathSegment(id); + .addPathSegment(PathSegments.validate(id)); if (requestOptions != null) { requestOptions.getQueryParameters().forEach((_key, _value) -> { httpUrl.addQueryParameter(_key, _value); @@ -462,7 +492,10 @@ public CompletableFuture> delete( } Request okhttpRequest = new Request.Builder() .url(httpUrl.build()) - .method("DELETE", null) + .method( + "DELETE", + BodyProperties.toRequestBody( + requestOptions != null ? requestOptions.getBodyProperties() : null, null)) .headers(Headers.of(clientOptions.headers(requestOptions))) .addHeader("Accept", "application/json") .build(); @@ -480,7 +513,8 @@ public CompletableFuture> delete( .build(); } CompletableFuture> future = new CompletableFuture<>(); - client.newCall(okhttpRequest).enqueue(new Callback() { + RetryInterceptor.AsyncCall okhttpCall = RetryInterceptor.newAsyncCall(client, okhttpRequest); + okhttpCall.enqueue(new Callback() { @Override public void onResponse(@NotNull Call call, @NotNull Response response) throws IOException { try (ResponseBody responseBody = response.body()) { @@ -534,6 +568,11 @@ public void onFailure(@NotNull Call call, @NotNull IOException e) { future.completeExceptionally(new PhenomlClientException("Network error executing HTTP request", e)); } }); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + okhttpCall.cancel(); + } + }); return future; } } diff --git a/src/main/java/com/phenoml/api/resources/summary/templates/AsyncTemplatesClient.java b/src/main/java/com/phenoml/api/resources/summary/templates/AsyncTemplatesClient.java index 360dbca0..06ac60d9 100644 --- a/src/main/java/com/phenoml/api/resources/summary/templates/AsyncTemplatesClient.java +++ b/src/main/java/com/phenoml/api/resources/summary/templates/AsyncTemplatesClient.java @@ -4,6 +4,7 @@ package com.phenoml.api.resources.summary.templates; import com.phenoml.api.core.ClientOptions; +import com.phenoml.api.core.PhenomlClientHttpResponse; import com.phenoml.api.core.RequestOptions; import com.phenoml.api.resources.summary.templates.requests.CreateSummaryTemplateRequest; import com.phenoml.api.resources.summary.templates.requests.UpdateSummaryTemplateRequest; @@ -35,21 +36,43 @@ public AsyncRawTemplatesClient withRawResponse() { * Retrieves all summary templates for the authenticated user */ public CompletableFuture list() { - return this.rawClient.list().thenApply(response -> response.body()); + CompletableFuture> rawFuture = this.rawClient.list(); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** * Retrieves all summary templates for the authenticated user */ public CompletableFuture list(RequestOptions requestOptions) { - return this.rawClient.list(requestOptions).thenApply(response -> response.body()); + CompletableFuture> rawFuture = this.rawClient.list(requestOptions); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** * Creates a summary template from an example using LLM function calling */ public CompletableFuture create(CreateSummaryTemplateRequest request) { - return this.rawClient.create(request).thenApply(response -> response.body()); + CompletableFuture> rawFuture = + this.rawClient.create(request); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** @@ -57,28 +80,57 @@ public CompletableFuture create(CreateSummaryTemp */ public CompletableFuture create( CreateSummaryTemplateRequest request, RequestOptions requestOptions) { - return this.rawClient.create(request, requestOptions).thenApply(response -> response.body()); + CompletableFuture> rawFuture = + this.rawClient.create(request, requestOptions); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** * Retrieves a specific summary template */ public CompletableFuture get(String id) { - return this.rawClient.get(id).thenApply(response -> response.body()); + CompletableFuture> rawFuture = this.rawClient.get(id); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** * Retrieves a specific summary template */ public CompletableFuture get(String id, RequestOptions requestOptions) { - return this.rawClient.get(id, requestOptions).thenApply(response -> response.body()); + CompletableFuture> rawFuture = this.rawClient.get(id, requestOptions); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** * Updates an existing summary template */ public CompletableFuture update(String id, UpdateSummaryTemplateRequest request) { - return this.rawClient.update(id, request).thenApply(response -> response.body()); + CompletableFuture> rawFuture = this.rawClient.update(id, request); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** @@ -86,20 +138,43 @@ public CompletableFuture update(String id, UpdateSummaryTemplate */ public CompletableFuture update( String id, UpdateSummaryTemplateRequest request, RequestOptions requestOptions) { - return this.rawClient.update(id, request, requestOptions).thenApply(response -> response.body()); + CompletableFuture> rawFuture = + this.rawClient.update(id, request, requestOptions); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** * Deletes a summary template */ public CompletableFuture delete(String id) { - return this.rawClient.delete(id).thenApply(response -> response.body()); + CompletableFuture> rawFuture = this.rawClient.delete(id); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** * Deletes a summary template */ public CompletableFuture delete(String id, RequestOptions requestOptions) { - return this.rawClient.delete(id, requestOptions).thenApply(response -> response.body()); + CompletableFuture> rawFuture = + this.rawClient.delete(id, requestOptions); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } } diff --git a/src/main/java/com/phenoml/api/resources/summary/templates/RawTemplatesClient.java b/src/main/java/com/phenoml/api/resources/summary/templates/RawTemplatesClient.java index 13912d24..df57bb77 100644 --- a/src/main/java/com/phenoml/api/resources/summary/templates/RawTemplatesClient.java +++ b/src/main/java/com/phenoml/api/resources/summary/templates/RawTemplatesClient.java @@ -4,9 +4,11 @@ package com.phenoml.api.resources.summary.templates; import com.fasterxml.jackson.core.JsonProcessingException; +import com.phenoml.api.core.BodyProperties; import com.phenoml.api.core.ClientOptions; import com.phenoml.api.core.MediaTypes; import com.phenoml.api.core.ObjectMappers; +import com.phenoml.api.core.PathSegments; import com.phenoml.api.core.PhenomlClientApiException; import com.phenoml.api.core.PhenomlClientException; import com.phenoml.api.core.PhenomlClientHttpResponse; @@ -130,7 +132,9 @@ public PhenomlClientHttpResponse create( RequestBody body; try { body = RequestBody.create( - ObjectMappers.JSON_MAPPER.writeValueAsBytes(request), MediaTypes.APPLICATION_JSON); + ObjectMappers.JSON_MAPPER.writeValueAsBytes(BodyProperties.merge( + request, requestOptions != null ? requestOptions.getBodyProperties() : null)), + MediaTypes.APPLICATION_JSON); } catch (JsonProcessingException e) { throw new PhenomlClientException("Failed to serialize request", e); } @@ -201,7 +205,7 @@ public PhenomlClientHttpResponse get(String id, RequestOptions requ HttpUrl.Builder httpUrl = HttpUrl.parse(this.clientOptions.environment().getUrl()) .newBuilder() .addPathSegments("fhir2summary/template") - .addPathSegment(id); + .addPathSegment(PathSegments.validate(id)); if (requestOptions != null) { requestOptions.getQueryParameters().forEach((_key, _value) -> { httpUrl.addQueryParameter(_key, _value); @@ -276,7 +280,7 @@ public PhenomlClientHttpResponse update( HttpUrl.Builder httpUrl = HttpUrl.parse(this.clientOptions.environment().getUrl()) .newBuilder() .addPathSegments("fhir2summary/template") - .addPathSegment(id); + .addPathSegment(PathSegments.validate(id)); if (requestOptions != null) { requestOptions.getQueryParameters().forEach((_key, _value) -> { httpUrl.addQueryParameter(_key, _value); @@ -285,7 +289,9 @@ public PhenomlClientHttpResponse update( RequestBody body; try { body = RequestBody.create( - ObjectMappers.JSON_MAPPER.writeValueAsBytes(request), MediaTypes.APPLICATION_JSON); + ObjectMappers.JSON_MAPPER.writeValueAsBytes(BodyProperties.merge( + request, requestOptions != null ? requestOptions.getBodyProperties() : null)), + MediaTypes.APPLICATION_JSON); } catch (JsonProcessingException e) { throw new PhenomlClientException("Failed to serialize request", e); } @@ -361,7 +367,7 @@ public PhenomlClientHttpResponse delete(String id, RequestOption HttpUrl.Builder httpUrl = HttpUrl.parse(this.clientOptions.environment().getUrl()) .newBuilder() .addPathSegments("fhir2summary/template") - .addPathSegment(id); + .addPathSegment(PathSegments.validate(id)); if (requestOptions != null) { requestOptions.getQueryParameters().forEach((_key, _value) -> { httpUrl.addQueryParameter(_key, _value); @@ -369,7 +375,10 @@ public PhenomlClientHttpResponse delete(String id, RequestOption } Request okhttpRequest = new Request.Builder() .url(httpUrl.build()) - .method("DELETE", null) + .method( + "DELETE", + BodyProperties.toRequestBody( + requestOptions != null ? requestOptions.getBodyProperties() : null, null)) .headers(Headers.of(clientOptions.headers(requestOptions))) .addHeader("Accept", "application/json") .build(); diff --git a/src/main/java/com/phenoml/api/resources/summary/templates/requests/CreateSummaryTemplateRequest.java b/src/main/java/com/phenoml/api/resources/summary/templates/requests/CreateSummaryTemplateRequest.java index c1a27382..21ea975a 100644 --- a/src/main/java/com/phenoml/api/resources/summary/templates/requests/CreateSummaryTemplateRequest.java +++ b/src/main/java/com/phenoml/api/resources/summary/templates/requests/CreateSummaryTemplateRequest.java @@ -210,7 +210,6 @@ public static final class Builder implements NameStage, ExampleSummaryStage, Mod private Optional description = Optional.empty(); - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -340,6 +339,19 @@ public CreateSummaryTemplateRequest build() { name, description, exampleSummary, targetResources, exampleFhirData, mode, additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("name") + || key.equalsIgnoreCase("description") + || key.equalsIgnoreCase("example_summary") + || key.equalsIgnoreCase("target_resources") + || key.equalsIgnoreCase("example_fhir_data") + || key.equalsIgnoreCase("mode")) { + return; + } + this.additionalProperties.put(key, value); + } + @java.lang.Override public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); diff --git a/src/main/java/com/phenoml/api/resources/summary/templates/requests/UpdateSummaryTemplateRequest.java b/src/main/java/com/phenoml/api/resources/summary/templates/requests/UpdateSummaryTemplateRequest.java index a7e6058f..18f350be 100644 --- a/src/main/java/com/phenoml/api/resources/summary/templates/requests/UpdateSummaryTemplateRequest.java +++ b/src/main/java/com/phenoml/api/resources/summary/templates/requests/UpdateSummaryTemplateRequest.java @@ -164,7 +164,6 @@ public static final class Builder implements NameStage, TemplateStage, ModeStage private Optional description = Optional.empty(); - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -251,6 +250,18 @@ public UpdateSummaryTemplateRequest build() { name, description, template, targetResources, mode, additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("name") + || key.equalsIgnoreCase("description") + || key.equalsIgnoreCase("template") + || key.equalsIgnoreCase("target_resources") + || key.equalsIgnoreCase("mode")) { + return; + } + this.additionalProperties.put(key, value); + } + @java.lang.Override public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); diff --git a/src/main/java/com/phenoml/api/resources/summary/templates/types/DeleteResponse.java b/src/main/java/com/phenoml/api/resources/summary/templates/types/DeleteResponse.java index ecc5ae34..ee408a79 100644 --- a/src/main/java/com/phenoml/api/resources/summary/templates/types/DeleteResponse.java +++ b/src/main/java/com/phenoml/api/resources/summary/templates/types/DeleteResponse.java @@ -78,7 +78,6 @@ public static final class Builder { private Optional message = Optional.empty(); - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -115,6 +114,14 @@ public DeleteResponse build() { return new DeleteResponse(success, message, additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("success") || key.equalsIgnoreCase("message")) { + return; + } + this.additionalProperties.put(key, value); + } + public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); return this; diff --git a/src/main/java/com/phenoml/api/resources/summary/templates/types/GetResponse.java b/src/main/java/com/phenoml/api/resources/summary/templates/types/GetResponse.java index f1e44a66..3ecf0e0a 100644 --- a/src/main/java/com/phenoml/api/resources/summary/templates/types/GetResponse.java +++ b/src/main/java/com/phenoml/api/resources/summary/templates/types/GetResponse.java @@ -79,7 +79,6 @@ public static final class Builder { private Optional template = Optional.empty(); - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -116,6 +115,14 @@ public GetResponse build() { return new GetResponse(success, template, additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("success") || key.equalsIgnoreCase("template")) { + return; + } + this.additionalProperties.put(key, value); + } + public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); return this; diff --git a/src/main/java/com/phenoml/api/resources/summary/templates/types/ListResponse.java b/src/main/java/com/phenoml/api/resources/summary/templates/types/ListResponse.java index c0ca602f..c8168464 100644 --- a/src/main/java/com/phenoml/api/resources/summary/templates/types/ListResponse.java +++ b/src/main/java/com/phenoml/api/resources/summary/templates/types/ListResponse.java @@ -82,7 +82,6 @@ public static final class Builder { private Optional> templates = Optional.empty(); - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -119,6 +118,14 @@ public ListResponse build() { return new ListResponse(success, templates, additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("success") || key.equalsIgnoreCase("templates")) { + return; + } + this.additionalProperties.put(key, value); + } + public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); return this; diff --git a/src/main/java/com/phenoml/api/resources/summary/templates/types/UpdateResponse.java b/src/main/java/com/phenoml/api/resources/summary/templates/types/UpdateResponse.java index 82e3680b..a8fbfde2 100644 --- a/src/main/java/com/phenoml/api/resources/summary/templates/types/UpdateResponse.java +++ b/src/main/java/com/phenoml/api/resources/summary/templates/types/UpdateResponse.java @@ -92,7 +92,6 @@ public static final class Builder { private Optional template = Optional.empty(); - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -141,6 +140,16 @@ public UpdateResponse build() { return new UpdateResponse(success, message, template, additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("success") + || key.equalsIgnoreCase("message") + || key.equalsIgnoreCase("template")) { + return; + } + this.additionalProperties.put(key, value); + } + public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); return this; diff --git a/src/main/java/com/phenoml/api/resources/summary/types/CreateSummaryResponse.java b/src/main/java/com/phenoml/api/resources/summary/types/CreateSummaryResponse.java index 7cf15f51..b7aefefd 100644 --- a/src/main/java/com/phenoml/api/resources/summary/types/CreateSummaryResponse.java +++ b/src/main/java/com/phenoml/api/resources/summary/types/CreateSummaryResponse.java @@ -112,7 +112,6 @@ public static final class Builder { private Optional> warnings = Optional.empty(); - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -179,6 +178,17 @@ public CreateSummaryResponse build() { return new CreateSummaryResponse(success, message, summary, warnings, additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("success") + || key.equalsIgnoreCase("message") + || key.equalsIgnoreCase("summary") + || key.equalsIgnoreCase("warnings")) { + return; + } + this.additionalProperties.put(key, value); + } + public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); return this; diff --git a/src/main/java/com/phenoml/api/resources/summary/types/CreateSummaryTemplateResponse.java b/src/main/java/com/phenoml/api/resources/summary/types/CreateSummaryTemplateResponse.java index b0074c45..c73e41a1 100644 --- a/src/main/java/com/phenoml/api/resources/summary/types/CreateSummaryTemplateResponse.java +++ b/src/main/java/com/phenoml/api/resources/summary/types/CreateSummaryTemplateResponse.java @@ -105,7 +105,6 @@ public static final class Builder { private Optional template = Optional.empty(); - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -166,6 +165,17 @@ public CreateSummaryTemplateResponse build() { return new CreateSummaryTemplateResponse(success, message, templateId, template, additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("success") + || key.equalsIgnoreCase("message") + || key.equalsIgnoreCase("template_id") + || key.equalsIgnoreCase("template")) { + return; + } + this.additionalProperties.put(key, value); + } + public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); return this; diff --git a/src/main/java/com/phenoml/api/resources/summary/types/SummaryTemplate.java b/src/main/java/com/phenoml/api/resources/summary/types/SummaryTemplate.java index 196457c7..8c9fd23e 100644 --- a/src/main/java/com/phenoml/api/resources/summary/types/SummaryTemplate.java +++ b/src/main/java/com/phenoml/api/resources/summary/types/SummaryTemplate.java @@ -185,7 +185,6 @@ public static final class Builder { private Optional updatedAt = Optional.empty(); - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -325,6 +324,22 @@ public SummaryTemplate build() { additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("id") + || key.equalsIgnoreCase("name") + || key.equalsIgnoreCase("description") + || key.equalsIgnoreCase("template") + || key.equalsIgnoreCase("target_resources") + || key.equalsIgnoreCase("mode") + || key.equalsIgnoreCase("metadata") + || key.equalsIgnoreCase("created_at") + || key.equalsIgnoreCase("updated_at")) { + return; + } + this.additionalProperties.put(key, value); + } + public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); return this; diff --git a/src/main/java/com/phenoml/api/resources/tools/AsyncRawToolsClient.java b/src/main/java/com/phenoml/api/resources/tools/AsyncRawToolsClient.java index 04073978..fc9c1b2d 100644 --- a/src/main/java/com/phenoml/api/resources/tools/AsyncRawToolsClient.java +++ b/src/main/java/com/phenoml/api/resources/tools/AsyncRawToolsClient.java @@ -4,6 +4,7 @@ package com.phenoml.api.resources.tools; import com.fasterxml.jackson.core.JsonProcessingException; +import com.phenoml.api.core.BodyProperties; import com.phenoml.api.core.ClientOptions; import com.phenoml.api.core.MediaTypes; import com.phenoml.api.core.ObjectMappers; @@ -68,7 +69,9 @@ public CompletableFuture> RequestBody body; try { body = RequestBody.create( - ObjectMappers.JSON_MAPPER.writeValueAsBytes(request), MediaTypes.APPLICATION_JSON); + ObjectMappers.JSON_MAPPER.writeValueAsBytes(BodyProperties.merge( + request, requestOptions != null ? requestOptions.getBodyProperties() : null)), + MediaTypes.APPLICATION_JSON); } catch (Exception e) { throw new RuntimeException(e); } @@ -101,7 +104,8 @@ public CompletableFuture> .build(); } CompletableFuture> future = new CompletableFuture<>(); - client.newCall(okhttpRequest).enqueue(new Callback() { + RetryInterceptor.AsyncCall okhttpCall = RetryInterceptor.newAsyncCall(client, okhttpRequest); + okhttpCall.enqueue(new Callback() { @Override public void onResponse(@NotNull Call call, @NotNull Response response) throws IOException { try (ResponseBody responseBody = response.body()) { @@ -156,6 +160,11 @@ public void onFailure(@NotNull Call call, @NotNull IOException e) { future.completeExceptionally(new PhenomlClientException("Network error executing HTTP request", e)); } }); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + okhttpCall.cancel(); + } + }); return future; } @@ -191,7 +200,9 @@ public CompletableFuture> future = new CompletableFuture<>(); - client.newCall(okhttpRequest).enqueue(new Callback() { + RetryInterceptor.AsyncCall okhttpCall = RetryInterceptor.newAsyncCall(client, okhttpRequest); + okhttpCall.enqueue(new Callback() { @Override public void onResponse(@NotNull Call call, @NotNull Response response) throws IOException { try (ResponseBody responseBody = response.body()) { @@ -280,6 +292,11 @@ public void onFailure(@NotNull Call call, @NotNull IOException e) { future.completeExceptionally(new PhenomlClientException("Network error executing HTTP request", e)); } }); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + okhttpCall.cancel(); + } + }); return future; } @@ -307,7 +324,9 @@ public CompletableFuture> RequestBody body; try { body = RequestBody.create( - ObjectMappers.JSON_MAPPER.writeValueAsBytes(request), MediaTypes.APPLICATION_JSON); + ObjectMappers.JSON_MAPPER.writeValueAsBytes(BodyProperties.merge( + request, requestOptions != null ? requestOptions.getBodyProperties() : null)), + MediaTypes.APPLICATION_JSON); } catch (Exception e) { throw new RuntimeException(e); } @@ -340,7 +359,8 @@ public CompletableFuture> .build(); } CompletableFuture> future = new CompletableFuture<>(); - client.newCall(okhttpRequest).enqueue(new Callback() { + RetryInterceptor.AsyncCall okhttpCall = RetryInterceptor.newAsyncCall(client, okhttpRequest); + okhttpCall.enqueue(new Callback() { @Override public void onResponse(@NotNull Call call, @NotNull Response response) throws IOException { try (ResponseBody responseBody = response.body()) { @@ -395,6 +415,11 @@ public void onFailure(@NotNull Call call, @NotNull IOException e) { future.completeExceptionally(new PhenomlClientException("Network error executing HTTP request", e)); } }); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + okhttpCall.cancel(); + } + }); return future; } @@ -421,7 +446,9 @@ public CompletableFuture> analyzeCohor RequestBody body; try { body = RequestBody.create( - ObjectMappers.JSON_MAPPER.writeValueAsBytes(request), MediaTypes.APPLICATION_JSON); + ObjectMappers.JSON_MAPPER.writeValueAsBytes(BodyProperties.merge( + request, requestOptions != null ? requestOptions.getBodyProperties() : null)), + MediaTypes.APPLICATION_JSON); } catch (Exception e) { throw new RuntimeException(e); } @@ -454,7 +481,8 @@ public CompletableFuture> analyzeCohor .build(); } CompletableFuture> future = new CompletableFuture<>(); - client.newCall(okhttpRequest).enqueue(new Callback() { + RetryInterceptor.AsyncCall okhttpCall = RetryInterceptor.newAsyncCall(client, okhttpRequest); + okhttpCall.enqueue(new Callback() { @Override public void onResponse(@NotNull Call call, @NotNull Response response) throws IOException { try (ResponseBody responseBody = response.body()) { @@ -508,6 +536,11 @@ public void onFailure(@NotNull Call call, @NotNull IOException e) { future.completeExceptionally(new PhenomlClientException("Network error executing HTTP request", e)); } }); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + okhttpCall.cancel(); + } + }); return future; } } diff --git a/src/main/java/com/phenoml/api/resources/tools/AsyncToolsClient.java b/src/main/java/com/phenoml/api/resources/tools/AsyncToolsClient.java index ddff23bb..995f4aa6 100644 --- a/src/main/java/com/phenoml/api/resources/tools/AsyncToolsClient.java +++ b/src/main/java/com/phenoml/api/resources/tools/AsyncToolsClient.java @@ -4,6 +4,7 @@ package com.phenoml.api.resources.tools; import com.phenoml.api.core.ClientOptions; +import com.phenoml.api.core.PhenomlClientHttpResponse; import com.phenoml.api.core.RequestOptions; import com.phenoml.api.core.Suppliers; import com.phenoml.api.resources.tools.mcpservers.AsyncMcpServersClient; @@ -46,7 +47,15 @@ public AsyncRawToolsClient withRawResponse() { * Converts natural language to FHIR resource and optionally stores it in a FHIR server */ public CompletableFuture createFhirResource(Lang2FhirAndCreateRequest request) { - return this.rawClient.createFhirResource(request).thenApply(response -> response.body()); + CompletableFuture> rawFuture = + this.rawClient.createFhirResource(request); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** @@ -54,7 +63,15 @@ public CompletableFuture createFhirResource(Lang2Fhi */ public CompletableFuture createFhirResource( Lang2FhirAndCreateRequest request, RequestOptions requestOptions) { - return this.rawClient.createFhirResource(request, requestOptions).thenApply(response -> response.body()); + CompletableFuture> rawFuture = + this.rawClient.createFhirResource(request, requestOptions); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** @@ -66,7 +83,15 @@ public CompletableFuture createFhirResource( */ public CompletableFuture createFhirResourcesMulti( Lang2FhirAndCreateMultiRequest request) { - return this.rawClient.createFhirResourcesMulti(request).thenApply(response -> response.body()); + CompletableFuture> rawFuture = + this.rawClient.createFhirResourcesMulti(request); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** @@ -78,14 +103,30 @@ public CompletableFuture createFhirResourcesMul */ public CompletableFuture createFhirResourcesMulti( Lang2FhirAndCreateMultiRequest request, RequestOptions requestOptions) { - return this.rawClient.createFhirResourcesMulti(request, requestOptions).thenApply(response -> response.body()); + CompletableFuture> rawFuture = + this.rawClient.createFhirResourcesMulti(request, requestOptions); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** * Converts natural language to FHIR search parameters and executes search in FHIR server */ public CompletableFuture searchFhirResources(Lang2FhirAndSearchRequest request) { - return this.rawClient.searchFhirResources(request).thenApply(response -> response.body()); + CompletableFuture> rawFuture = + this.rawClient.searchFhirResources(request); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** @@ -93,21 +134,44 @@ public CompletableFuture searchFhirResources(Lang2Fh */ public CompletableFuture searchFhirResources( Lang2FhirAndSearchRequest request, RequestOptions requestOptions) { - return this.rawClient.searchFhirResources(request, requestOptions).thenApply(response -> response.body()); + CompletableFuture> rawFuture = + this.rawClient.searchFhirResources(request, requestOptions); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** * Uses LLM to extract search concepts from natural language and builds patient cohorts with inclusion/exclusion criteria */ public CompletableFuture analyzeCohort(CohortRequest request) { - return this.rawClient.analyzeCohort(request).thenApply(response -> response.body()); + CompletableFuture> rawFuture = this.rawClient.analyzeCohort(request); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** * Uses LLM to extract search concepts from natural language and builds patient cohorts with inclusion/exclusion criteria */ public CompletableFuture analyzeCohort(CohortRequest request, RequestOptions requestOptions) { - return this.rawClient.analyzeCohort(request, requestOptions).thenApply(response -> response.body()); + CompletableFuture> rawFuture = + this.rawClient.analyzeCohort(request, requestOptions); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } public AsyncMcpServersClient mcpServers() { diff --git a/src/main/java/com/phenoml/api/resources/tools/RawToolsClient.java b/src/main/java/com/phenoml/api/resources/tools/RawToolsClient.java index fad807ca..46eb4fcb 100644 --- a/src/main/java/com/phenoml/api/resources/tools/RawToolsClient.java +++ b/src/main/java/com/phenoml/api/resources/tools/RawToolsClient.java @@ -4,6 +4,7 @@ package com.phenoml.api.resources.tools; import com.fasterxml.jackson.core.JsonProcessingException; +import com.phenoml.api.core.BodyProperties; import com.phenoml.api.core.ClientOptions; import com.phenoml.api.core.MediaTypes; import com.phenoml.api.core.ObjectMappers; @@ -63,7 +64,9 @@ public PhenomlClientHttpResponse createFhirResource( RequestBody body; try { body = RequestBody.create( - ObjectMappers.JSON_MAPPER.writeValueAsBytes(request), MediaTypes.APPLICATION_JSON); + ObjectMappers.JSON_MAPPER.writeValueAsBytes(BodyProperties.merge( + request, requestOptions != null ? requestOptions.getBodyProperties() : null)), + MediaTypes.APPLICATION_JSON); } catch (Exception e) { throw new RuntimeException(e); } @@ -163,7 +166,9 @@ public PhenomlClientHttpResponse createFhirReso RequestBody body; try { body = RequestBody.create( - ObjectMappers.JSON_MAPPER.writeValueAsBytes(request), MediaTypes.APPLICATION_JSON); + ObjectMappers.JSON_MAPPER.writeValueAsBytes(BodyProperties.merge( + request, requestOptions != null ? requestOptions.getBodyProperties() : null)), + MediaTypes.APPLICATION_JSON); } catch (Exception e) { throw new RuntimeException(e); } @@ -255,7 +260,9 @@ public PhenomlClientHttpResponse searchFhirResources RequestBody body; try { body = RequestBody.create( - ObjectMappers.JSON_MAPPER.writeValueAsBytes(request), MediaTypes.APPLICATION_JSON); + ObjectMappers.JSON_MAPPER.writeValueAsBytes(BodyProperties.merge( + request, requestOptions != null ? requestOptions.getBodyProperties() : null)), + MediaTypes.APPLICATION_JSON); } catch (Exception e) { throw new RuntimeException(e); } @@ -346,7 +353,9 @@ public PhenomlClientHttpResponse analyzeCohort( RequestBody body; try { body = RequestBody.create( - ObjectMappers.JSON_MAPPER.writeValueAsBytes(request), MediaTypes.APPLICATION_JSON); + ObjectMappers.JSON_MAPPER.writeValueAsBytes(BodyProperties.merge( + request, requestOptions != null ? requestOptions.getBodyProperties() : null)), + MediaTypes.APPLICATION_JSON); } catch (Exception e) { throw new RuntimeException(e); } diff --git a/src/main/java/com/phenoml/api/resources/tools/mcpservers/AsyncMcpServersClient.java b/src/main/java/com/phenoml/api/resources/tools/mcpservers/AsyncMcpServersClient.java index 839b0cc4..6427becc 100644 --- a/src/main/java/com/phenoml/api/resources/tools/mcpservers/AsyncMcpServersClient.java +++ b/src/main/java/com/phenoml/api/resources/tools/mcpservers/AsyncMcpServersClient.java @@ -4,6 +4,7 @@ package com.phenoml.api.resources.tools.mcpservers; import com.phenoml.api.core.ClientOptions; +import com.phenoml.api.core.PhenomlClientHttpResponse; import com.phenoml.api.core.RequestOptions; import com.phenoml.api.resources.tools.mcpservers.requests.McpServerCreateRequest; import com.phenoml.api.resources.tools.types.McpServerResponse; @@ -30,55 +31,114 @@ public AsyncRawMcpServersClient withRawResponse() { * Creates a new MCP server */ public CompletableFuture create(McpServerCreateRequest request) { - return this.rawClient.create(request).thenApply(response -> response.body()); + CompletableFuture> rawFuture = this.rawClient.create(request); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** * Creates a new MCP server */ public CompletableFuture create(McpServerCreateRequest request, RequestOptions requestOptions) { - return this.rawClient.create(request, requestOptions).thenApply(response -> response.body()); + CompletableFuture> rawFuture = + this.rawClient.create(request, requestOptions); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** * Lists all MCP servers for a specific user */ public CompletableFuture list() { - return this.rawClient.list().thenApply(response -> response.body()); + CompletableFuture> rawFuture = this.rawClient.list(); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** * Lists all MCP servers for a specific user */ public CompletableFuture list(RequestOptions requestOptions) { - return this.rawClient.list(requestOptions).thenApply(response -> response.body()); + CompletableFuture> rawFuture = this.rawClient.list(requestOptions); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** * Gets a MCP server by ID */ public CompletableFuture get(String mcpServerId) { - return this.rawClient.get(mcpServerId).thenApply(response -> response.body()); + CompletableFuture> rawFuture = this.rawClient.get(mcpServerId); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** * Gets a MCP server by ID */ public CompletableFuture get(String mcpServerId, RequestOptions requestOptions) { - return this.rawClient.get(mcpServerId, requestOptions).thenApply(response -> response.body()); + CompletableFuture> rawFuture = + this.rawClient.get(mcpServerId, requestOptions); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** * Deletes a MCP server by ID */ public CompletableFuture delete(String mcpServerId) { - return this.rawClient.delete(mcpServerId).thenApply(response -> response.body()); + CompletableFuture> rawFuture = this.rawClient.delete(mcpServerId); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** * Deletes a MCP server by ID */ public CompletableFuture delete(String mcpServerId, RequestOptions requestOptions) { - return this.rawClient.delete(mcpServerId, requestOptions).thenApply(response -> response.body()); + CompletableFuture> rawFuture = + this.rawClient.delete(mcpServerId, requestOptions); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } } diff --git a/src/main/java/com/phenoml/api/resources/tools/mcpservers/AsyncRawMcpServersClient.java b/src/main/java/com/phenoml/api/resources/tools/mcpservers/AsyncRawMcpServersClient.java index fcaeec10..1a280df0 100644 --- a/src/main/java/com/phenoml/api/resources/tools/mcpservers/AsyncRawMcpServersClient.java +++ b/src/main/java/com/phenoml/api/resources/tools/mcpservers/AsyncRawMcpServersClient.java @@ -4,9 +4,11 @@ package com.phenoml.api.resources.tools.mcpservers; import com.fasterxml.jackson.core.JsonProcessingException; +import com.phenoml.api.core.BodyProperties; import com.phenoml.api.core.ClientOptions; import com.phenoml.api.core.MediaTypes; import com.phenoml.api.core.ObjectMappers; +import com.phenoml.api.core.PathSegments; import com.phenoml.api.core.PhenomlClientApiException; import com.phenoml.api.core.PhenomlClientException; import com.phenoml.api.core.PhenomlClientHttpResponse; @@ -62,7 +64,9 @@ public CompletableFuture> create( RequestBody body; try { body = RequestBody.create( - ObjectMappers.JSON_MAPPER.writeValueAsBytes(request), MediaTypes.APPLICATION_JSON); + ObjectMappers.JSON_MAPPER.writeValueAsBytes(BodyProperties.merge( + request, requestOptions != null ? requestOptions.getBodyProperties() : null)), + MediaTypes.APPLICATION_JSON); } catch (JsonProcessingException e) { throw new PhenomlClientException("Failed to serialize request", e); } @@ -87,7 +91,8 @@ public CompletableFuture> create( .build(); } CompletableFuture> future = new CompletableFuture<>(); - client.newCall(okhttpRequest).enqueue(new Callback() { + RetryInterceptor.AsyncCall okhttpCall = RetryInterceptor.newAsyncCall(client, okhttpRequest); + okhttpCall.enqueue(new Callback() { @Override public void onResponse(@NotNull Call call, @NotNull Response response) throws IOException { try (ResponseBody responseBody = response.body()) { @@ -141,6 +146,11 @@ public void onFailure(@NotNull Call call, @NotNull IOException e) { future.completeExceptionally(new PhenomlClientException("Network error executing HTTP request", e)); } }); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + okhttpCall.cancel(); + } + }); return future; } @@ -183,7 +193,8 @@ public CompletableFuture> list(Requ .build(); } CompletableFuture> future = new CompletableFuture<>(); - client.newCall(okhttpRequest).enqueue(new Callback() { + RetryInterceptor.AsyncCall okhttpCall = RetryInterceptor.newAsyncCall(client, okhttpRequest); + okhttpCall.enqueue(new Callback() { @Override public void onResponse(@NotNull Call call, @NotNull Response response) throws IOException { try (ResponseBody responseBody = response.body()) { @@ -232,6 +243,11 @@ public void onFailure(@NotNull Call call, @NotNull IOException e) { future.completeExceptionally(new PhenomlClientException("Network error executing HTTP request", e)); } }); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + okhttpCall.cancel(); + } + }); return future; } @@ -250,7 +266,7 @@ public CompletableFuture> get( HttpUrl.Builder httpUrl = HttpUrl.parse(this.clientOptions.environment().getUrl()) .newBuilder() .addPathSegments("tools/mcp-server") - .addPathSegment(mcpServerId); + .addPathSegment(PathSegments.validate(mcpServerId)); if (requestOptions != null) { requestOptions.getQueryParameters().forEach((_key, _value) -> { httpUrl.addQueryParameter(_key, _value); @@ -276,7 +292,8 @@ public CompletableFuture> get( .build(); } CompletableFuture> future = new CompletableFuture<>(); - client.newCall(okhttpRequest).enqueue(new Callback() { + RetryInterceptor.AsyncCall okhttpCall = RetryInterceptor.newAsyncCall(client, okhttpRequest); + okhttpCall.enqueue(new Callback() { @Override public void onResponse(@NotNull Call call, @NotNull Response response) throws IOException { try (ResponseBody responseBody = response.body()) { @@ -330,6 +347,11 @@ public void onFailure(@NotNull Call call, @NotNull IOException e) { future.completeExceptionally(new PhenomlClientException("Network error executing HTTP request", e)); } }); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + okhttpCall.cancel(); + } + }); return future; } @@ -348,7 +370,7 @@ public CompletableFuture> delete( HttpUrl.Builder httpUrl = HttpUrl.parse(this.clientOptions.environment().getUrl()) .newBuilder() .addPathSegments("tools/mcp-server") - .addPathSegment(mcpServerId); + .addPathSegment(PathSegments.validate(mcpServerId)); if (requestOptions != null) { requestOptions.getQueryParameters().forEach((_key, _value) -> { httpUrl.addQueryParameter(_key, _value); @@ -356,7 +378,10 @@ public CompletableFuture> delete( } Request okhttpRequest = new Request.Builder() .url(httpUrl.build()) - .method("DELETE", null) + .method( + "DELETE", + BodyProperties.toRequestBody( + requestOptions != null ? requestOptions.getBodyProperties() : null, null)) .headers(Headers.of(clientOptions.headers(requestOptions))) .addHeader("Accept", "application/json") .build(); @@ -374,7 +399,8 @@ public CompletableFuture> delete( .build(); } CompletableFuture> future = new CompletableFuture<>(); - client.newCall(okhttpRequest).enqueue(new Callback() { + RetryInterceptor.AsyncCall okhttpCall = RetryInterceptor.newAsyncCall(client, okhttpRequest); + okhttpCall.enqueue(new Callback() { @Override public void onResponse(@NotNull Call call, @NotNull Response response) throws IOException { try (ResponseBody responseBody = response.body()) { @@ -428,6 +454,11 @@ public void onFailure(@NotNull Call call, @NotNull IOException e) { future.completeExceptionally(new PhenomlClientException("Network error executing HTTP request", e)); } }); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + okhttpCall.cancel(); + } + }); return future; } } diff --git a/src/main/java/com/phenoml/api/resources/tools/mcpservers/RawMcpServersClient.java b/src/main/java/com/phenoml/api/resources/tools/mcpservers/RawMcpServersClient.java index 655d7f30..308caf05 100644 --- a/src/main/java/com/phenoml/api/resources/tools/mcpservers/RawMcpServersClient.java +++ b/src/main/java/com/phenoml/api/resources/tools/mcpservers/RawMcpServersClient.java @@ -4,9 +4,11 @@ package com.phenoml.api.resources.tools.mcpservers; import com.fasterxml.jackson.core.JsonProcessingException; +import com.phenoml.api.core.BodyProperties; import com.phenoml.api.core.ClientOptions; import com.phenoml.api.core.MediaTypes; import com.phenoml.api.core.ObjectMappers; +import com.phenoml.api.core.PathSegments; import com.phenoml.api.core.PhenomlClientApiException; import com.phenoml.api.core.PhenomlClientException; import com.phenoml.api.core.PhenomlClientHttpResponse; @@ -58,7 +60,9 @@ public PhenomlClientHttpResponse create( RequestBody body; try { body = RequestBody.create( - ObjectMappers.JSON_MAPPER.writeValueAsBytes(request), MediaTypes.APPLICATION_JSON); + ObjectMappers.JSON_MAPPER.writeValueAsBytes(BodyProperties.merge( + request, requestOptions != null ? requestOptions.getBodyProperties() : null)), + MediaTypes.APPLICATION_JSON); } catch (JsonProcessingException e) { throw new PhenomlClientException("Failed to serialize request", e); } @@ -201,7 +205,7 @@ public PhenomlClientHttpResponse get(String mcpServerId, Requ HttpUrl.Builder httpUrl = HttpUrl.parse(this.clientOptions.environment().getUrl()) .newBuilder() .addPathSegments("tools/mcp-server") - .addPathSegment(mcpServerId); + .addPathSegment(PathSegments.validate(mcpServerId)); if (requestOptions != null) { requestOptions.getQueryParameters().forEach((_key, _value) -> { httpUrl.addQueryParameter(_key, _value); @@ -275,7 +279,7 @@ public PhenomlClientHttpResponse delete(String mcpServerId, R HttpUrl.Builder httpUrl = HttpUrl.parse(this.clientOptions.environment().getUrl()) .newBuilder() .addPathSegments("tools/mcp-server") - .addPathSegment(mcpServerId); + .addPathSegment(PathSegments.validate(mcpServerId)); if (requestOptions != null) { requestOptions.getQueryParameters().forEach((_key, _value) -> { httpUrl.addQueryParameter(_key, _value); @@ -283,7 +287,10 @@ public PhenomlClientHttpResponse delete(String mcpServerId, R } Request okhttpRequest = new Request.Builder() .url(httpUrl.build()) - .method("DELETE", null) + .method( + "DELETE", + BodyProperties.toRequestBody( + requestOptions != null ? requestOptions.getBodyProperties() : null, null)) .headers(Headers.of(clientOptions.headers(requestOptions))) .addHeader("Accept", "application/json") .build(); diff --git a/src/main/java/com/phenoml/api/resources/tools/mcpservers/requests/McpServerCreateRequest.java b/src/main/java/com/phenoml/api/resources/tools/mcpservers/requests/McpServerCreateRequest.java index 84dda750..6c629992 100644 --- a/src/main/java/com/phenoml/api/resources/tools/mcpservers/requests/McpServerCreateRequest.java +++ b/src/main/java/com/phenoml/api/resources/tools/mcpservers/requests/McpServerCreateRequest.java @@ -106,7 +106,6 @@ public static final class Builder implements NameStage, McpServerUrlStage, _Fina private String mcpServerUrl; - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -145,6 +144,14 @@ public McpServerCreateRequest build() { return new McpServerCreateRequest(name, mcpServerUrl, additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("name") || key.equalsIgnoreCase("mcp_server_url")) { + return; + } + this.additionalProperties.put(key, value); + } + @java.lang.Override public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); diff --git a/src/main/java/com/phenoml/api/resources/tools/mcptools/AsyncMcpToolsClient.java b/src/main/java/com/phenoml/api/resources/tools/mcptools/AsyncMcpToolsClient.java index 7582af29..e4dbdcd1 100644 --- a/src/main/java/com/phenoml/api/resources/tools/mcptools/AsyncMcpToolsClient.java +++ b/src/main/java/com/phenoml/api/resources/tools/mcptools/AsyncMcpToolsClient.java @@ -4,6 +4,7 @@ package com.phenoml.api.resources.tools.mcptools; import com.phenoml.api.core.ClientOptions; +import com.phenoml.api.core.PhenomlClientHttpResponse; import com.phenoml.api.core.RequestOptions; import com.phenoml.api.resources.tools.types.McpServerToolResponse; import java.util.concurrent.CompletableFuture; @@ -29,41 +30,89 @@ public AsyncRawMcpToolsClient withRawResponse() { * Lists all MCP server tools for a specific MCP server */ public CompletableFuture list(String mcpServerId) { - return this.rawClient.list(mcpServerId).thenApply(response -> response.body()); + CompletableFuture> rawFuture = + this.rawClient.list(mcpServerId); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** * Lists all MCP server tools for a specific MCP server */ public CompletableFuture list(String mcpServerId, RequestOptions requestOptions) { - return this.rawClient.list(mcpServerId, requestOptions).thenApply(response -> response.body()); + CompletableFuture> rawFuture = + this.rawClient.list(mcpServerId, requestOptions); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** * Gets a MCP server tool by ID */ public CompletableFuture get(String mcpServerToolId) { - return this.rawClient.get(mcpServerToolId).thenApply(response -> response.body()); + CompletableFuture> rawFuture = + this.rawClient.get(mcpServerToolId); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** * Gets a MCP server tool by ID */ public CompletableFuture get(String mcpServerToolId, RequestOptions requestOptions) { - return this.rawClient.get(mcpServerToolId, requestOptions).thenApply(response -> response.body()); + CompletableFuture> rawFuture = + this.rawClient.get(mcpServerToolId, requestOptions); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** * Deletes a MCP server tool by ID */ public CompletableFuture delete(String mcpServerToolId) { - return this.rawClient.delete(mcpServerToolId).thenApply(response -> response.body()); + CompletableFuture> rawFuture = + this.rawClient.delete(mcpServerToolId); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** * Deletes a MCP server tool by ID */ public CompletableFuture delete(String mcpServerToolId, RequestOptions requestOptions) { - return this.rawClient.delete(mcpServerToolId, requestOptions).thenApply(response -> response.body()); + CompletableFuture> rawFuture = + this.rawClient.delete(mcpServerToolId, requestOptions); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } } diff --git a/src/main/java/com/phenoml/api/resources/tools/mcptools/AsyncRawMcpToolsClient.java b/src/main/java/com/phenoml/api/resources/tools/mcptools/AsyncRawMcpToolsClient.java index fd7476b6..d4357c00 100644 --- a/src/main/java/com/phenoml/api/resources/tools/mcptools/AsyncRawMcpToolsClient.java +++ b/src/main/java/com/phenoml/api/resources/tools/mcptools/AsyncRawMcpToolsClient.java @@ -4,8 +4,10 @@ package com.phenoml.api.resources.tools.mcptools; import com.fasterxml.jackson.core.JsonProcessingException; +import com.phenoml.api.core.BodyProperties; import com.phenoml.api.core.ClientOptions; import com.phenoml.api.core.ObjectMappers; +import com.phenoml.api.core.PathSegments; import com.phenoml.api.core.PhenomlClientApiException; import com.phenoml.api.core.PhenomlClientException; import com.phenoml.api.core.PhenomlClientHttpResponse; @@ -50,7 +52,7 @@ public CompletableFuture> list( HttpUrl.Builder httpUrl = HttpUrl.parse(this.clientOptions.environment().getUrl()) .newBuilder() .addPathSegments("tools/mcp-server") - .addPathSegment(mcpServerId) + .addPathSegment(PathSegments.validate(mcpServerId)) .addPathSegments("list"); if (requestOptions != null) { requestOptions.getQueryParameters().forEach((_key, _value) -> { @@ -77,7 +79,8 @@ public CompletableFuture> list( .build(); } CompletableFuture> future = new CompletableFuture<>(); - client.newCall(okhttpRequest).enqueue(new Callback() { + RetryInterceptor.AsyncCall okhttpCall = RetryInterceptor.newAsyncCall(client, okhttpRequest); + okhttpCall.enqueue(new Callback() { @Override public void onResponse(@NotNull Call call, @NotNull Response response) throws IOException { try (ResponseBody responseBody = response.body()) { @@ -126,6 +129,11 @@ public void onFailure(@NotNull Call call, @NotNull IOException e) { future.completeExceptionally(new PhenomlClientException("Network error executing HTTP request", e)); } }); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + okhttpCall.cancel(); + } + }); return future; } @@ -144,7 +152,7 @@ public CompletableFuture> get( HttpUrl.Builder httpUrl = HttpUrl.parse(this.clientOptions.environment().getUrl()) .newBuilder() .addPathSegments("tools/mcp-server/tool") - .addPathSegment(mcpServerToolId); + .addPathSegment(PathSegments.validate(mcpServerToolId)); if (requestOptions != null) { requestOptions.getQueryParameters().forEach((_key, _value) -> { httpUrl.addQueryParameter(_key, _value); @@ -170,7 +178,8 @@ public CompletableFuture> get( .build(); } CompletableFuture> future = new CompletableFuture<>(); - client.newCall(okhttpRequest).enqueue(new Callback() { + RetryInterceptor.AsyncCall okhttpCall = RetryInterceptor.newAsyncCall(client, okhttpRequest); + okhttpCall.enqueue(new Callback() { @Override public void onResponse(@NotNull Call call, @NotNull Response response) throws IOException { try (ResponseBody responseBody = response.body()) { @@ -224,6 +233,11 @@ public void onFailure(@NotNull Call call, @NotNull IOException e) { future.completeExceptionally(new PhenomlClientException("Network error executing HTTP request", e)); } }); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + okhttpCall.cancel(); + } + }); return future; } @@ -242,7 +256,7 @@ public CompletableFuture> delet HttpUrl.Builder httpUrl = HttpUrl.parse(this.clientOptions.environment().getUrl()) .newBuilder() .addPathSegments("tools/mcp-server/tool") - .addPathSegment(mcpServerToolId); + .addPathSegment(PathSegments.validate(mcpServerToolId)); if (requestOptions != null) { requestOptions.getQueryParameters().forEach((_key, _value) -> { httpUrl.addQueryParameter(_key, _value); @@ -250,7 +264,10 @@ public CompletableFuture> delet } Request okhttpRequest = new Request.Builder() .url(httpUrl.build()) - .method("DELETE", null) + .method( + "DELETE", + BodyProperties.toRequestBody( + requestOptions != null ? requestOptions.getBodyProperties() : null, null)) .headers(Headers.of(clientOptions.headers(requestOptions))) .addHeader("Accept", "application/json") .build(); @@ -268,7 +285,8 @@ public CompletableFuture> delet .build(); } CompletableFuture> future = new CompletableFuture<>(); - client.newCall(okhttpRequest).enqueue(new Callback() { + RetryInterceptor.AsyncCall okhttpCall = RetryInterceptor.newAsyncCall(client, okhttpRequest); + okhttpCall.enqueue(new Callback() { @Override public void onResponse(@NotNull Call call, @NotNull Response response) throws IOException { try (ResponseBody responseBody = response.body()) { @@ -322,6 +340,11 @@ public void onFailure(@NotNull Call call, @NotNull IOException e) { future.completeExceptionally(new PhenomlClientException("Network error executing HTTP request", e)); } }); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + okhttpCall.cancel(); + } + }); return future; } } diff --git a/src/main/java/com/phenoml/api/resources/tools/mcptools/RawMcpToolsClient.java b/src/main/java/com/phenoml/api/resources/tools/mcptools/RawMcpToolsClient.java index 6ce776fe..89039309 100644 --- a/src/main/java/com/phenoml/api/resources/tools/mcptools/RawMcpToolsClient.java +++ b/src/main/java/com/phenoml/api/resources/tools/mcptools/RawMcpToolsClient.java @@ -4,8 +4,10 @@ package com.phenoml.api.resources.tools.mcptools; import com.fasterxml.jackson.core.JsonProcessingException; +import com.phenoml.api.core.BodyProperties; import com.phenoml.api.core.ClientOptions; import com.phenoml.api.core.ObjectMappers; +import com.phenoml.api.core.PathSegments; import com.phenoml.api.core.PhenomlClientApiException; import com.phenoml.api.core.PhenomlClientException; import com.phenoml.api.core.PhenomlClientHttpResponse; @@ -45,7 +47,7 @@ public PhenomlClientHttpResponse list(String mcpServerId, HttpUrl.Builder httpUrl = HttpUrl.parse(this.clientOptions.environment().getUrl()) .newBuilder() .addPathSegments("tools/mcp-server") - .addPathSegment(mcpServerId) + .addPathSegment(PathSegments.validate(mcpServerId)) .addPathSegments("list"); if (requestOptions != null) { requestOptions.getQueryParameters().forEach((_key, _value) -> { @@ -117,7 +119,7 @@ public PhenomlClientHttpResponse get(String mcpServerTool HttpUrl.Builder httpUrl = HttpUrl.parse(this.clientOptions.environment().getUrl()) .newBuilder() .addPathSegments("tools/mcp-server/tool") - .addPathSegment(mcpServerToolId); + .addPathSegment(PathSegments.validate(mcpServerToolId)); if (requestOptions != null) { requestOptions.getQueryParameters().forEach((_key, _value) -> { httpUrl.addQueryParameter(_key, _value); @@ -192,7 +194,7 @@ public PhenomlClientHttpResponse delete( HttpUrl.Builder httpUrl = HttpUrl.parse(this.clientOptions.environment().getUrl()) .newBuilder() .addPathSegments("tools/mcp-server/tool") - .addPathSegment(mcpServerToolId); + .addPathSegment(PathSegments.validate(mcpServerToolId)); if (requestOptions != null) { requestOptions.getQueryParameters().forEach((_key, _value) -> { httpUrl.addQueryParameter(_key, _value); @@ -200,7 +202,10 @@ public PhenomlClientHttpResponse delete( } Request okhttpRequest = new Request.Builder() .url(httpUrl.build()) - .method("DELETE", null) + .method( + "DELETE", + BodyProperties.toRequestBody( + requestOptions != null ? requestOptions.getBodyProperties() : null, null)) .headers(Headers.of(clientOptions.headers(requestOptions))) .addHeader("Accept", "application/json") .build(); diff --git a/src/main/java/com/phenoml/api/resources/tools/requests/CohortRequest.java b/src/main/java/com/phenoml/api/resources/tools/requests/CohortRequest.java index c324c8b3..5ea1ae03 100644 --- a/src/main/java/com/phenoml/api/resources/tools/requests/CohortRequest.java +++ b/src/main/java/com/phenoml/api/resources/tools/requests/CohortRequest.java @@ -160,7 +160,6 @@ public static final class Builder implements TextStage, ProviderStage, _FinalSta private Optional phenomlOnBehalfOf = Optional.empty(); - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -243,6 +242,14 @@ public CohortRequest build() { return new CohortRequest(phenomlOnBehalfOf, phenomlFhirProvider, text, provider, additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("text") || key.equalsIgnoreCase("provider")) { + return; + } + this.additionalProperties.put(key, value); + } + @java.lang.Override public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); diff --git a/src/main/java/com/phenoml/api/resources/tools/requests/Lang2FhirAndCreateMultiRequest.java b/src/main/java/com/phenoml/api/resources/tools/requests/Lang2FhirAndCreateMultiRequest.java index 03c3dd09..e0de1fec 100644 --- a/src/main/java/com/phenoml/api/resources/tools/requests/Lang2FhirAndCreateMultiRequest.java +++ b/src/main/java/com/phenoml/api/resources/tools/requests/Lang2FhirAndCreateMultiRequest.java @@ -183,7 +183,6 @@ public static final class Builder implements TextStage, ProviderStage, _FinalSta private Optional phenomlOnBehalfOf = Optional.empty(); - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -288,6 +287,14 @@ public Lang2FhirAndCreateMultiRequest build() { phenomlOnBehalfOf, phenomlFhirProvider, text, version, provider, additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("text") || key.equalsIgnoreCase("version") || key.equalsIgnoreCase("provider")) { + return; + } + this.additionalProperties.put(key, value); + } + @java.lang.Override public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); diff --git a/src/main/java/com/phenoml/api/resources/tools/requests/Lang2FhirAndCreateRequest.java b/src/main/java/com/phenoml/api/resources/tools/requests/Lang2FhirAndCreateRequest.java index 1866bf21..0ac6f54b 100644 --- a/src/main/java/com/phenoml/api/resources/tools/requests/Lang2FhirAndCreateRequest.java +++ b/src/main/java/com/phenoml/api/resources/tools/requests/Lang2FhirAndCreateRequest.java @@ -184,7 +184,6 @@ public static final class Builder implements ResourceStage, TextStage, _FinalSta private Optional phenomlOnBehalfOf = Optional.empty(); - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -289,6 +288,14 @@ public Lang2FhirAndCreateRequest build() { phenomlOnBehalfOf, phenomlFhirProvider, resource, text, provider, additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("resource") || key.equalsIgnoreCase("text") || key.equalsIgnoreCase("provider")) { + return; + } + this.additionalProperties.put(key, value); + } + @java.lang.Override public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); diff --git a/src/main/java/com/phenoml/api/resources/tools/requests/Lang2FhirAndSearchRequest.java b/src/main/java/com/phenoml/api/resources/tools/requests/Lang2FhirAndSearchRequest.java index 27b1c655..44fb16f4 100644 --- a/src/main/java/com/phenoml/api/resources/tools/requests/Lang2FhirAndSearchRequest.java +++ b/src/main/java/com/phenoml/api/resources/tools/requests/Lang2FhirAndSearchRequest.java @@ -206,7 +206,6 @@ public static final class Builder implements TextStage, _FinalStage { private Optional phenomlOnBehalfOf = Optional.empty(); - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -341,6 +340,17 @@ public Lang2FhirAndSearchRequest build() { phenomlOnBehalfOf, phenomlFhirProvider, text, patientId, count, provider, additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("text") + || key.equalsIgnoreCase("patient_id") + || key.equalsIgnoreCase("count") + || key.equalsIgnoreCase("provider")) { + return; + } + this.additionalProperties.put(key, value); + } + @java.lang.Override public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); diff --git a/src/main/java/com/phenoml/api/resources/tools/types/CohortResponse.java b/src/main/java/com/phenoml/api/resources/tools/types/CohortResponse.java index eb9ea185..ae357ed8 100644 --- a/src/main/java/com/phenoml/api/resources/tools/types/CohortResponse.java +++ b/src/main/java/com/phenoml/api/resources/tools/types/CohortResponse.java @@ -133,7 +133,6 @@ public static final class Builder { private Optional> queries = Optional.empty(); - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -221,6 +220,18 @@ public CohortResponse build() { return new CohortResponse(success, message, patientIds, patientCount, queries, additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("success") + || key.equalsIgnoreCase("message") + || key.equalsIgnoreCase("patientIds") + || key.equalsIgnoreCase("patientCount") + || key.equalsIgnoreCase("queries")) { + return; + } + this.additionalProperties.put(key, value); + } + public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); return this; diff --git a/src/main/java/com/phenoml/api/resources/tools/types/Lang2FhirAndCreateMultiResponse.java b/src/main/java/com/phenoml/api/resources/tools/types/Lang2FhirAndCreateMultiResponse.java index 18b18fcf..3656f676 100644 --- a/src/main/java/com/phenoml/api/resources/tools/types/Lang2FhirAndCreateMultiResponse.java +++ b/src/main/java/com/phenoml/api/resources/tools/types/Lang2FhirAndCreateMultiResponse.java @@ -118,7 +118,6 @@ public static final class Builder { private Optional> resourceInfo = Optional.empty(); - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -192,6 +191,17 @@ public Lang2FhirAndCreateMultiResponse build() { success, message, responseBundle, resourceInfo, additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("success") + || key.equalsIgnoreCase("message") + || key.equalsIgnoreCase("response_bundle") + || key.equalsIgnoreCase("resource_info")) { + return; + } + this.additionalProperties.put(key, value); + } + public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); return this; diff --git a/src/main/java/com/phenoml/api/resources/tools/types/Lang2FhirAndCreateMultiResponseResourceInfoItem.java b/src/main/java/com/phenoml/api/resources/tools/types/Lang2FhirAndCreateMultiResponseResourceInfoItem.java index 7e763907..daa96999 100644 --- a/src/main/java/com/phenoml/api/resources/tools/types/Lang2FhirAndCreateMultiResponseResourceInfoItem.java +++ b/src/main/java/com/phenoml/api/resources/tools/types/Lang2FhirAndCreateMultiResponseResourceInfoItem.java @@ -103,7 +103,6 @@ public static final class Builder { private Optional description = Optional.empty(); - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -162,6 +161,16 @@ public Lang2FhirAndCreateMultiResponseResourceInfoItem build() { tempId, resourceType, description, additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("tempId") + || key.equalsIgnoreCase("resourceType") + || key.equalsIgnoreCase("description")) { + return; + } + this.additionalProperties.put(key, value); + } + public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); return this; diff --git a/src/main/java/com/phenoml/api/resources/tools/types/Lang2FhirAndCreateMultiResponseResponseBundle.java b/src/main/java/com/phenoml/api/resources/tools/types/Lang2FhirAndCreateMultiResponseResponseBundle.java index 098bda3f..7da45509 100644 --- a/src/main/java/com/phenoml/api/resources/tools/types/Lang2FhirAndCreateMultiResponseResponseBundle.java +++ b/src/main/java/com/phenoml/api/resources/tools/types/Lang2FhirAndCreateMultiResponseResponseBundle.java @@ -93,7 +93,6 @@ public static final class Builder { private Optional>> entry = Optional.empty(); - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -142,6 +141,14 @@ public Lang2FhirAndCreateMultiResponseResponseBundle build() { return new Lang2FhirAndCreateMultiResponseResponseBundle(resourceType, type, entry, additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("resourceType") || key.equalsIgnoreCase("type") || key.equalsIgnoreCase("entry")) { + return; + } + this.additionalProperties.put(key, value); + } + public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); return this; diff --git a/src/main/java/com/phenoml/api/resources/tools/types/Lang2FhirAndCreateResponse.java b/src/main/java/com/phenoml/api/resources/tools/types/Lang2FhirAndCreateResponse.java index 6150af5b..0f0fdaf8 100644 --- a/src/main/java/com/phenoml/api/resources/tools/types/Lang2FhirAndCreateResponse.java +++ b/src/main/java/com/phenoml/api/resources/tools/types/Lang2FhirAndCreateResponse.java @@ -114,7 +114,6 @@ public static final class Builder { private Optional message = Optional.empty(); - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -184,6 +183,17 @@ public Lang2FhirAndCreateResponse build() { return new Lang2FhirAndCreateResponse(fhirResource, fhirId, success, message, additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("fhir_resource") + || key.equalsIgnoreCase("fhir_id") + || key.equalsIgnoreCase("success") + || key.equalsIgnoreCase("message")) { + return; + } + this.additionalProperties.put(key, value); + } + public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); return this; diff --git a/src/main/java/com/phenoml/api/resources/tools/types/Lang2FhirAndSearchResponse.java b/src/main/java/com/phenoml/api/resources/tools/types/Lang2FhirAndSearchResponse.java index 50570ad8..7c3bc68a 100644 --- a/src/main/java/com/phenoml/api/resources/tools/types/Lang2FhirAndSearchResponse.java +++ b/src/main/java/com/phenoml/api/resources/tools/types/Lang2FhirAndSearchResponse.java @@ -130,7 +130,6 @@ public static final class Builder { private Optional message = Optional.empty(); - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -216,6 +215,18 @@ public Lang2FhirAndSearchResponse build() { resourceType, searchParams, fhirResults, success, message, additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("resource_type") + || key.equalsIgnoreCase("search_params") + || key.equalsIgnoreCase("fhir_results") + || key.equalsIgnoreCase("success") + || key.equalsIgnoreCase("message")) { + return; + } + this.additionalProperties.put(key, value); + } + public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); return this; diff --git a/src/main/java/com/phenoml/api/resources/tools/types/McpServer.java b/src/main/java/com/phenoml/api/resources/tools/types/McpServer.java index d9ae5ac1..f1cfba35 100644 --- a/src/main/java/com/phenoml/api/resources/tools/types/McpServer.java +++ b/src/main/java/com/phenoml/api/resources/tools/types/McpServer.java @@ -117,7 +117,6 @@ public static final class Builder { private Optional mcpServerUrl = Optional.empty(); - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -190,6 +189,17 @@ public McpServer build() { return new McpServer(id, name, description, mcpServerUrl, additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("id") + || key.equalsIgnoreCase("name") + || key.equalsIgnoreCase("description") + || key.equalsIgnoreCase("mcp_server_url")) { + return; + } + this.additionalProperties.put(key, value); + } + public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); return this; diff --git a/src/main/java/com/phenoml/api/resources/tools/types/McpServerResponse.java b/src/main/java/com/phenoml/api/resources/tools/types/McpServerResponse.java index 99f82e91..4f0aed8f 100644 --- a/src/main/java/com/phenoml/api/resources/tools/types/McpServerResponse.java +++ b/src/main/java/com/phenoml/api/resources/tools/types/McpServerResponse.java @@ -135,7 +135,6 @@ public static final class Builder { private Optional> mcpServerTools = Optional.empty(); - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -225,6 +224,18 @@ public McpServerResponse build() { return new McpServerResponse(success, message, data, mcpServers, mcpServerTools, additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("success") + || key.equalsIgnoreCase("message") + || key.equalsIgnoreCase("data") + || key.equalsIgnoreCase("mcp_servers") + || key.equalsIgnoreCase("mcp_server_tools")) { + return; + } + this.additionalProperties.put(key, value); + } + public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); return this; diff --git a/src/main/java/com/phenoml/api/resources/tools/types/McpServerTool.java b/src/main/java/com/phenoml/api/resources/tools/types/McpServerTool.java index db10c0b7..ac64611e 100644 --- a/src/main/java/com/phenoml/api/resources/tools/types/McpServerTool.java +++ b/src/main/java/com/phenoml/api/resources/tools/types/McpServerTool.java @@ -148,7 +148,6 @@ public static final class Builder { private Optional mcpServerUrl = Optional.empty(); - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -252,6 +251,19 @@ public McpServerTool build() { id, name, description, inputSchema, mcpServerId, mcpServerUrl, additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("id") + || key.equalsIgnoreCase("name") + || key.equalsIgnoreCase("description") + || key.equalsIgnoreCase("input_schema") + || key.equalsIgnoreCase("mcp_server_id") + || key.equalsIgnoreCase("mcp_server_url")) { + return; + } + this.additionalProperties.put(key, value); + } + public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); return this; diff --git a/src/main/java/com/phenoml/api/resources/tools/types/McpServerToolResponse.java b/src/main/java/com/phenoml/api/resources/tools/types/McpServerToolResponse.java index e6993ce0..e2e5a506 100644 --- a/src/main/java/com/phenoml/api/resources/tools/types/McpServerToolResponse.java +++ b/src/main/java/com/phenoml/api/resources/tools/types/McpServerToolResponse.java @@ -119,7 +119,6 @@ public static final class Builder { private Optional> mcpServerTools = Optional.empty(); - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -193,6 +192,17 @@ public McpServerToolResponse build() { return new McpServerToolResponse(success, message, data, mcpServerTools, additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("success") + || key.equalsIgnoreCase("message") + || key.equalsIgnoreCase("data") + || key.equalsIgnoreCase("mcp_server_tools")) { + return; + } + this.additionalProperties.put(key, value); + } + public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); return this; diff --git a/src/main/java/com/phenoml/api/resources/tools/types/SearchConcept.java b/src/main/java/com/phenoml/api/resources/tools/types/SearchConcept.java index ff6e64bf..3f8dcad6 100644 --- a/src/main/java/com/phenoml/api/resources/tools/types/SearchConcept.java +++ b/src/main/java/com/phenoml/api/resources/tools/types/SearchConcept.java @@ -117,7 +117,6 @@ public static final class Builder { private Optional exclude = Optional.empty(); - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -190,6 +189,17 @@ public SearchConcept build() { return new SearchConcept(resourceType, searchParams, concept, exclude, additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("resource_type") + || key.equalsIgnoreCase("search_params") + || key.equalsIgnoreCase("concept") + || key.equalsIgnoreCase("exclude")) { + return; + } + this.additionalProperties.put(key, value); + } + public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); return this; diff --git a/src/main/java/com/phenoml/api/resources/voice/types/TranscribeResponse.java b/src/main/java/com/phenoml/api/resources/voice/types/TranscribeResponse.java index 03a69f3a..19e877ff 100644 --- a/src/main/java/com/phenoml/api/resources/voice/types/TranscribeResponse.java +++ b/src/main/java/com/phenoml/api/resources/voice/types/TranscribeResponse.java @@ -86,7 +86,6 @@ public interface _FinalStage { public static final class Builder implements TranscriptStage, _FinalStage { private String transcript; - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -113,6 +112,14 @@ public TranscribeResponse build() { return new TranscribeResponse(transcript, additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("transcript")) { + return; + } + this.additionalProperties.put(key, value); + } + @java.lang.Override public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); diff --git a/src/main/java/com/phenoml/api/resources/voice/voice/AsyncRawVoiceClient.java b/src/main/java/com/phenoml/api/resources/voice/voice/AsyncRawVoiceClient.java index 49fab148..66ed6dcf 100644 --- a/src/main/java/com/phenoml/api/resources/voice/voice/AsyncRawVoiceClient.java +++ b/src/main/java/com/phenoml/api/resources/voice/voice/AsyncRawVoiceClient.java @@ -137,7 +137,8 @@ public CompletableFuture> transcri .build(); } CompletableFuture> future = new CompletableFuture<>(); - client.newCall(okhttpRequest).enqueue(new Callback() { + RetryInterceptor.AsyncCall okhttpCall = RetryInterceptor.newAsyncCall(client, okhttpRequest); + okhttpCall.enqueue(new Callback() { @Override public void onResponse(@NotNull Call call, @NotNull Response response) throws IOException { try (ResponseBody responseBody = response.body()) { @@ -201,6 +202,11 @@ public void onFailure(@NotNull Call call, @NotNull IOException e) { future.completeExceptionally(new PhenomlClientException("Network error executing HTTP request", e)); } }); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + okhttpCall.cancel(); + } + }); return future; } } diff --git a/src/main/java/com/phenoml/api/resources/voice/voice/AsyncVoiceClient.java b/src/main/java/com/phenoml/api/resources/voice/voice/AsyncVoiceClient.java index ba1ca0b1..31568536 100644 --- a/src/main/java/com/phenoml/api/resources/voice/voice/AsyncVoiceClient.java +++ b/src/main/java/com/phenoml/api/resources/voice/voice/AsyncVoiceClient.java @@ -4,6 +4,7 @@ package com.phenoml.api.resources.voice.voice; import com.phenoml.api.core.ClientOptions; +import com.phenoml.api.core.PhenomlClientHttpResponse; import com.phenoml.api.core.RequestOptions; import com.phenoml.api.resources.voice.types.TranscribeResponse; import com.phenoml.api.resources.voice.voice.requests.TranscribeRequest; @@ -38,7 +39,14 @@ public AsyncRawVoiceClient withRawResponse() { * FHIR resource.

            */ public CompletableFuture transcribe(byte[] body) { - return this.rawClient.transcribe(body).thenApply(response -> response.body()); + CompletableFuture> rawFuture = this.rawClient.transcribe(body); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** @@ -53,7 +61,15 @@ public CompletableFuture transcribe(byte[] body) { * FHIR resource.

            */ public CompletableFuture transcribe(byte[] body, RequestOptions requestOptions) { - return this.rawClient.transcribe(body, requestOptions).thenApply(response -> response.body()); + CompletableFuture> rawFuture = + this.rawClient.transcribe(body, requestOptions); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** @@ -68,7 +84,14 @@ public CompletableFuture transcribe(byte[] body, RequestOpti * FHIR resource.

            */ public CompletableFuture transcribe(TranscribeRequest request) { - return this.rawClient.transcribe(request).thenApply(response -> response.body()); + CompletableFuture> rawFuture = this.rawClient.transcribe(request); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** @@ -83,6 +106,14 @@ public CompletableFuture transcribe(TranscribeRequest reques * FHIR resource.

            */ public CompletableFuture transcribe(TranscribeRequest request, RequestOptions requestOptions) { - return this.rawClient.transcribe(request, requestOptions).thenApply(response -> response.body()); + CompletableFuture> rawFuture = + this.rawClient.transcribe(request, requestOptions); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } } diff --git a/src/main/java/com/phenoml/api/resources/voice/voice/requests/TranscribeRequest.java b/src/main/java/com/phenoml/api/resources/voice/voice/requests/TranscribeRequest.java index 83017815..e83861e0 100644 --- a/src/main/java/com/phenoml/api/resources/voice/voice/requests/TranscribeRequest.java +++ b/src/main/java/com/phenoml/api/resources/voice/voice/requests/TranscribeRequest.java @@ -106,7 +106,6 @@ public static final class Builder implements BodyStage, _FinalStage { private Optional> language = Optional.empty(); - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -156,6 +155,14 @@ public TranscribeRequest build() { return new TranscribeRequest(language, body, additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("language") || key.equalsIgnoreCase("body")) { + return; + } + this.additionalProperties.put(key, value); + } + @java.lang.Override public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); diff --git a/src/main/java/com/phenoml/api/resources/workflows/AsyncRawWorkflowsClient.java b/src/main/java/com/phenoml/api/resources/workflows/AsyncRawWorkflowsClient.java index 61030d52..90914f16 100644 --- a/src/main/java/com/phenoml/api/resources/workflows/AsyncRawWorkflowsClient.java +++ b/src/main/java/com/phenoml/api/resources/workflows/AsyncRawWorkflowsClient.java @@ -4,9 +4,11 @@ package com.phenoml.api.resources.workflows; import com.fasterxml.jackson.core.JsonProcessingException; +import com.phenoml.api.core.BodyProperties; import com.phenoml.api.core.ClientOptions; import com.phenoml.api.core.MediaTypes; import com.phenoml.api.core.ObjectMappers; +import com.phenoml.api.core.PathSegments; import com.phenoml.api.core.PhenomlClientApiException; import com.phenoml.api.core.PhenomlClientException; import com.phenoml.api.core.PhenomlClientHttpResponse; @@ -108,7 +110,8 @@ public CompletableFuture> list( .build(); } CompletableFuture> future = new CompletableFuture<>(); - client.newCall(okhttpRequest).enqueue(new Callback() { + RetryInterceptor.AsyncCall okhttpCall = RetryInterceptor.newAsyncCall(client, okhttpRequest); + okhttpCall.enqueue(new Callback() { @Override public void onResponse(@NotNull Call call, @NotNull Response response) throws IOException { try (ResponseBody responseBody = response.body()) { @@ -157,6 +160,11 @@ public void onFailure(@NotNull Call call, @NotNull IOException e) { future.completeExceptionally(new PhenomlClientException("Network error executing HTTP request", e)); } }); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + okhttpCall.cancel(); + } + }); return future; } @@ -187,7 +195,9 @@ public CompletableFuture> crea RequestBody body; try { body = RequestBody.create( - ObjectMappers.JSON_MAPPER.writeValueAsBytes(request), MediaTypes.APPLICATION_JSON); + ObjectMappers.JSON_MAPPER.writeValueAsBytes(BodyProperties.merge( + request, requestOptions != null ? requestOptions.getBodyProperties() : null)), + MediaTypes.APPLICATION_JSON); } catch (Exception e) { throw new RuntimeException(e); } @@ -212,7 +222,8 @@ public CompletableFuture> crea .build(); } CompletableFuture> future = new CompletableFuture<>(); - client.newCall(okhttpRequest).enqueue(new Callback() { + RetryInterceptor.AsyncCall okhttpCall = RetryInterceptor.newAsyncCall(client, okhttpRequest); + okhttpCall.enqueue(new Callback() { @Override public void onResponse(@NotNull Call call, @NotNull Response response) throws IOException { try (ResponseBody responseBody = response.body()) { @@ -266,6 +277,11 @@ public void onFailure(@NotNull Call call, @NotNull IOException e) { future.completeExceptionally(new PhenomlClientException("Network error executing HTTP request", e)); } }); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + okhttpCall.cancel(); + } + }); return future; } @@ -298,7 +314,7 @@ public CompletableFuture> get( HttpUrl.Builder httpUrl = HttpUrl.parse(this.clientOptions.environment().getUrl()) .newBuilder() .addPathSegments("workflows") - .addPathSegment(id); + .addPathSegment(PathSegments.validate(id)); if (request.getVerbose().isPresent()) { QueryStringMapper.addQueryParameter( httpUrl, "verbose", request.getVerbose().get(), false); @@ -328,7 +344,8 @@ public CompletableFuture> get( .build(); } CompletableFuture> future = new CompletableFuture<>(); - client.newCall(okhttpRequest).enqueue(new Callback() { + RetryInterceptor.AsyncCall okhttpCall = RetryInterceptor.newAsyncCall(client, okhttpRequest); + okhttpCall.enqueue(new Callback() { @Override public void onResponse(@NotNull Call call, @NotNull Response response) throws IOException { try (ResponseBody responseBody = response.body()) { @@ -386,6 +403,11 @@ public void onFailure(@NotNull Call call, @NotNull IOException e) { future.completeExceptionally(new PhenomlClientException("Network error executing HTTP request", e)); } }); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + okhttpCall.cancel(); + } + }); return future; } @@ -405,7 +427,7 @@ public CompletableFuture> update( HttpUrl.Builder httpUrl = HttpUrl.parse(this.clientOptions.environment().getUrl()) .newBuilder() .addPathSegments("workflows") - .addPathSegment(id); + .addPathSegment(PathSegments.validate(id)); if (request.getVerbose().isPresent()) { QueryStringMapper.addQueryParameter( httpUrl, "verbose", request.getVerbose().get(), false); @@ -418,7 +440,9 @@ public CompletableFuture> update( RequestBody body; try { body = RequestBody.create( - ObjectMappers.JSON_MAPPER.writeValueAsBytes(request), MediaTypes.APPLICATION_JSON); + ObjectMappers.JSON_MAPPER.writeValueAsBytes(BodyProperties.merge( + request, requestOptions != null ? requestOptions.getBodyProperties() : null)), + MediaTypes.APPLICATION_JSON); } catch (Exception e) { throw new RuntimeException(e); } @@ -443,7 +467,8 @@ public CompletableFuture> update( .build(); } CompletableFuture> future = new CompletableFuture<>(); - client.newCall(okhttpRequest).enqueue(new Callback() { + RetryInterceptor.AsyncCall okhttpCall = RetryInterceptor.newAsyncCall(client, okhttpRequest); + okhttpCall.enqueue(new Callback() { @Override public void onResponse(@NotNull Call call, @NotNull Response response) throws IOException { try (ResponseBody responseBody = response.body()) { @@ -507,6 +532,11 @@ public void onFailure(@NotNull Call call, @NotNull IOException e) { future.completeExceptionally(new PhenomlClientException("Network error executing HTTP request", e)); } }); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + okhttpCall.cancel(); + } + }); return future; } @@ -525,7 +555,7 @@ public CompletableFuture> delete( HttpUrl.Builder httpUrl = HttpUrl.parse(this.clientOptions.environment().getUrl()) .newBuilder() .addPathSegments("workflows") - .addPathSegment(id); + .addPathSegment(PathSegments.validate(id)); if (requestOptions != null) { requestOptions.getQueryParameters().forEach((_key, _value) -> { httpUrl.addQueryParameter(_key, _value); @@ -533,7 +563,10 @@ public CompletableFuture> delete( } Request okhttpRequest = new Request.Builder() .url(httpUrl.build()) - .method("DELETE", null) + .method( + "DELETE", + BodyProperties.toRequestBody( + requestOptions != null ? requestOptions.getBodyProperties() : null, null)) .headers(Headers.of(clientOptions.headers(requestOptions))) .addHeader("Accept", "application/json") .build(); @@ -551,7 +584,8 @@ public CompletableFuture> delete( .build(); } CompletableFuture> future = new CompletableFuture<>(); - client.newCall(okhttpRequest).enqueue(new Callback() { + RetryInterceptor.AsyncCall okhttpCall = RetryInterceptor.newAsyncCall(client, okhttpRequest); + okhttpCall.enqueue(new Callback() { @Override public void onResponse(@NotNull Call call, @NotNull Response response) throws IOException { try (ResponseBody responseBody = response.body()) { @@ -610,6 +644,11 @@ public void onFailure(@NotNull Call call, @NotNull IOException e) { future.completeExceptionally(new PhenomlClientException("Network error executing HTTP request", e)); } }); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + okhttpCall.cancel(); + } + }); return future; } @@ -629,7 +668,7 @@ public CompletableFuture> exe HttpUrl.Builder httpUrl = HttpUrl.parse(this.clientOptions.environment().getUrl()) .newBuilder() .addPathSegments("workflows") - .addPathSegment(id) + .addPathSegment(PathSegments.validate(id)) .addPathSegments("execute"); if (requestOptions != null) { requestOptions.getQueryParameters().forEach((_key, _value) -> { @@ -639,7 +678,9 @@ public CompletableFuture> exe RequestBody body; try { body = RequestBody.create( - ObjectMappers.JSON_MAPPER.writeValueAsBytes(request), MediaTypes.APPLICATION_JSON); + ObjectMappers.JSON_MAPPER.writeValueAsBytes(BodyProperties.merge( + request, requestOptions != null ? requestOptions.getBodyProperties() : null)), + MediaTypes.APPLICATION_JSON); } catch (JsonProcessingException e) { throw new PhenomlClientException("Failed to serialize request", e); } @@ -664,7 +705,8 @@ public CompletableFuture> exe .build(); } CompletableFuture> future = new CompletableFuture<>(); - client.newCall(okhttpRequest).enqueue(new Callback() { + RetryInterceptor.AsyncCall okhttpCall = RetryInterceptor.newAsyncCall(client, okhttpRequest); + okhttpCall.enqueue(new Callback() { @Override public void onResponse(@NotNull Call call, @NotNull Response response) throws IOException { try (ResponseBody responseBody = response.body()) { @@ -728,6 +770,11 @@ public void onFailure(@NotNull Call call, @NotNull IOException e) { future.completeExceptionally(new PhenomlClientException("Network error executing HTTP request", e)); } }); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + okhttpCall.cancel(); + } + }); return future; } } diff --git a/src/main/java/com/phenoml/api/resources/workflows/AsyncWorkflowsClient.java b/src/main/java/com/phenoml/api/resources/workflows/AsyncWorkflowsClient.java index cc27686f..d4d85888 100644 --- a/src/main/java/com/phenoml/api/resources/workflows/AsyncWorkflowsClient.java +++ b/src/main/java/com/phenoml/api/resources/workflows/AsyncWorkflowsClient.java @@ -4,6 +4,7 @@ package com.phenoml.api.resources.workflows; import com.phenoml.api.core.ClientOptions; +import com.phenoml.api.core.PhenomlClientHttpResponse; import com.phenoml.api.core.RequestOptions; import com.phenoml.api.resources.workflows.requests.CreateWorkflowRequest; import com.phenoml.api.resources.workflows.requests.ExecuteWorkflowRequest; @@ -39,35 +40,72 @@ public AsyncRawWorkflowsClient withRawResponse() { * Retrieves all workflow definitions for the authenticated user */ public CompletableFuture list() { - return this.rawClient.list().thenApply(response -> response.body()); + CompletableFuture> rawFuture = this.rawClient.list(); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** * Retrieves all workflow definitions for the authenticated user */ public CompletableFuture list(RequestOptions requestOptions) { - return this.rawClient.list(requestOptions).thenApply(response -> response.body()); + CompletableFuture> rawFuture = + this.rawClient.list(requestOptions); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** * Retrieves all workflow definitions for the authenticated user */ public CompletableFuture list(ListRequest request) { - return this.rawClient.list(request).thenApply(response -> response.body()); + CompletableFuture> rawFuture = this.rawClient.list(request); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** * Retrieves all workflow definitions for the authenticated user */ public CompletableFuture list(ListRequest request, RequestOptions requestOptions) { - return this.rawClient.list(request, requestOptions).thenApply(response -> response.body()); + CompletableFuture> rawFuture = + this.rawClient.list(request, requestOptions); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** * Creates a new workflow definition with graph generation from workflow instructions */ public CompletableFuture create(CreateWorkflowRequest request) { - return this.rawClient.create(request).thenApply(response -> response.body()); + CompletableFuture> rawFuture = this.rawClient.create(request); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** @@ -75,42 +113,86 @@ public CompletableFuture create(CreateWorkflowRequest re */ public CompletableFuture create( CreateWorkflowRequest request, RequestOptions requestOptions) { - return this.rawClient.create(request, requestOptions).thenApply(response -> response.body()); + CompletableFuture> rawFuture = + this.rawClient.create(request, requestOptions); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** * Retrieves a workflow definition by its ID */ public CompletableFuture get(String id) { - return this.rawClient.get(id).thenApply(response -> response.body()); + CompletableFuture> rawFuture = this.rawClient.get(id); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** * Retrieves a workflow definition by its ID */ public CompletableFuture get(String id, RequestOptions requestOptions) { - return this.rawClient.get(id, requestOptions).thenApply(response -> response.body()); + CompletableFuture> rawFuture = this.rawClient.get(id, requestOptions); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** * Retrieves a workflow definition by its ID */ public CompletableFuture get(String id, GetRequest request) { - return this.rawClient.get(id, request).thenApply(response -> response.body()); + CompletableFuture> rawFuture = this.rawClient.get(id, request); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** * Retrieves a workflow definition by its ID */ public CompletableFuture get(String id, GetRequest request, RequestOptions requestOptions) { - return this.rawClient.get(id, request, requestOptions).thenApply(response -> response.body()); + CompletableFuture> rawFuture = + this.rawClient.get(id, request, requestOptions); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** * Updates an existing workflow definition */ public CompletableFuture update(String id, UpdateWorkflowRequest request) { - return this.rawClient.update(id, request).thenApply(response -> response.body()); + CompletableFuture> rawFuture = this.rawClient.update(id, request); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** @@ -118,28 +200,59 @@ public CompletableFuture update(String id, UpdateWorkflowRequest */ public CompletableFuture update( String id, UpdateWorkflowRequest request, RequestOptions requestOptions) { - return this.rawClient.update(id, request, requestOptions).thenApply(response -> response.body()); + CompletableFuture> rawFuture = + this.rawClient.update(id, request, requestOptions); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** * Deletes a workflow definition by its ID */ public CompletableFuture delete(String id) { - return this.rawClient.delete(id).thenApply(response -> response.body()); + CompletableFuture> rawFuture = this.rawClient.delete(id); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** * Deletes a workflow definition by its ID */ public CompletableFuture delete(String id, RequestOptions requestOptions) { - return this.rawClient.delete(id, requestOptions).thenApply(response -> response.body()); + CompletableFuture> rawFuture = + this.rawClient.delete(id, requestOptions); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** * Executes a workflow with provided input data and returns results */ public CompletableFuture execute(String id, ExecuteWorkflowRequest request) { - return this.rawClient.execute(id, request).thenApply(response -> response.body()); + CompletableFuture> rawFuture = + this.rawClient.execute(id, request); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } /** @@ -147,6 +260,14 @@ public CompletableFuture execute(String id, ExecuteWork */ public CompletableFuture execute( String id, ExecuteWorkflowRequest request, RequestOptions requestOptions) { - return this.rawClient.execute(id, request, requestOptions).thenApply(response -> response.body()); + CompletableFuture> rawFuture = + this.rawClient.execute(id, request, requestOptions); + CompletableFuture future = rawFuture.thenApply(response -> response.body()); + future.whenComplete((result_, throwable_) -> { + if (future.isCancelled()) { + rawFuture.cancel(true); + } + }); + return future; } } diff --git a/src/main/java/com/phenoml/api/resources/workflows/RawWorkflowsClient.java b/src/main/java/com/phenoml/api/resources/workflows/RawWorkflowsClient.java index 515a7fd7..6a77d788 100644 --- a/src/main/java/com/phenoml/api/resources/workflows/RawWorkflowsClient.java +++ b/src/main/java/com/phenoml/api/resources/workflows/RawWorkflowsClient.java @@ -4,9 +4,11 @@ package com.phenoml.api.resources.workflows; import com.fasterxml.jackson.core.JsonProcessingException; +import com.phenoml.api.core.BodyProperties; import com.phenoml.api.core.ClientOptions; import com.phenoml.api.core.MediaTypes; import com.phenoml.api.core.ObjectMappers; +import com.phenoml.api.core.PathSegments; import com.phenoml.api.core.PhenomlClientApiException; import com.phenoml.api.core.PhenomlClientException; import com.phenoml.api.core.PhenomlClientHttpResponse; @@ -161,7 +163,9 @@ public PhenomlClientHttpResponse create( RequestBody body; try { body = RequestBody.create( - ObjectMappers.JSON_MAPPER.writeValueAsBytes(request), MediaTypes.APPLICATION_JSON); + ObjectMappers.JSON_MAPPER.writeValueAsBytes(BodyProperties.merge( + request, requestOptions != null ? requestOptions.getBodyProperties() : null)), + MediaTypes.APPLICATION_JSON); } catch (Exception e) { throw new RuntimeException(e); } @@ -249,7 +253,7 @@ public PhenomlClientHttpResponse get(String id, GetRequest request, HttpUrl.Builder httpUrl = HttpUrl.parse(this.clientOptions.environment().getUrl()) .newBuilder() .addPathSegments("workflows") - .addPathSegment(id); + .addPathSegment(PathSegments.validate(id)); if (request.getVerbose().isPresent()) { QueryStringMapper.addQueryParameter( httpUrl, "verbose", request.getVerbose().get(), false); @@ -331,7 +335,7 @@ public PhenomlClientHttpResponse update( HttpUrl.Builder httpUrl = HttpUrl.parse(this.clientOptions.environment().getUrl()) .newBuilder() .addPathSegments("workflows") - .addPathSegment(id); + .addPathSegment(PathSegments.validate(id)); if (request.getVerbose().isPresent()) { QueryStringMapper.addQueryParameter( httpUrl, "verbose", request.getVerbose().get(), false); @@ -344,7 +348,9 @@ public PhenomlClientHttpResponse update( RequestBody body; try { body = RequestBody.create( - ObjectMappers.JSON_MAPPER.writeValueAsBytes(request), MediaTypes.APPLICATION_JSON); + ObjectMappers.JSON_MAPPER.writeValueAsBytes(BodyProperties.merge( + request, requestOptions != null ? requestOptions.getBodyProperties() : null)), + MediaTypes.APPLICATION_JSON); } catch (Exception e) { throw new RuntimeException(e); } @@ -423,7 +429,7 @@ public PhenomlClientHttpResponse delete(String id, RequestOption HttpUrl.Builder httpUrl = HttpUrl.parse(this.clientOptions.environment().getUrl()) .newBuilder() .addPathSegments("workflows") - .addPathSegment(id); + .addPathSegment(PathSegments.validate(id)); if (requestOptions != null) { requestOptions.getQueryParameters().forEach((_key, _value) -> { httpUrl.addQueryParameter(_key, _value); @@ -431,7 +437,10 @@ public PhenomlClientHttpResponse delete(String id, RequestOption } Request okhttpRequest = new Request.Builder() .url(httpUrl.build()) - .method("DELETE", null) + .method( + "DELETE", + BodyProperties.toRequestBody( + requestOptions != null ? requestOptions.getBodyProperties() : null, null)) .headers(Headers.of(clientOptions.headers(requestOptions))) .addHeader("Accept", "application/json") .build(); @@ -501,7 +510,7 @@ public PhenomlClientHttpResponse execute( HttpUrl.Builder httpUrl = HttpUrl.parse(this.clientOptions.environment().getUrl()) .newBuilder() .addPathSegments("workflows") - .addPathSegment(id) + .addPathSegment(PathSegments.validate(id)) .addPathSegments("execute"); if (requestOptions != null) { requestOptions.getQueryParameters().forEach((_key, _value) -> { @@ -511,7 +520,9 @@ public PhenomlClientHttpResponse execute( RequestBody body; try { body = RequestBody.create( - ObjectMappers.JSON_MAPPER.writeValueAsBytes(request), MediaTypes.APPLICATION_JSON); + ObjectMappers.JSON_MAPPER.writeValueAsBytes(BodyProperties.merge( + request, requestOptions != null ? requestOptions.getBodyProperties() : null)), + MediaTypes.APPLICATION_JSON); } catch (JsonProcessingException e) { throw new PhenomlClientException("Failed to serialize request", e); } diff --git a/src/main/java/com/phenoml/api/resources/workflows/requests/CreateWorkflowRequest.java b/src/main/java/com/phenoml/api/resources/workflows/requests/CreateWorkflowRequest.java index 43c49604..cd191dcd 100644 --- a/src/main/java/com/phenoml/api/resources/workflows/requests/CreateWorkflowRequest.java +++ b/src/main/java/com/phenoml/api/resources/workflows/requests/CreateWorkflowRequest.java @@ -212,7 +212,6 @@ public static final class Builder private Optional verbose = Optional.empty(); - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -349,6 +348,18 @@ public CreateWorkflowRequest build() { additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("name") + || key.equalsIgnoreCase("workflow_instructions") + || key.equalsIgnoreCase("sample_data") + || key.equalsIgnoreCase("fhir_provider_id") + || key.equalsIgnoreCase("dynamic_generation")) { + return; + } + this.additionalProperties.put(key, value); + } + @java.lang.Override public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); diff --git a/src/main/java/com/phenoml/api/resources/workflows/requests/ExecuteWorkflowRequest.java b/src/main/java/com/phenoml/api/resources/workflows/requests/ExecuteWorkflowRequest.java index a8818c50..3d4b3c9d 100644 --- a/src/main/java/com/phenoml/api/resources/workflows/requests/ExecuteWorkflowRequest.java +++ b/src/main/java/com/phenoml/api/resources/workflows/requests/ExecuteWorkflowRequest.java @@ -85,7 +85,6 @@ public static final class Builder { private Optional preview = Optional.empty(); - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -138,6 +137,14 @@ public ExecuteWorkflowRequest build() { return new ExecuteWorkflowRequest(inputData, preview, additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("input_data") || key.equalsIgnoreCase("preview")) { + return; + } + this.additionalProperties.put(key, value); + } + public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); return this; diff --git a/src/main/java/com/phenoml/api/resources/workflows/requests/GetRequest.java b/src/main/java/com/phenoml/api/resources/workflows/requests/GetRequest.java index e58ff21c..ac83033e 100644 --- a/src/main/java/com/phenoml/api/resources/workflows/requests/GetRequest.java +++ b/src/main/java/com/phenoml/api/resources/workflows/requests/GetRequest.java @@ -70,7 +70,6 @@ public static Builder builder() { public static final class Builder { private Optional verbose = Optional.empty(); - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -98,6 +97,14 @@ public GetRequest build() { return new GetRequest(verbose, additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("verbose")) { + return; + } + this.additionalProperties.put(key, value); + } + public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); return this; diff --git a/src/main/java/com/phenoml/api/resources/workflows/requests/ListRequest.java b/src/main/java/com/phenoml/api/resources/workflows/requests/ListRequest.java index 89c4b749..29496d9b 100644 --- a/src/main/java/com/phenoml/api/resources/workflows/requests/ListRequest.java +++ b/src/main/java/com/phenoml/api/resources/workflows/requests/ListRequest.java @@ -70,7 +70,6 @@ public static Builder builder() { public static final class Builder { private Optional verbose = Optional.empty(); - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -98,6 +97,14 @@ public ListRequest build() { return new ListRequest(verbose, additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("verbose")) { + return; + } + this.additionalProperties.put(key, value); + } + public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); return this; diff --git a/src/main/java/com/phenoml/api/resources/workflows/requests/UpdateWorkflowRequest.java b/src/main/java/com/phenoml/api/resources/workflows/requests/UpdateWorkflowRequest.java index fc7cd4fe..9371685c 100644 --- a/src/main/java/com/phenoml/api/resources/workflows/requests/UpdateWorkflowRequest.java +++ b/src/main/java/com/phenoml/api/resources/workflows/requests/UpdateWorkflowRequest.java @@ -212,7 +212,6 @@ public static final class Builder private Optional verbose = Optional.empty(); - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -349,6 +348,18 @@ public UpdateWorkflowRequest build() { additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("name") + || key.equalsIgnoreCase("workflow_instructions") + || key.equalsIgnoreCase("sample_data") + || key.equalsIgnoreCase("fhir_provider_id") + || key.equalsIgnoreCase("dynamic_generation")) { + return; + } + this.additionalProperties.put(key, value); + } + @java.lang.Override public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); diff --git a/src/main/java/com/phenoml/api/resources/workflows/types/CreateWorkflowResponse.java b/src/main/java/com/phenoml/api/resources/workflows/types/CreateWorkflowResponse.java index ad271b7d..8f4ce525 100644 --- a/src/main/java/com/phenoml/api/resources/workflows/types/CreateWorkflowResponse.java +++ b/src/main/java/com/phenoml/api/resources/workflows/types/CreateWorkflowResponse.java @@ -132,7 +132,6 @@ public static final class Builder { private Optional workflowDetails = Optional.empty(); - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -221,6 +220,18 @@ public CreateWorkflowResponse build() { success, message, workflowId, workflow, workflowDetails, additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("success") + || key.equalsIgnoreCase("message") + || key.equalsIgnoreCase("workflow_id") + || key.equalsIgnoreCase("workflow") + || key.equalsIgnoreCase("workflow_details")) { + return; + } + this.additionalProperties.put(key, value); + } + public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); return this; diff --git a/src/main/java/com/phenoml/api/resources/workflows/types/DecisionNodeDefinition.java b/src/main/java/com/phenoml/api/resources/workflows/types/DecisionNodeDefinition.java index 6c53ac2d..7f95f869 100644 --- a/src/main/java/com/phenoml/api/resources/workflows/types/DecisionNodeDefinition.java +++ b/src/main/java/com/phenoml/api/resources/workflows/types/DecisionNodeDefinition.java @@ -102,7 +102,6 @@ public static final class Builder { private Optional> runtimePlaceholders = Optional.empty(); - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -160,6 +159,16 @@ public DecisionNodeDefinition build() { return new DecisionNodeDefinition(expression, paths, runtimePlaceholders, additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("expression") + || key.equalsIgnoreCase("paths") + || key.equalsIgnoreCase("runtime_placeholders")) { + return; + } + this.additionalProperties.put(key, value); + } + public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); return this; diff --git a/src/main/java/com/phenoml/api/resources/workflows/types/DeleteResponse.java b/src/main/java/com/phenoml/api/resources/workflows/types/DeleteResponse.java index 95ad9a79..ee2d8301 100644 --- a/src/main/java/com/phenoml/api/resources/workflows/types/DeleteResponse.java +++ b/src/main/java/com/phenoml/api/resources/workflows/types/DeleteResponse.java @@ -78,7 +78,6 @@ public static final class Builder { private Optional message = Optional.empty(); - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -115,6 +114,14 @@ public DeleteResponse build() { return new DeleteResponse(success, message, additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("success") || key.equalsIgnoreCase("message")) { + return; + } + this.additionalProperties.put(key, value); + } + public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); return this; diff --git a/src/main/java/com/phenoml/api/resources/workflows/types/ExecuteWorkflowResponse.java b/src/main/java/com/phenoml/api/resources/workflows/types/ExecuteWorkflowResponse.java index ed65d2dd..439bfdbb 100644 --- a/src/main/java/com/phenoml/api/resources/workflows/types/ExecuteWorkflowResponse.java +++ b/src/main/java/com/phenoml/api/resources/workflows/types/ExecuteWorkflowResponse.java @@ -114,7 +114,6 @@ public static final class Builder { private Optional preview = Optional.empty(); - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -184,6 +183,17 @@ public ExecuteWorkflowResponse build() { return new ExecuteWorkflowResponse(success, message, results, preview, additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("success") + || key.equalsIgnoreCase("message") + || key.equalsIgnoreCase("results") + || key.equalsIgnoreCase("preview")) { + return; + } + this.additionalProperties.put(key, value); + } + public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); return this; diff --git a/src/main/java/com/phenoml/api/resources/workflows/types/ExecuteWorkflowResponseResults.java b/src/main/java/com/phenoml/api/resources/workflows/types/ExecuteWorkflowResponseResults.java index ca3cd4b0..0419a4d4 100644 --- a/src/main/java/com/phenoml/api/resources/workflows/types/ExecuteWorkflowResponseResults.java +++ b/src/main/java/com/phenoml/api/resources/workflows/types/ExecuteWorkflowResponseResults.java @@ -71,7 +71,6 @@ public static Builder builder() { public static final class Builder { private Optional> steps = Optional.empty(); - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -99,6 +98,14 @@ public ExecuteWorkflowResponseResults build() { return new ExecuteWorkflowResponseResults(steps, additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("steps")) { + return; + } + this.additionalProperties.put(key, value); + } + public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); return this; diff --git a/src/main/java/com/phenoml/api/resources/workflows/types/GetResponse.java b/src/main/java/com/phenoml/api/resources/workflows/types/GetResponse.java index d631bd5a..4a6a2159 100644 --- a/src/main/java/com/phenoml/api/resources/workflows/types/GetResponse.java +++ b/src/main/java/com/phenoml/api/resources/workflows/types/GetResponse.java @@ -96,7 +96,6 @@ public static final class Builder { private Optional workflowDetails = Optional.empty(); - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -148,6 +147,16 @@ public GetResponse build() { return new GetResponse(success, workflow, workflowDetails, additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("success") + || key.equalsIgnoreCase("workflow") + || key.equalsIgnoreCase("workflow_details")) { + return; + } + this.additionalProperties.put(key, value); + } + public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); return this; diff --git a/src/main/java/com/phenoml/api/resources/workflows/types/Lang2FhirCreateDefinition.java b/src/main/java/com/phenoml/api/resources/workflows/types/Lang2FhirCreateDefinition.java index fa1beb89..a1274933 100644 --- a/src/main/java/com/phenoml/api/resources/workflows/types/Lang2FhirCreateDefinition.java +++ b/src/main/java/com/phenoml/api/resources/workflows/types/Lang2FhirCreateDefinition.java @@ -118,7 +118,6 @@ public static final class Builder { private Optional> runtimePlaceholders = Optional.empty(); - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -192,6 +191,17 @@ public Lang2FhirCreateDefinition build() { originalDescription, resourceType, resourceTemplate, runtimePlaceholders, additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("original_description") + || key.equalsIgnoreCase("resource_type") + || key.equalsIgnoreCase("resource_template") + || key.equalsIgnoreCase("runtime_placeholders")) { + return; + } + this.additionalProperties.put(key, value); + } + public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); return this; diff --git a/src/main/java/com/phenoml/api/resources/workflows/types/Lang2FhirSearchDefinition.java b/src/main/java/com/phenoml/api/resources/workflows/types/Lang2FhirSearchDefinition.java index 80f58f68..c722519c 100644 --- a/src/main/java/com/phenoml/api/resources/workflows/types/Lang2FhirSearchDefinition.java +++ b/src/main/java/com/phenoml/api/resources/workflows/types/Lang2FhirSearchDefinition.java @@ -138,7 +138,6 @@ public static final class Builder { private Optional> runtimePlaceholders = Optional.empty(); - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -232,6 +231,18 @@ public Lang2FhirSearchDefinition build() { additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("original_query") + || key.equalsIgnoreCase("resource_type") + || key.equalsIgnoreCase("search_parameters") + || key.equalsIgnoreCase("required_fields") + || key.equalsIgnoreCase("runtime_placeholders")) { + return; + } + this.additionalProperties.put(key, value); + } + public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); return this; diff --git a/src/main/java/com/phenoml/api/resources/workflows/types/ListWorkflowsResponse.java b/src/main/java/com/phenoml/api/resources/workflows/types/ListWorkflowsResponse.java index 298d9ad9..bd48dcc3 100644 --- a/src/main/java/com/phenoml/api/resources/workflows/types/ListWorkflowsResponse.java +++ b/src/main/java/com/phenoml/api/resources/workflows/types/ListWorkflowsResponse.java @@ -118,7 +118,6 @@ public static final class Builder { private Optional> workflowDetails = Optional.empty(); - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -191,6 +190,17 @@ public ListWorkflowsResponse build() { return new ListWorkflowsResponse(success, message, workflows, workflowDetails, additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("success") + || key.equalsIgnoreCase("message") + || key.equalsIgnoreCase("workflows") + || key.equalsIgnoreCase("workflow_details")) { + return; + } + this.additionalProperties.put(key, value); + } + public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); return this; diff --git a/src/main/java/com/phenoml/api/resources/workflows/types/StepOperation.java b/src/main/java/com/phenoml/api/resources/workflows/types/StepOperation.java index 4f0faed4..07d7706c 100644 --- a/src/main/java/com/phenoml/api/resources/workflows/types/StepOperation.java +++ b/src/main/java/com/phenoml/api/resources/workflows/types/StepOperation.java @@ -106,7 +106,6 @@ public static final class Builder { private Optional decisionDefinition = Optional.empty(); - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -168,6 +167,17 @@ public StepOperation build() { searchDefinition, createDefinition, workflowDefinition, decisionDefinition, additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("search_definition") + || key.equalsIgnoreCase("create_definition") + || key.equalsIgnoreCase("workflow_definition") + || key.equalsIgnoreCase("decision_definition")) { + return; + } + this.additionalProperties.put(key, value); + } + public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); return this; diff --git a/src/main/java/com/phenoml/api/resources/workflows/types/SubWorkflowDefinition.java b/src/main/java/com/phenoml/api/resources/workflows/types/SubWorkflowDefinition.java index ca33b910..7feae12b 100644 --- a/src/main/java/com/phenoml/api/resources/workflows/types/SubWorkflowDefinition.java +++ b/src/main/java/com/phenoml/api/resources/workflows/types/SubWorkflowDefinition.java @@ -102,7 +102,6 @@ public static final class Builder { private Optional> runtimePlaceholders = Optional.empty(); - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -160,6 +159,16 @@ public SubWorkflowDefinition build() { return new SubWorkflowDefinition(workflowId, input, runtimePlaceholders, additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("workflow_id") + || key.equalsIgnoreCase("input") + || key.equalsIgnoreCase("runtime_placeholders")) { + return; + } + this.additionalProperties.put(key, value); + } + public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); return this; diff --git a/src/main/java/com/phenoml/api/resources/workflows/types/UpdateResponse.java b/src/main/java/com/phenoml/api/resources/workflows/types/UpdateResponse.java index 0a4b085e..77fd3f60 100644 --- a/src/main/java/com/phenoml/api/resources/workflows/types/UpdateResponse.java +++ b/src/main/java/com/phenoml/api/resources/workflows/types/UpdateResponse.java @@ -111,7 +111,6 @@ public static final class Builder { private Optional workflowDetails = Optional.empty(); - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -178,6 +177,17 @@ public UpdateResponse build() { return new UpdateResponse(success, message, workflow, workflowDetails, additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("success") + || key.equalsIgnoreCase("message") + || key.equalsIgnoreCase("workflow") + || key.equalsIgnoreCase("workflow_details")) { + return; + } + this.additionalProperties.put(key, value); + } + public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); return this; diff --git a/src/main/java/com/phenoml/api/resources/workflows/types/WorkflowConfig.java b/src/main/java/com/phenoml/api/resources/workflows/types/WorkflowConfig.java index 00ad5169..ef38b882 100644 --- a/src/main/java/com/phenoml/api/resources/workflows/types/WorkflowConfig.java +++ b/src/main/java/com/phenoml/api/resources/workflows/types/WorkflowConfig.java @@ -87,7 +87,6 @@ public static final class Builder { private Optional dynamicGeneration = Optional.empty(); - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -130,6 +129,14 @@ public WorkflowConfig build() { return new WorkflowConfig(fhirProviderIds, dynamicGeneration, additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("fhir_provider_ids") || key.equalsIgnoreCase("dynamic_generation")) { + return; + } + this.additionalProperties.put(key, value); + } + public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); return this; diff --git a/src/main/java/com/phenoml/api/resources/workflows/types/WorkflowDefinition.java b/src/main/java/com/phenoml/api/resources/workflows/types/WorkflowDefinition.java index ef307aa0..e5945744 100644 --- a/src/main/java/com/phenoml/api/resources/workflows/types/WorkflowDefinition.java +++ b/src/main/java/com/phenoml/api/resources/workflows/types/WorkflowDefinition.java @@ -180,7 +180,6 @@ public static final class Builder { private Optional updatedAt = Optional.empty(); - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -316,6 +315,21 @@ public WorkflowDefinition build() { additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("id") + || key.equalsIgnoreCase("name") + || key.equalsIgnoreCase("workflow_instructions") + || key.equalsIgnoreCase("sample_data") + || key.equalsIgnoreCase("config") + || key.equalsIgnoreCase("graph") + || key.equalsIgnoreCase("created_at") + || key.equalsIgnoreCase("updated_at")) { + return; + } + this.additionalProperties.put(key, value); + } + public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); return this; diff --git a/src/main/java/com/phenoml/api/resources/workflows/types/WorkflowGraph.java b/src/main/java/com/phenoml/api/resources/workflows/types/WorkflowGraph.java index f5ec733d..7b04bf73 100644 --- a/src/main/java/com/phenoml/api/resources/workflows/types/WorkflowGraph.java +++ b/src/main/java/com/phenoml/api/resources/workflows/types/WorkflowGraph.java @@ -71,7 +71,6 @@ public static Builder builder() { public static final class Builder { private Optional> steps = Optional.empty(); - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -99,6 +98,14 @@ public WorkflowGraph build() { return new WorkflowGraph(steps, additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("steps")) { + return; + } + this.additionalProperties.put(key, value); + } + public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); return this; diff --git a/src/main/java/com/phenoml/api/resources/workflows/types/WorkflowResponse.java b/src/main/java/com/phenoml/api/resources/workflows/types/WorkflowResponse.java index 89adf897..64556b34 100644 --- a/src/main/java/com/phenoml/api/resources/workflows/types/WorkflowResponse.java +++ b/src/main/java/com/phenoml/api/resources/workflows/types/WorkflowResponse.java @@ -180,7 +180,6 @@ public static final class Builder { private Optional updatedAt = Optional.empty(); - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -316,6 +315,21 @@ public WorkflowResponse build() { additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("id") + || key.equalsIgnoreCase("name") + || key.equalsIgnoreCase("workflow_instructions") + || key.equalsIgnoreCase("sample_data") + || key.equalsIgnoreCase("config") + || key.equalsIgnoreCase("graph") + || key.equalsIgnoreCase("created_at") + || key.equalsIgnoreCase("updated_at")) { + return; + } + this.additionalProperties.put(key, value); + } + public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); return this; diff --git a/src/main/java/com/phenoml/api/resources/workflows/types/WorkflowResponseGraph.java b/src/main/java/com/phenoml/api/resources/workflows/types/WorkflowResponseGraph.java index db051353..4f5061e7 100644 --- a/src/main/java/com/phenoml/api/resources/workflows/types/WorkflowResponseGraph.java +++ b/src/main/java/com/phenoml/api/resources/workflows/types/WorkflowResponseGraph.java @@ -71,7 +71,6 @@ public static Builder builder() { public static final class Builder { private Optional> steps = Optional.empty(); - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -99,6 +98,14 @@ public WorkflowResponseGraph build() { return new WorkflowResponseGraph(steps, additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("steps")) { + return; + } + this.additionalProperties.put(key, value); + } + public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); return this; diff --git a/src/main/java/com/phenoml/api/resources/workflows/types/WorkflowStep.java b/src/main/java/com/phenoml/api/resources/workflows/types/WorkflowStep.java index 9a7e8871..fbdf855e 100644 --- a/src/main/java/com/phenoml/api/resources/workflows/types/WorkflowStep.java +++ b/src/main/java/com/phenoml/api/resources/workflows/types/WorkflowStep.java @@ -183,7 +183,6 @@ public static final class Builder { private Optional dynamicGeneration = Optional.empty(); - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -322,6 +321,21 @@ public WorkflowStep build() { additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("id") + || key.equalsIgnoreCase("name") + || key.equalsIgnoreCase("description") + || key.equalsIgnoreCase("type") + || key.equalsIgnoreCase("operation") + || key.equalsIgnoreCase("provider_id") + || key.equalsIgnoreCase("dependencies") + || key.equalsIgnoreCase("dynamic_generation")) { + return; + } + this.additionalProperties.put(key, value); + } + public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); return this; diff --git a/src/main/java/com/phenoml/api/resources/workflows/types/WorkflowStepSummary.java b/src/main/java/com/phenoml/api/resources/workflows/types/WorkflowStepSummary.java index c10ea0c3..a4511a4f 100644 --- a/src/main/java/com/phenoml/api/resources/workflows/types/WorkflowStepSummary.java +++ b/src/main/java/com/phenoml/api/resources/workflows/types/WorkflowStepSummary.java @@ -132,7 +132,6 @@ public static final class Builder { private Optional providerId = Optional.empty(); - @JsonAnySetter private Map additionalProperties = new HashMap<>(); private Builder() {} @@ -220,6 +219,18 @@ public WorkflowStepSummary build() { return new WorkflowStepSummary(id, name, description, type, providerId, additionalProperties); } + @JsonAnySetter + private void putAdditionalPropertyFromJson(String key, Object value) { + if (key.equalsIgnoreCase("id") + || key.equalsIgnoreCase("name") + || key.equalsIgnoreCase("description") + || key.equalsIgnoreCase("type") + || key.equalsIgnoreCase("provider_id")) { + return; + } + this.additionalProperties.put(key, value); + } + public Builder additionalProperty(String key, Object value) { this.additionalProperties.put(key, value); return this; diff --git a/src/main/resources/openapi/openapi.json b/src/main/resources/openapi/openapi.json index 0a1be3f1..11469111 100644 --- a/src/main/resources/openapi/openapi.json +++ b/src/main/resources/openapi/openapi.json @@ -2,7 +2,7 @@ "openapi": "3.0.3", "info": { "title": "Phenoml API", - "version": "49f1258330df448935b5304a1d8c405a2834d227" + "version": "aa8767e4b2d618273a8ffe13773f517f18a4d3fa" }, "x-services": [ { @@ -3266,7 +3266,7 @@ "post": { "operationId": "fhir2omop_create", "summary": "Map FHIR resources to OMOP CDM v5.4", - "description": "Maps a FHIR R4 resource or Bundle into OMOP Common Data Model v5.4 rows,\ngrouped by destination table in `tables`.\n\nSet `vocab_version` to select the OMOP vocabulary release used for coded\nconcept resolution. If omitted or empty, the API uses its default\nrelease. The response's `vocab_version`, when present, identifies the\nrelease used. Specify a release explicitly when reproducibility matters.\n\nStandards basis: [FHIR R4 (v4.0.1)](https://hl7.org/fhir/R4/) defines\nthe accepted source elements and [OMOP CDM\nv5.4](https://ohdsi.github.io/CommonDataModel/cdm54.html) defines the\noutput columns. The published [Vulcan FHIR-to-OMOP IG\nv1.0.0](https://hl7.org/fhir/uv/omop/) is an informative FHIR R5\nbaseline; this endpoint documents and implements the equivalent R4\nsource elements, rather than accepting R5-only fields.\n\nThis response is a mapping result, not a complete CDM load pipeline.\nWhen a source cannot supply a field that CDM v5.4 requires, the row is\nstill returned with that field unset; the value is not inferred. Common\ncases are `year_of_birth` without a usable `birthDate`,\n`drug_exposure_end_date` without an explicit end or single-event timing,\nand a required event date (such as `condition_start_date`,\n`procedure_date`, or `death_date`) whose source has no timing with at\nleast day precision. Apply your own policy to such rows before loading\nthem into a strictly conformant CDM instance.\n\nCurrent resource coverage:\n- `Patient` -> `person`; `deceased[x]` can also produce `death`, the\n first address can produce `location`, and more than one supplied race\n produces `observation` race rows (see Patient demographics below)\n- `observation_period` -> one request-local derived row per person,\n spanning the populated dates of that person's visit, clinical, and\n death rows; this is not enrollment or capture-completeness evidence\n- `Location` -> `location` and `care_site`\n- `Organization` -> `care_site`; its first address can produce `location`\n- `HealthcareService` -> `care_site`\n- `Practitioner` and `PractitionerRole` -> `provider`\n- `Encounter` -> `visit_occurrence`\n- `Condition` -> `condition_occurrence`\n- `Procedure` -> `procedure_occurrence`\n- `MedicationRequest`, `MedicationStatement`, and\n `MedicationAdministration` -> `drug_exposure`\n- `Immunization` -> `drug_exposure`\n- `Observation` -> `measurement` or `observation`. For coded\n Observations, the resolved OMOP concept domain selects the table; value\n form only breaks ties. For text-only Observations, numeric values route\n to `measurement` and nonnumeric values to `observation`.\n- `AllergyIntolerance` -> `observation`\n\n`Medication` is reference data for medication resources; it does not\ncreate its own row because OMOP CDM has no Medication table. Administrative\nlinkages (provider, care site, and location) are best-effort and limited to\nreferences supplied in the request. `Patient.managingOrganization` is a\nrecord custodian, not a care-delivery site. Provider specialty is not\nmapped. Recorded `Practitioner.gender` is distinct from Person\ndemographics: `male` and `female` resolve to validated OMOP Gender\nconcepts in `provider.gender_concept_id`; `other`, `unknown`, and absent\ngender remain unmapped. `Address.country` is resolved to `location.country_concept_id`,\nand CMS Place of Service codings in `Location.type` are resolved to\n`care_site.place_of_service_concept_id`.\nA `PractitionerRole` that identifies one supplied `Practitioner` aliases\nthat canonical provider: by a top-level structural reference, a\nparent-contained `#id` reference, or an exact `identifier.system` and\n`identifier.value` match against a top-level Practitioner. No remote\nidentifier lookup is performed. When `Reference.type` is present it must\nbe `Practitioner`; duplicate contained IDs and identifier matches are\nambiguous. An explicit reference that is unresolved, ambiguous, or\nunsupported retains a role-fallback provider row and is returned in\n`diagnostics`. `provider_role_contexts` preserves role-specific\nspecialty and care-site context that a canonical OMOP provider row cannot\nrepresent together.\n\nPatient demographics:\n- Sex at birth, race, and ethnicity are read from these US Core\n extensions, with their US Core 6.1.0 structures and value sets, on\n any Patient (US Core profile conformance is not required). Sex at\n birth falls back to Patient `gender`. Other extensions, including US\n Core sex and gender identity, are ignored.\n - Birth sex:\n `http://hl7.org/fhir/us/core/StructureDefinition/us-core-birthsex`\n (`valueCode` from `http://hl7.org/fhir/us/core/ValueSet/birthsex`)\n - Race:\n `http://hl7.org/fhir/us/core/StructureDefinition/us-core-race`\n (`ombCategory` from\n `http://hl7.org/fhir/us/core/ValueSet/omb-race-category`)\n - Ethnicity:\n `http://hl7.org/fhir/us/core/StructureDefinition/us-core-ethnicity`\n (`ombCategory` from\n `http://hl7.org/fhir/us/core/ValueSet/omb-ethnicity-category`)\n- `gender_concept_id` is sex at birth. A supplied birth sex always\n decides it: `M` and `F` are resolved; `UNK`, `ASKU`, `OTH`, a code\n outside the value set, conflicting values, and a birth sex without\n `valueCode` leave it `0`, and Patient `gender` is not used.\n- Without a birth sex, Patient `gender` `male` or `female` is resolved\n under the OMOP convention that the supplied gender represents sex at\n birth; `other` and `unknown` keep concept `0`.\n- `gender_source_value` is the chosen source code (`F` for birth sex,\n `female` for Patient `gender`).\n- Each race category is resolved separately, and null flavors (`UNK`,\n `ASKU`) are ignored. One distinct standard race sets\n `race_concept_id`. More than one sets it to `1546847` (More than one\n race) and adds one `observation` row per race, with\n `observation_concept_id` `4013886` (Race), the race in\n `value_as_concept_id`, `observation_type_concept_id` `32817`, the\n category code in `value_source_value`, and no\n `observation_source_value` or `observation_date`. A loading pipeline\n that requires `observation_date` must apply its own date policy.\n- The single non-null ethnicity category is resolved, and null flavors\n are ignored; more than one distinct category leaves\n `ethnicity_concept_id` `0`. Ethnicity is not derived from race, and\n no demographic is inferred from names, addresses, or other\n extensions.\n- `race_source_value` and `ethnicity_source_value` list every supplied\n category and detailed code in source order, joined with `|`, or the\n extension text when no code is supplied. Detailed codes and text are\n not resolved.\n- Every supplied birth sex, gender, and OMB category code has a\n `mappings` entry whose `note` names its source and outcome. When a\n birth sex is supplied, Patient `gender` is reported unselected with\n the note `FHIR administrative gender; not used, birth sex supplied`.\n Conflicting values and a birth sex without `valueCode` are also\n returned in `diagnostics` with path `extension:birthsex` or\n `extension:ethnicity`. `summary` counts each demographic field once,\n as described under `Summary`.\n- Differences from the reference conventions: Vulcan's example gender\n ConceptMap maps `other` and `unknown` to concepts, which stay `0`\n here; more than one race follows the OHDSI THEMIS convention, also\n used by Vulcan, rather than the CDM 5.4 note that mixed races use\n `0`; and Vulcan's suggested `observation` rows for multiple\n ethnicities are not produced.\n\n`DiagnosticReport`, `ServiceRequest`, `CarePlan`, `DocumentReference`,\n`Composition`, `Specimen`, `DeviceUseStatement`, `Coverage`, `Claim`, and\nother unsupported resource types are accepted in a Bundle but ignored: they\ncreate no row and no `dropped` entry. `dropped` is reserved for supported\nrow-producing resources that cannot safely produce a positive OMOP row:\nrequired subject/patient, clinical code/text, or medication data may be\nunusable, or the resource may explicitly negate or fail the documented\nclinical-event eligibility policy. A single-Patient Bundle uses the sole\nPatient only when `subject`/`patient` is absent. An explicit subject/patient\nreference that is unresolved, ambiguous, or unsupported drops the clinical\nresource in every request scope.\n\nEligibility distinguishes clinical validity, performed/taken evidence, and\nadministrative workflow state. Checks run before terminology resolution;\nentered-in-error and explicitly non-performed events never produce a positive\nclinical-event row, and an unrecognized required status fails closed. Condition\nrequires absent or confirmed canonical-HL7 `verificationStatus`; clinical course is not a\ndiagnosis-role mapping. Procedures accept completed or stopped events.\nMedication requests are prescription evidence only: `doNotPerform`, drafts,\ncancellations, and non-authorizing intents are dropped. Medication statements\naccept active, completed, stopped, or on-hold reported use; administrations\naccept completed, in-progress, on-hold, or stopped events. An on-hold\nadministration also needs an `effectiveDateTime` or `effectivePeriod.start`\nthat supplies start evidence; a valid partial date remains an undated row.\nAn on-hold administration is started evidence that is temporarily paused and\nexpected to continue. Immunizations require completed\nstatus. Observations require final,\namended, or corrected status; registered,\npreliminary, cancelled, entered-in-error, unknown, and missing statuses are\ndropped. AllergyIntolerance accepts absent, unconfirmed, or confirmed canonical-HL7\n`verificationStatus` as a reported allergy, but drops refuted,\nentered-in-error, and unreadable supplied verification statuses. Encounter\naccepts arrived, triaged, in-progress, onleave, or finished status, but drops\nplanned, cancelled, entered-in-error, unknown, and missing statuses. An eligible\nencounter without `Period.end` remains a partial visit; no end date is invented.\n\nCoded Observation routing is selected from the resolved OMOP concept\ndomain. Numeric and nonnumeric `value[x]` forms establish the preferred\ntarget only when the code is valid for both tables. A text-only\nObservation has no resolver target, so numeric values route to\n`measurement` and nonnumeric values to `observation`. Numeric values\npopulate `value_as_number` in the selected row; other non-coded values\npopulate `value_as_string` for an `observation` or `value_source_value`\nfor a `measurement`. Coded `valueCodeableConcept` values are resolved\nagainst the selected row's `value_as_concept_id` and use the selected\nbare code in `value_source_value`, leaving an `observation`'s\n`value_as_string` empty; unmapped or target-invalid coded values remain\n`0`.\nOther unsupported `value[x]` forms and Observation components do not\npopulate separate converted values. A\nnumeric comparator (`<`, `<=`, `>`, `>=`) is represented only by a\nmeasurement's `operator_concept_id`; units remain source text and have\n`unit_concept_id` of `0`.\n\nA standard OMOP `concept_id` is selected for each eligible primary clinical coding\nafter considering all of the resource's supplied codings. An unambiguous\ncoded medication route is resolved independently to\n`drug_exposure.route_concept_id`. Alongside the OMOP rows grouped by\ntable (`tables`), the response carries `mappings` (an entry for every\nsupported source coding from a resource that shaped a row, linked back to\nthat row; some, such\nas demographic null flavors, are reported without being resolved),\n`provider_role_contexts` (source role details linked to provider rows),\n`dropped` (resources that could not be shaped into a row),\n`diagnostics` (explicit references that could not safely create a link,\nand conflicting or unsupported Patient demographic extensions),\n`vocab_version` (the OMOP vocabulary release codes were resolved\nagainst), and a small `summary` of the resolution outcomes.\n\nA `concept_id` of `0` is reported, not omitted (OMOP \"no matching\nconcept\" semantics): it covers both a coding with no standard match\n(`UNMAPPED`) and an unverified suggestion for a text-only resource\n(`UNCHECKED`). Visit and unit concept fields currently remain `0`;\nPerson demographics and Provider recorded gender follow the policies\nabove. Coded Observation values may populate `value_as_concept_id`.\nConcepts set by a fixed convention rather than terminology resolution\nare measurement `operator_concept_id`, set from a value comparator (`<`,\n`<=`, `>`, `>=`), and the multiple-race concepts described above. Clinical\n`*_source_value` fields contain the selected FHIR code (or source text\nfor text-only resources).\nThe corresponding selected `mappings` entry preserves the coding system\nand full source-coding provenance.\nKnown OID-form coding systems are accepted as either FHIR OID URNs (for\nexample, `urn:oid:2.16.840.1.113883.6.1` for LOINC) or bare OIDs, and\nare normalized to their canonical system URLs before terminology\nresolution. `mappings[].source_system` reports that canonical URL, so the\nOID and URL forms produce the same mapping. An unknown OID is not\nrewritten and may be `UNMAPPED`.\nOther `*_source_value` fields preserve row-specific raw source values,\nsuch as resource identifiers, names, units, or status codes.\n`MedicationRequest` uses `32838` (EHR prescription) for\n`drug_type_concept_id`; other current resources use `32817` (EHR). This\nis a coarse provenance policy: it does not infer patient-reported,\nmedication-history, or other more-specific type concepts from FHIR\nstatus fields.\n\nDates and datetimes:\n- A `*_date` is the calendar date (`YYYY-MM-DD`) of a source value with\n at least day precision. A `*_datetime` is set only when that value\n has a time of day, as local time without a UTC offset\n (`YYYY-MM-DDTHH:MM:SS`, with fractional seconds to microseconds when\n supplied): `2024-01-15T23:30:00-05:00` becomes `2024-01-15T23:30:00`.\n A datetime without a timezone is read as local time.\n- A partial date (`2024` or `2024-03`) leaves both columns unset. It\n still counts as that source's value, so later sources in the list\n below are not used.\n- Free-text timing (such as `onsetString`), `Age` and `Range` forms, and\n values that are not dates are ignored, so a later source in the list\n is used if there is one.\n- Other than the sources below and the same-day ends of single-event\n medication records, no date is imputed: partial dates are not\n completed, and a row is not dated from another resource such as its\n Encounter.\n- Valid date values never cause a request to be rejected, and the response\n does not report which date elements were unusable. An on-hold\n MedicationAdministration is an eligibility exception: it needs a supplied\n effective start as evidence that administration began.\n\nTiming sources, in priority order where several are listed:\n- `Patient`: `birthDate` sets `year_of_birth`, `month_of_birth`, and\n `day_of_birth` from the parts it supplies; `birth_datetime` is not\n set. `deceasedDateTime` sets the `death` dates.\n- `Practitioner`: `birthDate` sets the provider's `year_of_birth`.\n- `Encounter`: `period.start` and `period.end`.\n- `Condition`: start from `onsetDateTime` or `onsetPeriod.start`, then\n `recordedDate` (when the condition was recorded, not when it began);\n end from `abatementDateTime` or `abatementPeriod.end`.\n- `Procedure`: start from `performedDateTime` or\n `performedPeriod.start`; end from `performedPeriod.end` only.\n- `Observation`: `effectiveDateTime`, `effectivePeriod.start`, or\n `effectiveInstant`.\n- `AllergyIntolerance`: `recordedDate`, then `onsetDateTime` or\n `onsetPeriod.start`.\n- `MedicationStatement`: start from `effectiveDateTime` or\n `effectivePeriod.start`; end and `verbatim_end_date` from\n `effectivePeriod.end`. `dateAsserted` records when the statement was\n made and is not used.\n- `MedicationAdministration`: an `effectiveDateTime` is a single event\n that sets both start and end; an `effectivePeriod` sets the start\n and, when present, the end and `verbatim_end_date`.\n- `Immunization`: `occurrenceDateTime` is a single event that sets both\n start and end; `expirationDate` is not used.\n- `MedicationRequest`: `authoredOn`, the order date, sets the start; it\n is not evidence of administration. No end is set, and the validity\n period is not used as an exposure duration.\n- Differences from the Vulcan maps: `birth_datetime` is not set from\n `birthDate`, Condition also reads `onsetPeriod.start`, and\n AllergyIntolerance falls back to its onset when `recordedDate` is\n missing.\n\nMedication details:\n- For `MedicationRequest`, `dispenseRequest.numberOfRepeatsAllowed` sets\n `refills` and a whole-day `expectedSupplyDuration` sets `days_supply`.\n- All non-empty dosage text is preserved in `sig`.\n- Coded dosage routes and `Immunization.route` are target-validated in\n the OMOP Route domain. Conflicting routes are left unset; route\n codings shared by every dosage instruction identify the same route.\n- `Immunization.lotNumber` is preserved in `lot_number`.\n\nMedication codes are resolved whether they appear inline\n(`medicationCodeableConcept`) or via a `medicationReference` to a contained,\nrelative (`Type/id`), or bundle-entry (`urn:uuid`) `Medication` resource.\nA reference that resolves to another resource type is dropped even when it\nsupplies display text; an unresolved or display-only reference may use\nits display as text-only medication input.\nResources that cannot be shaped into a row \u2014 a medication with no usable\ncode, resolvable reference, or display; any clinical resource whose\nsubject/patient reference cannot be tied to a person; or an event excluded\nby eligibility \u2014 are reported under `dropped` rather than emitted as blank\nrows. The Bundle must contain at least one Patient resource.\n\nStructural references resolve only to top-level resources supplied in the\nrequest, by `Type/id` or an exactly matching Bundle `fullUrl` (including\n`urn:uuid`). Contained references are supported for medication code lookup\nand `PractitionerRole.practitioner` enrichment; the latter also supports\nexact request-local identifier matching without a remote lookup. Every\nnonzero structural foreign key targets a row in the same response. Missing\noptional links remain unset without a diagnostic; an explicit optional\nreference that is unresolved, ambiguous, conflicting with the row's\nperson, or unsupported remains unset and is returned in `diagnostics`\nwith its source path and outcome.\n\nAll row IDs start at `1` for each request and are not stable or global.\nFor clinical conversion rows whose resource supplies an `id`, `mappings`\nassociates each row with that source FHIR resource ID. A `person` row\nretains the Patient ID or its first identifier value in\n`person_source_value`, when present; other reference and derived rows do\nnot uniformly carry a FHIR resource ID. Input resources without those\nsource identifiers cannot be correlated across responses from the\nreturned rows alone. Consumers combining responses need to establish\ntheir own stable keys and remap every primary and foreign key together.\n", + "description": "Maps a FHIR R4 resource or Bundle into OMOP Common Data Model v5.4 rows,\ngrouped by destination table in `tables`. Uncertain-status and unresolved-\nprimary-concept clinical rows stay in those tables and are identified by\nthe optional `rows_for_review` index for consumer review and filtering.\nStatus-based retention covers uncertain supplied Condition verification,\nunknown/missing/unreadable Encounter status, and explicit `unknown` status\non MedicationStatement, Observation, and Procedure. Other status exclusions\nfollow the resource-specific eligibility policy below.\n\nSet `vocab_version` to select the OMOP vocabulary release used for coded\nand inferred concept resolution. If omitted or empty, the API uses its\ndefault release. The response's `vocab_version`, when present, identifies\nthe release used. Specify a release explicitly when reproducibility matters.\n\nSupported concept fields use semantic mapping for text-only inputs and\nunresolved source codings, with whole-term equivalence validation.\nCodeableConcept text takes precedence over coding display. A single\nequivalent suggestion must be validated before a real OMOP concept ID is\nreturned. The suggested vocabulary/code must\nitself be an active standard concept valid for the destination; broader\ncrosswalk targets are not accepted for inferred suggestions.\nDeterministic mappings take precedence; ambiguous or domain-mismatched\ndeterministic results are not reinterpreted. Units, routes, visits,\ndemographics, and collection sites use their field-specific resolution\npolicies. Semantic mapping returns MAPPED with a real concept ID on\nsuccess, or UNMAPPED with concept ID 0 when no standard concept is\nselected. A bare code is not treated as a description. No source coding\nis invented.\n\nContextual allergy finding decisions follow their dedicated policy and\nare not replaced by description-based mapping.\n\nEvery inferred mapping has\n`mappings.resolution.semantic_mapping.status: MAPPED`, with the OMOP\nvocabulary release. Filter linked rows using the mapping's table/row ID\nto exclude inferred results.\nOriginal source coding and clinical-status eligibility are preserved.\nFor clinical rows covered by `rows_for_review`, successful inference\ndoes not remove uncertain-status rows from the index; unresolved primary\nconcepts also remain indexed.\nAll shaped rows appear once in `tables`.\n\nStandards basis: [FHIR R4 (v4.0.1)](https://hl7.org/fhir/R4/) defines\nthe accepted source elements and [OMOP CDM\nv5.4](https://ohdsi.github.io/CommonDataModel/cdm54.html) defines the\noutput columns. The published [Vulcan FHIR-to-OMOP IG\nv1.0.0](https://hl7.org/fhir/uv/omop/) is an informative FHIR R5\nbaseline; this endpoint documents and implements the equivalent R4\nsource elements, rather than accepting R5-only fields.\n\nThis response is a mapping result, not a complete CDM load pipeline.\nWhen a source cannot supply a field that CDM v5.4 requires, the row is\nstill returned with that field unset; the value is not inferred. Common\ncases are `year_of_birth` without a usable `birthDate`,\n`drug_exposure_end_date` without an explicit end or single-event timing,\nand a required event date (such as `condition_start_date`,\n`procedure_date`, or `death_date`) whose source has no timing with at\nleast day precision. Apply your own policy to such rows before loading\nthem into a strictly conformant CDM instance.\n\nCurrent resource coverage:\n- `Patient` -> `person`; `deceased[x]` can also produce `death`, the\n first address can produce `location`, and more than one supplied race\n produces `observation` race rows (see Patient demographics below)\n- `observation_period` -> one request-local derived row per person,\n spanning dates from that person's visit and clinical rows not listed\n in `rows_for_review`, plus Specimen and Death rows. Source end dates\n contribute even when the output table has no end column. No period\n row is returned for a person without qualifying dates; this is not\n enrollment or capture-completeness evidence\n- `Location` -> `location` and `care_site`\n- `Organization` -> `care_site`; its first address can produce `location`\n- `HealthcareService` -> `care_site`\n- `Practitioner` and `PractitionerRole` -> `provider`\n- `Encounter` -> `visit_occurrence`\n- `Specimen` -> `specimen`, with an explicit Patient subject; missing\n collection date or material retains a partial row with diagnostics\n- `DeviceUseStatement` -> `device_exposure`, using referenced `Device.type`\n- `Condition` -> domain-selected clinical table; default `condition_occurrence`\n- `Procedure` -> domain-selected clinical table; default `procedure_occurrence`\n- `MedicationRequest`, `MedicationStatement`, `MedicationAdministration`, and\n `MedicationDispense` -> domain-selected clinical table; default `drug_exposure`\n- `Immunization` -> domain-selected clinical table; default `drug_exposure`\n- `Observation` -> domain-selected clinical table. Numeric values prefer\n `measurement` and other values prefer `observation` when the code is\n valid for both tables.\n If no supported standard concept is selected, numeric values retain the\n `measurement` default and nonnumeric values retain `observation`.\n- `AllergyIntolerance` -> `observation` for supported contextual\n allergy/intolerance mappings; other coded statements use domain-based\n placement excluding `drug_exposure` and `device_exposure`; default `observation`\n\nClinical placement follows the informative Vulcan IG's vocabulary-driven\ndomain assignment. Supported destinations are `condition_occurrence`,\n`procedure_occurrence`, `drug_exposure`, `device_exposure`, `measurement`, and `observation`.\nResolved clinical records are returned in the table corresponding to the\ndomain of their mapped OMOP standard concept. The source resource\ndetermines eligibility, field values, and the default table when no\nsupported mapping is available.\n`AllergyIntolerance` records do not produce `drug_exposure` or\n`device_exposure` rows. Device placement requires eligible use evidence:\nprescriptions, uncertain diagnoses, and explicit negative Observation\nresults (false, SNOMED Negative, Known absent, or No, or interpretation\ncode `NEG` from\n`http://terminology.hl7.org/CodeSystem/v3-ObservationInterpretation`)\ndo not accept Device placement, even when other supplied values are positive.\nA recorder, asserter, or information source is retained as context rather\nthan treated as the device event's provider.\n\nCoded `AllergyIntolerance.code` statements are interpreted using their\noriginal codings, optional `type`, and `category`. Supported substances\nproduce an Observation finding separately from the causative substance\nin `value_as_concept_id`. Drug-domain substances are valid values and do\nnot imply drug exposure or medication allergy. The finding is selected\nby an explicit mapping policy, not a published substance-to-finding\ncrosswalk. Contextual findings report `MAPPED`,\n`resolution.via: CONTEXTUAL`. `observation_source_concept_id` retains\nthe known OMOP identity of the original coding written to\n`observation_source_value`, including when the finding is unresolved;\nit is `0` when no source identity is known. The configured finding is\nnever substituted for the original source concept.\n\nThis adapts the informative R5 [Vulcan Value-as-Concept\npattern](https://hl7.org/fhir/uv/omop/INFORMATIVE1/en/ValueAsConceptPattern.html)\nto R4's substance/product codes, optional `type`, and repeating `category`.\nIt selects a contextual finding and resolves the substance separately;\npublished `Maps to value` decomposition of a composite finding is not performed.\n\nFor `type: allergy`, medication (optionally with biologic) maps to\nAllergy to drug (`439224`); other category sets use Allergic disposition\n(`43530807`). For `type: intolerance`, medication (optionally biologic)\nuses Intolerance to drug (`4240582`), food alone uses Intolerance to food\n(`4340252`), and other sets use Intolerance to substance (`36684107`).\nAll selected concepts must be active and standard in the requested\nvocabulary release. Limited reviewed substance mappings can derive an\nabsent category: for example, peanut and egg extracts supply food context, and\nPenicillin G supplies medication context. No category is inferred solely\nfrom Drug-domain membership.\n\nWith absent `type`, a supported substance maps to a mechanism-neutral\npropensity finding: medication (optionally with biologic) uses Propensity\nto adverse reactions to drug (`4305025`), food alone uses Propensity to\nadverse reactions to food (`4303802`), and other category sets or an\nunknown category use Propensity to adverse reaction (`4172024`). The\noriginal type remains absent; no allergy or intolerance mechanism is\ninferred. The substance value is resolved independently. Existing\nsupported findings can supply their own mechanism and negative\nstatements retain their meaning. Conflicting evidence leaves the\naffected concept unmapped.\nA single identified substance can still populate the value when only\nits category context conflicts. Distinct conflicting substances,\npositive/negative mixtures, finding/substance mixtures, and multiple\nstandard substance targets do not select a substance value by input\norder. Equivalent supported translations may share one value;\nunresolved translations are disclosed and are not used as substances.\nFinding-only, value-only, and neither-resolved partial rows are retained.\n\n`mappings.resolution.allergy` retains supplied type/categories, coded\nclinical/verification statuses, the date-source element, per-coding\ninterpretation, derived categories, and reported information loss. The\nfinding and substance have separate mappings linked to the same row and\nfields. Broader findings retain source specificity in this metadata.\nCodes outside contextual policy coverage retain existing domain-based\nmapping, with the contextual coverage outcome reported separately.\nText-only inputs retain their text-only behavior. Reaction-substance\nfallback and published `Maps to value` relationships are not applied.\nNull placeholders in repeating FHIR category primitives carry no category\nvalue and are omitted from category interpretation and audit values.\nAn invalid supplied type/category drops only the affected AllergyIntolerance\nbefore concept resolution. Other resources are still processed. The\n`dropped` entry identifies the resource and reports\n`allergy_type_unrecognized` or `allergy_category_unrecognized`.\n\nFor example, a Condition coded with ICD10CM `Z79.4` can produce a mapped\n`observation` row. `Z86.73` and ICD-O-3 `8140/3` also map to concepts in\nthe Observation domain. Use `mappings.omop_table` and `mappings.omop_id`\nto locate each result; the FHIR resource type alone does not determine\nthe output table.\n\nClinical rows include available person/visit/provider links, concept and\nsource concept IDs, source value, source type provenance, and start\ntiming. Dates follow the source resource's timing rules below, regardless\nof the output table. For example, a Condition follows its\n`onset[x]` / `recordedDate` timing rule even when returned as an\n`observation`. End dates are populated only in tables with end columns;\nfor rows not listed in `rows_for_review`, they also contribute to the\nrequest-local observation period when the output table has no end column.\n\nSource-specific fields are populated only for the corresponding source\nand destination: Condition status in `condition_occurrence`, medication\ndosage/supply and Immunization lot details in `drug_exposure`, and\nObservation values/units/ranges in `measurement` or `observation` where\nthose columns exist. Mapping notes explain the output table and which\nfields are populated. Coded medication/immunization routes on rows outside\n`drug_exposure`, and Observation values on rows outside `measurement` /\n`observation`, are retained in `mappings` linked to the output row, with\n`omop_field` omitted and `selected: false`. Coded routes are checked\nagainst the OMOP Route domain; those Observation values are preserved\nwithout concept resolution.\n\nNote destinations are unsupported. If no standard concept\nvalid for a supported destination is found, the resource produces a row\nin its default table with concept ID `0` and `UNMAPPED` status. Its\nsource concept ID is also `0`, except that allergy rows retain a known\noriginal source identity from their coding evidence. A `DOMAIN_MISMATCH`\noutcome is identified in the mapping\nnote. Text-only clinical inputs can also produce rows in another supported\ntable when semantic mapping selects a standard concept valid for that destination;\nwithout a validated match, they use their default tables. Domain-routed\nclinical records produce one row each even when their code has multiple\nvocabulary mapping targets; DeviceUseStatement event granularity is\ndescribed below. Vocabulary `Maps to value` mappings are not applied.\n\n`Medication` is reference data for medication resources; it does not\ncreate its own row because OMOP CDM has no Medication table. Administrative\nlinkages (provider, care site, and location) are best-effort and limited to\nreferences supplied in the request. `Patient.managingOrganization` is a\nrecord custodian, not a care-delivery site. Provider specialties resolve\nindependently to standard Provider concepts. Recorded `Practitioner.gender`\nis distinct from Person\ndemographics: `male` and `female` resolve to validated OMOP Gender\nconcepts in `provider.gender_concept_id`; `other`, `unknown`, and absent\ngender remain unmapped. `Address.country` is resolved to `location.country_concept_id`,\nand CMS Place of Service codings in `Location.type` are resolved to\n`care_site.place_of_service_concept_id`.\nWithin one request, Practitioners with the same single distinct supplied\nNPI value merge into one provider row only when all supplied mapped identity\nattributes in the entire NPI group agree. The NPI system may be\n`http://hl7.org/fhir/sid/us-npi` (preferred in FHIR R4),\n`urn:oid:2.16.840.1.113883.4.6`, or bare `2.16.840.1.113883.4.6`;\nthese forms are equivalent for matching. OID forms are accepted for compatibility.\nThe same matching rule applies to Practitioners contained in valid role\nreferences. NPI values are compared exactly as supplied, without format\nor checksum validation. Blank values and unknown markers `UNK` and\n`UNKNOWN` are treated as missing and do not populate `npi` or participate\nin matching. Marker detection ignores case and surrounding whitespace;\nall other values are preserved without trimming. Other identifier systems and names\ndo not trigger a merge. Missing attributes can enrich a compatible merged row.\nCompatibility compares the name rendered from each Practitioner's first\n`name` entry, its gender, its first non-empty DEA identifier, and its parsed\nbirth year. Differences in birth month/day within one year do not conflict.\nRendered names are compared exactly: `Jane Smith` and `Dr. Jane Smith`\nconflict even when they share an NPI.\nIf any of these attributes conflict, every resource in that NPI group stays\nseparate, including resources missing the conflicting attribute. Source\nattributes and unambiguous structural reference links are preserved; no\nconflicting value is selected. Each affected row has `CONFLICTING`\ndiagnostics at `name`, `gender`, `identifier`, or `birthDate`, linked by\n`omop_table: provider` and `omop_id`. All source gender mappings link to\ntheir retained or merged provider row.\nRepeated copies of one NPI on a resource are allowed. A resource with\nmultiple distinct NPIs stays separate, leaves `npi` unset, preserves its\nsource-resource identity in `provider_source_value`, and reports a\n`CONFLICTING` diagnostic at `identifier` linked by\n`omop_table: provider` and `omop_id`.\nRow IDs are per response. The API does not merge across requests. For a\nPractitioner with one NPI, `provider_source_value` is that NPI, even when\nits group conflicts. Consumers can use it to deduplicate compatible\nproviders across requests, but must preserve rows flagged by identity\nconflict diagnostics and check compatibility across responses. Without\none unambiguous NPI, source-resource identity is preserved without a\nperson-level deduplication guarantee.\nA `PractitionerRole` that identifies one supplied `Practitioner` aliases\nthat canonical provider: by a top-level structural reference, a\nparent-contained `#id` reference, or an `identifier.system` and\n`identifier.value` match against a top-level Practitioner. Identifier\nmatching uses the equivalent NPI systems and missing-value rules above;\nall other systems and all supplied identifiers match exactly. No remote\nidentifier lookup is performed. When `Reference.type` is present it must\nbe `Practitioner`; duplicate contained IDs and identifier matches to\ndifferent canonical providers are ambiguous. An explicit reference that\nis unresolved, ambiguous, or unsupported retains a role-fallback provider\nrow and is returned in `diagnostics`. `provider_role_contexts` preserves role-specific\nspecialty and care-site context that a canonical OMOP provider row cannot\nrepresent together.\n\nA role whose practitioner reference has only a blank or unknown NPI\nidentifier follows the missing-identifier behavior: its fallback row has\nan `UNSUPPORTED` diagnostic explaining that a usable identifier or literal\nreference is required. An unambiguous literal reference still links to\nits Practitioner when the NPI is missing or unknown. Original supplied\nunknown markers remain in the role's `practitioner_identifier` metadata.\n\nEach `PractitionerRole.specialty` entry resolves independently using its\nsupplied codings and explicit text from that entry. NUCC codings resolve\ndirectly; other supported codings may use the UMLS bridge. Text fallback\nis permitted when every coding has a nonblank code and a well-formed,\nunsupported system identifier. Supported systems, mixed supported and\nunsupported systems, and incomplete or malformed codings block fallback.\nA supported coding remains authoritative even when the text differs.\nWithin one specialty entry, complete codings from well-formed unsupported\nsystems do not block supported codings that independently resolve to the\nsame standard Provider concept. Unsupported companions remain unsupported\nin the coding audit and acquire no crosswalk. Missing or malformed\ncompanions, failures of recognized codings, and disagreements between\nsupported codings still block selection. For example, NUCC `208000000X` alongside a complete local\n`PEDS` coding can resolve to Pediatric Medicine, with or without text\nand in either coding order. This uses the supported coding's mapping\nand source concept, rather than text fallback.\nCoding displays do not supply text fallback evidence.\nUnsupported system identifiers must be absolute URIs without embedded\nwhitespace, user information, or fragments. HTTP(S) identifiers require\na host; URNs require a namespace and a nonempty namespace-specific value.\nBare labels such as `Epic` do not qualify.\nText uses deterministic vocabulary assertions in order: standard Provider\nconcept name, English OMOP synonym, then an English UMLS string on a\nCUI with a NUCC atom, then a NUCC preferred-term label with its grouping\nprefix removed. The last layer retains the entire classification and\nspecialization label, including qualifications. An ambiguous layer stops\nresolution. No semantic search or ML matching is used.\nEach role's input remains visible in `mappings`, including repeated\nidentical inputs across one provider's roles.\n`specialty_concept_id` is populated only when every distinct supplied\ninput resolves to the same standard Provider concept. With no inputs,\nor an unresolved, ambiguous, or conflicting input, it stays `0`.\nA role without a specialty contributes no input. The endpoint applies\nno frequency or hierarchy selection. Each input's outcome and matching\nroute (layer, matched string, and UMLS CUI/SAB when applicable) appear\nin `mappings` with `omop_field: specialty_concept_id`. An individually\nresolved input can coexist with a provider concept of `0` when another\ninput fails or disagrees. Specialty mappings have `selected: false`:\nthe source text remains in `specialty_source_value` when supplied source\nvalues agree, and every original value remains in role contexts and mappings.\nSpecialty mapping evidence includes `specialty_resolution` with the text\nfallback decision and each coding's independent outcome. A successful\ntext fallback reports `MAPPED` and source concept ID `0`; its local\ncodings remain unsupported in that audit. For example, complete local\ncodings with explicit `Pediatrics` text can resolve to Pediatric Medicine\nthrough `NUCC_LABEL`. Adding a different or unresolved specialty still\nkeeps the provider's singular concept at `0`.\nCompound phrasings without a vocabulary assertion remain unresolved.\nFor example, `Pulmonology` stays unresolved while `Pulmonologist`\nresolves to Pulmonary Disease. Results depend on `vocab_version`.\n\nPatient demographics:\n- Sex at birth, race, and ethnicity are read from these US Core\n extensions, with their US Core 6.1.0 structures and value sets, on\n any Patient (US Core profile conformance is not required). Sex at\n birth falls back to Patient `gender`. Other extensions, including US\n Core sex and gender identity, are ignored.\n - Birth sex:\n `http://hl7.org/fhir/us/core/StructureDefinition/us-core-birthsex`\n (`valueCode` from `http://hl7.org/fhir/us/core/ValueSet/birthsex`)\n - Race:\n `http://hl7.org/fhir/us/core/StructureDefinition/us-core-race`\n (`ombCategory` from\n `http://hl7.org/fhir/us/core/ValueSet/omb-race-category`)\n - Ethnicity:\n `http://hl7.org/fhir/us/core/StructureDefinition/us-core-ethnicity`\n (`ombCategory` from\n `http://hl7.org/fhir/us/core/ValueSet/omb-ethnicity-category`)\n- `gender_concept_id` is sex at birth. A supplied birth sex always\n decides it: `M` and `F` are resolved; `UNK`, `ASKU`, `OTH`, a code\n outside the value set, conflicting values, and a birth sex without\n `valueCode` leave it `0`, and Patient `gender` is not used.\n- Without a birth sex, Patient `gender` `male` or `female` is resolved\n under the OMOP convention that the supplied gender represents sex at\n birth; `other` and `unknown` keep concept `0`.\n- `gender_source_value` is the chosen source code (`F` for birth sex,\n `female` for Patient `gender`).\n- Each race category is resolved separately, and null flavors (`UNK`,\n `ASKU`) are ignored. One distinct standard race sets\n `race_concept_id`. More than one sets it to `1546847` (More than one\n race) and adds one `observation` row per race, with\n `observation_concept_id` `4013886` (Race), the race in\n `value_as_concept_id`, `observation_type_concept_id` `32817`, the\n category code in `value_source_value`, and no\n `observation_source_value` or `observation_date`. A loading pipeline\n that requires `observation_date` must apply its own date policy.\n- The single non-null ethnicity category is resolved, and null flavors\n are ignored; more than one distinct category leaves\n `ethnicity_concept_id` `0`. Ethnicity is not derived from race, and\n no demographic is inferred from names, addresses, or other\n extensions.\n- `race_source_value` and `ethnicity_source_value` list every supplied\n category and detailed code in source order, joined with `|`, or the\n extension text when no code is supplied. Detailed codes and text are\n not resolved.\n- Every supplied birth sex, gender, and OMB category code has a\n `mappings` entry whose `note` names its source and outcome. When a\n birth sex is supplied, Patient `gender` is reported unselected with\n the note `FHIR administrative gender; not used, birth sex supplied`.\n Conflicting values and a birth sex without `valueCode` are also\n returned in `diagnostics` with path `extension:birthsex` or\n `extension:ethnicity`. `summary` counts each demographic field once,\n as described under `Summary`.\n- Differences from the reference conventions: Vulcan's example gender\n ConceptMap maps `other` and `unknown` to concepts, which stay `0`\n here; more than one race follows the OHDSI THEMIS convention, also\n used by Vulcan, rather than the CDM 5.4 note that mixed races use\n `0`; and Vulcan's suggested `observation` rows for multiple\n ethnicities are not produced.\n\n`Specimen` produces a `specimen` row only with an explicitly resolvable\ntop-level Patient subject. Missing collection dates or material retain\nuseful partial rows with linked diagnostics; those rows may require\ndownstream completion before loading into a strict CDM instance.\nReferences support `Patient/id` and exact Bundle\nfullUrl matches (including URNs and absolute URLs); contained, logical,\ndisplay-only, missing, non-Patient, and unresolved subjects never use the\nsole Patient fallback. Reference.type, when supplied, must identify Patient.\nUnsafe patient attribution and ineligible status drop only that Specimen\nwith `dropped` and `diagnostics`; a missing Patient in the entire request\nremains a 400. Missing material is concept ID 0 with a `type` diagnostic\nand no terminology mapping/summary count. Supplied unmapped material is\nalso 0, with its separate vocabulary mapping outcome and retained source code.\nMaterial (`type`) resolves against the Specimen domain, coded UCUM\ncollection quantity units against Unit, and `collection.bodySite` against\nSpec Anatomic Site. Supplied unmapped optional concepts are 0; absent\nunit and site IDs are explicit null. Text-only body sites preserve their\nsource text with site concept ID 0 and an `UNMAPPED` mapping; they do not\nproduce a text-based concept suggestion. Material descriptions follow\nthe semantic-mapping policy above. Original source codes and text-only\ndescriptions are preserved.\nNumeric quantity is copied without\nunit conversion; invalid or comparator-bearing quantities are omitted\nwith diagnostics without dropping the row. Missing optional data is allowed.\nCollection time uses `collection.collectedDateTime` or\n`collection.collectedPeriod.start`; end-only, partial, invalid, and absent\ndates leave specimen_date/specimen_datetime omitted with linked diagnostics.\nBoth collected forms together are conflicting: retain the row without\nselecting a date. Period end and receivedTime never substitute for collection\nstart. `specimen_source_contexts.collection_timing` preserves raw dateTime,\nPeriod boundaries, receivedTime, and the selected source path when a date\nis populated, including partial precision and original timezone offsets.\n`specimen_collection_date_missing`, `specimen_collection_date_partial`, and\n`specimen_collection_date_unusable` distinguish absent, partial, and invalid\nsource dates; `specimen_collection_multiple_forms` reports conflicting choices.\n`specimen_type_concept_id: 32817` is EHR provenance, independent of material\nand availability. Status absent/available/unavailable/unsatisfactory is\nsupported; entered-in-error and unrecognized statuses drop the specimen.\n`specimen_source_contexts.status` preserves the supplied availability\nstatus for emitted rows; it is omitted when no status was supplied.\nDisease status is not inferred. Source identity precedence is resource id,\nthen first identifier with a nonempty value in source array order, then\naccessionIdentifier.value, then Bundle fullUrl. No identity is fabricated;\n`specimen_source_contexts` preserves all valued identifier system/value\npairs, accession identity, resource id/fullUrl, and the selected source path.\nParent/derived specimens emit independently when each has its own Patient;\nmissing date/material remains partial rather than being inherited. Parent\nand Measurement-to-Specimen links are not represented. Consuming CDM\nloaders own required-date completion, durable IDs, and dataset completeness.\nSource baseline is FHIR R4 4.0.1, with supported common fields reviewed against\nUS Core Specimen 7.0.0; full profile conformance is not validated here.\n\n**Device use and exposure**\n\nDeviceUseStatement accepts active, completed, stopped, and on-hold use;\nintended, entered-in-error, missing, and unrecognized statuses are omitted\nwith source-specific diagnostics. Its subject must explicitly resolve to\nan emitted Patient. Group, contained-Patient, identifier-only, external\nfetching, and sole-Patient attribution are unsupported for this path.\nDevice references support exact Type/id and Bundle fullUrl/URN matches,\nplus unambiguous contained Devices scoped to the referring statement.\nMissing, wrong-type, unresolved, or ambiguous Device details retain a\nknown-patient use row with device concept 0 and diagnostics. Missing type\nevidence has no vocabulary mapping entry; supplied unmapped type evidence\nhas a mapping outcome. Inventory-only Device records emit no exposure.\n\ntimingDateTime supplies the start only; timingPeriod preserves supplied\nboundaries independently. Each timingTiming.event produces a separate\nrow, including a partial row for an unusable event, with its zero-based\nevent_index in device_exposure_contexts.exposures. Repeat rules without events\nretain one partial row; events plus rules produce only the event rows.\nNo recurrence, continuous min/max interval, status-derived end date, or\nrecordedOn fallback is inferred. Year/month precision and malformed\nindividual dates leave affected columns absent. Competing timing choices\nor a provably reversed Period leave both bounds absent. Supplied precision,\noffsets, schedules, and omission/conflict reasons remain in source context\nand diagnostics. Other clinical paths retain their own timing semantics.\n\ndevice_exposure_contexts contains one entry per source record that produced\ndevice exposures. Its exposures array links all rows derived from that\nrecord by device_exposure_id and, for Timing events, event_index. Shared\ntiming, device evidence, and quantity_basis appear once per source record.\nContexts are not combined by resource id or device identity; distinct\nrecords remain separate even when their identifiers match or are absent.\n\ndevice_source_value uses original type/clinical coding, never inventory\nor resource identity. An unambiguous Device.udiCarrier.deviceIdentifier\nsupplies unique_device_id. Competing identifiers leave it absent and are\npreserved with issuer/jurisdiction/carrier context. production_id remains\nabsent; carriers, lot, serial, manufacture, and expiry evidence remain in\ndevice_exposure_contexts.\n\nDeviceUseStatement rows use device_type_concept_id 32817 (EHR), with\nprovider_id, visit_occurrence_id, and visit_detail_id unset.\nRouted rows retain their source classification, explicit visit links to\nemitted same-patient visits, and compatible performer links;\nCondition/Procedure recorder/asserter and\nMedicationStatement informationSource do not supply device provider_id.\nSupplied source, encounter, context, recorder, asserter, performer, and\ninformationSource values on DeviceUseStatement are retained only in\ndevice_exposure_contexts.\n\nDevice exposure quantity is reported as follows:\n\n- When the source has no quantity, dose, rate, or numeric Observation\n result field, quantity is 1 and device_exposure_contexts.quantity_basis\n is default_absent_count.\n- When such a field is supplied, including a null or malformed value,\n quantity is omitted and quantity_basis is unsupported_supplied_evidence.\n The original values remain in device_exposure_contexts.evidence, and\n an UNSUPPORTED diagnostic identifies each supplied field.\n\nFor example, an Observation with valueQuantity.value of 2 returned as a\ndevice exposure has no quantity; the value 2 remains in its source context.\ntimingTiming.repeat.count remains in source context and does not set\nquantity. unit_concept_id and unit_source_concept_id are always null.\n\nDistinct eligible statements and routed clinical records remain separate,\nincluding overlapping use and equal Timing events. derivedFrom is retained\nas supporting context, not duplicate identity. Row counts therefore need\nnot count distinct uses or physical devices. IDs are request-local exposure\nidentities. Before loading a CDM dataset, callers must assign durable IDs\nand populate any required fields omitted from partial rows.\n\nUnsupported resource types are accepted in a Bundle but ignored: they\ncreate no row and no `dropped` entry. `dropped` is reserved for supported\nrow-producing resources that cannot safely produce a positive OMOP row:\nrequired subject/patient, clinical code/text, or medication data may be\nunusable, or the resource may explicitly negate or fail the documented\nclinical-event eligibility policy. A single-Patient Bundle uses the sole\nPatient only when `subject`/`patient` is absent, except DeviceUseStatement\nand Specimen, which require explicit Patient subjects. An explicit subject/patient\nreference that is unresolved, ambiguous, or unsupported drops the clinical\nresource in every request scope.\n\nEligibility distinguishes clinical validity, performed/taken evidence, and\nadministrative workflow state. Entered-in-error and explicitly\nnon-performed events never produce a positive clinical-event row, and\nunrecognized required statuses are dropped except the retained Condition and\nEncounter cases below. Condition accepts absent or confirmed canonical-HL7\n`verificationStatus` without a status retention reason; uncertain or unreadable\nsupplied verification is retained and indexed. Clinical course is not a\ndiagnosis-role mapping. Procedures accept completed or stopped events.\nMedication requests are prescription evidence only: `doNotPerform`, drafts,\ncancellations, and non-authorizing intents are dropped. Medication statements\naccept active, completed, stopped, or on-hold reported use; administrations\naccept completed, in-progress, on-hold, or stopped events. An on-hold\nadministration also needs an `effectiveDateTime` or `effectivePeriod.start`\nthat supplies start evidence; a valid partial date remains an undated row.\nAn on-hold administration is started evidence that is temporarily paused and\nexpected to continue. MedicationDispense accepts completed records,\nincluding completed partial fills. Stopped records require a valid\n`whenHandedOver`, including partial dates, as evidence of actual handover.\nPreparation, in-progress (ready for pickup), on-hold, cancelled, declined,\nentered-in-error, unknown, missing, and unrecognized statuses are dropped.\nA completed dispense with missing or unusable handover timing remains a\npartial row; preparation time never substitutes for handover.\nImmunizations require completed\nstatus. Observations accept final,\namended, or corrected status; registered,\npreliminary, cancelled, entered-in-error, and missing statuses are\ndropped. AllergyIntolerance accepts absent, unconfirmed, or confirmed canonical-HL7\n`verificationStatus` as a reported allergy, but drops refuted,\nentered-in-error, and unreadable supplied verification statuses. Invalid\nsupplied allergy type/category also drops that resource, for both coded\nand text-only statements. Encounter\naccepts arrived, triaged, in-progress, onleave, or finished status, but drops\nplanned, cancelled, and entered-in-error statuses. Unknown, missing, and unreadable\nEncounter statuses are retained and indexed without imputation. An eligible\nencounter without `Period.end` remains a partial visit; no end date is invented.\nMedicationStatement, Observation, and Procedure with explicit `unknown`\nstatus are also retained and indexed in `rows_for_review` when otherwise\nshapeable. Missing or unreadable statuses on these three resource types\nremain dropped. Retention preserves source status and does not establish\nthat an event occurred or medication was taken. Device placement still\nrequires eligible use evidence; unknown-status records cannot produce\n`device_exposure` rows.\n\nThe mapped OMOP concept's domain determines the clinical output table.\nFor Observations, numeric and nonnumeric `value[x]` forms determine the\npreferred table only when the code is valid for both `measurement` and\n`observation`. Unresolved Observations retain `measurement` rows for\nnumeric values and `observation` rows for other values. In these two\ntables, numeric values populate `value_as_number`; other non-coded values\npopulate `value_as_string` for an `observation` or `value_source_value`\nfor a `measurement`. Coded `valueCodeableConcept` values are resolved\nagainst `value_as_concept_id` in these tables and use the selected\nbare code in `value_source_value`, leaving an `observation`'s\n`value_as_string` empty; unmapped or target-invalid coded values remain\n`0`. Active standard concepts from any OMOP domain are valid for\n`value_as_concept_id` per CDM 5.4. For example, a blood-culture Measurement\ncan record *Staphylococcus aureus* as its coded result, even though the\norganism concept belongs to OMOP's Observation domain.\nFor other clinical output tables, Observation values are retained only\nin `mappings`, without concept resolution or an `omop_field`, and with\n`selected: false`.\nOther unsupported `value[x]` forms and Observation components do not\npopulate separate converted values. A\nnumeric comparator (`<`, `<=`, `>`, `>=`) is represented only by a\nmeasurement's `operator_concept_id`.\n\nFor Observations returned as `measurement` or `observation` rows,\nQuantity units resolve independently against the table's\n`unit_concept_id`.\nOnly coded UCUM (`http://unitsofmeasure.org` or its\nOID `2.16.840.1.113883.6.8`, bare or `urn:oid:`) is supported. Codes are\ncase-sensitive: no spelling aliases, text-only guesses, or numeric\nconversion are applied. Coded UCUM units map to valid standard OMOP\nUnit concepts using the release selected by `vocab_version`.\nMappings are direct or follow an active OMOP `Maps to` relationship;\nUCUM units do not use a UMLS bridge.\nUnknown codes,\nvocabulary gaps, text-only units, missing coding, and unsupported\nsystems keep the row and use `0`; absent units use JSON `null` (the\nfield is present). Explicit dimensionless code `1` is a supplied unit\nand resolves or remains `0`; it is never treated as absent.\n`unit_source_value` contains Quantity.code when supplied, otherwise\nQuantity.unit. A separate unit `mappings` entry preserves the original\nQuantity.system/code in `source_system`/`source_code` and the exact\nhuman-readable Quantity.unit in `source_name`, with its own outcome.\nQuantity.unit provides display text and does not affect unit matching.\nMeasurement also returns nullable `unit_source_concept_id`, identifying\nthe OMOP source concept for Quantity.system/code when available,\notherwise `0` for a supplied unit.\nObservation has no such column. Resolving a unit does not change\nnumeric values, comparators, or reference ranges.\nSupported coded UCUM units include `resolution` in their mappings,\nwhether the unit resolves or remains unmapped:\nits outcome and reason code distinguish vocabulary gaps, target-domain\nmismatches, and ambiguity. Unresolved or ambiguous units retain the row\nwith concept ID `0`. Resolved mappings retain the selected concept's\ndirect or crosswalk provenance. Text-only units, units with missing\ncoding, and unsupported unit systems omit `resolution`; their `note`\nexplains the unmapped outcome.\n\nFor Observations returned in other clinical tables, units are not resolved\nor included as separate mapping outcomes. Device exposure unit IDs remain\nnull, with supplied quantity and unit evidence retained in\n`device_exposure_contexts`.\n\nCompatibility: absent `unit_concept_id` values previously returned `0`\nand now return `null`. Coded units use their code in `unit_source_value`\nrather than their human-readable text; the text remains in `mappings`.\nThe Measurement `unit_source_concept_id` field is new.\n\nA standard OMOP `concept_id` is selected for each eligible primary clinical coding\nafter considering all of the resource's supplied codings. An unambiguous\ncoded medication or immunization route is checked independently against\nthe OMOP Route domain. For `drug_exposure` rows, `route_concept_id`\ncontains the resolved concept ID, or `0` if unmapped; for other output\ntables the route is reported only in\n`mappings`, without `omop_field` and with `selected: false`.\nAlongside the OMOP rows grouped by\ntable (`tables`), the response carries `mappings` (an entry for every\nsupported source coding from a resource that shaped a row, linked back to\nthat row; some, such\nas demographic null flavors, are reported without being resolved),\n`rows_for_review` (an index of uncertain-status or primary-concept-0 clinical\nrows in tables, with original statuses, reasons, and source/row identity),\n`provider_role_contexts` (source role details linked to provider rows),\n`dropped` (resources that could not be shaped into a row),\n`diagnostics` (explicit references that could not safely create a link,\nconflicting Practitioner identity attributes, conflicting or unsupported\nPatient demographic extensions, and conflicting Encounter classifications),\n`vocab_version` (the OMOP vocabulary release codes were resolved\nagainst), and a small `summary` of the resolution outcomes.\n\nA `concept_id` of `0` is reported, not omitted (OMOP \"no matching\nconcept\" semantics): it covers a supplied concept without a validated\nstandard match (`UNMAPPED`).\nSupported `Encounter.class` or `Encounter.type` values\nmay populate `visit_concept_id`. Person demographics and Provider\nrecorded gender follow the policies above.\nCoded Observation values and supported allergy substances may populate\n`value_as_concept_id`.\nMeasurement `operator_concept_id` represents a value comparator (`<`,\n`<=`, `>`, `>=`); multiple-race concepts follow the conventions described\nabove. Clinical `*_source_value` fields contain the selected FHIR code\n(or source text for text-only resources).\nThe corresponding selected `mappings` entry preserves the coding system\nand full source-coding provenance.\nKnown OID-form coding systems are accepted as either FHIR OID URNs (for\nexample, `urn:oid:2.16.840.1.113883.6.1` for LOINC) or bare OIDs, and\nproduce the same mappings as their canonical system URLs. Coding\nmappings report that canonical URL in `source_system`; Quantity unit\nmappings preserve the original Quantity.system, including OID spelling.\nAn unknown OID remains unchanged in `source_system` and may be\n`UNMAPPED`.\nOther `*_source_value` fields preserve row-specific raw source values,\nsuch as resource identifiers, names, units, or status codes.\n`MedicationRequest` uses `32838` (EHR prescription) and\n`MedicationDispense` uses `32825` (EHR dispensing record) for\n`drug_type_concept_id`; other current resources use `32817` (EHR). This\nis a coarse provenance policy: it does not infer patient-reported,\nmedication-history, or other more-specific type concepts from FHIR\nstatus fields.\n\nDates and datetimes:\n- A `*_date` is the calendar date (`YYYY-MM-DD`) of a source value with\n at least day precision. A `*_datetime` is set only when that value\n has a time of day, as local time without a UTC offset\n (`YYYY-MM-DDTHH:MM:SS`, with fractional seconds to microseconds when\n supplied): `2024-01-15T23:30:00-05:00` becomes `2024-01-15T23:30:00`.\n A datetime without a timezone is read as local time.\n- A partial date (`2024` or `2024-03`) leaves both columns unset.\n It still counts as that source's value, so later sources in the list\n below are not used.\n- Free-text timing (such as `onsetString`), `Age` and `Range` forms, and\n values that are not dates are ignored, so a later source in the list\n is used if there is one.\n- Other than the sources below and the same-day ends of single-event\n medication records, no date is imputed: partial dates are not\n completed, and a row is not dated from another resource such as its\n Encounter.\n- Valid date values never cause a request to be rejected, and the response\n reports missing, partial, malformed, or conflicting timing for Specimen,\n DeviceUseStatement, and MedicationDispense in `diagnostics` while retaining\n partial rows for downstream load completion. Other resources do not report which\n date elements were unusable. An on-hold\n MedicationAdministration is an eligibility exception: it needs a supplied\n effective start as evidence that administration began.\n A stopped MedicationDispense similarly requires supplied handover evidence.\n\nTiming sources, in priority order where several are listed. These rules\napply regardless of the clinical output table; end dates are populated\nonly where the table has an end column, and `verbatim_end_date` is set\nonly for medication resources returned as `drug_exposure` rows:\n- `Patient`: `birthDate` sets `year_of_birth`, `month_of_birth`, and\n `day_of_birth` from the parts it supplies; `birth_datetime` is not\n set. `deceasedDateTime` sets the `death` dates.\n- `Practitioner`: `birthDate` sets the provider's `year_of_birth`.\n- `Encounter`: `period.start` and `period.end`.\n- `Condition`: start from `onsetDateTime` or `onsetPeriod.start`, then\n `recordedDate` (when the condition was recorded, not when it began);\n end from `abatementDateTime` or `abatementPeriod.end`.\n- `Procedure`: start from `performedDateTime` or\n `performedPeriod.start`; end from `performedPeriod.end` only.\n- `DeviceUseStatement`: `timingDateTime` sets the start only;\n `timingPeriod.start` and `timingPeriod.end` supply independent bounds;\n each `timingTiming.event` produces one start-only row. A schedule\n without explicit events produces one row without dates.\n- `Specimen`: `collection.collectedDateTime` or `collection.collectedPeriod.start`;\n missing day precision retains an undated specimen with diagnostics.\n- `Observation`: `effectiveDateTime`, `effectivePeriod.start`, or\n `effectiveInstant`.\n- `AllergyIntolerance`: `recordedDate`, then `onsetDateTime` or\n `onsetPeriod.start`.\n- `MedicationStatement`: start from `effectiveDateTime` or\n `effectivePeriod.start`; end and `verbatim_end_date` from\n `effectivePeriod.end`. `dateAsserted` records when the statement was\n made and is not used.\n- `MedicationAdministration`: an `effectiveDateTime` is a single event\n that sets both start and end; an `effectivePeriod` sets the start\n and, when present, the end and `verbatim_end_date`.\n- `Immunization`: `occurrenceDateTime` is a single event that sets both\n start and end; `expirationDate` is not used.\n- `MedicationRequest`: `authoredOn`, the order date, sets the start; it\n is not evidence of administration. No end is set, and the validity\n period is not used as an exposure duration.\n- `MedicationDispense`: `whenHandedOver` sets the start. Missing, partial,\n or invalid handover leaves the start unset with a diagnostic. Comparable\n handover/preparation values proving handover before preparation also\n leave the start unset with a `CONFLICTING` diagnostic. `whenPrepared`,\n authorizing orders, and related administrations never supply timing.\n No exposure end or `verbatim_end_date` is inferred, including from\n `daysSupply`. Raw handover/preparation values retain precision and offsets\n in `medication_dispense_source_contexts.source`.\n- Differences from the Vulcan maps: `birth_datetime` is not set from\n `birthDate`, Condition also reads `onsetPeriod.start`, and\n AllergyIntolerance falls back to its onset when `recordedDate` is\n missing.\n\nMedication details for resources returned as `drug_exposure` rows:\n- For `MedicationRequest`, `dispenseRequest.numberOfRepeatsAllowed` sets\n `refills` and a whole-day `expectedSupplyDuration` sets `days_supply`.\n- For `MedicationDispense`, `daysSupply` sets `days_supply` only for a\n positive whole number of coded UCUM `d` days without a supplied comparator.\n The canonical UCUM URL, bare OID, and OID URN are accepted. Other units,\n fractions, text-only units, and malformed values leave it absent with a\n diagnostic; absent daysSupply is allowed. Refills are not inherited.\n- All non-empty dosage text is preserved in `sig`.\n- Coded dosage routes and `Immunization.route` are target-validated in\n the OMOP Route domain. Conflicting routes are left unset; route\n codings shared by every dosage instruction identify the same route.\n- `Immunization.lotNumber` is preserved in `lot_number`.\n\nMedicationDispense requires an explicit subject resolving to a Patient\nsupplied in the request; Groups and missing subjects are unsupported.\nSource fields are based on FHIR R4 4.0.1 and reviewed against\n[US Core MedicationDispense 7.0.0](https://hl7.org/fhir/us/core/STU7/StructureDefinition-us-core-medicationdispense.html).\nFull profile conformance is not validated. Although US Core requires\nhandover on completed records, this API retains such incomplete records\nwith missing-handover diagnostics as partial conversion output.\nSupply either a usable `medicationCodeableConcept` (coded or text-only),\nor a `medicationReference` resolving to a Medication with usable code/text.\nBoth choice forms together are conflicting and drop the dispense.\nExact relative, Bundle fullUrl, and unambiguous contained Medication\nreferences are supported. Unresolved references are not rescued by display\ntext; missing or ambiguous medication identity drops the dispense with a\ndiagnostic. A usable but unmapped drug retains concept ID 0 and its\nmapping outcome in a row indexed for review.\n`context` links only to a supplied Encounter for the same patient.\nThe first resolvable Practitioner/PractitionerRole in `performer.actor`\nsupplies `provider_id`; other actor types do not supply a provider.\nFailed explicit links have diagnostics. Authorizing prescriptions are\nchecked for request-local MedicationRequest identity and patient agreement;\nfailures do not discard the dispense. They never supply medication,\ninstructions, refills, timing, or prescription provenance to a dispense.\nDispense instructions alone supply `sig` and route; conflicting routes\nremain unset with a diagnostic and original instructions in source context.\nOrders, dispenses, and administrations emit independently, with separate\nsource identity and no deduplication merely because they concern the same drug.\nEvery emitted dispensing drug row has a `whenHandedOver/daysSupply`\ndiagnostic beginning `medication_dispense_end_date_absent` because the\nCDM-required exposure end remains absent. Missing/partial/unusable handover\ndiagnostics begin `medication_dispense_handover_missing`,\n`medication_dispense_handover_partial`, or `medication_dispense_handover_unusable`.\n\nEncounter details:\n- Encounter classifications must supply both `Coding.system` and\n `Coding.code` to map to a standard OMOP Visit concept. For HL7\n encounter class codes with system\n `http://terminology.hl7.org/CodeSystem/v3-ActCode` or\n `http://hl7.org/fhir/v3/ActCode`, the supported mappings are:\n - `IMP` (inpatient encounter) -> 9201 (Inpatient Visit)\n - `AMB` (ambulatory encounter) -> 9202 (Outpatient Visit)\n - `EMER` (emergency encounter) -> 9203 (Emergency Room Visit)\n - `VR` (virtual encounter) -> 722455 (Telehealth)\n - `HH` (home health encounter) -> 581476 (Home Visit)\n\n `OBSENC` (observation encounter) has no direct class mapping. Other\n class or type codes can be used when they have a valid standard OMOP\n Visit mapping in the selected vocabulary release. Unsupported\n classifications retain `visit_concept_id` of `0` unless a supported\n type supplies the Visit as described below; they never default to\n outpatient.\n- A mapped `Encounter.class` determines `visit_concept_id`. A mapped\n type that disagrees with it produces a `CONFLICTING` diagnostic with\n path `class/type`, while the class mapping remains selected.\n If class is missing or unmapped, type supplies the Visit only when\n all mapped type classifications agree on the same standard Visit\n concept. Codings within one type CodeableConcept are alternative\n encodings of that classification. When separate type classifications\n map to different Visit concepts, no type is selected,\n `visit_concept_id` remains `0`, and a `CONFLICTING` diagnostic has\n path `type`.\n- `visit_source_value` contains the selected class or type code;\n `mappings` preserves its coding system and identifies which\n classification was selected. `visit_source_concept_id` identifies\n the selected original source coding's OMOP concept when available,\n and is `0` otherwise, including for the HL7 encounter class codes\n listed above. Each eligible Encounter produces one Visit;\n `classHistory`, `partOf`, and admission/discharge concepts are not\n mapped.\n\n- Direct `MedicationRequest.dispenseRequest.quantity`, `MedicationDispense.quantity`,\n `MedicationAdministration.dosage.dose`, and `Immunization.doseQuantity`\n can set `quantity` under the formulation policies below. Quantities\n must be positive finite numbers with a supported `system`/`code` pair\n and no supplied `comparator`. Ingredient amounts and injection volumes\n use `http://unitsofmeasure.org` (or its bare OID or\n `urn:oid:2.16.840.1.113883.6.8`) with case-sensitive codes matching or\n convertible to vocabulary-backed strength units. Exact unit matches,\n including `[U]` for insulin and heparin strengths, are accepted without\n a unit conversion.\n When codes differ, both source and strength units must belong to the\n same supported group: mass (`ug`, `mg`, `g`), volume (`uL`, `mL`, `L`),\n or milliequivalent representations (`meq`, `10*-3.eq`). Other conversions,\n including `kg`, `ML`, `Mg`, `[drp]`, `mol`, `mmol`, `%`, `1`, and generic\n annotations such as `{vial}` or `{syringe}`, leave `quantity` unset with\n `unit_mismatch`. Codes are not case-corrected or inferred from display text.\n The original source system is preserved in the quantity audit.\n Fixed-form counts also accept the explicit codings below. Free-text,\n local, invalid, and cross-dimensional units are not interpreted.\n\nDrug quantity policies follow [CDM v5.4 DRUG_EXPOSURE conventions](https://ohdsi.github.io/CommonDataModel/cdm54.html#drug_exposure).\nThe selected drug must be a `Clinical Drug` or `Branded Drug`, with one\nactive dose form and valid vocabulary evidence for the chosen policy.\nStrength-based conversions also require one ingredient strength. Supported RxNorm dose forms:\n- `fixed_dose_form_count`: Oral Tablet (`317541`), Oral Capsule\n (`316965`), Extended Release Oral Tablet (`316945`), Extended\n Release Oral Capsule (`316943`), Delayed Release Oral Tablet (`10312`),\n or Delayed Release Oral Capsule (`316995`). A source ingredient amount is divided\n by the single positive per-unit ingredient amount after conversion to that\n strength unit when necessary.\n Concentration-based strengths cannot derive counts from ingredient amounts. The\n result is a count of the selected dose form, limited to whole units or\n multiples of one-half or one-third. Derived counts within a relative\n tolerance of 1e-12 of an allowed fraction are normalized to that count\n to remove arithmetic roundoff; other fractions remain unsupported.\n For example, 1,000 mg for a drug\n with 500 mg per tablet produces `quantity: 2` tablets (`DERIVED`). A source\n of 1 g is converted to 1,000 mg and produces the same count.\n Explicit counts are copied (`DIRECT`) when their coding matches the\n selected tablet or capsule form, including the corresponding extended-\n and delayed-release forms: `TAB` / `CAP` under\n `http://terminology.hl7.org/CodeSystem/v3-orderableDrugForm`, or\n `{tbl}` / `{Capsule}` under `http://unitsofmeasure.org`. These exact\n case-sensitive count codings identify the supplied form; they are not\n dimensionally converted as UCUM units. Direct counts do not require\n ingredient strength: combination products and missing/invalid strengths\n do not change the supplied count. Ingredient-strength gaps, including\n missing ingredient or strength-unit references, do not prevent copying\n an explicit count. Drug or dose-form evidence gaps, unclassified or\n ambiguous forms, and gaps whose relevance cannot be established still\n prevent conversion. The whole/half/third rules still apply.\n For example, 20 `TAB` for amoxicillin/clavulanate is 20 tablets, and 30 `TAB` for\n a supported tablet produces `quantity: 30`; `CAP` for that tablet is\n omitted with `unit_mismatch`. Generic counts (`1`, `{Each}`), other\n annotations, packs, and free-text counts are not interpreted.\n See the [FHIR R4 count example](https://hl7.org/fhir/R4/medicationrequest0301.html).\n- `clinical_injection_ingredient_amount`: Injectable Solution (`316949`),\n Injectable Suspension (`316950`), or Injection (`1649574`). A source\n ingredient amount is converted to the vocabulary ingredient unit when\n necessary; an exact-unit amount is copied directly. Otherwise, a source\n amount convertible to the concentration's denominator unit is converted\n and multiplied by numerator value / the effective denominator reported in\n the audit evidence. Volume conversion requires a positive finite numerator,\n dimensionally distinct validated UCUM units, a null raw denominator value, and\n `evidence.strength.denominator_basis` with `kind: PER_UNIT` and\n `value: 1`. A null denominator alone is not interpreted as 1. Missing, unknown,\n non-applicable, quantified, or conflicting denominator evidence does\n not support volume conversion. For example, 80 mL with 2 mg/mL\n strength and a `PER_UNIT` basis produces `quantity: 160` mg. A supplied\n 160 mg amount for the same drug produces `quantity: 160` directly. A source\n of 0.08 L converts to 80 mL before producing the same 160 mg result.\n This output is already\n the ingredient amount: do not apply the drug-dose guide's\n `dose = quantity \u00d7 numerator` formula to these injection rows, which\n would multiply by the concentration a second time. Use\n `quantity_transformations.result_unit` to identify the output unit.\n\nQuantified products, packs, prefilled syringes, other liquids and dose\nforms, strength-based conversions of combination products, conflicting or incomplete required evidence, and\nunsupported source quantities leave `quantity` unset. Other supported\nfields and the clinical row are still returned. For example, a quantity\nsupplied only as `value: 2, unit: tablet` is omitted with\n`unsupported_unit`, because a free-text unit cannot establish this policy.\nRate-only administrations and MedicationStatement dosages do not establish\na supported total quantity and are not used for quantity conversion.\n\n`quantity_transformations` audits each supplied quantity at one of the\nthree direct paths above on a resource returned as a `drug_exposure` row,\nincluding an explicit null or malformed quantity. Each entry preserves\nthe source, identifies the destination row, and reports `DIRECT`, `DERIVED`, or\n`OMITTED`. Successful entries identify their policy, evidence, result,\nand result unit; omitted entries give a stable reason. When a source code\ndiffers from the strength unit, `unit_conversion` records the intermediate\nconverted amount and target unit. Converted results are `DERIVED`, even\nwhen no further concentration arithmetic is needed. A successful conversion\ncan also be reported when a later policy check omits the final quantity.\nDose-form and unit\nconcept IDs refer to the response's `vocab_version`. Missing quantities\nproduce no entry. Resources returned in other clinical tables have no\nquantity entry. Unsupported rates and statement dosages produce no entry,\nand resources in `dropped` have no quantity entry or destination row.\n\nMedication codes are resolved whether they appear inline\n(`medicationCodeableConcept`) or via a `medicationReference` to a contained,\nrelative (`Type/id`), or bundle-entry (`urn:uuid`) `Medication` resource.\nA reference that resolves to another resource type is dropped even when it\nsupplies display text; an unresolved or display-only reference may use\nits display as text-only medication input.\nResources that cannot be shaped into a row \u2014 a medication with no usable\ncode, resolvable reference, or display; any clinical resource whose\nsubject/patient reference cannot be tied to a person; or an event excluded\nby eligibility \u2014 are reported under `dropped` rather than emitted as blank\nrows. The Bundle must contain at least one Patient resource.\n\nReferences to top-level resources supplied in the request resolve by\n`Type/id` or an exactly matching Bundle `fullUrl` (including `urn:uuid`).\nParent-scoped contained references are supported for medication code lookup,\n`DeviceUseStatement.device` details, and `PractitionerRole.practitioner`\nenrichment; the latter also supports\nrequest-local identifier matching with the NPI rules above, without a remote lookup. Every\nnonzero structural foreign key targets a row in the same response. Missing\noptional links remain unset without a diagnostic; an explicit optional\nreference that is unresolved, ambiguous, conflicting with the row's\nperson, or unsupported remains unset and is returned in `diagnostics`\nwith its source path and outcome.\n\nAll row IDs start at `1` for each request and are not stable or global.\nFor clinical conversion rows whose resource supplies an `id`, `mappings`\nassociates each row with that source FHIR resource ID. A `person` row\nretains the Patient ID or its first identifier value in\n`person_source_value`, when present; other reference and derived rows do\nnot uniformly carry a FHIR resource ID. Input resources without those\nsource identifiers cannot be correlated across responses from the\nreturned rows alone. Consumers combining responses need to establish\ntheir own stable keys and remap every primary and foreign key together.\n", "requestBody": { "required": true, "content": { @@ -3346,7 +3346,7 @@ }, "oid_form_coding": { "summary": "OID-form LOINC coding", - "description": "Known FHIR OID URNs are accepted for `Coding.system`. This\nLOINC OID is resolved as `http://loinc.org`; responses use\nthat canonical URL in source fields and mappings.\n", + "description": "Known FHIR OID URNs are accepted for `Coding.system`. This\nLOINC OID is resolved as `http://loinc.org`; responses use\nthat canonical URL in `mappings[].source_system`; clinical\n`*_source_value` fields contain the selected bare code.\n", "value": { "fhir_resources": { "resourceType": "Bundle", @@ -3362,6 +3362,7 @@ "resource": { "resourceType": "Observation", "id": "hemoglobin-1", + "status": "final", "subject": { "reference": "Patient/patient-1" }, @@ -3500,7 +3501,7 @@ "selected": true } ], - "vocab_version": "v20260227", + "vocab_version": "v20260829", "summary": { "codes_already_standard": 2, "codes_normalized": 1, @@ -4775,7 +4776,7 @@ }, "create": { "type": "object", - "description": "The JSON body of `POST /lang2fhir/create/multi`. Accepts that\nendpoint's fields (`text`, `version`, `provider`,\n`primary_patient`, `patient_reference` (deprecated), `implementation_guide`, `detection_effort` (deprecated),\n`validation_method`, `resource_review`). This is the\n**multi**-resource body: it has no single-`resource` field, and\nthe item's result is a `CreateMultiResponse` (a Bundle of\nresources). Mutually exclusive with `document`; must **not** be\naccompanied by a `file`. Do not combine `primary_patient` with\n`patient_reference`.\n" + "description": "The JSON body of `POST /lang2fhir/create/multi`. Accepts that\nendpoint's fields (`text`, `version`, `provider`,\n`primary_patient`, `patient_reference` (deprecated), `implementation_guide`, `detection_effort` (deprecated),\n`validation_method`, `resource_review`, `small_note`). This is the\n**multi**-resource body: it has no single-`resource` field, and\nthe item's result is a `CreateMultiResponse` (a Bundle of\nresources). Mutually exclusive with `document`; must **not** be\naccompanied by a `file`. Do not combine `primary_patient` with\n`patient_reference`.\n" }, "file": { "type": "string", @@ -4815,7 +4816,7 @@ } }, "400": { - "description": "Invalid upload \u2014 e.g. both or neither of document/create set, a\nnon-object document/create, a document item missing its file, a\ncreate item carrying a file, or an over-length request_id/id.\n" + "description": "Invalid upload \u2014 e.g. both or neither of document/create set, a\nnon-object document/create, a document item missing its file, a\ncreate item carrying a file, a `small_note` create item whose\n`text` exceeds 15,360 bytes, or an over-length request_id/id.\n" }, "401": { "description": "Unauthorized" @@ -5387,7 +5388,7 @@ "post": { "operationId": "lang2fhir_createMulti", "summary": "Extract multiple FHIR resources from text", - "description": "Analyzes natural language text and extracts multiple FHIR resources, returning them as a transaction Bundle.\nAutomatically detects Patient, Condition, MedicationRequest, Observation, and other resource types from the text.\nResources are linked with proper references (e.g., Conditions reference the Patient).\n\nThe complete JSON request body is limited to 32 MiB (33,554,432 bytes). The application enforces this whole-envelope limit.\n\n**Patient identifier handling.** US Core requires `Patient.identifier` (a business identifier such as an MRN). When the source text contains an identifier, it is extracted with an appropriate URI system. When the source text does not contain a detectable identifier, a synthetic one is generated with `system: \"urn:phenoml:lang2fhir-generated-id\"` and a UUID `value` so the bundle remains FHIR-valid and US Core conformant. Callers who need a tenant-specific namespace should rewrite the synthetic system after extraction.\n", + "description": "Analyzes natural language text and extracts multiple FHIR resources, returning them as a transaction Bundle.\nAutomatically detects Patient, Condition, MedicationRequest, Observation, and other resource types from the text.\nResources are linked with proper references (e.g., Conditions reference the Patient).\n\nThe complete JSON request body is limited to 32 MiB (33,554,432 bytes). The application enforces this whole-envelope limit.\n\n**Patient handling.** Every bundle contains exactly one Patient, and every clinical resource references it. When the text names its patient, the Patient is built from the text. When it names several patients, the Patient is the one `primary_patient` names, otherwise the one most of the text is about; the others are not represented as Patients. Otherwise it is built from `primary_patient` when supplied, or is an unidentified Patient carrying only a synthetic identifier. When the text names its patient, the `primary_patient` identifier is added to that Patient unless its name, birth date, or gender contradicts the text's; the response `message` says whether it was added or did not match.\n\n**Patient identifier handling.** US Core requires `Patient.identifier` (a business identifier such as an MRN). Every Patient carries a synthetic identifier with `system: \"urn:phenoml:lang2fhir-generated-id\"` and a UUID `value`, so the bundle remains FHIR-valid and US Core conformant. A medical record number stated in the text is added with `system: \"urn:phenoml:lang2fhir-document-mrn\"`, and the `primary_patient` identifier is added as described above. Callers who need a tenant-specific namespace should rewrite these systems after extraction.\n", "requestBody": { "required": true, "content": { @@ -5486,7 +5487,7 @@ "description": "Unauthorized" }, "404": { - "description": "Profile not found" + "description": "Profile or implementation guide not found" }, "422": { "description": "Generated FHIR resource failed validation or terminology constraints" @@ -5570,7 +5571,7 @@ "operationId": "lang2fhir_uploadProfile", "deprecated": true, "summary": "Upload custom FHIR profile (deprecated)", - "description": "**Deprecated \u2014 use `POST /fhir/profiles` instead.** This route continues to work\nand operates on the same custom profiles, so no migration is required; it\nwill be removed in a future release. Note that `POST /fhir/profiles` does not\naccept `profile_context`; set implementation-guide context with\n`PUT /fhir/implementation-guides/{name}`.\n\nUpload a custom FHIR StructureDefinition profile for use with the lang2fhir service.\n\nAll metadata is derived from the StructureDefinition JSON itself. The lowercase `id` field\nfrom the StructureDefinition is used as the profile's unique identifier and lookup key.\nTo use the uploaded profile with `/lang2fhir/create`, pass this id as the `resource` parameter.\n\nUploads will be rejected if:\n- A built-in US Core or R4 base profile already exists with the same id\n- A custom profile with the same id has already been uploaded\n- A custom profile with the same url has already been uploaded\n", + "description": "**Deprecated \u2014 use `POST /fhir/profiles` instead.** This route continues to work\nand operates on the same custom profiles, so no migration is required; it\nwill be removed in a future release. Note that `POST /fhir/profiles` does not\naccept `profile_context`; set implementation-guide context with\n`PUT /fhir/implementation-guides/{name}`.\n\nUpload a custom FHIR StructureDefinition profile for use with the lang2fhir service.\n\nAll metadata is derived from the StructureDefinition JSON itself. The lowercase `id` field\nfrom the StructureDefinition is used as the profile's unique identifier and lookup key.\nTo use the uploaded profile with `/lang2fhir/create`, pass this id as the `resource` parameter.\n\nUploads will be rejected if:\n- A built-in profile (US Core, PhenoML HPO, or R4 base) already exists with the same id, or the id starts with a reserved prefix (`us-core-`, `phenoml-hpo-`)\n- A custom profile with the same id has already been uploaded\n- A custom profile with the same url has already been uploaded\n", "requestBody": { "required": true, "content": { @@ -5625,7 +5626,7 @@ } }, "400": { - "description": "Invalid request or profile validation failed. Possible reasons:\n- Missing or invalid StructureDefinition JSON\n- Missing required fields (id, url, type, snapshot with elements)\n- A built-in US Core or R4 base profile exists with the same id\n- A custom profile with the same id or url already exists\n" + "description": "Invalid request or profile validation failed. Possible reasons:\n- Missing or invalid StructureDefinition JSON\n- Missing required fields (id, url, type, snapshot with elements)\n- A built-in profile (US Core, PhenoML HPO, or R4 base) exists with the same id, or the id starts with a reserved prefix (`us-core-`, `phenoml-hpo-`)\n- A custom profile with the same id or url already exists\n" }, "401": { "description": "Unauthorized" @@ -5706,7 +5707,7 @@ "description": "Profile not found" }, "422": { - "description": "Document could not be processed or generated resource failed validation" + "description": "Document could not be processed, or the generated resource failed validation or terminology constraints" }, "499": { "description": "Client closed request before response was ready" @@ -5730,7 +5731,7 @@ "post": { "operationId": "lang2fhir_documentMulti", "summary": "Extract multiple FHIR resources from a document", - "description": "Extracts text from a PDF, image, RTF, or XML/C-CDA document and converts it into multiple FHIR resources,\nreturned as a transaction Bundle. Combines document text extraction with multi-resource detection.\nAutomatically detects Patient, Condition, MedicationRequest, Observation, and other resource types.\nResources are linked with proper references (e.g., Conditions reference the Patient).\n\nThe complete JSON request body is limited to 32 MiB (33,554,432 bytes). The application enforces this whole-envelope limit, including base64-encoded document `content`.\n\n**Patient identifier handling.** US Core requires `Patient.identifier` (a business identifier such as an MRN). When the source text contains an identifier, it is extracted with an appropriate URI system. When the source text does not contain a detectable identifier, a synthetic one is generated with `system: \"urn:phenoml:lang2fhir-generated-id\"` and a UUID `value` so the bundle remains FHIR-valid and US Core conformant. Callers who need a tenant-specific namespace should rewrite the synthetic system after extraction.\n\n**Split classifications (optional).** `config.split_classifications` is a caller-defined list, not a fixed taxonomy. Choose each classification `id` and write a natural-language `description` for the per-page classifier. For each page, the classifier assigns the best-matching classification or leaves the page ungrouped. Classifications with `operation: \"group\"` keep matching pages and label resources extracted from those pages; classifications with `operation: \"drop\"` remove matching pages before extraction. The `clinical` and `admin` ids in the example are illustrative, not a fixed set.\n", + "description": "Extracts text from a PDF, image, RTF, or XML/C-CDA document and converts it into multiple FHIR resources,\nreturned as a transaction Bundle. Combines document text extraction with multi-resource detection.\nAutomatically detects Patient, Condition, MedicationRequest, Observation, and other resource types.\nResources are linked with proper references (e.g., Conditions reference the Patient).\n\nThe complete JSON request body is limited to 32 MiB (33,554,432 bytes). The application enforces this whole-envelope limit, including base64-encoded document `content`.\n\n**Patient handling.** Every bundle contains exactly one Patient, and every clinical resource references it. When the document names its patient, the Patient is built from the document. When it names several patients, the Patient is the one `primary_patient` names, otherwise the one most of the document is about; the others are not represented as Patients. Otherwise it is built from `primary_patient` when supplied, or is an unidentified Patient carrying only a synthetic identifier. When the document names its patient, the `primary_patient` identifier is added to that Patient unless its name, birth date, or gender contradicts the document's; the response `message` says whether it was added or did not match.\n\n**Patient identifier handling.** US Core requires `Patient.identifier` (a business identifier such as an MRN). Every Patient carries a synthetic identifier with `system: \"urn:phenoml:lang2fhir-generated-id\"` and a UUID `value`, so the bundle remains FHIR-valid and US Core conformant. A medical record number stated in the document is added with `system: \"urn:phenoml:lang2fhir-document-mrn\"`, and the `primary_patient` identifier is added as described above. Callers who need a tenant-specific namespace should rewrite these systems after extraction.\n\n**Page classification.** When `config` sets neither `split_classifications` nor `page_filter`, pages classified as test requisition forms (lab or diagnostic order forms) are dropped before extraction and reported in `page_classifications`. Supplying either setting replaces this default.\n\n**Split classifications (optional).** `config.split_classifications` is a caller-defined list, not a fixed taxonomy. Choose each classification `id` and write a natural-language `description` for the per-page classifier. For each page, the classifier assigns the best-matching classification or leaves the page ungrouped. Classifications with `operation: \"group\"` keep matching pages and label resources extracted from those pages; classifications with `operation: \"drop\"` remove matching pages before extraction. The `clinical` and `admin` ids in the example are illustrative, not a fixed set.\n", "requestBody": { "required": true, "content": { @@ -5919,7 +5920,7 @@ "description": "Forbidden - TIFF, RTF, and XML/C-CDA uploads are only available on dedicated instances" }, "404": { - "description": "Profile not found" + "description": "Profile or implementation guide not found" }, "422": { "description": "Document could not be processed or generated resource failed validation" @@ -6842,7 +6843,7 @@ } }, "400": { - "description": "Invalid request or profile validation failed. Possible reasons:\n- Missing or invalid StructureDefinition JSON\n- The id collides with a built-in US Core or R4 base profile\n- A custom profile with the same id or url already exists\n- The implementation_guide is reserved or invalid\n" + "description": "Invalid request or profile validation failed. Possible reasons:\n- Missing or invalid StructureDefinition JSON\n- The id collides with a built-in profile (US Core, PhenoML HPO, or R4 base) or starts with a reserved prefix (`us-core-`, `phenoml-hpo-`)\n- A custom profile with the same id or url already exists\n- The implementation_guide is reserved or invalid\n" }, "401": { "description": "Unauthorized" @@ -9699,11 +9700,12 @@ "enum": [ "none", "simple", + "semantic_mapping", "medication_search", "chunk_code_jaccard_similarity" ], "default": "simple", - "description": "Method for validating extracted codes:\n* none - No validation, returns all candidate codes\n* simple - LLM-based validation\n* medication_search - LLM-based validation tailored for medication concepts\n* chunk_code_jaccard_similarity - Token-level Jaccard similarity between source text chunk and code description\n" + "description": "Method for validating extracted codes:\n* none - No validation, returns all candidate codes\n* simple - LLM-based validation\n* semantic_mapping - LLM validation requiring equivalent meaning for an entire local clinical term, preserving negation and specificity. Disables code-to-code similarity filtering so distinct equivalent candidates remain visible\n* medication_search - LLM-based validation tailored for medication concepts\n* chunk_code_jaccard_similarity - Token-level Jaccard similarity between source text chunk and code description\n" }, "chunk_code_jaccard_similarity_filtering_threshold": { "type": "number", @@ -10441,12 +10443,12 @@ "properties": { "vocab_version": { "type": "string", - "description": "OMOP vocabulary release to use for coded concept resolution. If\nomitted or empty, the API uses its default release. Specify a\nrelease explicitly when reproducibility matters. The response's\n`vocab_version`, when present, identifies the release used.\n" + "description": "OMOP vocabulary release to use for coded concept resolution. If\nomitted or empty, the API uses its default release (`v20260829`).\nSpecify a release explicitly when reproducibility matters. The response's\n`vocab_version`, when present, identifies the release used.\n" }, "fhir_resources": { "type": "object", "additionalProperties": true, - "description": "FHIR resources (single resource or Bundle). Must contain at least one\nPatient resource. Supported row-producing resources are Patient,\nLocation, Organization, HealthcareService, Practitioner,\nPractitionerRole, Encounter, Condition, Procedure, MedicationRequest,\nMedicationStatement, MedicationAdministration, Immunization,\nObservation, and AllergyIntolerance. Standalone Medication resources\nare consumed by medication references rather than mapped to their own\ntable. Unsupported resource types are accepted in a Bundle but ignored.\n" + "description": "FHIR resources (single resource or Bundle). Must contain at least one\nPatient resource. Supported row-producing resources are Patient,\nLocation, Organization, HealthcareService, Practitioner,\nPractitionerRole, Encounter, Specimen, DeviceUseStatement, Condition, Procedure,\nMedicationRequest, MedicationStatement, MedicationAdministration, MedicationDispense,\nImmunization, Observation, and AllergyIntolerance. Medication and Device\nresources provide referenced details without independently producing rows.\nUnsupported resource types are accepted in a Bundle but ignored.\n" } } }, @@ -10462,13 +10464,190 @@ "tables": { "$ref": "#/components/schemas/fhir2omop_OmopTables" }, + "quantity_transformations": { + "type": "array", + "description": "Audits supplied direct quantities on emitted drug_exposure rows, including derivations and omissions. Resources returned in other clinical tables have no quantity entry. Omitted when no applicable quantity was supplied on a drug_exposure row. Entries use the response's vocabulary release and do not affect terminology mapping counts in summary. The examples use illustrative concept IDs; actual IDs and evidence belong to vocab_version.", + "items": { + "$ref": "#/components/schemas/fhir2omop_QuantityTransformation" + }, + "example": [ + { + "resource_type": "MedicationRequest", + "resource_id": "tablet-order", + "source_path": "dispenseRequest.quantity", + "source_quantity": { + "value": 1000, + "system": "http://unitsofmeasure.org", + "code": "mg" + }, + "omop_table": "drug_exposure", + "omop_field": "quantity", + "omop_id": 1, + "outcome": "DERIVED", + "policy": "fixed_dose_form_count", + "evidence": { + "drug_concept_id": 100, + "drug_concept_class": "Clinical Drug", + "dose_form": { + "concept_id": 200, + "code": "317541", + "vocabulary_id": "RxNorm" + }, + "strength": { + "ingredient_concept_id": 300, + "amount_value": 500, + "amount_unit": { + "concept_id": 400, + "code": "mg", + "vocabulary_id": "UCUM" + } + } + }, + "result": 2, + "result_unit": { + "concept_id": 200, + "code": "317541", + "vocabulary_id": "RxNorm" + } + }, + { + "resource_type": "MedicationAdministration", + "resource_id": "injection-dose", + "source_path": "dosage.dose", + "source_quantity": { + "value": 80, + "system": "http://unitsofmeasure.org", + "code": "mL" + }, + "omop_table": "drug_exposure", + "omop_field": "quantity", + "omop_id": 2, + "outcome": "DERIVED", + "policy": "clinical_injection_ingredient_amount", + "evidence": { + "drug_concept_id": 101, + "drug_concept_class": "Clinical Drug", + "dose_form": { + "concept_id": 201, + "code": "316949", + "vocabulary_id": "RxNorm" + }, + "strength": { + "ingredient_concept_id": 301, + "numerator_value": 2, + "numerator_unit": { + "concept_id": 400, + "code": "mg", + "vocabulary_id": "UCUM" + }, + "denominator_basis": { + "kind": "PER_UNIT", + "value": 1 + }, + "denominator_unit": { + "concept_id": 401, + "code": "mL", + "vocabulary_id": "UCUM" + } + } + }, + "result": 160, + "result_unit": { + "concept_id": 400, + "code": "mg", + "vocabulary_id": "UCUM" + } + }, + { + "resource_type": "MedicationRequest", + "resource_id": "counted-tablets", + "source_path": "dispenseRequest.quantity", + "source_quantity": { + "value": 30, + "system": "http://terminology.hl7.org/CodeSystem/v3-orderableDrugForm", + "code": "TAB" + }, + "omop_table": "drug_exposure", + "omop_field": "quantity", + "omop_id": 4, + "outcome": "DIRECT", + "policy": "fixed_dose_form_count", + "evidence": { + "drug_concept_id": 100, + "drug_concept_class": "Clinical Drug", + "dose_form": { + "concept_id": 200, + "code": "317541", + "vocabulary_id": "RxNorm" + }, + "strength": { + "ingredient_concept_id": 300, + "amount_value": 500, + "amount_unit": { + "concept_id": 400, + "code": "mg", + "vocabulary_id": "UCUM" + } + } + }, + "result": 30, + "result_unit": { + "concept_id": 200, + "code": "317541", + "vocabulary_id": "RxNorm" + } + }, + { + "resource_type": "MedicationRequest", + "resource_id": "free-text-unit", + "source_path": "dispenseRequest.quantity", + "source_quantity": { + "value": 2, + "unit": "tablet" + }, + "omop_table": "drug_exposure", + "omop_field": "quantity", + "omop_id": 3, + "outcome": "OMITTED", + "reason": "unsupported_unit" + } + ] + }, + "rows_for_review": { + "type": "array", + "description": "Index of otherwise shapeable clinical rows with uncertain source status\nand/or primary concept ID 0. Each entry identifies an existing row in\ntables; row payloads are neither moved nor duplicated. Omitted when empty.\nCondition unconfirmed/provisional/differential or unreadable supplied\nverificationStatus and Encounter unknown/missing/unreadable status are\nindexed. Explicit unknown status on MedicationStatement, Observation,\nand Procedure is also indexed; missing or unreadable status on these\nthree types remains dropped. Absent Condition verificationStatus keeps\nits existing policy.\nRefuted/entered-in-error Conditions and planned/cancelled/entered-in-error\nEncounters remain dropped; all patient-reference checks still apply.\nOther eligible clinical rows with primary concept 0 are indexed,\nincluding text-only clinical concepts without a standard match and\nunresolved Visit classifications.\nUnresolved units, routes, result values, or alternate codings alone\ndo not index a mapped row. Administrative/demographic, Specimen, and Death\nrows are not indexed. No missing status, date, or concept is invented.\nConsumers must filter by this index to exclude uncertain or unresolved\nrows: tables contains all shaped rows, including uncertain events that\nwere previously excluded. Existing concept-0 rows keep their placement.\nValidated visit references remain on the rows. After filtering, include\nreferenced visits or clear optional visit_occurrence_id values pointing\nto excluded visits in the load copy. For example, to include indexed\ncondition 3 and visit 2, keep their existing rows and IDs in tables;\nif only condition 3 is selected, clear its reference to excluded visit 2.\nReturned observation periods use unindexed clinical rows plus Specimen and Death;\nindexed dates never expand them. Recompute periods for a selected load\nif needed. Indexed rows may still lack CDM-required fields.\n", + "items": { + "$ref": "#/components/schemas/fhir2omop_RowForReview" + } + }, "mappings": { "type": "array", - "description": "One entry per supported source coding from a resource that shaped a row (or one entry for a text-only primary resource with no coding), describing how it resolved and linking back to the row it produced. A coded route or Observation valueCodeableConcept is a separate entry linked to its medication, vaccine, or observation row. A Patient demographic code links to its person row, or to its `observation` race row when the person has more than one race.", + "description": "One entry per supported source coding from a resource that shaped a\nrow, plus entries for text-only primary resources and supplied\nQuantity units or specimen sites without coding, describing each\nresolution or source-preservation outcome and linking it to its row.\nA coded route or Observation valueCodeableConcept is a separate\nentry linked to the output row. Observation values returned outside\n`measurement` / `observation` are preserved here without concept\nresolution or a corresponding row column.\nFor Observations returned as measurement or observation rows, each\nsupplied Quantity unit has a separate entry linked to that row;\nabsent units have no entry.\nEach supplied specimen unit or site also has a separate entry;\ntext-only sites are `UNMAPPED` without a `resolution` object.\nA Patient demographic code links to its person row, or to its\n`observation` race row when the person has more than one race.\n", "items": { "$ref": "#/components/schemas/fhir2omop_MappingEntry" } }, + "device_exposure_contexts": { + "type": "array", + "description": "One entry per source record that produced device exposures. Each entry groups the derived row links and returns their shared timing, device evidence, and quantity explanation once. Original source values may contain clinical data.", + "items": { + "$ref": "#/components/schemas/fhir2omop_DeviceExposureContext" + } + }, + "specimen_source_contexts": { + "type": "array", + "description": "Source identities, availability status, and collection timing for emitted specimens, linked by request-local specimen_id. Preserves valued identifier system/value pairs, accession identity, resource id/fullUrl, the chosen source-ID path, and supplied collection/received timing with original precision and offsets.", + "items": { + "$ref": "#/components/schemas/fhir2omop_SpecimenSourceContext" + } + }, + "medication_dispense_source_contexts": { + "type": "array", + "description": "Supplied dispensing status, timing, instructions, and references linked to each emitted dispensing row, including rows placed in another clinical table. Omitted when there are no emitted dispenses. This response metadata is independent of rows_for_review and does not establish administration or adherence.", + "items": { + "$ref": "#/components/schemas/fhir2omop_MedicationDispenseSourceContext" + } + }, "provider_role_contexts": { "type": "array", "description": "Additive FHIR provenance for every supplied PractitionerRole. Each\ncontext identifies the canonical or role-fallback provider row and\npreserves source role facts that OMOP's singular provider columns\ncannot represent together.\n", @@ -10478,30 +10657,83 @@ }, "dropped": { "type": "array", - "description": "Supported resource instances that could not be shaped into an OMOP\nrow because the subject/patient, clinical code or text, or medication\ndata was missing or unusable, including an explicit subject/patient\nreference that was unresolved, ambiguous, or unsupported, or because\ntheir clinical-event eligibility status excluded them. A resource that\nlacks only a date or another CDM-required field is returned as a row\ninstead. Unsupported resource types are ignored and do not appear here.\nEligibility exclusions use stable, resource-specific `reason` codes;\nother shaping failures retain an explanatory reason string.\n", + "description": "Supported resource instances that could not be shaped into an OMOP\nrow because the subject/patient, clinical code or text, or medication\ndata was missing or unusable, including an explicit subject/patient\nreference that was unresolved, ambiguous, or unsupported, or because\ntheir clinical-event eligibility status excluded them. A resource that\nlacks only a date or another CDM-required field is usually returned\nas a partial row. Specimen requires an explicit resolvable Patient\nsubject but retains missing dates/material with linked diagnostics.\nDeviceUseStatement also requires an explicit resolvable Patient subject;\nmissing Device details or timing retain partial exposure rows.\nUnsupported resource types are ignored and do not appear here.\nEligibility exclusions use stable, resource-specific `reason` codes;\nother shaping failures retain an explanatory reason string.\n", "items": { "$ref": "#/components/schemas/fhir2omop_DroppedResource" } }, "diagnostics": { "type": "array", - "description": "Explanations for explicit references that could not safely produce\nan OMOP link or canonicalize a `PractitionerRole` provider identity, or explicit\nsubject/patient references that caused a clinical row to be dropped.\nMissing optional references are normal and do not produce a diagnostic.\nReferences resolve only against resources supplied in this request.\nOutcomes distinguish unresolved, ambiguous, conflicting, and unsupported\nreferences. Patient demographic extensions that conflict, or a birth\nsex without `valueCode`, are also reported here; their `path` is\n`extension:birthsex` or `extension:ethnicity` and they have no\n`reference`.\n", + "description": "Explanations for explicit references that could not safely produce\nan OMOP link or canonicalize a `PractitionerRole` provider identity, or explicit\nsubject/patient references that caused a clinical row to be dropped.\nSpecimen missing/unusable collection dates, material, subject, status,\nand invalid optional quantity are also explained here. Device-use status,\nattribution, missing detail, timing omissions/conflicts, UDI ambiguity,\nand unsupported device counts are also reported with row links when\na row is retained. A shared issue can link to multiple event rows.\nMedicationDispense missing/unusable handover, absent exposure end,\nunsupported supplied daysSupply, conflicting routes, and unsafe\nmedication or authorizing-prescription references are also reported.\nMissing optional references are normal and do not produce a diagnostic.\nReferences resolve only against resources supplied in this request.\nOutcomes distinguish unresolved, ambiguous, conflicting, and unsupported\nconditions as defined in `ReferenceDiagnostic.outcome`. Patient\ndemographic extensions that conflict, or a birth sex without\n`valueCode`, are also reported here; their `path` is\n`extension:birthsex` or `extension:ethnicity` and they have no\n`reference`. Practitioner identity conflicts that prevent merging also\nhave no `reference`: their paths are `name`, `gender`, `birthDate`, or\n`identifier`, their outcome is `CONFLICTING`, and `omop_table: provider`\ntogether with `omop_id` identifies the affected row. For a conflicting\nNPI group, every member is flagged, including members missing the conflicting attribute.\nThe reason identifies the differing mapped attribute and explains why\nthe entire group remains separate, rather than asserting that each\nmember individually supplied a conflicting value.\nMultiple distinct NPIs on one Practitioner are also diagnosed at\n`identifier` with `omop_table: provider` and the retained row's `omop_id`.\nConflicting Encounter classifications are also reported with outcome\n`CONFLICTING` and no `reference`: path `class/type` means a mapped\ntype disagrees with the selected class, which still determines the\nVisit; path `type` means separate type classifications disagree and\nno type can supply a Visit when class is missing or unmapped.\n", "items": { "$ref": "#/components/schemas/fhir2omop_ReferenceDiagnostic" } }, "vocab_version": { "type": "string", - "description": "The OMOP vocabulary release used for coded concept resolution\n(for example, \"v20260227\"), for reproducibility. Omitted when no\nvocabulary resolution was performed.\n" + "description": "The OMOP vocabulary release used for coded concept resolution\n(for example, \"v20260829\"), for reproducibility. Omitted when no\nvocabulary resolution was performed.\n" }, "summary": { "$ref": "#/components/schemas/fhir2omop_Summary" } } }, + "fhir2omop_RowForReview": { + "type": "object", + "description": "Metadata for one existing shaped row in tables. omop_table and omop_id\nform its unique response-local row key, also used by mappings and\ndiagnostics. One entry per indexed row, even with multiple reasons.\n", + "required": [ + "resource_type", + "resource_id", + "omop_table", + "omop_id", + "reasons" + ], + "properties": { + "resource_type": { + "type": "string" + }, + "resource_id": { + "type": "string" + }, + "source_full_url": { + "type": "string", + "description": "Original Bundle entry fullUrl, when supplied." + }, + "omop_table": { + "type": "string" + }, + "omop_id": { + "type": "integer", + "format": "int64" + }, + "reasons": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "condition_verification_unconfirmed", + "condition_verification_provisional", + "condition_verification_differential", + "condition_verification_unrecognized", + "encounter_status_unknown", + "encounter_status_unrecognized", + "medication_statement_status_unknown", + "observation_status_unknown", + "procedure_status_unknown", + "primary_concept_unresolved" + ] + } + }, + "source_statuses": { + "type": "object", + "description": "Supplied status, verificationStatus, and clinicalStatus fields copied\nverbatim from FHIR, including nulls or unreadable JSON values. Missing\nfields have no key. No inferred status is supplied.\n", + "additionalProperties": true + } + } + }, "fhir2omop_OmopTables": { "type": "object", - "description": "OMOP CDM v5.4 rows grouped by destination table. IDs are sequential and\nscoped to one response; they are not stable keys across requests.\nFields with no value are unset (omitted from the row), except concept\nIDs reported as `0`. Each `*_datetime` comes from the same source as its\n`*_date` and is set only when that source has a time of day.\n", + "description": "OMOP CDM v5.4 rows grouped by destination table. IDs are sequential and\nscoped to one response; they are not stable keys across requests.\nFields with no value are unset (omitted from the row), except concept\nIDs reported as `0` and the following nullable concept fields, which are\nalways present: `unit_concept_id` on Measurement, Observation, and\nSpecimen rows, `unit_source_concept_id` on Measurement rows, and\n`anatomic_site_concept_id` on Specimen rows. These nullable fields are\n`null` when the source value is absent and `0` when supplied but unmapped.\nDevice exposure `unit_concept_id` and `unit_source_concept_id` are always\npresent as `null`.\nEach `*_datetime` comes from the same source as its `*_date` and is set\nonly when that source has a time of day.\n", "properties": { "location": { "type": "array", @@ -10569,6 +10801,18 @@ "$ref": "#/components/schemas/fhir2omop_MeasurementRow" } }, + "device_exposure": { + "type": "array", + "items": { + "$ref": "#/components/schemas/fhir2omop_DeviceExposureRow" + } + }, + "specimen": { + "type": "array", + "items": { + "$ref": "#/components/schemas/fhir2omop_SpecimenRow" + } + }, "observation": { "type": "array", "items": { @@ -10659,14 +10903,16 @@ "type": "string" }, "npi": { - "type": "string" + "type": "string", + "description": "Single distinct source NPI value, preserved without format or checksum validation, from an identifier with system `http://hl7.org/fhir/sid/us-npi`, `urn:oid:2.16.840.1.113883.4.6`, or `2.16.840.1.113883.4.6`. Blank values and `UNK`/`UNKNOWN` markers (case-insensitive, ignoring surrounding whitespace) are treated as missing. Omitted when no usable NPI value exists or multiple distinct values are supplied." }, "dea": { "type": "string" }, "specialty_concept_id": { "type": "integer", - "format": "int64" + "format": "int64", + "description": "Standard Provider concept shared by every distinct supplied role specialty; otherwise `0`. Inputs resolve independently, without frequency or hierarchy selection." }, "care_site_id": { "type": "integer", @@ -10683,14 +10929,16 @@ }, "provider_source_value": { "type": "string", - "description": "The source practitioner identity. A Practitioner contained by a PractitionerRole is scoped as `PractitionerRole/#` so identical local contained IDs do not collide; an id-less parent uses an explicitly marked response-local role ordinal such as `@role-index:1`." + "description": "The single distinct source NPI, when present. Compatible providers\ncan use this value as a cross-request deduplication key; row IDs are\nper response, and the API does not merge across requests. Conflicting\nNPI groups retain separate rows sharing this value. Preserve rows\nflagged by identity diagnostics using `omop_table: provider` and\n`omop_id`, and check identity compatibility across responses before\ndeduplicating.\nWithout one unambiguous NPI, this is the source identity of the\nPractitioner or fallback role.\nMissing NPI values and unknown markers are excluded when selecting\nthat source identity. When no source identity is available, an\nexplicitly marked response-local provider ordinal such as\n`@provider-index:1` supplies a local scope.\nMultiple distinct NPIs leave `npi` unset and use the resource ID,\nfull URL, or an explicitly marked response-local provider ordinal\nsuch as `@provider-index:1` when neither is supplied.\nA Practitioner contained by a PractitionerRole without one unambiguous\nNPI is scoped as `PractitionerRole/#`\nso identical local contained IDs do not collide; an id-less parent\nuses an explicitly marked response-local role ordinal such as\n`@role-index:1`.\n" }, "specialty_source_value": { - "type": "string" + "type": "string", + "description": "Supplied specialty text (or coding display/code) when source values across roles agree, including unresolved text. Conflicting source values remain in provider_role_contexts and mappings." }, "specialty_source_concept_id": { "type": "integer", - "format": "int64" + "format": "int64", + "description": "Shared input OMOP source concept when all specialties agree on both target and source concept. Text, UMLS routes, conflicting inputs, or differing source concepts leave `0`." }, "gender_source_value": { "type": "string", @@ -10699,7 +10947,7 @@ "gender_source_concept_id": { "type": "integer", "format": "int64", - "description": "Remains `0` for FHIR administrative-gender enum-policy results." + "description": "Remains `0` because FHIR administrative-gender codes do not themselves identify OMOP source concepts." } } }, @@ -10757,7 +11005,7 @@ }, "fhir2omop_ProviderRolePractitionerIdentifier": { "type": "object", - "description": "The logical identifier supplied on a PractitionerRole's practitioner reference.", + "description": "The logical identifier supplied on a PractitionerRole's practitioner reference. Preserves unknown NPI markers even though they are ignored for identity matching.", "required": [ "value" ], @@ -10944,11 +11192,11 @@ }, "observation_period_start_date": { "type": "string", - "description": "Earliest populated date among the person's visit, clinical, and death rows in this request; not enrollment evidence." + "description": "Earliest date selected by the source timing rules from the person's\nvisit and clinical rows not listed in rows_for_review, plus Specimen\nand Death rows in this request. No period row is returned without\nqualifying dates; not enrollment evidence.\n" }, "observation_period_end_date": { "type": "string", - "description": "Latest populated date, including end dates, among the person's visit, clinical, and death rows in this request; not enrollment evidence." + "description": "Latest date selected by the source timing rules from the person's\nvisit and clinical rows not listed in rows_for_review, plus Specimen\nand Death rows in this request. Includes source end dates from\nunindexed rows even when the output table has no end column.\nNo period row is returned without qualifying dates; not enrollment evidence.\n" }, "period_type_concept_id": { "type": "integer", @@ -10999,6 +11247,11 @@ }, "visit_source_value": { "type": "string" + }, + "visit_source_concept_id": { + "type": "integer", + "format": "int64", + "description": "OMOP source concept for the selected Encounter.class or Encounter.type coding when available; `0` when the source coding has no OMOP source concept, including the HL7 encounter class codes listed above." } } }, @@ -11019,14 +11272,14 @@ }, "condition_start_date": { "type": "string", - "description": "Date from Condition.onsetDateTime or onsetPeriod.start, otherwise Condition.recordedDate." + "description": "Start date selected by the source resource's timing rules. For a Condition, onsetDateTime or onsetPeriod.start, otherwise recordedDate; see the endpoint's timing sources for other resources." }, "condition_start_datetime": { "type": "string" }, "condition_end_date": { "type": "string", - "description": "Date from Condition.abatementDateTime or abatementPeriod.end." + "description": "End date selected by the source resource's timing rules, when available. For a Condition, abatementDateTime or abatementPeriod.end; see the endpoint's timing sources for other resources." }, "condition_end_datetime": { "type": "string" @@ -11072,14 +11325,14 @@ }, "drug_exposure_start_date": { "type": "string", - "description": "Date from the resource's timing source, such as effective[x], occurrenceDateTime, or MedicationRequest.authoredOn." + "description": "Start date selected by the source resource's timing rules, such as effective[x], occurrenceDateTime, MedicationRequest.authoredOn, or MedicationDispense.whenHandedOver; see the endpoint's timing sources for all supported resources." }, "drug_exposure_start_datetime": { "type": "string" }, "drug_exposure_end_date": { "type": "string", - "description": "Date from an explicit FHIR Period end, or the same day as the start for a single-event administration or immunization. Unset when the source supplies neither." + "description": "End date selected by the source resource's timing rules, when available. For medication resources, an explicit effectivePeriod.end or the start date of a single-event administration or immunization; see the endpoint's timing sources for other resources." }, "drug_exposure_end_datetime": { "type": "string" @@ -11090,7 +11343,8 @@ }, "drug_type_concept_id": { "type": "integer", - "format": "int64" + "format": "int64", + "description": "MedicationDispense uses 32825 (EHR dispensing record); MedicationRequest uses 32838 (EHR prescription). Other current resources use 32817 (EHR). These identify record provenance, not proof of medication consumption." }, "stop_reason": { "type": "string" @@ -11103,7 +11357,14 @@ "days_supply": { "type": "integer", "format": "int64", - "description": "Direct positive whole-day MedicationRequest.dispenseRequest.expectedSupplyDuration; no dose or quantity conversion is applied." + "description": "Direct positive whole-day MedicationRequest.dispenseRequest.expectedSupplyDuration or coded UCUM-day MedicationDispense.daysSupply; never derived from drug quantity, dosage, refills, or timing. Does not derive an exposure end date." + }, + "quantity": { + "type": "number", + "format": "double", + "minimum": 0, + "exclusiveMinimum": true, + "description": "Formulation-specific quantity from a direct MedicationRequest dispense quantity, MedicationDispense quantity, MedicationAdministration dose, or Immunization dose quantity. Under fixed_dose_form_count this is a matched coded tablet/capsule count copied directly, or an ingredient amount divided by per-unit strength. Under clinical_injection_ingredient_amount this is the ingredient amount, converted to the ingredient unit or derived using explicit concentration evidence; exact-unit ingredient amounts are copied directly. See the endpoint's supported dose forms and prerequisites; quantity_transformations identifies the policy, source, calculation evidence, intermediate UCUM conversion, and output unit, or explains why a supplied quantity was omitted." }, "sig": { "type": "string", @@ -11156,14 +11417,14 @@ }, "procedure_date": { "type": "string", - "description": "Date from Procedure.performedDateTime or performedPeriod.start." + "description": "Start date selected by the source resource's timing rules. For a Procedure, performedDateTime or performedPeriod.start; see the endpoint's timing sources for other resources." }, "procedure_datetime": { "type": "string" }, "procedure_end_date": { "type": "string", - "description": "Date from Procedure.performedPeriod.end." + "description": "End date selected by the source resource's timing rules, when available. For a Procedure, performedPeriod.end; see the endpoint's timing sources for other resources." }, "procedure_end_datetime": { "type": "string" @@ -11191,6 +11452,10 @@ }, "fhir2omop_MeasurementRow": { "type": "object", + "required": [ + "unit_concept_id", + "unit_source_concept_id" + ], "properties": { "measurement_id": { "type": "integer", @@ -11206,7 +11471,7 @@ }, "measurement_date": { "type": "string", - "description": "Date from Observation.effectiveDateTime, effectivePeriod.start, or effectiveInstant." + "description": "Start date selected by the source resource's timing rules. For an Observation, effectiveDateTime, effectivePeriod.start, or effectiveInstant; see the endpoint's timing sources for other resources." }, "measurement_datetime": { "type": "string" @@ -11230,7 +11495,9 @@ }, "unit_concept_id": { "type": "integer", - "format": "int64" + "format": "int64", + "nullable": true, + "description": "Valid standard Unit concept for coded UCUM; 0 for supplied but unmapped units, null for absent units. Always present in JSON." }, "range_low": { "type": "number", @@ -11255,8 +11522,15 @@ "type": "integer", "format": "int64" }, + "unit_source_concept_id": { + "type": "integer", + "format": "int64", + "nullable": true, + "description": "OMOP source concept ID corresponding to Quantity.system/code; 0 for supplied units without a matching source concept, null for absent units. Always present in JSON." + }, "unit_source_value": { - "type": "string" + "type": "string", + "description": "Original Quantity.code if supplied, otherwise Quantity.unit; human-readable unit text is preserved in the unit mapping's source_name." }, "value_source_value": { "type": "string" @@ -11265,6 +11539,9 @@ }, "fhir2omop_ObservationRow": { "type": "object", + "required": [ + "unit_concept_id" + ], "properties": { "observation_id": { "type": "integer", @@ -11280,7 +11557,7 @@ }, "observation_date": { "type": "string", - "description": "For an Observation, date from effectiveDateTime, effectivePeriod.start, or effectiveInstant. For an AllergyIntolerance, date from recordedDate, otherwise onsetDateTime or onsetPeriod.start." + "description": "Start date selected by the source resource's timing rules. For an Observation, effectiveDateTime, effectivePeriod.start, or effectiveInstant. For an AllergyIntolerance, recordedDate, otherwise onsetDateTime or onsetPeriod.start; see the endpoint's timing sources for other resources." }, "observation_datetime": { "type": "string" @@ -11302,7 +11579,9 @@ }, "unit_concept_id": { "type": "integer", - "format": "int64" + "format": "int64", + "nullable": true, + "description": "Valid standard Unit concept for coded UCUM; 0 for supplied but unmapped units, null for absent units. Always present in JSON." }, "visit_occurrence_id": { "type": "integer", @@ -11317,139 +11596,1214 @@ }, "observation_source_concept_id": { "type": "integer", - "format": "int64" + "format": "int64", + "description": "For coded AllergyIntolerance rows, the known OMOP identity of the original coding rendered in observation_source_value, even when the finding is unresolved. 0 when no original source identity is known; the configured contextual finding is not a source concept." }, "unit_source_value": { - "type": "string" + "type": "string", + "description": "Original Quantity.code if supplied, otherwise Quantity.unit; human-readable unit text is preserved in the unit mapping's source_name." }, "value_source_value": { "type": "string" } } }, - "fhir2omop_MappingEntry": { + "fhir2omop_MedicationDispenseSourceContext": { "type": "object", - "description": "How one source coding (or a text-only primary resource's free text) resolved to an OMOP standard concept. `omop_table`, `omop_field`, and `omop_id` link it to the row and concept field it produced. A coded medication route is a separate entry linked to the same drug_exposure row as its medication or vaccine coding. `selected` identifies the coding rendered in the associated row's `*_source_value`; it is false for alternate codings and text-only rows.", "required": [ - "selected" + "omop_table", + "omop_id", + "source" ], + "description": "Original dispensing context associated with one emitted clinical row. Response-local table/id links identify the actual destination, including a vocabulary-selected destination outside drug_exposure. This preserves source values and does not infer a duration, prescription, administration, or adherence.", "properties": { - "resource_type": { - "type": "string" - }, "resource_id": { - "type": "string" + "type": "string", + "description": "Original MedicationDispense.id, when supplied." + }, + "full_url": { + "type": "string", + "description": "Original Bundle entry fullUrl, when supplied." }, "omop_table": { - "type": "string" + "type": "string", + "enum": [ + "drug_exposure", + "condition_occurrence", + "procedure_occurrence", + "device_exposure", + "measurement", + "observation" + ] }, "omop_id": { "type": "integer", - "format": "int64", - "description": "The id of the OMOP row this coding produced (e.g. `condition_occurrence_id`),\nwithin `omop_table`. A resource with multiple codings yields one entry\nper coding, all sharing this id.\n" + "format": "int64" }, - "omop_field": { + "source": { + "type": "object", + "additionalProperties": true, + "description": "Only supplied fields are present, using original FHIR names and values: status, statusReasonCodeableConcept, statusReasonReference, category, type, whenPrepared, whenHandedOver, daysSupply, performer, context, authorizingPrescription, dosageInstruction, and medicationReference. Raw values preserve original precision, offsets, explicit nulls, and malformed values. Presence is not a promise that a value was mapped; diagnostics explain unusable values and mappings describe coded outcomes. Direct quantities are audited separately in quantity_transformations for drug_exposure rows." + } + } + }, + "fhir2omop_QuantityTransformation": { + "type": "object", + "description": "Audit of one supplied quantity on an emitted drug_exposure row. All evidence uses the response's vocab_version. Result and result_unit are present only for successful outcomes; reason is present only for OMITTED. Policy is present once a supported formulation policy was selected.", + "required": [ + "resource_type", + "resource_id", + "source_path", + "source_quantity", + "omop_table", + "omop_field", + "omop_id", + "outcome" + ], + "properties": { + "resource_type": { "type": "string", - "description": "The OMOP concept-ID field populated from this source coding, such as `condition_concept_id`, `route_concept_id`, or `race_concept_id`." + "enum": [ + "MedicationRequest", + "MedicationDispense", + "MedicationAdministration", + "Immunization" + ] }, - "source_system": { - "type": "string" + "resource_id": { + "type": "string", + "description": "Source FHIR resource id; empty when none was supplied." }, - "source_code": { - "type": "string" + "source_path": { + "type": "string", + "enum": [ + "dispenseRequest.quantity", + "quantity", + "dosage.dose", + "doseQuantity" + ], + "description": "Exact source element path relative to resource_type." }, - "source_name": { - "type": "string" + "source_quantity": { + "nullable": true, + "description": "Original supplied Quantity object, including value, system, code, unit, comparator, and other supplied fields. Explicit nulls and malformed values are also preserved as supplied." }, - "target_vocabulary": { - "type": "string" + "omop_table": { + "type": "string", + "enum": [ + "drug_exposure" + ] }, - "target_code": { + "omop_field": { "type": "string", - "description": "The standard concept's own code: the source code itself for an\nALREADY_STANDARD row, the standard concept's code for a MAPPED row,\nor the suggested code for an UNCHECKED row. Omitted for UNMAPPED\nrows.\n" + "enum": [ + "quantity" + ] }, - "target_name": { - "type": "string" + "omop_id": { + "type": "integer", + "format": "int64", + "description": "The drug_exposure_id of the row in this response." }, - "mapping_status": { + "outcome": { "type": "string", "enum": [ - "ALREADY_STANDARD", - "MAPPED", - "UNCHECKED", - "UNMAPPED" + "DIRECT", + "DERIVED", + "OMITTED" ], - "description": "ALREADY_STANDARD (source coding is already a standard OMOP concept),\nMAPPED (source coding was mapped to a standard concept), UNCHECKED (a\nstandard code was suggested for a text-only resource but not verified\nagainst the OMOP vocabulary, so `concept_id` stays `0`), or UNMAPPED\n(no standard concept found).\n" + "description": "DIRECT copies a matched fixed-form count or an ingredient amount in the same unit; DERIVED converts units or calculates a dose-form count or ingredient amount under the named policy; OMITTED leaves quantity unset while retaining the row." }, - "selected": { - "type": "boolean", - "description": "Whether this source coding was selected for the linked row's `*_source_value` field. Always present; false for alternate codings and text-only rows. For a Patient demographic, it marks the code that determined the PERSON field or race `observation` row, even when that code has no concept; it is false for race and ethnicity null flavors, conflicting values, a Patient `gender` overridden by birth sex, and race categories that did not determine the field." + "policy": { + "type": "string", + "enum": [ + "fixed_dose_form_count", + "clinical_injection_ingredient_amount" + ], + "description": "The formulation policy documented in the endpoint description. May be present for an omission occurring after policy selection." }, - "note": { + "reason": { "type": "string", - "description": "Additional context for the entry. A coded route is noted as\n`FHIR route`. Patient demographic entries name their source and, when\nnot applied, why:\n- `US Core birth sex`; `US Core birth sex; null flavor`;\n `US Core birth sex; outside value set`;\n `US Core birth sex; conflicting values`\n- `FHIR administrative gender; assumed sex at birth`;\n `FHIR administrative gender; not a sex-at-birth value`;\n `FHIR administrative gender; not used, birth sex supplied`\n- `US Core race OMB category`;\n `US Core race OMB category; more than one race` (linked to its\n `observation` race row); `US Core race; null flavor`\n- `US Core ethnicity OMB category`;\n `US Core ethnicity OMB category; conflicting categories`;\n `US Core ethnicity; null flavor`\n" + "enum": [ + "unsupported_quantity", + "unsupported_comparator", + "unsupported_unit", + "drug_unresolved", + "missing_drug_evidence", + "incomplete_drug_evidence", + "ambiguous_drug_evidence", + "missing_classification", + "unsupported_drug_class", + "unsupported_dose_form", + "unsupported_strength", + "unit_mismatch", + "unsupported_fraction", + "invalid_result" + ], + "description": "Stable omission reason:\n- unsupported_quantity: supplied value is not a Quantity object with a positive finite numeric value.\n- unsupported_comparator: a comparator field was supplied.\n- unsupported_unit: source system/code does not identify a UCUM-coded quantity or a supported HL7 TAB/CAP count.\n- drug_unresolved: no standard drug concept was resolved.\n- missing_drug_evidence: formulation and strength evidence is unavailable.\n- incomplete_drug_evidence: required vocabulary evidence is missing or invalid.\n- ambiguous_drug_evidence: more than one dose form exists, or more than one strength exists for a conversion requiring strength. Direct counts allow combination products.\n- missing_classification: the selected drug's concept class is unavailable.\n- unsupported_drug_class: the drug is outside Clinical Drug or Branded Drug, including quantified products and packs.\n- unsupported_dose_form: the dose form has no supported classification for these policies.\n- unsupported_strength: strength does not establish the selected policy, including missing, non-positive, non-finite, or conflicting values or an absent/unsupported denominator basis for volume conversion.\n- unit_mismatch: the source neither matches a validated vocabulary-backed strength unit nor converts within the documented supported unit groups, or an explicit count disagrees with the selected tablet/capsule form.\n- unsupported_fraction: a fixed-form count is not whole units or multiples of one-half or one-third.\n- invalid_result: arithmetic does not produce a positive finite result.\n" + }, + "evidence": { + "$ref": "#/components/schemas/fhir2omop_QuantityEvidence" + }, + "unit_conversion": { + "$ref": "#/components/schemas/fhir2omop_QuantityUnitConversion" + }, + "result": { + "type": "number", + "format": "double", + "minimum": 0, + "exclusiveMinimum": true, + "description": "Same numeric value as the linked row's quantity." + }, + "result_unit": { + "$ref": "#/components/schemas/fhir2omop_QuantityUnit" } } }, - "fhir2omop_Summary": { + "fhir2omop_QuantityUnitConversion": { "type": "object", - "description": "The request's data-quality headline: how row-producing coded concepts,\nselected routes, and Patient demographic fields split, and the share\nthat was not already in a target standard vocabulary. Each is counted\nonce even when it carried several codings \u2014 unlike `mappings`, which has\none entry per coding. For example, a medication code and its selected\ncoded route on the same `drug_exposure` row are separate outcomes;\nindependently checked report-only alternate route codings do not alter\n`summary`. A Patient demographic counts once per PERSON field, or per\nrace `observation` row, that received a non-null value; conflicting\nvalues count once as unmapped. Null flavors, Patient `gender` `other` or\n`unknown`, a Patient `gender` overridden by birth sex, and race\ncategories that did not determine the field are reported in `mappings`\nonly.\n", + "description": "Successful case-sensitive UCUM conversion within the documented supported unit groups, from the original source magnitude to a vocabulary-backed strength unit before applying the formulation policy. Present only when the unit code changes. May be present on an OMITTED outcome if a later policy check rejects the final result; result here is the intermediate converted amount, not the linked row's quantity.", + "required": [ + "source_code", + "result", + "result_unit" + ], "properties": { - "codes_already_standard": { - "type": "integer", - "description": "Resolution outcomes already a standard OMOP concept (ALREADY_STANDARD)." - }, - "codes_normalized": { - "type": "integer", - "description": "Resolution outcomes mapped or suggested to a standard concept (MAPPED or UNCHECKED)." - }, - "codes_unmapped": { - "type": "integer", - "description": "Resolution outcomes with no standard concept found (UNMAPPED)." + "source_code": { + "type": "string", + "description": "Original UCUM code from source_quantity.code." }, - "off_vocab_rate": { + "result": { "type": "number", "format": "double", - "description": "Share of resolution outcomes not already standard ((normalized + unmapped) / total)." + "minimum": 0, + "exclusiveMinimum": true, + "description": "Original source magnitude converted to result_unit, before strength arithmetic." + }, + "result_unit": { + "$ref": "#/components/schemas/fhir2omop_QuantityUnit" } } }, - "fhir2omop_DroppedResource": { + "fhir2omop_QuantityUnit": { "type": "object", + "description": "Vocabulary concept identifying the measurement unit. For fixed_dose_form_count results this is the RxNorm dose form being counted; for clinical_injection_ingredient_amount results it is the UCUM ingredient unit. Strength units use UCUM. Codes are vocabulary-native and concept IDs belong to vocab_version.", + "required": [ + "concept_id", + "code", + "vocabulary_id" + ], "properties": { - "resource_type": { - "type": "string" + "concept_id": { + "type": "integer", + "format": "int64" }, - "resource_id": { + "code": { "type": "string" }, - "reason": { + "vocabulary_id": { "type": "string" } } }, - "fhir2omop_ReferenceDiagnostic": { + "fhir2omop_QuantityEvidence": { "type": "object", - "description": "Explains why a source value or reference could not be used. When an affected OMOP row is emitted, omop_table and omop_id identify that row within this response, regardless of diagnostic outcome. They identify the affected output row, not a missing or rejected reference target. Multiple source resources can link to the same row. Both fields are omitted when there is no affected output row, including when the source resource is dropped. These IDs are response-local and are not stable across requests.", + "description": "Available selected-drug, dose-form, and single-ingredient strength evidence. Missing or ambiguous evidence is not replaced with an arbitrary value. On successful outcomes these fields explain the named policy's calculation.", + "required": [ + "drug_concept_id" + ], "properties": { - "resource_type": { - "type": "string" + "drug_concept_id": { + "type": "integer", + "format": "int64" }, - "resource_id": { + "drug_concept_class": { "type": "string" }, - "omop_table": { - "type": "string", - "description": "Table containing the affected row in this response. Present together with omop_id when that row is emitted." + "dose_form": { + "$ref": "#/components/schemas/fhir2omop_QuantityUnit" }, - "omop_id": { - "type": "integer", - "format": "int64", + "strength": { + "$ref": "#/components/schemas/fhir2omop_QuantityStrength" + } + } + }, + "fhir2omop_QuantityStrength": { + "type": "object", + "description": "Single-ingredient vocabulary strength evidence. Amount is ingredient per fixed unit; numerator/denominator describe a concentration. Raw missing values remain absent and are never overwritten by defaults. denominator_basis separately preserves terminology's interpretation of the denominator. No box-size or refill arithmetic is performed.", + "required": [ + "ingredient_concept_id" + ], + "properties": { + "ingredient_concept_id": { + "type": "integer", + "format": "int64" + }, + "amount_value": { + "type": "number", + "format": "double" + }, + "amount_unit": { + "$ref": "#/components/schemas/fhir2omop_QuantityUnit" + }, + "numerator_value": { + "type": "number", + "format": "double" + }, + "numerator_unit": { + "$ref": "#/components/schemas/fhir2omop_QuantityUnit" + }, + "denominator_value": { + "type": "number", + "format": "double" + }, + "denominator_unit": { + "$ref": "#/components/schemas/fhir2omop_QuantityUnit" + }, + "denominator_basis": { + "$ref": "#/components/schemas/fhir2omop_QuantityDenominatorBasis" + } + } + }, + "fhir2omop_QuantityDenominatorBasis": { + "type": "object", + "description": "Terminology interpretation of the raw vocabulary denominator, preserved even when conversion is omitted. PER_UNIT supplies value 1 for a normalized concentration; QUANTIFIED_TOTAL supplies the explicit product total; NOT_APPLICABLE and UNKNOWN have no value. Only PER_UNIT supports the clinical-injection volume policy. Omitted when terminology supplies no basis.", + "required": [ + "kind" + ], + "properties": { + "kind": { + "type": "string", + "enum": [ + "PER_UNIT", + "QUANTIFIED_TOTAL", + "NOT_APPLICABLE", + "UNKNOWN" + ] + }, + "value": { + "type": "number", + "format": "double", + "minimum": 0, + "exclusiveMinimum": true + } + } + }, + "fhir2omop_DeviceExposureRow": { + "type": "object", + "required": [ + "device_exposure_id", + "person_id", + "device_concept_id", + "device_type_concept_id", + "device_source_concept_id", + "unit_concept_id", + "unit_source_concept_id" + ], + "properties": { + "device_exposure_id": { + "type": "integer", + "format": "int64", + "description": "Request-local exposure identity, distinct from physical or model device identity." + }, + "person_id": { + "type": "integer", + "format": "int64", + "description": "Emitted Patient identity." + }, + "device_concept_id": { + "type": "integer", + "format": "int64", + "description": "Validated standard Device concept; 0 for missing or unresolved type evidence." + }, + "device_exposure_start_date": { + "type": "string", + "format": "date", + "description": "Source use start at day precision; absent when unknown or unusable." + }, + "device_exposure_start_datetime": { + "type": "string", + "description": "Source local wall-clock start, without UTC offset; absent for date-only input.", + "example": "2026-01-01T23:30:00" + }, + "device_exposure_end_date": { + "type": "string", + "format": "date", + "description": "Explicit source end; absent when unknown or unusable." + }, + "device_exposure_end_datetime": { + "type": "string", + "description": "Source local wall-clock end, without UTC offset; absent for date-only input.", + "example": "2026-01-03T08:00:00" + }, + "device_type_concept_id": { + "type": "integer", + "format": "int64", + "description": "Coarse source provenance; DeviceUseStatement uses generic EHR 32817." + }, + "unique_device_id": { + "type": "string", + "description": "Unambiguous supplied UDI-DI; competing values remain in source context." + }, + "production_id": { + "type": "string", + "description": "Unset; supplied production components and carriers remain in source context." + }, + "quantity": { + "type": "integer", + "format": "int64", + "description": "1 when the source has no quantity, dose, rate, or numeric Observation result field; omitted when such a field is supplied, including null or malformed values. See device_exposure_contexts.quantity_basis." + }, + "provider_id": { + "type": "integer", + "format": "int64", + "description": "Compatible explicit performer on routed rows; absent for DeviceUseStatement and reporting-only actors." + }, + "visit_occurrence_id": { + "type": "integer", + "format": "int64", + "description": "Explicit emitted same-patient visit for routed rows; absent for DeviceUseStatement." + }, + "visit_detail_id": { + "type": "integer", + "format": "int64", + "description": "Unset; no visit-detail linkage is inferred." + }, + "device_source_value": { + "type": "string", + "description": "Original selected type or clinical code, or supplied text; never a resource/inventory identifier." + }, + "device_source_concept_id": { + "type": "integer", + "format": "int64", + "description": "Original source concept when established by vocabulary resolution; otherwise 0." + }, + "unit_concept_id": { + "type": "integer", + "format": "int64", + "nullable": true, + "description": "Always null." + }, + "unit_source_value": { + "type": "string", + "description": "Unset; unsupported clinical units remain in source context." + }, + "unit_source_concept_id": { + "type": "integer", + "format": "int64", + "nullable": true, + "description": "Always null." + } + } + }, + "fhir2omop_DeviceExposureContext": { + "type": "object", + "description": "Shared evidence and quantity explanation for all device exposures derived from one source record. Matching or absent resource identifiers do not merge distinct source records.", + "required": [ + "resource_type", + "exposures", + "quantity_basis" + ], + "properties": { + "resource_type": { + "type": "string" + }, + "resource_id": { + "type": "string" + }, + "source_full_url": { + "type": "string" + }, + "exposures": { + "type": "array", + "minItems": 1, + "description": "Links to all device exposure rows derived from this source record.", + "items": { + "$ref": "#/components/schemas/fhir2omop_DeviceExposureLink" + } + }, + "quantity_basis": { + "type": "string", + "enum": [ + "default_absent_count", + "unsupported_supplied_evidence" + ], + "description": "Applies to every linked exposure. default_absent_count accompanies quantity 1 when the source has no quantity, dose, rate, or numeric Observation result field. unsupported_supplied_evidence accompanies an omitted quantity and UNSUPPORTED diagnostics when such a field is supplied, including null or malformed values." + }, + "evidence": { + "type": "array", + "description": "Original supplied source values with source paths. Device detail paths begin with device.; other paths refer to the clinical source resource. Timing precision, offsets, schedules, statuses, relationships, UDI carriers, and unsupported quantities are preserved here.", + "items": { + "$ref": "#/components/schemas/fhir2omop_DeviceExposureEvidence" + } + } + }, + "example": { + "resource_type": "DeviceUseStatement", + "resource_id": "use-1", + "exposures": [ + { + "device_exposure_id": 1, + "event_index": 0 + }, + { + "device_exposure_id": 2, + "event_index": 1 + } + ], + "quantity_basis": "default_absent_count", + "evidence": [ + { + "path": "timingTiming", + "value": { + "event": [ + "2026-01-01T09:00:00Z", + "2026-01-03T09:00:00Z" + ] + } + } + ] + } + }, + "fhir2omop_DeviceExposureLink": { + "type": "object", + "required": [ + "device_exposure_id" + ], + "properties": { + "device_exposure_id": { + "type": "integer", + "format": "int64", + "description": "Request-local ID of a row in tables.device_exposure." + }, + "event_index": { + "type": "integer", + "minimum": 0, + "description": "Zero-based index in this context's supplied timingTiming.event evidence, including unusable events retained as partial rows. Omitted for other timing forms." + } + } + }, + "fhir2omop_DeviceExposureEvidence": { + "type": "object", + "required": [ + "path", + "value" + ], + "properties": { + "path": { + "type": "string" + }, + "value": { + "description": "Original supplied JSON value, including null or malformed source evidence.", + "nullable": true + } + } + }, + "fhir2omop_SpecimenRow": { + "type": "object", + "required": [ + "specimen_id", + "person_id", + "specimen_concept_id", + "specimen_type_concept_id", + "unit_concept_id", + "anatomic_site_concept_id" + ], + "properties": { + "specimen_id": { + "type": "integer", + "format": "int64", + "description": "Request-local generated row identity; distinct from specimen_source_id." + }, + "person_id": { + "type": "integer", + "format": "int64", + "description": "Explicitly resolved top-level Patient in this response." + }, + "specimen_concept_id": { + "type": "integer", + "format": "int64", + "description": "Material validated against Specimen; 0 for supplied unmapped material or absent material. Absence has a linked type diagnostic and no mapping/summary count; supplied unmapped material has a vocabulary mapping outcome." + }, + "specimen_type_concept_id": { + "type": "integer", + "format": "int64", + "description": "32817 (EHR record provenance), independent of material and availability." + }, + "specimen_date": { + "type": "string", + "description": "Collection date from collectedDateTime or collectedPeriod.start when day precision exists. Omitted for missing, partial, invalid, or conflicting timing, with a linked diagnostic. Required by CDM 5.4 for loading, but not required for partial API output; downstream loaders own completion." + }, + "specimen_datetime": { + "type": "string", + "description": "Collection local wall-clock time, only when supplied; no fabricated time or offset conversion." + }, + "quantity": { + "type": "number", + "format": "double", + "description": "Exact nonnegative collection.quantity.value, without comparator or numeric conversion." + }, + "unit_concept_id": { + "type": "integer", + "format": "int64", + "nullable": true, + "description": "Valid standard Unit concept for coded UCUM; 0 for supplied unmapped units, null for absence." + }, + "anatomic_site_concept_id": { + "type": "integer", + "format": "int64", + "nullable": true, + "description": "Valid standard Spec Anatomic Site concept for a coded site; 0 for a supplied unmapped or text-only site, null for absence. Text-only sites retain source text without a concept suggestion." + }, + "specimen_source_id": { + "type": "string", + "description": "Resource id, first valued identifier, accession value, then fullUrl. All source identities and selection path are in specimen_source_contexts. Kept verbatim; downstream loaders must handle CDM varchar(50) limits." + }, + "specimen_source_value": { + "type": "string", + "description": "Selected bare material code or source text." + }, + "unit_source_value": { + "type": "string", + "description": "Exact Quantity.code when supplied, otherwise Quantity.unit." + }, + "anatomic_site_source_value": { + "type": "string", + "description": "Selected bare bodySite code or source text." + } + } + }, + "fhir2omop_SpecimenSourceContext": { + "type": "object", + "required": [ + "specimen_id" + ], + "properties": { + "specimen_id": { + "type": "integer", + "format": "int64" + }, + "resource_id": { + "type": "string" + }, + "full_url": { + "type": "string" + }, + "status": { + "type": "string", + "enum": [ + "available", + "unavailable", + "unsatisfactory" + ], + "description": "Original FHIR Specimen.status for this emitted row, describing sample availability or suitability. Omitted when no status was supplied; no availability is inferred. This is source metadata, not an OMOP disease-status concept." + }, + "identifiers": { + "type": "array", + "items": { + "$ref": "#/components/schemas/fhir2omop_SpecimenIdentifier" + } + }, + "accession_identifier": { + "$ref": "#/components/schemas/fhir2omop_SpecimenIdentifier" + }, + "source_id_path": { + "type": "string", + "description": "Selected source path (id, identifier[index].value, accessionIdentifier.value, or fullUrl); absent if no source identity was supplied." + }, + "collection_timing": { + "$ref": "#/components/schemas/fhir2omop_SpecimenCollectionTiming" + } + } + }, + "fhir2omop_SpecimenCollectionTiming": { + "type": "object", + "description": "Supplied FHIR timing retained verbatim for audit and downstream completion. This is response provenance, not a CDM table or an inferred collection date.", + "properties": { + "collected_date_time": { + "type": "string", + "description": "Original collection.collectedDateTime, including partial precision, invalid text, or timezone offset." + }, + "collected_period": { + "$ref": "#/components/schemas/fhir2omop_SpecimenCollectionPeriod" + }, + "received_time": { + "type": "string", + "description": "Original receivedTime, never substituted for collection timing." + }, + "selected_path": { + "type": "string", + "description": "collection.collectedDateTime or collection.collectedPeriod.start when specimen_date was populated; omitted when no date could safely be selected." + } + } + }, + "fhir2omop_SpecimenCollectionPeriod": { + "type": "object", + "properties": { + "start": { + "type": "string", + "description": "Original collectedPeriod.start, without precision completion." + }, + "end": { + "type": "string", + "description": "Original collectedPeriod.end, retained without using it as collection start." + } + } + }, + "fhir2omop_SpecimenIdentifier": { + "type": "object", + "required": [ + "value" + ], + "properties": { + "system": { + "type": "string" + }, + "value": { + "type": "string" + } + } + }, + "fhir2omop_MappingEntry": { + "type": "object", + "description": "How one source coding or supplied source text resolved to an OMOP standard concept, or was retained as source information. `omop_table` and `omop_id` link it to the output row; `omop_field` identifies the populated concept field, when applicable. Coded result values and supplied Quantity units have separate entries linked to their clinical row. Unit entries preserve the original Quantity.system/code and Quantity.unit in source_system/source_code/source_name, including OID spellings. A coded medication route is a separate entry linked to the same output row as its medication or vaccine coding. Routes on rows outside `drug_exposure`, and Observation values on rows outside `measurement` / `observation`, omit `omop_field`. `selected` identifies the coding rendered in the associated row's `*_source_value`; it is false for alternate codings, text-only rows, and entries without a corresponding row column.", + "required": [ + "selected" + ], + "example": { + "resource_type": "Condition", + "resource_id": "condition-123", + "omop_table": "condition_occurrence", + "omop_id": 7, + "omop_field": "condition_concept_id", + "source_system": "https://your-org.example/custom-codes", + "source_code": "DX-123", + "source_name": "Type 2 diabetes mellitus", + "target_vocabulary": "SNOMED", + "target_code": "44054006", + "mapping_status": "MAPPED", + "selected": true, + "resolution": { + "status": "RESOLVED", + "semantic_mapping": { + "status": "MAPPED", + "vocab_version": "v20260829" + } + } + }, + "properties": { + "resource_type": { + "type": "string" + }, + "resource_id": { + "type": "string" + }, + "omop_table": { + "type": "string" + }, + "omop_id": { + "type": "integer", + "format": "int64", + "description": "The id of the OMOP row this coding produced (e.g. `condition_occurrence_id`),\nwithin `omop_table`. A resource with multiple codings yields one entry\nper coding, all sharing this id.\n" + }, + "omop_field": { + "type": "string", + "description": "The OMOP concept-ID field populated from this source coding, such as `condition_concept_id`, `route_concept_id`, or `race_concept_id`. Omitted when the entry preserves source information without populating a row column." + }, + "source_system": { + "type": "string" + }, + "source_code": { + "type": "string" + }, + "source_name": { + "type": "string" + }, + "source_coding_index": { + "type": "integer", + "minimum": 0, + "description": "Zero-based index of this coding in its originating specialty entry's coding array. When specialty audit is available, it also identifies the corresponding resolution.specialty_resolution.coding_results outcome. Present only for coded specialty mappings, including empty Coding objects; these mappings retain the supplied coding order." + }, + "target_vocabulary": { + "type": "string" + }, + "target_code": { + "type": "string", + "description": "The standard concept's own code: the source code itself for an\nALREADY_STANDARD row, the standard concept's code for a MAPPED row,\nor the suggested code for an UNCHECKED row. Omitted for UNMAPPED\nrows.\n" + }, + "target_name": { + "type": "string" + }, + "mapping_status": { + "type": "string", + "enum": [ + "ALREADY_STANDARD", + "MAPPED", + "UNCHECKED", + "UNMAPPED" + ], + "description": "ALREADY_STANDARD (source coding is already a standard OMOP concept),\nMAPPED (source coding or descriptive source text was mapped to a standard\nconcept), UNCHECKED (a suggested code has not been validated against\nthe OMOP vocabulary, so `concept_id` is `0`), or UNMAPPED\n(no target concept selected, including missing mappings,\ntarget-domain mismatches and ambiguity, or source information\nretained without concept resolution as explained in `note`). For\nspecialties, this describes the entry-level result selected from\nsupported codings or explicit text. A local coding can remain\nunsupported even when the entry resolves; its independent outcome\nremains in resolution.specialty_resolution.coding_results.\n" + }, + "selected": { + "type": "boolean", + "description": "Whether this source coding was selected for the linked row's `*_source_value` field. Always present; false for alternate codings, text-only rows, and entries without a corresponding row column. For a Patient demographic, it marks the code that determined the PERSON field or race `observation` row, even when that code has no concept; it is false for race and ethnicity null flavors, conflicting values, a Patient `gender` overridden by birth sex, and race categories that did not determine the field." + }, + "resolution": { + "$ref": "#/components/schemas/fhir2omop_MappingResolution" + }, + "note": { + "type": "string", + "description": "Additional context for the entry, including the output table,\npopulated fields, source information without a corresponding row\ncolumn, and `DOMAIN_MISMATCH` outcomes. A coded route is noted as\n`FHIR route`. Patient demographic entries name their source and, when\nnot applied, why:\n- `US Core birth sex`; `US Core birth sex; null flavor`;\n `US Core birth sex; outside value set`;\n `US Core birth sex; conflicting values`\n- `FHIR administrative gender; assumed sex at birth`;\n `FHIR administrative gender; not a sex-at-birth value`;\n `FHIR administrative gender; not used, birth sex supplied`\n- `US Core race OMB category`;\n `US Core race OMB category; more than one race` (linked to its\n `observation` race row); `US Core race; null flavor`\n- `US Core ethnicity OMB category`;\n `US Core ethnicity OMB category; conflicting categories`;\n `US Core ethnicity; null flavor`\n" + } + } + }, + "fhir2omop_SemanticMappingResult": { + "type": "object", + "required": [ + "status", + "vocab_version" + ], + "description": "Result of semantic mapping for a supported concept field. Present when\nsemantic mapping was attempted; omitted otherwise. The containing\nMappingEntry identifies the source and output field. Use its `omop_table`\nto select a table in `tables` and its\n`omop_id` to find the row by that table's primary key; `omop_field` identifies\nthe concept column. For example, `omop_table: condition_occurrence` and\n`omop_id: 7` refer to the row with `condition_occurrence_id: 7` in\n`tables.condition_occurrence`. Multiple source codings can produce entries\nfor the same output field; group by `omop_table`, `omop_id`, and `omop_field`\nto process each field once.\n\nOriginal source coding and source concept IDs are preserved;\ntext-only inputs retain their description without an invented source coding.\n", + "example": { + "status": "UNMAPPED", + "vocab_version": "v20260829", + "reason_code": "AMBIGUOUS" + }, + "properties": { + "status": { + "type": "string", + "enum": [ + "MAPPED", + "UNMAPPED" + ], + "description": "MAPPED when semantic mapping selected a validated standard OMOP concept; UNMAPPED when no standard concept was selected." + }, + "vocab_version": { + "type": "string", + "description": "OMOP vocabulary release selected for this mapping." + }, + "reason_code": { + "type": "string", + "enum": [ + "MISSING_DESCRIPTION", + "NO_VALID_SUGGESTION", + "AMBIGUOUS", + "INVALID_TARGET" + ], + "description": "Why semantic mapping did not select a standard concept. Present when\n`status` is UNMAPPED; omitted when `status` is MAPPED.\n\n- `MISSING_DESCRIPTION`: no usable source text or coding display was supplied.\n- `NO_VALID_SUGGESTION`: no candidate was accepted as having the same clinical meaning as the source description.\n- `AMBIGUOUS`: a unique standard concept could not be selected from the possible matches.\n- `INVALID_TARGET`: the suggested code is not an active standard OMOP concept valid for the destination field.\n\nThe linked concept field retains ID 0. An unresolved result-value field\ndoes not unset the row's primary concept.\n" + } + } + }, + "fhir2omop_MappingResolution": { + "type": "object", + "description": "Optional field-level mapping outcome and provenance. When a field has\nseveral source-coding entries, they share this outcome; paths describe\nthe selected concept rather than separate mappings of every alternate\ncoding. Present for semantic mapping attempts. Omitted for fields\nretained without concept resolution, including text-only collection\nsites, text-only units, units with missing coding, unsupported unit\nsystems, and result values whose output table has no value column.\nFree-text diagnostics and unselected candidates are not returned.\nAllergy source evidence can report complete standard target IDs to\nexplain source interpretation and ambiguity; these are not independently\nselected substance values.\n", + "required": [ + "status" + ], + "properties": { + "semantic_mapping": { + "$ref": "#/components/schemas/fhir2omop_SemanticMappingResult" + }, + "specialty_resolution": { + "$ref": "#/components/schemas/fhir2omop_SpecialtyResolution" + }, + "source_text": { + "type": "string", + "description": "Explicit source text used to select a specialty concept. Its vocabulary spelling or full preferred term is retained separately in paths[].matched_text." + }, + "status": { + "type": "string", + "enum": [ + "RESOLVED", + "UNRESOLVED", + "DOMAIN_MISMATCH", + "AMBIGUOUS" + ], + "description": "Vocabulary resolution outcome. Any outcome other than RESOLVED yields mapping_status UNMAPPED and concept ID 0 while retaining an otherwise usable row." + }, + "reason_code": { + "type": "string", + "enum": [ + "NO_SOURCE_CODE", + "NO_OMOP_SOURCE_CONCEPT", + "NO_STANDARD_MAP", + "AMBIGUOUS", + "DOMAIN_MISMATCH", + "UNSUPPORTED_SYSTEM", + "UNSUPPORTED_CODE", + "CONFLICTING_ALLERGY_EVIDENCE", + "UNSUPPORTED_ALLERGY_SOURCE", + "UNSUPPORTED_ALLERGY_CONTEXT", + "UNSUPPORTED_SOURCE_MEANING" + ], + "description": "Machine-readable explanation for an unresolved mapping, when available:\n\n- `NO_SOURCE_CODE`: no source coding was supplied.\n- `NO_OMOP_SOURCE_CONCEPT`: no OMOP source concept matches the supplied coding.\n- `NO_STANDARD_MAP`: no usable standard OMOP mapping was found for the supplied coding.\n- `AMBIGUOUS`: multiple candidate concepts could not be disambiguated.\n- `DOMAIN_MISMATCH`: candidate standard concepts do not match the domain required by the linked OMOP field.\n- `UNSUPPORTED_SYSTEM`: the source code system is unsupported.\n- `UNSUPPORTED_CODE`: the source code is unsupported for the linked OMOP field.\n- `CONFLICTING_ALLERGY_EVIDENCE`: allergy source meanings or context conflict.\n- `UNSUPPORTED_ALLERGY_SOURCE`: no supported allergy source meaning was established.\n- `UNSUPPORTED_ALLERGY_CONTEXT`: the source context has no supported finding policy.\n- `UNSUPPORTED_SOURCE_MEANING`: a known source concept has no supported allergy interpretation.\n\nUnknown reason codes are omitted; `status` still records the outcome.\n" + }, + "via": { + "type": "string", + "enum": [ + "DIRECT", + "UMLS_BRIDGE", + "CONTEXTUAL", + "CONCEPT_NAME", + "CONCEPT_SYNONYM", + "UMLS_STRING", + "NUCC_LABEL" + ], + "description": "How the selected concept was reached. DIRECT may use an OMOP Maps to relationship; UMLS_BRIDGE identifies resolution through a shared UMLS concept; CONTEXTUAL identifies an explicit transformation of a substance-coded allergy/intolerance statement using its FHIR context. Specialty text uses CONCEPT_NAME, CONCEPT_SYNONYM, UMLS_STRING, then NUCC_LABEL in that order. NUCC_LABEL removes only the grouping prefix from a NUCC preferred term while retaining the full classification and specialization. Omitted when unavailable or no concept was selected." + }, + "paths": { + "type": "array", + "description": "Mapping paths to the selected concept, in response order. Omitted when unavailable or no concept was selected.", + "items": { + "$ref": "#/components/schemas/fhir2omop_MappingPath" + } + }, + "truncated": { + "type": "boolean", + "description": "Present and true when candidate matches were truncated during resolution. Absence means no truncation was reported." + }, + "allergy": { + "$ref": "#/components/schemas/fhir2omop_AllergyMappingResolution" + } + } + }, + "fhir2omop_AllergyMappingResolution": { + "type": "object", + "description": "Allergy finding policy and source evidence, shared by that field's\ncoding entries. Its status/reason describe contextual interpretation.\nWhen contextual coverage is unavailable and existing domain-based\nmapping is used, the enclosing resolution describes that mapping while\nthis object retains the contextual no-match. Evidence refers to original\ncodings explicitly, independent of the order of mapping entries.\nSource clinical status and verification status are retained as codings;\nno absent mechanism, certainty, or activity is implied. The incomplete\nflag describes source interpretation coverage, not whether both OMOP\nfields resolved. Check each field's mapping separately.\n", + "required": [ + "method", + "status", + "categories", + "effective_categories", + "category_derived", + "incomplete", + "information_loss", + "evidence" + ], + "properties": { + "method": { + "type": "string", + "enum": [ + "DIRECT", + "CONTEXTUAL", + "DERIVED", + "UNRESOLVED" + ], + "description": "Direct vocabulary finding mapping, contextual finding selection, selection using a derived category, or no contextual finding selected." + }, + "status": { + "type": "string", + "enum": [ + "RESOLVED", + "UNRESOLVED", + "DOMAIN_MISMATCH", + "AMBIGUOUS" + ] + }, + "reason_code": { + "type": "string", + "description": "Machine-readable reason for the contextual policy outcome, when available." + }, + "type": { + "type": "string", + "enum": [ + "allergy", + "intolerance" + ], + "description": "Supplied FHIR type; omitted when absent." + }, + "categories": { + "type": "array", + "description": "Supplied FHIR category values, preserving their order and repeats; null primitive placeholders are omitted. Empty when no category value is supplied.", + "items": { + "type": "string", + "enum": [ + "food", + "medication", + "environment", + "biologic" + ] + } + }, + "clinical_status": { + "type": "array", + "description": "Supplied clinical-status codings; omitted when none are available.", + "items": { + "$ref": "#/components/schemas/fhir2omop_Coding" + } + }, + "verification_status": { + "type": "array", + "description": "Supplied verification-status codings; omitted when absent. A retained unconfirmed statement is not promoted to confirmed.", + "items": { + "$ref": "#/components/schemas/fhir2omop_Coding" + } + }, + "date_source": { + "type": "string", + "enum": [ + "recordedDate", + "onsetDateTime", + "onsetPeriod.start" + ], + "description": "Source element selected by the existing date rule, including when its value has partial precision. Omitted when no usable or partial date is supplied; no date is invented." + }, + "effective_categories": { + "type": "array", + "description": "Distinct sorted categories used by the contextual policy, including any reviewed derivation.", + "items": { + "type": "string" + } + }, + "category_derived": { + "type": "boolean", + "description": "Whether reviewed vocabulary evidence supplied an absent category. This can also accompany a direct finding." + }, + "incomplete": { + "type": "boolean", + "description": "Whether some coding evidence was uninterpretable or outside policy coverage." + }, + "information_loss": { + "type": "array", + "description": "Explanations of specificity omitted by the selected finding or published mapping. Source coding and context remain in the audit metadata.", + "items": { + "type": "string" + } + }, + "evidence": { + "type": "array", + "items": { + "$ref": "#/components/schemas/fhir2omop_AllergyCodingEvidence" + } + } + } + }, + "fhir2omop_AllergyCodingEvidence": { + "type": "object", + "description": "Interpretation of an original allergy code. Concept IDs are vocabulary evidence, not a selected value or a claim that a product's complete meaning fits one value field.", + "required": [ + "coding", + "meaning", + "standard_concept_ids" + ], + "properties": { + "coding": { + "$ref": "#/components/schemas/fhir2omop_Coding" + }, + "meaning": { + "type": "string", + "enum": [ + "SUBSTANCE", + "FINDING", + "NEGATIVE", + "UNSUPPORTED", + "UNINTERPRETABLE" + ] + }, + "source_concept_id": { + "type": "integer", + "format": "int64", + "description": "Original coding's OMOP source identity, when known. This evidence does not override the linked row's source-concept semantics." + }, + "standard_concept_ids": { + "type": "array", + "description": "Standard targets reported for the original code. Empty when none are available; multiple targets do not choose one ingredient as the complete substance value.", + "items": { + "type": "integer", + "format": "int64" + } + }, + "category": { + "type": "string", + "description": "Reviewed substance category, when available." + }, + "reason_code": { + "type": "string", + "description": "Machine-readable interpretation gap, when available." + } + } + }, + "fhir2omop_SpecialtyResolution": { + "type": "object", + "required": [ + "text_fallback_reason", + "coding_results" + ], + "description": "Specialty-level fallback decision and independent coding outcomes, shared\nby the specialty entry's mappings. Use resolution.status and the mapping\ntarget for the selected result; a coding can remain unsupported while\nexplicit text successfully resolves. Omitted when unavailable or when\naudit values are unrecognized or cannot be associated with the supplied\ncodings. Omission does not change the selected concept or field-level\nresolution outcome.\n", + "properties": { + "text_fallback_reason": { + "type": "string", + "enum": [ + "NO_CODINGS", + "INVALID_CODING", + "SUPPORTED_SYSTEM_PRESENT", + "NO_SPECIALTY_TEXT", + "UNSUPPORTED_SYSTEMS_ONLY" + ], + "description": "NO_CODINGS identifies ordinary text-only resolution. INVALID_CODING\nmeans a missing code or missing/malformed system blocks fallback.\nSUPPORTED_SYSTEM_PRESENT blocks fallback even when a supported code\ndoes not resolve. NO_SPECIALTY_TEXT means complete unsupported\ncodings lack explicit nonblank text. UNSUPPORTED_SYSTEMS_ONLY means\ncomplete unsupported codings permit a text attempt; the attempt may\nstill be unresolved or ambiguous.\n" + }, + "coding_results": { + "type": "array", + "description": "Independent outcome of every supplied coding in its original order, including empty or incomplete objects. Empty for text-only specialties. Each mapping's source_coding_index identifies its corresponding outcome.", + "items": { + "$ref": "#/components/schemas/fhir2omop_SpecialtyCodingResult" + } + } + } + }, + "fhir2omop_SpecialtyCodingResult": { + "type": "object", + "required": [ + "coding_index", + "system_support", + "status" + ], + "properties": { + "coding_index": { + "type": "integer", + "minimum": 0, + "description": "Zero-based index in the originating specialty entry's coding array." + }, + "system_support": { + "type": "string", + "enum": [ + "SUPPORTED", + "UNSUPPORTED", + "INVALID" + ], + "description": "Whether the coding system is supported for resolution, unsupported, or missing/malformed. A missing code can still have a supported or unsupported system." + }, + "status": { + "type": "string", + "enum": [ + "RESOLVED", + "UNRESOLVED", + "DOMAIN_MISMATCH", + "AMBIGUOUS" + ], + "description": "This coding's independent outcome before combining the entry's evidence." + }, + "reason_code": { + "type": "string", + "enum": [ + "NO_SOURCE_CODE", + "NO_OMOP_SOURCE_CONCEPT", + "NO_STANDARD_MAP", + "AMBIGUOUS", + "DOMAIN_MISMATCH", + "UNSUPPORTED_SYSTEM", + "UNSUPPORTED_CODE" + ], + "description": "Machine-readable explanation when available; omitted for successful codings and unknown reasons." + } + } + }, + "fhir2omop_MappingPath": { + "type": "object", + "description": "One mapping path to the selected concept. Its source is the\ninput code's OMOP concept on a direct path, a sibling concept on a UMLS\nbridge, or a concept from an explicit mapping rule. Path source IDs\ndescribe provenance and must not replace the original input's concept\nID in the corresponding OMOP source-concept column, where the table\ndefines one.\n", + "required": [ + "mapping_status", + "source_concept_id" + ], + "properties": { + "mapping_status": { + "type": "string", + "enum": [ + "ALREADY_STANDARD", + "MAPPED", + "POLICY_MAPPED" + ], + "description": "Path outcome. MAPPED denotes an OMOP Maps to crosswalk; POLICY_MAPPED identifies an explicit vocabulary mapping policy and is retained here even though the enclosing mapping_status reports MAPPED." + }, + "source_concept_id": { + "type": "integer", + "format": "int64", + "description": "OMOP concept at the start of this mapping path." + }, + "source_vocabulary": { + "type": "string" + }, + "source_code": { + "type": "string", + "description": "Code of the path's source concept in its OMOP vocabulary." + }, + "cui": { + "type": "string", + "description": "Shared UMLS concept identifier for UMLS_BRIDGE, UMLS_STRING, or NUCC_LABEL, when supplied." + }, + "sab": { + "type": "string", + "description": "UMLS sibling source abbreviation, such as NUCCHCPT for NUCC routes." + }, + "matched_text": { + "type": "string", + "description": "Actual vocabulary string matched by a specialty text layer. For NUCC_LABEL this is the full original preferred term, including its grouping prefix; source_text retains the supplied text." + } + } + }, + "fhir2omop_Summary": { + "type": "object", + "description": "The request's data-quality headline: how resolution outcomes split, and\nthe share that was not already in a target standard vocabulary. Counts\ninclude primary clinical concepts, whether coded or text-only; coded\nObservation result values on `measurement` or `observation` rows;\nreported allergy substance values;\nsupplied Quantity units on measurement, observation, or specimen rows;\nsupplied specimen material and sites; Encounter classification outcomes;\nAddress.country, CMS Place of Service, and recorded Practitioner gender;\ndistinct Provider specialty inputs per provider;\ncoded medication/immunization routes (including those reported only in\n`mappings` when the output table is not `drug_exposure`); and Patient\ndemographic fields. Each outcome is counted once even when its source\ncarried several codings \u2014 unlike `mappings`, which has\none entry per coding. For example, a medication code and its selected\ncoded route on the same `drug_exposure` row are separate outcomes;\na coded Observation result on a measurement or observation row counts\nseparately from its primary concept, even when the result is unresolved;\neach supplied Quantity unit on a measurement, observation, or specimen row also\ncounts once, including text-only units;\neach supplied specimen site counts once, including text-only sites;\nabsent specimen material and sites do not count;\nabsent units do not count;\nalternate route codings reported only in `mappings` do not alter\n`summary`. A Patient demographic counts once per PERSON field, or per\nrace `observation` row, that received a non-null value; conflicting\nvalues count once as unmapped. Null flavors, Patient `gender` `other` or\n`unknown`, a Patient `gender` overridden by birth sex, and race\ncategories that did not determine the field are reported in `mappings`\nonly. Observation values retained only in `mappings` because the output\ntable is neither `measurement` nor `observation` do not add resolution\noutcomes.\n", + "properties": { + "codes_already_standard": { + "type": "integer", + "description": "Resolution outcomes already a standard OMOP concept (ALREADY_STANDARD)." + }, + "codes_normalized": { + "type": "integer", + "description": "Resolution outcomes mapped or suggested to a standard concept (MAPPED or UNCHECKED)." + }, + "codes_unmapped": { + "type": "integer", + "description": "Resolution outcomes with no standard concept valid for the destination selected (UNMAPPED)." + }, + "rows_for_review": { + "type": "integer", + "description": "Length of the rows_for_review index; each row counts once even with multiple retention reasons. codes_* and off_vocab_rate include all shaped rows; dropped resources do not count." + }, + "off_vocab_rate": { + "type": "number", + "format": "double", + "description": "Share of resolution outcomes not already standard ((normalized + unmapped) / total)." + } + } + }, + "fhir2omop_DroppedResource": { + "type": "object", + "properties": { + "resource_type": { + "type": "string" + }, + "resource_id": { + "type": "string" + }, + "reason": { + "type": "string" + } + } + }, + "fhir2omop_ReferenceDiagnostic": { + "type": "object", + "description": "Explains why a source value or reference could not be used. When an affected OMOP row is emitted, omop_table and omop_id identify that row within this response, regardless of diagnostic outcome. They identify the affected output row, not a missing or rejected reference target. Multiple source resources can link to the same row. Both fields are omitted when there is no affected output row, including when the source resource is dropped. These IDs are response-local and are not stable across requests.", + "properties": { + "resource_type": { + "type": "string" + }, + "resource_id": { + "type": "string" + }, + "omop_table": { + "type": "string", + "description": "Table containing the affected row in this response. Present together with omop_id when that row is emitted." + }, + "omop_id": { + "type": "integer", + "format": "int64", "description": "Primary key of the affected row within omop_table in this response. Present together with omop_table when that row is emitted." }, "path": { "type": "string", - "description": "FHIR element path on the source resource." + "description": "FHIR element path on the source resource, or slash-separated related paths for a joint rule such as whenHandedOver/daysSupply." }, "reference": { "type": "string", @@ -11462,10 +12816,12 @@ "AMBIGUOUS", "CONFLICTING", "UNSUPPORTED" - ] + ], + "description": "UNRESOLVED: a source value needed to populate a field is missing,\nsuch as specimen material or collection start, or a supported\nreference cannot resolve to a resource or emitted target supplied\nin this request.\nAMBIGUOUS: a reference or identifier matches multiple possible\ntargets. Identifier-only `PractitionerRole.practitioner` references\nare unambiguous when all matches map to one canonical provider.\nLiteral references matching multiple source resources remain\nambiguous even if those Practitioners merge into one provider.\nCONFLICTING: supplied values or relationships cannot be combined\nunder the mapping policy, such as differing mapped provider identity\nattributes, multiple distinct NPIs on one Practitioner, conflicting\nPatient demographic extensions, or an Encounter belonging to a\ndifferent patient.\nUNSUPPORTED: a supplied reference form, resource type, or value\nshape is outside the supported mapping policy.\n" }, "reason": { - "type": "string" + "type": "string", + "description": "Explanation of the condition and, when applicable, its effect on mapping." } } }, @@ -12617,7 +13973,7 @@ }, "resource": { "type": "string", - "description": "Type of FHIR resource to create. Use 'auto' for automatic resource type detection, or specify a supported profile. The default profile set includes US Core profiles and selected base R4 resources; you can also use any custom profile you've uploaded (if you're a develop or launch customer).\n", + "description": "Type of FHIR resource to create. Use 'auto' for automatic resource type detection, or specify a supported profile. The default profile set includes US Core profiles and selected base R4 resources. The built-in PhenoML HPO profiles must be requested by id: `phenoml-hpo-condition` and `phenoml-hpo-phenotypic-observation` code findings with the Human Phenotype Ontology, and `phenoml-hpo-head-circumference-tape-measure` and `phenoml-hpo-head-circumference-percentile` record a head circumference measured as a length or stated as an age-adjusted percentile. You can also use any custom profile you've uploaded (if you're a develop or launch customer).\n", "example": "condition-encounter-diagnosis", "enum": [ "auto", @@ -12634,6 +13990,10 @@ "observation-clinical-result", "observation-lab", "patient", + "phenoml-hpo-condition", + "phenoml-hpo-phenotypic-observation", + "phenoml-hpo-head-circumference-tape-measure", + "phenoml-hpo-head-circumference-percentile", "procedure", "questionnaire", "questionnaireresponse", @@ -12722,7 +14082,7 @@ }, "implementation_guide": { "type": "string", - "description": "Implementation Guide name to group this profile under. Defaults to \"custom\" if omitted. Cannot be \"us_core\" (reserved). Use this to organize custom profiles into named IGs that can be referenced when calling create/multi or document/multi endpoints.\n", + "description": "Implementation Guide name to group this profile under. Defaults to \"custom\" if omitted. Cannot be \"us_core\" or \"phenoml_hpo\" (reserved). Use this to organize custom profiles into named IGs that can be referenced when calling create/multi or document/multi endpoints.\n", "example": "acme-cardiology" }, "profile_context": { @@ -12739,7 +14099,7 @@ }, "lang2fhir_PatientReference": { "type": "object", - "description": "Business identifier for the document's primary patient. When Lang2FHIR identifies that Patient in generated output, it adds this identifier to the Patient's identifier list (preserving existing identifiers). If no Patient is generated, Lang2FHIR uses it in logical references on generated clinical resources. Supply the patient-level identifier (not an order or specimen identifier).\n", + "description": "Business identifier for the primary patient. The bundle's Patient carries this identifier alongside its synthetic one, unless the Patient found in the source contradicts primary_patient. Supply the patient-level identifier (not an order or specimen identifier).\n", "required": [ "system", "value" @@ -12771,7 +14131,7 @@ }, "given": { "type": "array", - "description": "Given names. Matching succeeds when a generated name has a supplied given name.", + "description": "Given names.", "items": { "type": "string" }, @@ -12784,7 +14144,7 @@ "lang2fhir_PrimaryPatient": { "type": "object", "minProperties": 1, - "description": "Partial context for the patient the document is primarily about. This is not a complete FHIR Patient resource. Lang2FHIR uses the available context to identify a generated primary Patient reliably. An identifier supplied here is added to that Patient; when no Patient is generated, it is used in logical references on generated clinical resources.\n", + "description": "Partial context for the patient the source is primarily about. This is not a complete FHIR Patient resource. When the source does not name its patient, the bundle's Patient is built from exactly these fields. When it does, the identifier supplied here is added to that Patient unless its name, birth date, or gender contradicts these fields, so the bundle can be matched back to the caller's own record.\n", "properties": { "identifier": { "$ref": "#/components/schemas/lang2fhir_PatientReference" @@ -12806,7 +14166,7 @@ "other", "unknown" ], - "description": "Administrative gender. This corroborates another match but does not identify a patient alone.", + "description": "Administrative gender.", "example": "female" } }, @@ -12848,7 +14208,12 @@ "example": "canvas" }, "primary_patient": { - "$ref": "#/components/schemas/lang2fhir_PrimaryPatient" + "allOf": [ + { + "$ref": "#/components/schemas/lang2fhir_PrimaryPatient" + } + ], + "description": "Context for the patient the text is primarily about. When the text does not name its patient, the bundle's Patient is built from these fields. When it does, the identifier is added to that Patient unless its name, birth date, or gender contradicts these fields.\n" }, "patient_reference": { "$ref": "#/components/schemas/lang2fhir_PatientReference", @@ -12857,13 +14222,14 @@ }, "implementation_guide": { "type": "string", - "description": "Custom Implementation Guide name. When specified, profiles from this IG are included alongside the default profiles during resource detection. Default profiles are always the base layer; custom IG profiles are additive.\n", + "description": "Implementation Guide to generate resources under. `us_core` (the default) and `phenoml_hpo` select a built-in guide. Any other value is a custom Implementation Guide name: its profiles are included alongside the US Core profiles, which are always the base layer. A custom name that matches no uploaded Implementation Guide returns 404.\n", + "default": "us_core", "example": "acme-cardiology" }, "detection_effort": { "type": "string", "deprecated": true, - "description": "Deprecated; use the default 'standard' value. This field will be removed in a future release. 'standard' runs detection once; 'deep' runs detection multiple times for higher recall.", + "description": "Deprecated and ignored: both values produce the same result. This field will be removed in a future release.", "enum": [ "standard", "deep" @@ -12882,6 +14248,11 @@ }, "resource_review": { "$ref": "#/components/schemas/lang2fhir_ResourceReview" + }, + "small_note": { + "type": "boolean", + "default": false, + "description": "Bills the request at the lower small-note rate. When true, `text` must be at most 15,360 bytes (15 KiB) when UTF-8 encoded; longer text is rejected with a 400 error. Extraction behavior is the same as a standard request.\n" } } }, @@ -13097,7 +14468,12 @@ "example": "canvas" }, "primary_patient": { - "$ref": "#/components/schemas/lang2fhir_PrimaryPatient" + "allOf": [ + { + "$ref": "#/components/schemas/lang2fhir_PrimaryPatient" + } + ], + "description": "Context for the patient the document is primarily about. When the document does not name its patient, the bundle's Patient is built from these fields. When it does, the identifier is added to that Patient unless its name, birth date, or gender contradicts these fields.\n" }, "patient_reference": { "$ref": "#/components/schemas/lang2fhir_PatientReference", @@ -13106,13 +14482,14 @@ }, "implementation_guide": { "type": "string", - "description": "Custom Implementation Guide name. When specified, profiles from this IG are included alongside the default profiles during resource detection. Default profiles are always the base layer; custom IG profiles are additive.\n", + "description": "Implementation Guide to generate resources under. `us_core` (the default) and `phenoml_hpo` select a built-in guide. Any other value is a custom Implementation Guide name: its profiles are included alongside the US Core profiles, which are always the base layer. A custom name that matches no uploaded Implementation Guide returns 404.\n", + "default": "us_core", "example": "acme-cardiology" }, "detection_effort": { "type": "string", "deprecated": true, - "description": "Deprecated; use the default 'standard' value. This field will be removed in a future release. 'standard' runs detection once; 'deep' runs detection multiple times for higher recall.", + "description": "Deprecated and ignored: both values produce the same result. This field will be removed in a future release.", "enum": [ "standard", "deep" @@ -13235,16 +14612,15 @@ }, "lang2fhir_ResourceReview": { "type": "object", - "description": "Opt-in faithfulness audit (honored by /lang2fhir/create/multi and /lang2fhir/document/multi). For each selected resource type an LLM checks whether selected dates and clinical code concepts are actually supported by the full source document. An unsupported individual coding is removed when another coding remains in its concept. Resources with an unsupported structural field, a profile-required coding, or no coding remaining in an affected concept, are pulled out of the returned bundle and reported under resource_review in the response.\n", + "description": "Faithfulness audit of the generated resources (honored by /lang2fhir/create/multi and /lang2fhir/document/multi). When resource_review is omitted, the audit runs with these default targets: Encounter checks dates; Procedure and DiagnosticReport check dates and codes; Observation, Condition, and MedicationRequest check codes. Supplying resource_review replaces the defaults with the listed targets; an empty targets list turns the audit off. For each selected resource type an LLM checks whether selected dates and clinical code concepts are actually supported by the full source document. An unsupported individual coding is removed when another coding remains in its concept. Resources with an unsupported structural field, a profile-required coding, or no coding remaining in an affected concept, are pulled out of the returned bundle and reported under resource_review in the response. Encounters, and diagnostic reports built from the source's report dates, are never pulled out, because other resources in the bundle reference them: an unsupported field is removed from them when their profile does not require it, and kept and reported when it does.\n", "required": [ "targets" ], "properties": { "targets": { "type": "array", - "minItems": 1, "maxItems": 25, - "description": "The resource types to audit and which date or clinical code field kinds to check.", + "description": "The resource types to audit and which date or clinical code field kinds to check. An empty list turns the audit off.\n", "items": { "$ref": "#/components/schemas/lang2fhir_ResourceReviewTarget" } @@ -13281,7 +14657,7 @@ }, "lang2fhir_ResourceReviewResult": { "type": "object", - "description": "Present when resource_review was requested and at least one resource was quarantined or safely remediated. The returned bundle is authoritative and contains the post-review representation of every retained resource.\n", + "description": "Present when the resource review ran and at least one resource was quarantined, safely remediated, or kept with an unsupported required field. The returned bundle is authoritative and contains the post-review representation of every retained resource.\n", "properties": { "flagged": { "type": "array", @@ -13292,7 +14668,7 @@ }, "remediated": { "type": "array", - "description": "Resources retained in the bundle after unsupported codings were safely removed.", + "description": "Resources retained in the bundle after review: unsupported codings or fields were safely removed, or unsupported required fields were kept and are reported here (see action).\n", "items": { "$ref": "#/components/schemas/lang2fhir_ResourceReviewRemediated" } @@ -13339,9 +14715,11 @@ "action": { "type": "string", "enum": [ - "removed_codings" + "removed_codings", + "removed_fields", + "flagged_required" ], - "description": "The safe change applied to the resource in the returned bundle." + "description": "What review did to the resource in the returned bundle: removed_codings removed unsupported codings while each affected concept kept one; removed_fields removed unsupported fields its profile does not require; flagged_required kept unsupported fields its profile requires, so the findings report values that remain in the bundle.\n" }, "findings": { "type": "array", @@ -13399,7 +14777,7 @@ "properties": { "page_classifications": { "type": "array", - "description": "Per-page classifier decisions. Populated when page_filter or split_classifications was supplied in the request. Contains one entry per input page, including both kept and dropped pages.\n", + "description": "Per-page classifier decisions, one entry per input page, kept and dropped. Returned on every request: from page_filter or split_classifications when set, otherwise from the default test-requisition-form filter (see the endpoint description).\n", "items": { "$ref": "#/components/schemas/lang2fhir_PageClassification" } @@ -13895,7 +15273,7 @@ }, "implementation_guide": { "type": "string", - "description": "Implementation Guide name to group this profile under. On create, defaults to \"custom\" if omitted; on update, omitting it preserves the profile's current guide. Cannot be \"us_core\" (reserved). Use this to organize custom profiles into named IGs that can be referenced when calling create/multi or document/multi endpoints.\n", + "description": "Implementation Guide name to group this profile under. On create, defaults to \"custom\" if omitted; on update, omitting it preserves the profile's current guide. Cannot be \"us_core\" or \"phenoml_hpo\" (reserved). Use this to organize custom profiles into named IGs that can be referenced when calling create/multi or document/multi endpoints.\n", "example": "acme-cardiology" } } diff --git a/src/test/java/com/phenoml/api/AgentChatWireTest.java b/src/test/java/com/phenoml/api/AgentChatWireTest.java index a7e0e38c..f3d7453f 100644 --- a/src/test/java/com/phenoml/api/AgentChatWireTest.java +++ b/src/test/java/com/phenoml/api/AgentChatWireTest.java @@ -9,6 +9,13 @@ import com.phenoml.api.resources.agent.chat.types.ListMessagesRequestOrder; import com.phenoml.api.resources.agent.chat.types.ListMessagesRequestRole; import com.phenoml.api.resources.agent.chat.types.ListMessagesResponse; +import com.phenoml.api.resources.agent.errors.BadRequestError; +import com.phenoml.api.resources.agent.errors.ConflictError; +import com.phenoml.api.resources.agent.errors.ForbiddenError; +import com.phenoml.api.resources.agent.errors.GatewayTimeoutError; +import com.phenoml.api.resources.agent.errors.InternalServerError; +import com.phenoml.api.resources.agent.errors.NotFoundError; +import com.phenoml.api.resources.agent.errors.UnauthorizedError; import com.phenoml.api.resources.agent.types.AgentChatResponse; import com.phenoml.api.resources.agent.types.AgentChatStreamEvent; import okhttp3.mockwebserver.MockResponse; @@ -30,6 +37,7 @@ public void setup() throws Exception { server.start(); client = PhenomlClient.withCredentials("test-client-id", "test-client-secret") .url(server.url("/").toString()) + .maxRetries(0) .build(); } @@ -156,21 +164,923 @@ else if (actualResponseNode.has("kind")) } } + @Test + public void testSendCreateClinicalResources() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue( + new MockResponse() + .setResponseCode(200) + .setBody( + "{\"response\":\"Created the following resources for Jane Doe:\\n\\n1. Patient: Jane Doe, 32-year-old female\\n2. Condition: Iron deficiency anemia (ICD-10: D50.9)\\n3. MedicationRequest: Ferrous sulfate 325mg daily\\n\\nAll resources bundled as a FHIR transaction Bundle.\",\"success\":true,\"message\":\"Response generated successfully\",\"session_id\":\"session-def456\"}")); + AgentChatResponse response = client.agent() + .chat() + .send(AgentChatRequest.builder() + .message( + "Create a patient record for Jane Doe, 32F, diagnosed with iron deficiency anemia. Prescribe ferrous sulfate 325mg daily.") + .agentId("agent-123") + .phenomlOnBehalfOf("Patient/550e8400-e29b-41d4-a716-446655440000") + .phenomlFhirProvider( + "550e8400-e29b-41d4-a716-446655440000:eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c...") + .sessionId("session-def456") + .enhancedReasoning(true) + .build()); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("POST", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate headers + Assertions.assertEquals( + "Patient/550e8400-e29b-41d4-a716-446655440000", + request.getHeader("X-Phenoml-On-Behalf-Of"), + "Header 'X-Phenoml-On-Behalf-Of' should match expected value"); + Assertions.assertEquals( + "550e8400-e29b-41d4-a716-446655440000:eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c...", + request.getHeader("X-Phenoml-Fhir-Provider"), + "Header 'X-Phenoml-Fhir-Provider' should match expected value"); + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = "" + + "{\n" + + " \"message\": \"Create a patient record for Jane Doe, 32F, diagnosed with iron deficiency anemia. Prescribe ferrous sulfate 325mg daily.\",\n" + + " \"session_id\": \"session-def456\",\n" + + " \"agent_id\": \"agent-123\",\n" + + " \"enhanced_reasoning\": true\n" + + "}"; + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { + String discriminator = null; + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualJson.isNull()) { + Assertions.assertTrue( + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); + } + + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); + } + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); + } + + // Validate response body + Assertions.assertNotNull(response, "Response should not be null"); + String actualResponseJson = objectMapper.writeValueAsString(response); + String expectedResponseBody = "" + + "{\n" + + " \"response\": \"Created the following resources for Jane Doe:\\n\\n1. Patient: Jane Doe, 32-year-old female\\n2. Condition: Iron deficiency anemia (ICD-10: D50.9)\\n3. MedicationRequest: Ferrous sulfate 325mg daily\\n\\nAll resources bundled as a FHIR transaction Bundle.\",\n" + + " \"success\": true,\n" + + " \"message\": \"Response generated successfully\",\n" + + " \"session_id\": \"session-def456\"\n" + + "}"; + JsonNode actualResponseNode = objectMapper.readTree(actualResponseJson); + JsonNode expectedResponseNode = objectMapper.readTree(expectedResponseBody); + Assertions.assertTrue( + jsonEquals(expectedResponseNode, actualResponseNode), + "Response body structure does not match expected"); + if (actualResponseNode.has("type") || actualResponseNode.has("_type") || actualResponseNode.has("kind")) { + String discriminator = null; + if (actualResponseNode.has("type")) + discriminator = actualResponseNode.get("type").asText(); + else if (actualResponseNode.has("_type")) + discriminator = actualResponseNode.get("_type").asText(); + else if (actualResponseNode.has("kind")) + discriminator = actualResponseNode.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualResponseNode.isNull()) { + Assertions.assertTrue( + actualResponseNode.isObject() || actualResponseNode.isArray() || actualResponseNode.isValueNode(), + "response should be a valid JSON value"); + } + + if (actualResponseNode.isArray()) { + Assertions.assertTrue(actualResponseNode.size() >= 0, "Array should have valid size"); + } + if (actualResponseNode.isObject()) { + Assertions.assertTrue(actualResponseNode.size() >= 0, "Object should have valid field count"); + } + } + + @Test + public void testSendThrowsBadRequestError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(400).setBody("{\"key\":\"value\"}")); + BadRequestError exception = Assertions.assertThrows(BadRequestError.class, () -> client.agent() + .chat() + .send(AgentChatRequest.builder() + .message("message") + .agentId("agent_id") + .build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("POST", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = + "" + "{\n" + " \"message\": \"message\",\n" + " \"agent_id\": \"agent_id\"\n" + "}"; + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { + String discriminator = null; + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualJson.isNull()) { + Assertions.assertTrue( + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); + } + + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); + } + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); + } + + // Validate error response + Assertions.assertEquals(400, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testSendThrowsUnauthorizedError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(401).setBody("{\"key\":\"value\"}")); + UnauthorizedError exception = Assertions.assertThrows(UnauthorizedError.class, () -> client.agent() + .chat() + .send(AgentChatRequest.builder() + .message("message") + .agentId("agent_id") + .build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("POST", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = + "" + "{\n" + " \"message\": \"message\",\n" + " \"agent_id\": \"agent_id\"\n" + "}"; + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { + String discriminator = null; + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualJson.isNull()) { + Assertions.assertTrue( + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); + } + + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); + } + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); + } + + // Validate error response + Assertions.assertEquals(401, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testSendThrowsForbiddenError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(403).setBody("{\"key\":\"value\"}")); + ForbiddenError exception = Assertions.assertThrows(ForbiddenError.class, () -> client.agent() + .chat() + .send(AgentChatRequest.builder() + .message("message") + .agentId("agent_id") + .build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("POST", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = + "" + "{\n" + " \"message\": \"message\",\n" + " \"agent_id\": \"agent_id\"\n" + "}"; + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { + String discriminator = null; + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualJson.isNull()) { + Assertions.assertTrue( + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); + } + + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); + } + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); + } + + // Validate error response + Assertions.assertEquals(403, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testSendThrowsNotFoundError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(404).setBody("{\"key\":\"value\"}")); + NotFoundError exception = Assertions.assertThrows(NotFoundError.class, () -> client.agent() + .chat() + .send(AgentChatRequest.builder() + .message("message") + .agentId("agent_id") + .build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("POST", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = + "" + "{\n" + " \"message\": \"message\",\n" + " \"agent_id\": \"agent_id\"\n" + "}"; + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { + String discriminator = null; + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualJson.isNull()) { + Assertions.assertTrue( + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); + } + + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); + } + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); + } + + // Validate error response + Assertions.assertEquals(404, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testSendThrowsConflictError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(409).setBody("{\"key\":\"value\"}")); + ConflictError exception = Assertions.assertThrows(ConflictError.class, () -> client.agent() + .chat() + .send(AgentChatRequest.builder() + .message("message") + .agentId("agent_id") + .build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("POST", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = + "" + "{\n" + " \"message\": \"message\",\n" + " \"agent_id\": \"agent_id\"\n" + "}"; + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { + String discriminator = null; + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualJson.isNull()) { + Assertions.assertTrue( + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); + } + + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); + } + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); + } + + // Validate error response + Assertions.assertEquals(409, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testSendThrowsInternalServerError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(500).setBody("{\"key\":\"value\"}")); + InternalServerError exception = Assertions.assertThrows(InternalServerError.class, () -> client.agent() + .chat() + .send(AgentChatRequest.builder() + .message("message") + .agentId("agent_id") + .build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("POST", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = + "" + "{\n" + " \"message\": \"message\",\n" + " \"agent_id\": \"agent_id\"\n" + "}"; + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { + String discriminator = null; + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualJson.isNull()) { + Assertions.assertTrue( + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); + } + + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); + } + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); + } + + // Validate error response + Assertions.assertEquals(500, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testSendThrowsGatewayTimeoutError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(504).setBody("{\"key\":\"value\"}")); + GatewayTimeoutError exception = Assertions.assertThrows(GatewayTimeoutError.class, () -> client.agent() + .chat() + .send(AgentChatRequest.builder() + .message("message") + .agentId("agent_id") + .build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("POST", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = + "" + "{\n" + " \"message\": \"message\",\n" + " \"agent_id\": \"agent_id\"\n" + "}"; + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { + String discriminator = null; + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualJson.isNull()) { + Assertions.assertTrue( + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); + } + + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); + } + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); + } + + // Validate error response + Assertions.assertEquals(504, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + @Test public void testStream() throws Exception { // OAuth: enqueue token response (client fetches token before API call) server.enqueue(new MockResponse() .setResponseCode(200) .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); - server.enqueue(new MockResponse().setResponseCode(200).setBody("{}")); - Iterable response = client.agent().chat().stream(AgentStreamChatRequest.builder() - .message("What is the patient's current condition?") - .agentId("agent-123") - .phenomlOnBehalfOf("Patient/550e8400-e29b-41d4-a716-446655440000") - .phenomlFhirProvider( - "550e8400-e29b-41d4-a716-446655440000:eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c...") - .sessionId("session-abc123") - .build()); + server.enqueue(new MockResponse().setResponseCode(200).setBody("{}")); + Iterable response = client.agent().chat().stream(AgentStreamChatRequest.builder() + .message("What is the patient's current condition?") + .agentId("agent-123") + .phenomlOnBehalfOf("Patient/550e8400-e29b-41d4-a716-446655440000") + .phenomlFhirProvider( + "550e8400-e29b-41d4-a716-446655440000:eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c...") + .sessionId("session-abc123") + .build()); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("POST", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate headers + Assertions.assertEquals( + "Patient/550e8400-e29b-41d4-a716-446655440000", + request.getHeader("X-Phenoml-On-Behalf-Of"), + "Header 'X-Phenoml-On-Behalf-Of' should match expected value"); + Assertions.assertEquals( + "550e8400-e29b-41d4-a716-446655440000:eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c...", + request.getHeader("X-Phenoml-Fhir-Provider"), + "Header 'X-Phenoml-Fhir-Provider' should match expected value"); + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = "" + + "{\n" + + " \"message\": \"What is the patient's current condition?\",\n" + + " \"session_id\": \"session-abc123\",\n" + + " \"agent_id\": \"agent-123\"\n" + + "}"; + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { + String discriminator = null; + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualJson.isNull()) { + Assertions.assertTrue( + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); + } + + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); + } + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); + } + + // Validate response deserialization + Assertions.assertNotNull(response, "Response should not be null"); + // Verify the response can be serialized back to JSON + String responseJson = objectMapper.writeValueAsString(response); + Assertions.assertNotNull(responseJson); + Assertions.assertFalse(responseJson.isEmpty()); + } + + @Test + public void testStreamThrowsBadRequestError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(400).setBody("{\"key\":\"value\"}")); + BadRequestError exception = Assertions.assertThrows( + BadRequestError.class, + () -> client.agent().chat().stream(AgentStreamChatRequest.builder() + .message("message") + .agentId("agent_id") + .build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("POST", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = + "" + "{\n" + " \"message\": \"message\",\n" + " \"agent_id\": \"agent_id\"\n" + "}"; + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { + String discriminator = null; + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualJson.isNull()) { + Assertions.assertTrue( + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); + } + + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); + } + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); + } + + // Validate error response + Assertions.assertEquals(400, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testStreamThrowsUnauthorizedError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(401).setBody("{\"key\":\"value\"}")); + UnauthorizedError exception = Assertions.assertThrows( + UnauthorizedError.class, + () -> client.agent().chat().stream(AgentStreamChatRequest.builder() + .message("message") + .agentId("agent_id") + .build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("POST", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = + "" + "{\n" + " \"message\": \"message\",\n" + " \"agent_id\": \"agent_id\"\n" + "}"; + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { + String discriminator = null; + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualJson.isNull()) { + Assertions.assertTrue( + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); + } + + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); + } + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); + } + + // Validate error response + Assertions.assertEquals(401, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testStreamThrowsForbiddenError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(403).setBody("{\"key\":\"value\"}")); + ForbiddenError exception = Assertions.assertThrows( + ForbiddenError.class, + () -> client.agent().chat().stream(AgentStreamChatRequest.builder() + .message("message") + .agentId("agent_id") + .build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("POST", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = + "" + "{\n" + " \"message\": \"message\",\n" + " \"agent_id\": \"agent_id\"\n" + "}"; + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { + String discriminator = null; + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualJson.isNull()) { + Assertions.assertTrue( + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); + } + + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); + } + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); + } + + // Validate error response + Assertions.assertEquals(403, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testStreamThrowsNotFoundError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(404).setBody("{\"key\":\"value\"}")); + NotFoundError exception = Assertions.assertThrows( + NotFoundError.class, + () -> client.agent().chat().stream(AgentStreamChatRequest.builder() + .message("message") + .agentId("agent_id") + .build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("POST", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = + "" + "{\n" + " \"message\": \"message\",\n" + " \"agent_id\": \"agent_id\"\n" + "}"; + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { + String discriminator = null; + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualJson.isNull()) { + Assertions.assertTrue( + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); + } + + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); + } + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); + } + + // Validate error response + Assertions.assertEquals(404, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testStreamThrowsConflictError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(409).setBody("{\"key\":\"value\"}")); + ConflictError exception = Assertions.assertThrows( + ConflictError.class, + () -> client.agent().chat().stream(AgentStreamChatRequest.builder() + .message("message") + .agentId("agent_id") + .build())); // OAuth: consume the token request server.takeRequest(); RecordedRequest request = server.takeRequest(); @@ -182,24 +1092,138 @@ public void testStream() throws Exception { "Bearer test-token", request.getHeader("Authorization"), "OAuth Authorization header should contain Bearer token from OAuth flow"); + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = + "" + "{\n" + " \"message\": \"message\",\n" + " \"agent_id\": \"agent_id\"\n" + "}"; + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { + String discriminator = null; + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } - // Validate headers + if (!actualJson.isNull()) { + Assertions.assertTrue( + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); + } + + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); + } + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); + } + + // Validate error response + Assertions.assertEquals(409, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testStreamThrowsInternalServerError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(500).setBody("{\"key\":\"value\"}")); + InternalServerError exception = Assertions.assertThrows( + InternalServerError.class, + () -> client.agent().chat().stream(AgentStreamChatRequest.builder() + .message("message") + .agentId("agent_id") + .build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("POST", request.getMethod()); + + // Validate OAuth Authorization header Assertions.assertEquals( - "Patient/550e8400-e29b-41d4-a716-446655440000", - request.getHeader("X-Phenoml-On-Behalf-Of"), - "Header 'X-Phenoml-On-Behalf-Of' should match expected value"); + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = + "" + "{\n" + " \"message\": \"message\",\n" + " \"agent_id\": \"agent_id\"\n" + "}"; + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { + String discriminator = null; + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualJson.isNull()) { + Assertions.assertTrue( + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); + } + + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); + } + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); + } + + // Validate error response + Assertions.assertEquals(500, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testStreamThrowsGatewayTimeoutError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(504).setBody("{\"key\":\"value\"}")); + GatewayTimeoutError exception = Assertions.assertThrows( + GatewayTimeoutError.class, + () -> client.agent().chat().stream(AgentStreamChatRequest.builder() + .message("message") + .agentId("agent_id") + .build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("POST", request.getMethod()); + + // Validate OAuth Authorization header Assertions.assertEquals( - "550e8400-e29b-41d4-a716-446655440000:eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c...", - request.getHeader("X-Phenoml-Fhir-Provider"), - "Header 'X-Phenoml-Fhir-Provider' should match expected value"); + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); // Validate request body String actualRequestBody = request.getBody().readUtf8(); - String expectedRequestBody = "" - + "{\n" - + " \"message\": \"What is the patient's current condition?\",\n" - + " \"session_id\": \"session-abc123\",\n" - + " \"agent_id\": \"agent-123\"\n" - + "}"; + String expectedRequestBody = + "" + "{\n" + " \"message\": \"message\",\n" + " \"agent_id\": \"agent_id\"\n" + "}"; JsonNode actualJson = objectMapper.readTree(actualRequestBody); JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); @@ -227,12 +1251,13 @@ else if (actualJson.has("kind")) Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); } - // Validate response deserialization - Assertions.assertNotNull(response, "Response should not be null"); - // Verify the response can be serialized back to JSON - String responseJson = objectMapper.writeValueAsString(response); - Assertions.assertNotNull(responseJson); - Assertions.assertFalse(responseJson.isEmpty()); + // Validate error response + Assertions.assertEquals(504, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); } @Test @@ -339,6 +1364,171 @@ else if (actualResponseNode.has("kind")) } } + @Test + public void testListMessagesThrowsUnauthorizedError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(401).setBody("{\"key\":\"value\"}")); + UnauthorizedError exception = Assertions.assertThrows(UnauthorizedError.class, () -> client.agent() + .chat() + .listMessages(ListMessagesRequest.builder() + .chatSessionId("chat_session_id") + .build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("GET", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate error response + Assertions.assertEquals(401, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testListMessagesThrowsForbiddenError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(403).setBody("{\"key\":\"value\"}")); + ForbiddenError exception = Assertions.assertThrows(ForbiddenError.class, () -> client.agent() + .chat() + .listMessages(ListMessagesRequest.builder() + .chatSessionId("chat_session_id") + .build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("GET", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate error response + Assertions.assertEquals(403, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testListMessagesThrowsNotFoundError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(404).setBody("{\"key\":\"value\"}")); + NotFoundError exception = Assertions.assertThrows(NotFoundError.class, () -> client.agent() + .chat() + .listMessages(ListMessagesRequest.builder() + .chatSessionId("chat_session_id") + .build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("GET", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate error response + Assertions.assertEquals(404, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testListMessagesThrowsInternalServerError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(500).setBody("{\"key\":\"value\"}")); + InternalServerError exception = Assertions.assertThrows(InternalServerError.class, () -> client.agent() + .chat() + .listMessages(ListMessagesRequest.builder() + .chatSessionId("chat_session_id") + .build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("GET", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate error response + Assertions.assertEquals(500, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testListMessagesThrowsGatewayTimeoutError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(504).setBody("{\"key\":\"value\"}")); + GatewayTimeoutError exception = Assertions.assertThrows(GatewayTimeoutError.class, () -> client.agent() + .chat() + .listMessages(ListMessagesRequest.builder() + .chatSessionId("chat_session_id") + .build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("GET", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate error response + Assertions.assertEquals(504, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + /** * Compares two JsonNodes with numeric equivalence and null safety. * For objects, checks that all fields in 'expected' exist in 'actual' with matching values. diff --git a/src/test/java/com/phenoml/api/AgentPromptsWireTest.java b/src/test/java/com/phenoml/api/AgentPromptsWireTest.java index b0f6d184..ca75394d 100644 --- a/src/test/java/com/phenoml/api/AgentPromptsWireTest.java +++ b/src/test/java/com/phenoml/api/AgentPromptsWireTest.java @@ -3,6 +3,11 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.phenoml.api.core.ObjectMappers; +import com.phenoml.api.resources.agent.errors.BadRequestError; +import com.phenoml.api.resources.agent.errors.ForbiddenError; +import com.phenoml.api.resources.agent.errors.InternalServerError; +import com.phenoml.api.resources.agent.errors.NotFoundError; +import com.phenoml.api.resources.agent.errors.UnauthorizedError; import com.phenoml.api.resources.agent.prompts.requests.AgentPromptsCreateRequest; import com.phenoml.api.resources.agent.prompts.requests.AgentPromptsUpdateRequest; import com.phenoml.api.resources.agent.prompts.types.PromptsDeleteResponse; @@ -31,6 +36,7 @@ public void setup() throws Exception { server.start(); client = PhenomlClient.withCredentials("test-client-id", "test-client-secret") .url(server.url("/").toString()) + .maxRetries(0) .build(); } @@ -47,7 +53,7 @@ public void testCreate() throws Exception { .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); server.enqueue( new MockResponse() - .setResponseCode(200) + .setResponseCode(201) .setBody( "{\"success\":true,\"message\":\"Prompt created successfully\",\"data\":{\"id\":\"prompt_123\",\"name\":\"Medical Assistant System Prompt\",\"description\":\"System prompt for medical assistant agent\",\"content\":\"You are a helpful medical assistant...\",\"is_default\":false,\"tags\":[\"medical\",\"system\"]}}")); AgentPromptsResponse response = client.agent() @@ -160,6 +166,258 @@ else if (actualResponseNode.has("kind")) } } + @Test + public void testCreateThrowsBadRequestError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(400).setBody("{\"key\":\"value\"}")); + BadRequestError exception = Assertions.assertThrows(BadRequestError.class, () -> client.agent() + .prompts() + .create(AgentPromptsCreateRequest.builder() + .name("name") + .content("content") + .build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("POST", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = "" + "{\n" + " \"name\": \"name\",\n" + " \"content\": \"content\"\n" + "}"; + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { + String discriminator = null; + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualJson.isNull()) { + Assertions.assertTrue( + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); + } + + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); + } + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); + } + + // Validate error response + Assertions.assertEquals(400, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testCreateThrowsUnauthorizedError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(401).setBody("{\"key\":\"value\"}")); + UnauthorizedError exception = Assertions.assertThrows(UnauthorizedError.class, () -> client.agent() + .prompts() + .create(AgentPromptsCreateRequest.builder() + .name("name") + .content("content") + .build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("POST", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = "" + "{\n" + " \"name\": \"name\",\n" + " \"content\": \"content\"\n" + "}"; + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { + String discriminator = null; + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualJson.isNull()) { + Assertions.assertTrue( + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); + } + + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); + } + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); + } + + // Validate error response + Assertions.assertEquals(401, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testCreateThrowsForbiddenError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(403).setBody("{\"key\":\"value\"}")); + ForbiddenError exception = Assertions.assertThrows(ForbiddenError.class, () -> client.agent() + .prompts() + .create(AgentPromptsCreateRequest.builder() + .name("name") + .content("content") + .build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("POST", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = "" + "{\n" + " \"name\": \"name\",\n" + " \"content\": \"content\"\n" + "}"; + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { + String discriminator = null; + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualJson.isNull()) { + Assertions.assertTrue( + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); + } + + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); + } + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); + } + + // Validate error response + Assertions.assertEquals(403, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testCreateThrowsInternalServerError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(500).setBody("{\"key\":\"value\"}")); + InternalServerError exception = Assertions.assertThrows(InternalServerError.class, () -> client.agent() + .prompts() + .create(AgentPromptsCreateRequest.builder() + .name("name") + .content("content") + .build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("POST", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = "" + "{\n" + " \"name\": \"name\",\n" + " \"content\": \"content\"\n" + "}"; + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { + String discriminator = null; + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualJson.isNull()) { + Assertions.assertTrue( + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); + } + + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); + } + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); + } + + // Validate error response + Assertions.assertEquals(500, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + @Test public void testList() throws Exception { // OAuth: enqueue token response (client fetches token before API call) @@ -246,6 +504,96 @@ else if (actualResponseNode.has("kind")) } } + @Test + public void testListThrowsUnauthorizedError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(401).setBody("{\"key\":\"value\"}")); + UnauthorizedError exception = Assertions.assertThrows( + UnauthorizedError.class, () -> client.agent().prompts().list()); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("GET", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate error response + Assertions.assertEquals(401, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testListThrowsForbiddenError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(403).setBody("{\"key\":\"value\"}")); + ForbiddenError exception = Assertions.assertThrows( + ForbiddenError.class, () -> client.agent().prompts().list()); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("GET", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate error response + Assertions.assertEquals(403, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testListThrowsInternalServerError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(500).setBody("{\"key\":\"value\"}")); + InternalServerError exception = Assertions.assertThrows( + InternalServerError.class, () -> client.agent().prompts().list()); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("GET", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate error response + Assertions.assertEquals(500, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + @Test public void testGet() throws Exception { // OAuth: enqueue token response (client fetches token before API call) @@ -321,12 +669,132 @@ else if (actualResponseNode.has("kind")) } @Test - public void testUpdate() throws Exception { + public void testGetThrowsUnauthorizedError() throws Exception { // OAuth: enqueue token response (client fetches token before API call) server.enqueue(new MockResponse() .setResponseCode(200) .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); - server.enqueue( + server.enqueue(new MockResponse().setResponseCode(401).setBody("{\"key\":\"value\"}")); + UnauthorizedError exception = Assertions.assertThrows( + UnauthorizedError.class, () -> client.agent().prompts().get("id")); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("GET", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate error response + Assertions.assertEquals(401, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testGetThrowsForbiddenError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(403).setBody("{\"key\":\"value\"}")); + ForbiddenError exception = Assertions.assertThrows( + ForbiddenError.class, () -> client.agent().prompts().get("id")); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("GET", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate error response + Assertions.assertEquals(403, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testGetThrowsNotFoundError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(404).setBody("{\"key\":\"value\"}")); + NotFoundError exception = Assertions.assertThrows( + NotFoundError.class, () -> client.agent().prompts().get("id")); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("GET", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate error response + Assertions.assertEquals(404, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testGetThrowsInternalServerError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(500).setBody("{\"key\":\"value\"}")); + InternalServerError exception = Assertions.assertThrows( + InternalServerError.class, () -> client.agent().prompts().get("id")); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("GET", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate error response + Assertions.assertEquals(500, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testUpdate() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue( new MockResponse() .setResponseCode(200) .setBody( @@ -446,88 +914,80 @@ else if (actualResponseNode.has("kind")) } @Test - public void testDelete() throws Exception { + public void testUpdateThrowsBadRequestError() throws Exception { // OAuth: enqueue token response (client fetches token before API call) server.enqueue(new MockResponse() .setResponseCode(200) .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); - server.enqueue(new MockResponse() - .setResponseCode(200) - .setBody("{\"success\":true,\"message\":\"Prompt deleted successfully\"}")); - PromptsDeleteResponse response = client.agent().prompts().delete("id"); + server.enqueue(new MockResponse().setResponseCode(400).setBody("{\"key\":\"value\"}")); + BadRequestError exception = Assertions.assertThrows(BadRequestError.class, () -> client.agent() + .prompts() + .update("id", AgentPromptsUpdateRequest.builder().build())); // OAuth: consume the token request server.takeRequest(); RecordedRequest request = server.takeRequest(); Assertions.assertNotNull(request); - Assertions.assertEquals("DELETE", request.getMethod()); + Assertions.assertEquals("PUT", request.getMethod()); // Validate OAuth Authorization header Assertions.assertEquals( "Bearer test-token", request.getHeader("Authorization"), "OAuth Authorization header should contain Bearer token from OAuth flow"); - - // Validate response body - Assertions.assertNotNull(response, "Response should not be null"); - String actualResponseJson = objectMapper.writeValueAsString(response); - String expectedResponseBody = - "" + "{\n" + " \"success\": true,\n" + " \"message\": \"Prompt deleted successfully\"\n" + "}"; - JsonNode actualResponseNode = objectMapper.readTree(actualResponseJson); - JsonNode expectedResponseNode = objectMapper.readTree(expectedResponseBody); - Assertions.assertTrue( - jsonEquals(expectedResponseNode, actualResponseNode), - "Response body structure does not match expected"); - if (actualResponseNode.has("type") || actualResponseNode.has("_type") || actualResponseNode.has("kind")) { + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = "" + "{}"; + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { String discriminator = null; - if (actualResponseNode.has("type")) - discriminator = actualResponseNode.get("type").asText(); - else if (actualResponseNode.has("_type")) - discriminator = actualResponseNode.get("_type").asText(); - else if (actualResponseNode.has("kind")) - discriminator = actualResponseNode.get("kind").asText(); + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); } - if (!actualResponseNode.isNull()) { + if (!actualJson.isNull()) { Assertions.assertTrue( - actualResponseNode.isObject() || actualResponseNode.isArray() || actualResponseNode.isValueNode(), - "response should be a valid JSON value"); + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); } - if (actualResponseNode.isArray()) { - Assertions.assertTrue(actualResponseNode.size() >= 0, "Array should have valid size"); + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); } - if (actualResponseNode.isObject()) { - Assertions.assertTrue(actualResponseNode.size() >= 0, "Object should have valid field count"); + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); } + + // Validate error response + Assertions.assertEquals(400, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); } @Test - public void testPatch() throws Exception { + public void testUpdateThrowsUnauthorizedError() throws Exception { // OAuth: enqueue token response (client fetches token before API call) server.enqueue(new MockResponse() .setResponseCode(200) .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); - server.enqueue( - new MockResponse() - .setResponseCode(200) - .setBody( - "{\"success\":true,\"message\":\"Prompt patched successfully\",\"data\":{\"id\":\"prompt_123\",\"name\":\"Medical Assistant System Prompt\",\"description\":\"System prompt for medical assistant agent\",\"content\":\"You are a helpful medical assistant...\",\"is_default\":false,\"tags\":[\"medical\",\"system\"]}}")); - AgentPromptsResponse response = client.agent() + server.enqueue(new MockResponse().setResponseCode(401).setBody("{\"key\":\"value\"}")); + UnauthorizedError exception = Assertions.assertThrows(UnauthorizedError.class, () -> client.agent() .prompts() - .patch( - "id", - Arrays.asList(JsonPatchOperation.builder() - .op(JsonPatchOperationOp.REPLACE) - .path("/content") - .value("Updated prompt content.") - .build())); + .update("id", AgentPromptsUpdateRequest.builder().build())); // OAuth: consume the token request server.takeRequest(); RecordedRequest request = server.takeRequest(); Assertions.assertNotNull(request); - Assertions.assertEquals("PATCH", request.getMethod()); + Assertions.assertEquals("PUT", request.getMethod()); // Validate OAuth Authorization header Assertions.assertEquals( @@ -536,14 +996,7 @@ public void testPatch() throws Exception { "OAuth Authorization header should contain Bearer token from OAuth flow"); // Validate request body String actualRequestBody = request.getBody().readUtf8(); - String expectedRequestBody = "" - + "[\n" - + " {\n" - + " \"op\": \"replace\",\n" - + " \"path\": \"/content\",\n" - + " \"value\": \"Updated prompt content.\"\n" - + " }\n" - + "]"; + String expectedRequestBody = "" + "{}"; JsonNode actualJson = objectMapper.readTree(actualRequestBody); JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); @@ -571,54 +1024,889 @@ else if (actualJson.has("kind")) Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); } - // Validate response body - Assertions.assertNotNull(response, "Response should not be null"); - String actualResponseJson = objectMapper.writeValueAsString(response); - String expectedResponseBody = "" - + "{\n" - + " \"success\": true,\n" - + " \"message\": \"Prompt patched successfully\",\n" - + " \"data\": {\n" - + " \"id\": \"prompt_123\",\n" - + " \"name\": \"Medical Assistant System Prompt\",\n" - + " \"description\": \"System prompt for medical assistant agent\",\n" - + " \"content\": \"You are a helpful medical assistant...\",\n" - + " \"is_default\": false,\n" - + " \"tags\": [\n" - + " \"medical\",\n" - + " \"system\"\n" - + " ]\n" - + " }\n" - + "}"; - JsonNode actualResponseNode = objectMapper.readTree(actualResponseJson); - JsonNode expectedResponseNode = objectMapper.readTree(expectedResponseBody); - Assertions.assertTrue( - jsonEquals(expectedResponseNode, actualResponseNode), - "Response body structure does not match expected"); - if (actualResponseNode.has("type") || actualResponseNode.has("_type") || actualResponseNode.has("kind")) { + // Validate error response + Assertions.assertEquals(401, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testUpdateThrowsForbiddenError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(403).setBody("{\"key\":\"value\"}")); + ForbiddenError exception = Assertions.assertThrows(ForbiddenError.class, () -> client.agent() + .prompts() + .update("id", AgentPromptsUpdateRequest.builder().build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("PUT", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = "" + "{}"; + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { String discriminator = null; - if (actualResponseNode.has("type")) - discriminator = actualResponseNode.get("type").asText(); - else if (actualResponseNode.has("_type")) - discriminator = actualResponseNode.get("_type").asText(); - else if (actualResponseNode.has("kind")) - discriminator = actualResponseNode.get("kind").asText(); + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); } - if (!actualResponseNode.isNull()) { + if (!actualJson.isNull()) { Assertions.assertTrue( - actualResponseNode.isObject() || actualResponseNode.isArray() || actualResponseNode.isValueNode(), - "response should be a valid JSON value"); + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); } - if (actualResponseNode.isArray()) { - Assertions.assertTrue(actualResponseNode.size() >= 0, "Array should have valid size"); + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); } - if (actualResponseNode.isObject()) { - Assertions.assertTrue(actualResponseNode.size() >= 0, "Object should have valid field count"); + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); } + + // Validate error response + Assertions.assertEquals(403, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testUpdateThrowsNotFoundError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(404).setBody("{\"key\":\"value\"}")); + NotFoundError exception = Assertions.assertThrows(NotFoundError.class, () -> client.agent() + .prompts() + .update("id", AgentPromptsUpdateRequest.builder().build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("PUT", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = "" + "{}"; + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { + String discriminator = null; + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualJson.isNull()) { + Assertions.assertTrue( + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); + } + + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); + } + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); + } + + // Validate error response + Assertions.assertEquals(404, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testUpdateThrowsInternalServerError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(500).setBody("{\"key\":\"value\"}")); + InternalServerError exception = Assertions.assertThrows(InternalServerError.class, () -> client.agent() + .prompts() + .update("id", AgentPromptsUpdateRequest.builder().build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("PUT", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = "" + "{}"; + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { + String discriminator = null; + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualJson.isNull()) { + Assertions.assertTrue( + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); + } + + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); + } + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); + } + + // Validate error response + Assertions.assertEquals(500, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testDelete() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"success\":true,\"message\":\"Prompt deleted successfully\"}")); + PromptsDeleteResponse response = client.agent().prompts().delete("id"); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("DELETE", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate response body + Assertions.assertNotNull(response, "Response should not be null"); + String actualResponseJson = objectMapper.writeValueAsString(response); + String expectedResponseBody = + "" + "{\n" + " \"success\": true,\n" + " \"message\": \"Prompt deleted successfully\"\n" + "}"; + JsonNode actualResponseNode = objectMapper.readTree(actualResponseJson); + JsonNode expectedResponseNode = objectMapper.readTree(expectedResponseBody); + Assertions.assertTrue( + jsonEquals(expectedResponseNode, actualResponseNode), + "Response body structure does not match expected"); + if (actualResponseNode.has("type") || actualResponseNode.has("_type") || actualResponseNode.has("kind")) { + String discriminator = null; + if (actualResponseNode.has("type")) + discriminator = actualResponseNode.get("type").asText(); + else if (actualResponseNode.has("_type")) + discriminator = actualResponseNode.get("_type").asText(); + else if (actualResponseNode.has("kind")) + discriminator = actualResponseNode.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualResponseNode.isNull()) { + Assertions.assertTrue( + actualResponseNode.isObject() || actualResponseNode.isArray() || actualResponseNode.isValueNode(), + "response should be a valid JSON value"); + } + + if (actualResponseNode.isArray()) { + Assertions.assertTrue(actualResponseNode.size() >= 0, "Array should have valid size"); + } + if (actualResponseNode.isObject()) { + Assertions.assertTrue(actualResponseNode.size() >= 0, "Object should have valid field count"); + } + } + + @Test + public void testDeleteThrowsUnauthorizedError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(401).setBody("{\"key\":\"value\"}")); + UnauthorizedError exception = Assertions.assertThrows( + UnauthorizedError.class, () -> client.agent().prompts().delete("id")); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("DELETE", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate error response + Assertions.assertEquals(401, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testDeleteThrowsForbiddenError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(403).setBody("{\"key\":\"value\"}")); + ForbiddenError exception = Assertions.assertThrows( + ForbiddenError.class, () -> client.agent().prompts().delete("id")); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("DELETE", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate error response + Assertions.assertEquals(403, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testDeleteThrowsNotFoundError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(404).setBody("{\"key\":\"value\"}")); + NotFoundError exception = Assertions.assertThrows( + NotFoundError.class, () -> client.agent().prompts().delete("id")); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("DELETE", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate error response + Assertions.assertEquals(404, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testDeleteThrowsInternalServerError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(500).setBody("{\"key\":\"value\"}")); + InternalServerError exception = Assertions.assertThrows( + InternalServerError.class, () -> client.agent().prompts().delete("id")); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("DELETE", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate error response + Assertions.assertEquals(500, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testPatch() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue( + new MockResponse() + .setResponseCode(200) + .setBody( + "{\"success\":true,\"message\":\"Prompt patched successfully\",\"data\":{\"id\":\"prompt_123\",\"name\":\"Medical Assistant System Prompt\",\"description\":\"System prompt for medical assistant agent\",\"content\":\"You are a helpful medical assistant...\",\"is_default\":false,\"tags\":[\"medical\",\"system\"]}}")); + AgentPromptsResponse response = client.agent() + .prompts() + .patch( + "id", + Arrays.asList(JsonPatchOperation.builder() + .op(JsonPatchOperationOp.REPLACE) + .path("/content") + .value("Updated prompt content.") + .build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("PATCH", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = "" + + "[\n" + + " {\n" + + " \"op\": \"replace\",\n" + + " \"path\": \"/content\",\n" + + " \"value\": \"Updated prompt content.\"\n" + + " }\n" + + "]"; + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { + String discriminator = null; + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualJson.isNull()) { + Assertions.assertTrue( + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); + } + + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); + } + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); + } + + // Validate response body + Assertions.assertNotNull(response, "Response should not be null"); + String actualResponseJson = objectMapper.writeValueAsString(response); + String expectedResponseBody = "" + + "{\n" + + " \"success\": true,\n" + + " \"message\": \"Prompt patched successfully\",\n" + + " \"data\": {\n" + + " \"id\": \"prompt_123\",\n" + + " \"name\": \"Medical Assistant System Prompt\",\n" + + " \"description\": \"System prompt for medical assistant agent\",\n" + + " \"content\": \"You are a helpful medical assistant...\",\n" + + " \"is_default\": false,\n" + + " \"tags\": [\n" + + " \"medical\",\n" + + " \"system\"\n" + + " ]\n" + + " }\n" + + "}"; + JsonNode actualResponseNode = objectMapper.readTree(actualResponseJson); + JsonNode expectedResponseNode = objectMapper.readTree(expectedResponseBody); + Assertions.assertTrue( + jsonEquals(expectedResponseNode, actualResponseNode), + "Response body structure does not match expected"); + if (actualResponseNode.has("type") || actualResponseNode.has("_type") || actualResponseNode.has("kind")) { + String discriminator = null; + if (actualResponseNode.has("type")) + discriminator = actualResponseNode.get("type").asText(); + else if (actualResponseNode.has("_type")) + discriminator = actualResponseNode.get("_type").asText(); + else if (actualResponseNode.has("kind")) + discriminator = actualResponseNode.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualResponseNode.isNull()) { + Assertions.assertTrue( + actualResponseNode.isObject() || actualResponseNode.isArray() || actualResponseNode.isValueNode(), + "response should be a valid JSON value"); + } + + if (actualResponseNode.isArray()) { + Assertions.assertTrue(actualResponseNode.size() >= 0, "Array should have valid size"); + } + if (actualResponseNode.isObject()) { + Assertions.assertTrue(actualResponseNode.size() >= 0, "Object should have valid field count"); + } + } + + @Test + public void testPatchThrowsBadRequestError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(400).setBody("{\"key\":\"value\"}")); + BadRequestError exception = Assertions.assertThrows(BadRequestError.class, () -> client.agent() + .prompts() + .patch( + "id", + Arrays.asList( + JsonPatchOperation.builder() + .op(JsonPatchOperationOp.ADD) + .path("path") + .build(), + JsonPatchOperation.builder() + .op(JsonPatchOperationOp.ADD) + .path("path") + .build()))); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("PATCH", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = "" + + "[\n" + + " {\n" + + " \"op\": \"add\",\n" + + " \"path\": \"path\"\n" + + " },\n" + + " {\n" + + " \"op\": \"add\",\n" + + " \"path\": \"path\"\n" + + " }\n" + + "]"; + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { + String discriminator = null; + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualJson.isNull()) { + Assertions.assertTrue( + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); + } + + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); + } + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); + } + + // Validate error response + Assertions.assertEquals(400, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testPatchThrowsUnauthorizedError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(401).setBody("{\"key\":\"value\"}")); + UnauthorizedError exception = Assertions.assertThrows(UnauthorizedError.class, () -> client.agent() + .prompts() + .patch( + "id", + Arrays.asList( + JsonPatchOperation.builder() + .op(JsonPatchOperationOp.ADD) + .path("path") + .build(), + JsonPatchOperation.builder() + .op(JsonPatchOperationOp.ADD) + .path("path") + .build()))); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("PATCH", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = "" + + "[\n" + + " {\n" + + " \"op\": \"add\",\n" + + " \"path\": \"path\"\n" + + " },\n" + + " {\n" + + " \"op\": \"add\",\n" + + " \"path\": \"path\"\n" + + " }\n" + + "]"; + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { + String discriminator = null; + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualJson.isNull()) { + Assertions.assertTrue( + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); + } + + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); + } + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); + } + + // Validate error response + Assertions.assertEquals(401, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testPatchThrowsForbiddenError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(403).setBody("{\"key\":\"value\"}")); + ForbiddenError exception = Assertions.assertThrows(ForbiddenError.class, () -> client.agent() + .prompts() + .patch( + "id", + Arrays.asList( + JsonPatchOperation.builder() + .op(JsonPatchOperationOp.ADD) + .path("path") + .build(), + JsonPatchOperation.builder() + .op(JsonPatchOperationOp.ADD) + .path("path") + .build()))); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("PATCH", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = "" + + "[\n" + + " {\n" + + " \"op\": \"add\",\n" + + " \"path\": \"path\"\n" + + " },\n" + + " {\n" + + " \"op\": \"add\",\n" + + " \"path\": \"path\"\n" + + " }\n" + + "]"; + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { + String discriminator = null; + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualJson.isNull()) { + Assertions.assertTrue( + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); + } + + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); + } + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); + } + + // Validate error response + Assertions.assertEquals(403, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testPatchThrowsNotFoundError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(404).setBody("{\"key\":\"value\"}")); + NotFoundError exception = Assertions.assertThrows(NotFoundError.class, () -> client.agent() + .prompts() + .patch( + "id", + Arrays.asList( + JsonPatchOperation.builder() + .op(JsonPatchOperationOp.ADD) + .path("path") + .build(), + JsonPatchOperation.builder() + .op(JsonPatchOperationOp.ADD) + .path("path") + .build()))); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("PATCH", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = "" + + "[\n" + + " {\n" + + " \"op\": \"add\",\n" + + " \"path\": \"path\"\n" + + " },\n" + + " {\n" + + " \"op\": \"add\",\n" + + " \"path\": \"path\"\n" + + " }\n" + + "]"; + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { + String discriminator = null; + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualJson.isNull()) { + Assertions.assertTrue( + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); + } + + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); + } + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); + } + + // Validate error response + Assertions.assertEquals(404, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testPatchThrowsInternalServerError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(500).setBody("{\"key\":\"value\"}")); + InternalServerError exception = Assertions.assertThrows(InternalServerError.class, () -> client.agent() + .prompts() + .patch( + "id", + Arrays.asList( + JsonPatchOperation.builder() + .op(JsonPatchOperationOp.ADD) + .path("path") + .build(), + JsonPatchOperation.builder() + .op(JsonPatchOperationOp.ADD) + .path("path") + .build()))); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("PATCH", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = "" + + "[\n" + + " {\n" + + " \"op\": \"add\",\n" + + " \"path\": \"path\"\n" + + " },\n" + + " {\n" + + " \"op\": \"add\",\n" + + " \"path\": \"path\"\n" + + " }\n" + + "]"; + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { + String discriminator = null; + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualJson.isNull()) { + Assertions.assertTrue( + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); + } + + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); + } + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); + } + + // Validate error response + Assertions.assertEquals(500, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); } /** diff --git a/src/test/java/com/phenoml/api/AgentWireTest.java b/src/test/java/com/phenoml/api/AgentWireTest.java index b38c0d08..193577d1 100644 --- a/src/test/java/com/phenoml/api/AgentWireTest.java +++ b/src/test/java/com/phenoml/api/AgentWireTest.java @@ -3,6 +3,11 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.phenoml.api.core.ObjectMappers; +import com.phenoml.api.resources.agent.errors.BadRequestError; +import com.phenoml.api.resources.agent.errors.ForbiddenError; +import com.phenoml.api.resources.agent.errors.InternalServerError; +import com.phenoml.api.resources.agent.errors.NotFoundError; +import com.phenoml.api.resources.agent.errors.UnauthorizedError; import com.phenoml.api.resources.agent.requests.ListRequest; import com.phenoml.api.resources.agent.types.AgentCreateRequest; import com.phenoml.api.resources.agent.types.AgentCreateRequestProvider; @@ -32,6 +37,7 @@ public void setup() throws Exception { server.start(); client = PhenomlClient.withCredentials("test-client-id", "test-client-secret") .url(server.url("/").toString()) + .maxRetries(0) .build(); } @@ -48,7 +54,7 @@ public void testCreate() throws Exception { .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); server.enqueue( new MockResponse() - .setResponseCode(200) + .setResponseCode(201) .setBody( "{\"success\":true,\"message\":\"Agent created successfully\",\"data\":{\"id\":\"agent_123\",\"name\":\"Medical Assistant\",\"description\":\"An AI assistant for medical information processing\",\"prompts\":[\"prompt_123\",\"prompt_456\"],\"tools\":[\"mcp_server_123\",\"mcp_server_456\"],\"workflows\":[\"workflow_123\",\"workflow_456\"],\"tags\":[\"medical\",\"fhir\"],\"provider\":\"7002b0b4-8d09-445a-bf65-0fafdaf26c35\"}}")); AgentResponse response = client.agent() @@ -174,174 +180,165 @@ else if (actualResponseNode.has("kind")) } @Test - public void testList() throws Exception { + public void testCreateThrowsBadRequestError() throws Exception { // OAuth: enqueue token response (client fetches token before API call) server.enqueue(new MockResponse() .setResponseCode(200) .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); - server.enqueue(new MockResponse() - .setResponseCode(200) - .setBody(TestResources.loadResource("/wire-tests/AgentWireTest_testList_response.json"))); - ListResponse response = - client.agent().list(ListRequest.builder().tags("tags").build()); + server.enqueue(new MockResponse().setResponseCode(400).setBody("{\"key\":\"value\"}")); + BadRequestError exception = Assertions.assertThrows(BadRequestError.class, () -> client.agent() + .create(AgentCreateRequest.builder() + .name("name") + .provider(AgentCreateRequestProvider.of("provider")) + .prompts(Arrays.asList("prompts", "prompts")) + .build())); // OAuth: consume the token request server.takeRequest(); RecordedRequest request = server.takeRequest(); Assertions.assertNotNull(request); - Assertions.assertEquals("GET", request.getMethod()); + Assertions.assertEquals("POST", request.getMethod()); // Validate OAuth Authorization header Assertions.assertEquals( "Bearer test-token", request.getHeader("Authorization"), "OAuth Authorization header should contain Bearer token from OAuth flow"); - - // Validate response body - Assertions.assertNotNull(response, "Response should not be null"); - String actualResponseJson = objectMapper.writeValueAsString(response); - String expectedResponseBody = TestResources.loadResource("/wire-tests/AgentWireTest_testList_response.json"); - JsonNode actualResponseNode = objectMapper.readTree(actualResponseJson); - JsonNode expectedResponseNode = objectMapper.readTree(expectedResponseBody); - Assertions.assertTrue( - jsonEquals(expectedResponseNode, actualResponseNode), - "Response body structure does not match expected"); - if (actualResponseNode.has("type") || actualResponseNode.has("_type") || actualResponseNode.has("kind")) { + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = "" + + "{\n" + + " \"name\": \"name\",\n" + + " \"prompts\": [\n" + + " \"prompts\",\n" + + " \"prompts\"\n" + + " ],\n" + + " \"provider\": \"provider\"\n" + + "}"; + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { String discriminator = null; - if (actualResponseNode.has("type")) - discriminator = actualResponseNode.get("type").asText(); - else if (actualResponseNode.has("_type")) - discriminator = actualResponseNode.get("_type").asText(); - else if (actualResponseNode.has("kind")) - discriminator = actualResponseNode.get("kind").asText(); + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); } - if (!actualResponseNode.isNull()) { + if (!actualJson.isNull()) { Assertions.assertTrue( - actualResponseNode.isObject() || actualResponseNode.isArray() || actualResponseNode.isValueNode(), - "response should be a valid JSON value"); + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); } - if (actualResponseNode.isArray()) { - Assertions.assertTrue(actualResponseNode.size() >= 0, "Array should have valid size"); + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); } - if (actualResponseNode.isObject()) { - Assertions.assertTrue(actualResponseNode.size() >= 0, "Object should have valid field count"); + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); } + + // Validate error response + Assertions.assertEquals(400, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); } @Test - public void testGet() throws Exception { + public void testCreateThrowsUnauthorizedError() throws Exception { // OAuth: enqueue token response (client fetches token before API call) server.enqueue(new MockResponse() .setResponseCode(200) .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); - server.enqueue( - new MockResponse() - .setResponseCode(200) - .setBody( - "{\"success\":true,\"message\":\"Agent retrieved successfully\",\"data\":{\"id\":\"agent_123\",\"name\":\"Medical Assistant\",\"description\":\"An AI assistant for medical information processing\",\"prompts\":[\"prompt_123\",\"prompt_456\"],\"tools\":[\"mcp_server_123\",\"mcp_server_456\"],\"workflows\":[\"workflow_123\",\"workflow_456\"],\"tags\":[\"medical\",\"fhir\"],\"provider\":\"7002b0b4-8d09-445a-bf65-0fafdaf26c35\"}}")); - AgentResponse response = client.agent().get("id"); + server.enqueue(new MockResponse().setResponseCode(401).setBody("{\"key\":\"value\"}")); + UnauthorizedError exception = Assertions.assertThrows(UnauthorizedError.class, () -> client.agent() + .create(AgentCreateRequest.builder() + .name("name") + .provider(AgentCreateRequestProvider.of("provider")) + .prompts(Arrays.asList("prompts", "prompts")) + .build())); // OAuth: consume the token request server.takeRequest(); RecordedRequest request = server.takeRequest(); Assertions.assertNotNull(request); - Assertions.assertEquals("GET", request.getMethod()); + Assertions.assertEquals("POST", request.getMethod()); // Validate OAuth Authorization header Assertions.assertEquals( "Bearer test-token", request.getHeader("Authorization"), "OAuth Authorization header should contain Bearer token from OAuth flow"); - - // Validate response body - Assertions.assertNotNull(response, "Response should not be null"); - String actualResponseJson = objectMapper.writeValueAsString(response); - String expectedResponseBody = "" + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = "" + "{\n" - + " \"success\": true,\n" - + " \"message\": \"Agent retrieved successfully\",\n" - + " \"data\": {\n" - + " \"id\": \"agent_123\",\n" - + " \"name\": \"Medical Assistant\",\n" - + " \"description\": \"An AI assistant for medical information processing\",\n" - + " \"prompts\": [\n" - + " \"prompt_123\",\n" - + " \"prompt_456\"\n" - + " ],\n" - + " \"tools\": [\n" - + " \"mcp_server_123\",\n" - + " \"mcp_server_456\"\n" - + " ],\n" - + " \"workflows\": [\n" - + " \"workflow_123\",\n" - + " \"workflow_456\"\n" - + " ],\n" - + " \"tags\": [\n" - + " \"medical\",\n" - + " \"fhir\"\n" - + " ],\n" - + " \"provider\": \"7002b0b4-8d09-445a-bf65-0fafdaf26c35\"\n" - + " }\n" + + " \"name\": \"name\",\n" + + " \"prompts\": [\n" + + " \"prompts\",\n" + + " \"prompts\"\n" + + " ],\n" + + " \"provider\": \"provider\"\n" + "}"; - JsonNode actualResponseNode = objectMapper.readTree(actualResponseJson); - JsonNode expectedResponseNode = objectMapper.readTree(expectedResponseBody); - Assertions.assertTrue( - jsonEquals(expectedResponseNode, actualResponseNode), - "Response body structure does not match expected"); - if (actualResponseNode.has("type") || actualResponseNode.has("_type") || actualResponseNode.has("kind")) { + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { String discriminator = null; - if (actualResponseNode.has("type")) - discriminator = actualResponseNode.get("type").asText(); - else if (actualResponseNode.has("_type")) - discriminator = actualResponseNode.get("_type").asText(); - else if (actualResponseNode.has("kind")) - discriminator = actualResponseNode.get("kind").asText(); + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); } - if (!actualResponseNode.isNull()) { + if (!actualJson.isNull()) { Assertions.assertTrue( - actualResponseNode.isObject() || actualResponseNode.isArray() || actualResponseNode.isValueNode(), - "response should be a valid JSON value"); + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); } - if (actualResponseNode.isArray()) { - Assertions.assertTrue(actualResponseNode.size() >= 0, "Array should have valid size"); + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); } - if (actualResponseNode.isObject()) { - Assertions.assertTrue(actualResponseNode.size() >= 0, "Object should have valid field count"); + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); } + + // Validate error response + Assertions.assertEquals(401, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); } @Test - public void testUpdate() throws Exception { + public void testCreateThrowsForbiddenError() throws Exception { // OAuth: enqueue token response (client fetches token before API call) server.enqueue(new MockResponse() .setResponseCode(200) .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); - server.enqueue( - new MockResponse() - .setResponseCode(200) - .setBody( - "{\"success\":true,\"message\":\"Agent updated successfully\",\"data\":{\"id\":\"agent_123\",\"name\":\"Medical Assistant\",\"description\":\"An AI assistant for medical information processing\",\"prompts\":[\"prompt_123\",\"prompt_456\"],\"tools\":[\"mcp_server_123\",\"mcp_server_456\"],\"workflows\":[\"workflow_123\",\"workflow_456\"],\"tags\":[\"medical\",\"fhir\"],\"provider\":\"7002b0b4-8d09-445a-bf65-0fafdaf26c35\"}}")); - AgentResponse response = client.agent() - .update( - "id", - AgentCreateRequest.builder() - .name("Medical Assistant") - .provider(AgentCreateRequestProvider.of("7002b0b4-8d09-445a-bf65-0fafdaf26c35")) - .description("Updated description for the medical assistant") - .prompts(Arrays.asList("prompt_123")) - .tags(Optional.of(Arrays.asList("medical", "fhir", "updated"))) - .build()); + server.enqueue(new MockResponse().setResponseCode(403).setBody("{\"key\":\"value\"}")); + ForbiddenError exception = Assertions.assertThrows(ForbiddenError.class, () -> client.agent() + .create(AgentCreateRequest.builder() + .name("name") + .provider(AgentCreateRequestProvider.of("provider")) + .prompts(Arrays.asList("prompts", "prompts")) + .build())); // OAuth: consume the token request server.takeRequest(); RecordedRequest request = server.takeRequest(); Assertions.assertNotNull(request); - Assertions.assertEquals("PUT", request.getMethod()); + Assertions.assertEquals("POST", request.getMethod()); // Validate OAuth Authorization header Assertions.assertEquals( @@ -352,17 +349,12 @@ public void testUpdate() throws Exception { String actualRequestBody = request.getBody().readUtf8(); String expectedRequestBody = "" + "{\n" - + " \"name\": \"Medical Assistant\",\n" - + " \"description\": \"Updated description for the medical assistant\",\n" + + " \"name\": \"name\",\n" + " \"prompts\": [\n" - + " \"prompt_123\"\n" + + " \"prompts\",\n" + + " \"prompts\"\n" + " ],\n" - + " \"tags\": [\n" - + " \"medical\",\n" - + " \"fhir\",\n" - + " \"updated\"\n" - + " ],\n" - + " \"provider\": \"7002b0b4-8d09-445a-bf65-0fafdaf26c35\"\n" + + " \"provider\": \"provider\"\n" + "}"; JsonNode actualJson = objectMapper.readTree(actualRequestBody); JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); @@ -391,82 +383,102 @@ else if (actualJson.has("kind")) Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); } - // Validate response body - Assertions.assertNotNull(response, "Response should not be null"); - String actualResponseJson = objectMapper.writeValueAsString(response); - String expectedResponseBody = "" + // Validate error response + Assertions.assertEquals(403, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testCreateThrowsInternalServerError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(500).setBody("{\"key\":\"value\"}")); + InternalServerError exception = Assertions.assertThrows(InternalServerError.class, () -> client.agent() + .create(AgentCreateRequest.builder() + .name("name") + .provider(AgentCreateRequestProvider.of("provider")) + .prompts(Arrays.asList("prompts", "prompts")) + .build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("POST", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = "" + "{\n" - + " \"success\": true,\n" - + " \"message\": \"Agent updated successfully\",\n" - + " \"data\": {\n" - + " \"id\": \"agent_123\",\n" - + " \"name\": \"Medical Assistant\",\n" - + " \"description\": \"An AI assistant for medical information processing\",\n" - + " \"prompts\": [\n" - + " \"prompt_123\",\n" - + " \"prompt_456\"\n" - + " ],\n" - + " \"tools\": [\n" - + " \"mcp_server_123\",\n" - + " \"mcp_server_456\"\n" - + " ],\n" - + " \"workflows\": [\n" - + " \"workflow_123\",\n" - + " \"workflow_456\"\n" - + " ],\n" - + " \"tags\": [\n" - + " \"medical\",\n" - + " \"fhir\"\n" - + " ],\n" - + " \"provider\": \"7002b0b4-8d09-445a-bf65-0fafdaf26c35\"\n" - + " }\n" + + " \"name\": \"name\",\n" + + " \"prompts\": [\n" + + " \"prompts\",\n" + + " \"prompts\"\n" + + " ],\n" + + " \"provider\": \"provider\"\n" + "}"; - JsonNode actualResponseNode = objectMapper.readTree(actualResponseJson); - JsonNode expectedResponseNode = objectMapper.readTree(expectedResponseBody); - Assertions.assertTrue( - jsonEquals(expectedResponseNode, actualResponseNode), - "Response body structure does not match expected"); - if (actualResponseNode.has("type") || actualResponseNode.has("_type") || actualResponseNode.has("kind")) { + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { String discriminator = null; - if (actualResponseNode.has("type")) - discriminator = actualResponseNode.get("type").asText(); - else if (actualResponseNode.has("_type")) - discriminator = actualResponseNode.get("_type").asText(); - else if (actualResponseNode.has("kind")) - discriminator = actualResponseNode.get("kind").asText(); + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); } - if (!actualResponseNode.isNull()) { + if (!actualJson.isNull()) { Assertions.assertTrue( - actualResponseNode.isObject() || actualResponseNode.isArray() || actualResponseNode.isValueNode(), - "response should be a valid JSON value"); + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); } - if (actualResponseNode.isArray()) { - Assertions.assertTrue(actualResponseNode.size() >= 0, "Array should have valid size"); + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); } - if (actualResponseNode.isObject()) { - Assertions.assertTrue(actualResponseNode.size() >= 0, "Object should have valid field count"); + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); } + + // Validate error response + Assertions.assertEquals(500, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); } @Test - public void testDelete() throws Exception { + public void testList() throws Exception { // OAuth: enqueue token response (client fetches token before API call) server.enqueue(new MockResponse() .setResponseCode(200) .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); server.enqueue(new MockResponse() .setResponseCode(200) - .setBody("{\"success\":true,\"message\":\"Agent deleted successfully\"}")); - DeleteResponse response = client.agent().delete("id"); + .setBody(TestResources.loadResource("/wire-tests/AgentWireTest_testList_response.json"))); + ListResponse response = + client.agent().list(ListRequest.builder().tags("tags").build()); // OAuth: consume the token request server.takeRequest(); RecordedRequest request = server.takeRequest(); Assertions.assertNotNull(request); - Assertions.assertEquals("DELETE", request.getMethod()); + Assertions.assertEquals("GET", request.getMethod()); // Validate OAuth Authorization header Assertions.assertEquals( @@ -477,8 +489,7 @@ public void testDelete() throws Exception { // Validate response body Assertions.assertNotNull(response, "Response should not be null"); String actualResponseJson = objectMapper.writeValueAsString(response); - String expectedResponseBody = - "" + "{\n" + " \"success\": true,\n" + " \"message\": \"Agent deleted successfully\"\n" + "}"; + String expectedResponseBody = TestResources.loadResource("/wire-tests/AgentWireTest_testList_response.json"); JsonNode actualResponseNode = objectMapper.readTree(actualResponseJson); JsonNode expectedResponseNode = objectMapper.readTree(expectedResponseBody); Assertions.assertTrue( @@ -511,7 +522,97 @@ else if (actualResponseNode.has("kind")) } @Test - public void testPatch() throws Exception { + public void testListThrowsUnauthorizedError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(401).setBody("{\"key\":\"value\"}")); + UnauthorizedError exception = Assertions.assertThrows(UnauthorizedError.class, () -> client.agent() + .list(ListRequest.builder().build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("GET", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate error response + Assertions.assertEquals(401, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testListThrowsForbiddenError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(403).setBody("{\"key\":\"value\"}")); + ForbiddenError exception = Assertions.assertThrows(ForbiddenError.class, () -> client.agent() + .list(ListRequest.builder().build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("GET", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate error response + Assertions.assertEquals(403, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testListThrowsInternalServerError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(500).setBody("{\"key\":\"value\"}")); + InternalServerError exception = Assertions.assertThrows(InternalServerError.class, () -> client.agent() + .list(ListRequest.builder().build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("GET", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate error response + Assertions.assertEquals(500, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testGet() throws Exception { // OAuth: enqueue token response (client fetches token before API call) server.enqueue(new MockResponse() .setResponseCode(200) @@ -520,73 +621,19 @@ public void testPatch() throws Exception { new MockResponse() .setResponseCode(200) .setBody( - "{\"success\":true,\"message\":\"Agent patched successfully\",\"data\":{\"id\":\"agent_123\",\"name\":\"Medical Assistant\",\"description\":\"An AI assistant for medical information processing\",\"prompts\":[\"prompt_123\",\"prompt_456\"],\"tools\":[\"mcp_server_123\",\"mcp_server_456\"],\"workflows\":[\"workflow_123\",\"workflow_456\"],\"tags\":[\"medical\",\"fhir\"],\"provider\":\"7002b0b4-8d09-445a-bf65-0fafdaf26c35\"}}")); - AgentResponse response = client.agent() - .patch( - "id", - Arrays.asList( - JsonPatchOperation.builder() - .op(JsonPatchOperationOp.REPLACE) - .path("/description") - .value("patched description") - .build(), - JsonPatchOperation.builder() - .op(JsonPatchOperationOp.ADD) - .path("/tags/-") - .value("updated") - .build())); + "{\"success\":true,\"message\":\"Agent retrieved successfully\",\"data\":{\"id\":\"agent_123\",\"name\":\"Medical Assistant\",\"description\":\"An AI assistant for medical information processing\",\"prompts\":[\"prompt_123\",\"prompt_456\"],\"tools\":[\"mcp_server_123\",\"mcp_server_456\"],\"workflows\":[\"workflow_123\",\"workflow_456\"],\"tags\":[\"medical\",\"fhir\"],\"provider\":\"7002b0b4-8d09-445a-bf65-0fafdaf26c35\"}}")); + AgentResponse response = client.agent().get("id"); // OAuth: consume the token request server.takeRequest(); RecordedRequest request = server.takeRequest(); Assertions.assertNotNull(request); - Assertions.assertEquals("PATCH", request.getMethod()); + Assertions.assertEquals("GET", request.getMethod()); // Validate OAuth Authorization header Assertions.assertEquals( "Bearer test-token", request.getHeader("Authorization"), "OAuth Authorization header should contain Bearer token from OAuth flow"); - // Validate request body - String actualRequestBody = request.getBody().readUtf8(); - String expectedRequestBody = "" - + "[\n" - + " {\n" - + " \"op\": \"replace\",\n" - + " \"path\": \"/description\",\n" - + " \"value\": \"patched description\"\n" - + " },\n" - + " {\n" - + " \"op\": \"add\",\n" - + " \"path\": \"/tags/-\",\n" - + " \"value\": \"updated\"\n" - + " }\n" - + "]"; - JsonNode actualJson = objectMapper.readTree(actualRequestBody); - JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); - Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); - if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { - String discriminator = null; - if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); - else if (actualJson.has("_type")) - discriminator = actualJson.get("_type").asText(); - else if (actualJson.has("kind")) - discriminator = actualJson.get("kind").asText(); - Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); - Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); - } - - if (!actualJson.isNull()) { - Assertions.assertTrue( - actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), - "request should be a valid JSON value"); - } - - if (actualJson.isArray()) { - Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); - } - if (actualJson.isObject()) { - Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); - } // Validate response body Assertions.assertNotNull(response, "Response should not be null"); @@ -594,7 +641,7 @@ else if (actualJson.has("kind")) String expectedResponseBody = "" + "{\n" + " \"success\": true,\n" - + " \"message\": \"Agent patched successfully\",\n" + + " \"message\": \"Agent retrieved successfully\",\n" + " \"data\": {\n" + " \"id\": \"agent_123\",\n" + " \"name\": \"Medical Assistant\",\n" @@ -649,6 +696,1339 @@ else if (actualResponseNode.has("kind")) } } + @Test + public void testGetThrowsUnauthorizedError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(401).setBody("{\"key\":\"value\"}")); + UnauthorizedError exception = Assertions.assertThrows( + UnauthorizedError.class, () -> client.agent().get("id")); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("GET", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate error response + Assertions.assertEquals(401, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testGetThrowsForbiddenError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(403).setBody("{\"key\":\"value\"}")); + ForbiddenError exception = Assertions.assertThrows( + ForbiddenError.class, () -> client.agent().get("id")); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("GET", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate error response + Assertions.assertEquals(403, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testGetThrowsNotFoundError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(404).setBody("{\"key\":\"value\"}")); + NotFoundError exception = Assertions.assertThrows( + NotFoundError.class, () -> client.agent().get("id")); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("GET", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate error response + Assertions.assertEquals(404, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testGetThrowsInternalServerError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(500).setBody("{\"key\":\"value\"}")); + InternalServerError exception = Assertions.assertThrows( + InternalServerError.class, () -> client.agent().get("id")); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("GET", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate error response + Assertions.assertEquals(500, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testUpdate() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue( + new MockResponse() + .setResponseCode(200) + .setBody( + "{\"success\":true,\"message\":\"Agent updated successfully\",\"data\":{\"id\":\"agent_123\",\"name\":\"Medical Assistant\",\"description\":\"An AI assistant for medical information processing\",\"prompts\":[\"prompt_123\",\"prompt_456\"],\"tools\":[\"mcp_server_123\",\"mcp_server_456\"],\"workflows\":[\"workflow_123\",\"workflow_456\"],\"tags\":[\"medical\",\"fhir\"],\"provider\":\"7002b0b4-8d09-445a-bf65-0fafdaf26c35\"}}")); + AgentResponse response = client.agent() + .update( + "id", + AgentCreateRequest.builder() + .name("Medical Assistant") + .provider(AgentCreateRequestProvider.of("7002b0b4-8d09-445a-bf65-0fafdaf26c35")) + .description("Updated description for the medical assistant") + .prompts(Arrays.asList("prompt_123")) + .tags(Optional.of(Arrays.asList("medical", "fhir", "updated"))) + .build()); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("PUT", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = "" + + "{\n" + + " \"name\": \"Medical Assistant\",\n" + + " \"description\": \"Updated description for the medical assistant\",\n" + + " \"prompts\": [\n" + + " \"prompt_123\"\n" + + " ],\n" + + " \"tags\": [\n" + + " \"medical\",\n" + + " \"fhir\",\n" + + " \"updated\"\n" + + " ],\n" + + " \"provider\": \"7002b0b4-8d09-445a-bf65-0fafdaf26c35\"\n" + + "}"; + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { + String discriminator = null; + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualJson.isNull()) { + Assertions.assertTrue( + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); + } + + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); + } + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); + } + + // Validate response body + Assertions.assertNotNull(response, "Response should not be null"); + String actualResponseJson = objectMapper.writeValueAsString(response); + String expectedResponseBody = "" + + "{\n" + + " \"success\": true,\n" + + " \"message\": \"Agent updated successfully\",\n" + + " \"data\": {\n" + + " \"id\": \"agent_123\",\n" + + " \"name\": \"Medical Assistant\",\n" + + " \"description\": \"An AI assistant for medical information processing\",\n" + + " \"prompts\": [\n" + + " \"prompt_123\",\n" + + " \"prompt_456\"\n" + + " ],\n" + + " \"tools\": [\n" + + " \"mcp_server_123\",\n" + + " \"mcp_server_456\"\n" + + " ],\n" + + " \"workflows\": [\n" + + " \"workflow_123\",\n" + + " \"workflow_456\"\n" + + " ],\n" + + " \"tags\": [\n" + + " \"medical\",\n" + + " \"fhir\"\n" + + " ],\n" + + " \"provider\": \"7002b0b4-8d09-445a-bf65-0fafdaf26c35\"\n" + + " }\n" + + "}"; + JsonNode actualResponseNode = objectMapper.readTree(actualResponseJson); + JsonNode expectedResponseNode = objectMapper.readTree(expectedResponseBody); + Assertions.assertTrue( + jsonEquals(expectedResponseNode, actualResponseNode), + "Response body structure does not match expected"); + if (actualResponseNode.has("type") || actualResponseNode.has("_type") || actualResponseNode.has("kind")) { + String discriminator = null; + if (actualResponseNode.has("type")) + discriminator = actualResponseNode.get("type").asText(); + else if (actualResponseNode.has("_type")) + discriminator = actualResponseNode.get("_type").asText(); + else if (actualResponseNode.has("kind")) + discriminator = actualResponseNode.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualResponseNode.isNull()) { + Assertions.assertTrue( + actualResponseNode.isObject() || actualResponseNode.isArray() || actualResponseNode.isValueNode(), + "response should be a valid JSON value"); + } + + if (actualResponseNode.isArray()) { + Assertions.assertTrue(actualResponseNode.size() >= 0, "Array should have valid size"); + } + if (actualResponseNode.isObject()) { + Assertions.assertTrue(actualResponseNode.size() >= 0, "Object should have valid field count"); + } + } + + @Test + public void testUpdateThrowsBadRequestError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(400).setBody("{\"key\":\"value\"}")); + BadRequestError exception = Assertions.assertThrows(BadRequestError.class, () -> client.agent() + .update( + "id", + AgentCreateRequest.builder() + .name("name") + .provider(AgentCreateRequestProvider.of("provider")) + .prompts(Arrays.asList("prompts", "prompts")) + .build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("PUT", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = "" + + "{\n" + + " \"name\": \"name\",\n" + + " \"prompts\": [\n" + + " \"prompts\",\n" + + " \"prompts\"\n" + + " ],\n" + + " \"provider\": \"provider\"\n" + + "}"; + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { + String discriminator = null; + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualJson.isNull()) { + Assertions.assertTrue( + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); + } + + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); + } + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); + } + + // Validate error response + Assertions.assertEquals(400, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testUpdateThrowsUnauthorizedError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(401).setBody("{\"key\":\"value\"}")); + UnauthorizedError exception = Assertions.assertThrows(UnauthorizedError.class, () -> client.agent() + .update( + "id", + AgentCreateRequest.builder() + .name("name") + .provider(AgentCreateRequestProvider.of("provider")) + .prompts(Arrays.asList("prompts", "prompts")) + .build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("PUT", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = "" + + "{\n" + + " \"name\": \"name\",\n" + + " \"prompts\": [\n" + + " \"prompts\",\n" + + " \"prompts\"\n" + + " ],\n" + + " \"provider\": \"provider\"\n" + + "}"; + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { + String discriminator = null; + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualJson.isNull()) { + Assertions.assertTrue( + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); + } + + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); + } + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); + } + + // Validate error response + Assertions.assertEquals(401, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testUpdateThrowsForbiddenError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(403).setBody("{\"key\":\"value\"}")); + ForbiddenError exception = Assertions.assertThrows(ForbiddenError.class, () -> client.agent() + .update( + "id", + AgentCreateRequest.builder() + .name("name") + .provider(AgentCreateRequestProvider.of("provider")) + .prompts(Arrays.asList("prompts", "prompts")) + .build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("PUT", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = "" + + "{\n" + + " \"name\": \"name\",\n" + + " \"prompts\": [\n" + + " \"prompts\",\n" + + " \"prompts\"\n" + + " ],\n" + + " \"provider\": \"provider\"\n" + + "}"; + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { + String discriminator = null; + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualJson.isNull()) { + Assertions.assertTrue( + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); + } + + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); + } + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); + } + + // Validate error response + Assertions.assertEquals(403, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testUpdateThrowsNotFoundError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(404).setBody("{\"key\":\"value\"}")); + NotFoundError exception = Assertions.assertThrows(NotFoundError.class, () -> client.agent() + .update( + "id", + AgentCreateRequest.builder() + .name("name") + .provider(AgentCreateRequestProvider.of("provider")) + .prompts(Arrays.asList("prompts", "prompts")) + .build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("PUT", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = "" + + "{\n" + + " \"name\": \"name\",\n" + + " \"prompts\": [\n" + + " \"prompts\",\n" + + " \"prompts\"\n" + + " ],\n" + + " \"provider\": \"provider\"\n" + + "}"; + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { + String discriminator = null; + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualJson.isNull()) { + Assertions.assertTrue( + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); + } + + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); + } + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); + } + + // Validate error response + Assertions.assertEquals(404, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testUpdateThrowsInternalServerError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(500).setBody("{\"key\":\"value\"}")); + InternalServerError exception = Assertions.assertThrows(InternalServerError.class, () -> client.agent() + .update( + "id", + AgentCreateRequest.builder() + .name("name") + .provider(AgentCreateRequestProvider.of("provider")) + .prompts(Arrays.asList("prompts", "prompts")) + .build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("PUT", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = "" + + "{\n" + + " \"name\": \"name\",\n" + + " \"prompts\": [\n" + + " \"prompts\",\n" + + " \"prompts\"\n" + + " ],\n" + + " \"provider\": \"provider\"\n" + + "}"; + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { + String discriminator = null; + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualJson.isNull()) { + Assertions.assertTrue( + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); + } + + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); + } + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); + } + + // Validate error response + Assertions.assertEquals(500, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testDelete() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"success\":true,\"message\":\"Agent deleted successfully\"}")); + DeleteResponse response = client.agent().delete("id"); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("DELETE", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate response body + Assertions.assertNotNull(response, "Response should not be null"); + String actualResponseJson = objectMapper.writeValueAsString(response); + String expectedResponseBody = + "" + "{\n" + " \"success\": true,\n" + " \"message\": \"Agent deleted successfully\"\n" + "}"; + JsonNode actualResponseNode = objectMapper.readTree(actualResponseJson); + JsonNode expectedResponseNode = objectMapper.readTree(expectedResponseBody); + Assertions.assertTrue( + jsonEquals(expectedResponseNode, actualResponseNode), + "Response body structure does not match expected"); + if (actualResponseNode.has("type") || actualResponseNode.has("_type") || actualResponseNode.has("kind")) { + String discriminator = null; + if (actualResponseNode.has("type")) + discriminator = actualResponseNode.get("type").asText(); + else if (actualResponseNode.has("_type")) + discriminator = actualResponseNode.get("_type").asText(); + else if (actualResponseNode.has("kind")) + discriminator = actualResponseNode.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualResponseNode.isNull()) { + Assertions.assertTrue( + actualResponseNode.isObject() || actualResponseNode.isArray() || actualResponseNode.isValueNode(), + "response should be a valid JSON value"); + } + + if (actualResponseNode.isArray()) { + Assertions.assertTrue(actualResponseNode.size() >= 0, "Array should have valid size"); + } + if (actualResponseNode.isObject()) { + Assertions.assertTrue(actualResponseNode.size() >= 0, "Object should have valid field count"); + } + } + + @Test + public void testDeleteThrowsUnauthorizedError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(401).setBody("{\"key\":\"value\"}")); + UnauthorizedError exception = Assertions.assertThrows( + UnauthorizedError.class, () -> client.agent().delete("id")); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("DELETE", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate error response + Assertions.assertEquals(401, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testDeleteThrowsForbiddenError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(403).setBody("{\"key\":\"value\"}")); + ForbiddenError exception = Assertions.assertThrows( + ForbiddenError.class, () -> client.agent().delete("id")); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("DELETE", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate error response + Assertions.assertEquals(403, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testDeleteThrowsNotFoundError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(404).setBody("{\"key\":\"value\"}")); + NotFoundError exception = Assertions.assertThrows( + NotFoundError.class, () -> client.agent().delete("id")); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("DELETE", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate error response + Assertions.assertEquals(404, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testDeleteThrowsInternalServerError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(500).setBody("{\"key\":\"value\"}")); + InternalServerError exception = Assertions.assertThrows( + InternalServerError.class, () -> client.agent().delete("id")); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("DELETE", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate error response + Assertions.assertEquals(500, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testPatch() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue( + new MockResponse() + .setResponseCode(200) + .setBody( + "{\"success\":true,\"message\":\"Agent patched successfully\",\"data\":{\"id\":\"agent_123\",\"name\":\"Medical Assistant\",\"description\":\"An AI assistant for medical information processing\",\"prompts\":[\"prompt_123\",\"prompt_456\"],\"tools\":[\"mcp_server_123\",\"mcp_server_456\"],\"workflows\":[\"workflow_123\",\"workflow_456\"],\"tags\":[\"medical\",\"fhir\"],\"provider\":\"7002b0b4-8d09-445a-bf65-0fafdaf26c35\"}}")); + AgentResponse response = client.agent() + .patch( + "id", + Arrays.asList( + JsonPatchOperation.builder() + .op(JsonPatchOperationOp.REPLACE) + .path("/description") + .value("patched description") + .build(), + JsonPatchOperation.builder() + .op(JsonPatchOperationOp.ADD) + .path("/tags/-") + .value("updated") + .build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("PATCH", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = "" + + "[\n" + + " {\n" + + " \"op\": \"replace\",\n" + + " \"path\": \"/description\",\n" + + " \"value\": \"patched description\"\n" + + " },\n" + + " {\n" + + " \"op\": \"add\",\n" + + " \"path\": \"/tags/-\",\n" + + " \"value\": \"updated\"\n" + + " }\n" + + "]"; + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { + String discriminator = null; + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualJson.isNull()) { + Assertions.assertTrue( + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); + } + + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); + } + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); + } + + // Validate response body + Assertions.assertNotNull(response, "Response should not be null"); + String actualResponseJson = objectMapper.writeValueAsString(response); + String expectedResponseBody = "" + + "{\n" + + " \"success\": true,\n" + + " \"message\": \"Agent patched successfully\",\n" + + " \"data\": {\n" + + " \"id\": \"agent_123\",\n" + + " \"name\": \"Medical Assistant\",\n" + + " \"description\": \"An AI assistant for medical information processing\",\n" + + " \"prompts\": [\n" + + " \"prompt_123\",\n" + + " \"prompt_456\"\n" + + " ],\n" + + " \"tools\": [\n" + + " \"mcp_server_123\",\n" + + " \"mcp_server_456\"\n" + + " ],\n" + + " \"workflows\": [\n" + + " \"workflow_123\",\n" + + " \"workflow_456\"\n" + + " ],\n" + + " \"tags\": [\n" + + " \"medical\",\n" + + " \"fhir\"\n" + + " ],\n" + + " \"provider\": \"7002b0b4-8d09-445a-bf65-0fafdaf26c35\"\n" + + " }\n" + + "}"; + JsonNode actualResponseNode = objectMapper.readTree(actualResponseJson); + JsonNode expectedResponseNode = objectMapper.readTree(expectedResponseBody); + Assertions.assertTrue( + jsonEquals(expectedResponseNode, actualResponseNode), + "Response body structure does not match expected"); + if (actualResponseNode.has("type") || actualResponseNode.has("_type") || actualResponseNode.has("kind")) { + String discriminator = null; + if (actualResponseNode.has("type")) + discriminator = actualResponseNode.get("type").asText(); + else if (actualResponseNode.has("_type")) + discriminator = actualResponseNode.get("_type").asText(); + else if (actualResponseNode.has("kind")) + discriminator = actualResponseNode.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualResponseNode.isNull()) { + Assertions.assertTrue( + actualResponseNode.isObject() || actualResponseNode.isArray() || actualResponseNode.isValueNode(), + "response should be a valid JSON value"); + } + + if (actualResponseNode.isArray()) { + Assertions.assertTrue(actualResponseNode.size() >= 0, "Array should have valid size"); + } + if (actualResponseNode.isObject()) { + Assertions.assertTrue(actualResponseNode.size() >= 0, "Object should have valid field count"); + } + } + + @Test + public void testPatchThrowsBadRequestError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(400).setBody("{\"key\":\"value\"}")); + BadRequestError exception = Assertions.assertThrows(BadRequestError.class, () -> client.agent() + .patch( + "id", + Arrays.asList( + JsonPatchOperation.builder() + .op(JsonPatchOperationOp.ADD) + .path("path") + .build(), + JsonPatchOperation.builder() + .op(JsonPatchOperationOp.ADD) + .path("path") + .build()))); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("PATCH", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = "" + + "[\n" + + " {\n" + + " \"op\": \"add\",\n" + + " \"path\": \"path\"\n" + + " },\n" + + " {\n" + + " \"op\": \"add\",\n" + + " \"path\": \"path\"\n" + + " }\n" + + "]"; + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { + String discriminator = null; + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualJson.isNull()) { + Assertions.assertTrue( + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); + } + + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); + } + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); + } + + // Validate error response + Assertions.assertEquals(400, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testPatchThrowsUnauthorizedError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(401).setBody("{\"key\":\"value\"}")); + UnauthorizedError exception = Assertions.assertThrows(UnauthorizedError.class, () -> client.agent() + .patch( + "id", + Arrays.asList( + JsonPatchOperation.builder() + .op(JsonPatchOperationOp.ADD) + .path("path") + .build(), + JsonPatchOperation.builder() + .op(JsonPatchOperationOp.ADD) + .path("path") + .build()))); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("PATCH", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = "" + + "[\n" + + " {\n" + + " \"op\": \"add\",\n" + + " \"path\": \"path\"\n" + + " },\n" + + " {\n" + + " \"op\": \"add\",\n" + + " \"path\": \"path\"\n" + + " }\n" + + "]"; + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { + String discriminator = null; + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualJson.isNull()) { + Assertions.assertTrue( + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); + } + + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); + } + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); + } + + // Validate error response + Assertions.assertEquals(401, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testPatchThrowsForbiddenError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(403).setBody("{\"key\":\"value\"}")); + ForbiddenError exception = Assertions.assertThrows(ForbiddenError.class, () -> client.agent() + .patch( + "id", + Arrays.asList( + JsonPatchOperation.builder() + .op(JsonPatchOperationOp.ADD) + .path("path") + .build(), + JsonPatchOperation.builder() + .op(JsonPatchOperationOp.ADD) + .path("path") + .build()))); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("PATCH", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = "" + + "[\n" + + " {\n" + + " \"op\": \"add\",\n" + + " \"path\": \"path\"\n" + + " },\n" + + " {\n" + + " \"op\": \"add\",\n" + + " \"path\": \"path\"\n" + + " }\n" + + "]"; + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { + String discriminator = null; + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualJson.isNull()) { + Assertions.assertTrue( + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); + } + + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); + } + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); + } + + // Validate error response + Assertions.assertEquals(403, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testPatchThrowsNotFoundError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(404).setBody("{\"key\":\"value\"}")); + NotFoundError exception = Assertions.assertThrows(NotFoundError.class, () -> client.agent() + .patch( + "id", + Arrays.asList( + JsonPatchOperation.builder() + .op(JsonPatchOperationOp.ADD) + .path("path") + .build(), + JsonPatchOperation.builder() + .op(JsonPatchOperationOp.ADD) + .path("path") + .build()))); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("PATCH", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = "" + + "[\n" + + " {\n" + + " \"op\": \"add\",\n" + + " \"path\": \"path\"\n" + + " },\n" + + " {\n" + + " \"op\": \"add\",\n" + + " \"path\": \"path\"\n" + + " }\n" + + "]"; + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { + String discriminator = null; + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualJson.isNull()) { + Assertions.assertTrue( + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); + } + + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); + } + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); + } + + // Validate error response + Assertions.assertEquals(404, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testPatchThrowsInternalServerError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(500).setBody("{\"key\":\"value\"}")); + InternalServerError exception = Assertions.assertThrows(InternalServerError.class, () -> client.agent() + .patch( + "id", + Arrays.asList( + JsonPatchOperation.builder() + .op(JsonPatchOperationOp.ADD) + .path("path") + .build(), + JsonPatchOperation.builder() + .op(JsonPatchOperationOp.ADD) + .path("path") + .build()))); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("PATCH", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = "" + + "[\n" + + " {\n" + + " \"op\": \"add\",\n" + + " \"path\": \"path\"\n" + + " },\n" + + " {\n" + + " \"op\": \"add\",\n" + + " \"path\": \"path\"\n" + + " }\n" + + "]"; + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { + String discriminator = null; + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualJson.isNull()) { + Assertions.assertTrue( + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); + } + + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); + } + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); + } + + // Validate error response + Assertions.assertEquals(500, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + /** * Compares two JsonNodes with numeric equivalence and null safety. * For objects, checks that all fields in 'expected' exist in 'actual' with matching values. diff --git a/src/test/java/com/phenoml/api/CohortWireTest.java b/src/test/java/com/phenoml/api/CohortWireTest.java index 406e1a87..cd2b15bf 100644 --- a/src/test/java/com/phenoml/api/CohortWireTest.java +++ b/src/test/java/com/phenoml/api/CohortWireTest.java @@ -3,6 +3,9 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.phenoml.api.core.ObjectMappers; +import com.phenoml.api.resources.cohort.errors.BadRequestError; +import com.phenoml.api.resources.cohort.errors.InternalServerError; +import com.phenoml.api.resources.cohort.errors.UnauthorizedError; import com.phenoml.api.resources.cohort.requests.CohortRequest; import com.phenoml.api.resources.cohort.types.CohortResponse; import okhttp3.mockwebserver.MockResponse; @@ -24,6 +27,7 @@ public void setup() throws Exception { server.start(); client = PhenomlClient.withCredentials("test-client-id", "test-client-secret") .url(server.url("/").toString()) + .maxRetries(0) .build(); } @@ -148,6 +152,183 @@ else if (actualResponseNode.has("kind")) } } + @Test + public void testAnalyzeThrowsBadRequestError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(400).setBody("{\"key\":\"value\"}")); + BadRequestError exception = Assertions.assertThrows(BadRequestError.class, () -> client.cohort() + .analyze(CohortRequest.builder().text("text").build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("POST", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = "" + "{\n" + " \"text\": \"text\"\n" + "}"; + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { + String discriminator = null; + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualJson.isNull()) { + Assertions.assertTrue( + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); + } + + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); + } + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); + } + + // Validate error response + Assertions.assertEquals(400, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testAnalyzeThrowsUnauthorizedError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(401).setBody("{\"key\":\"value\"}")); + UnauthorizedError exception = Assertions.assertThrows(UnauthorizedError.class, () -> client.cohort() + .analyze(CohortRequest.builder().text("text").build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("POST", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = "" + "{\n" + " \"text\": \"text\"\n" + "}"; + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { + String discriminator = null; + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualJson.isNull()) { + Assertions.assertTrue( + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); + } + + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); + } + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); + } + + // Validate error response + Assertions.assertEquals(401, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testAnalyzeThrowsInternalServerError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(500).setBody("{\"key\":\"value\"}")); + InternalServerError exception = Assertions.assertThrows(InternalServerError.class, () -> client.cohort() + .analyze(CohortRequest.builder().text("text").build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("POST", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = "" + "{\n" + " \"text\": \"text\"\n" + "}"; + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { + String discriminator = null; + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualJson.isNull()) { + Assertions.assertTrue( + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); + } + + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); + } + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); + } + + // Validate error response + Assertions.assertEquals(500, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + /** * Compares two JsonNodes with numeric equivalence and null safety. * For objects, checks that all fields in 'expected' exist in 'actual' with matching values. diff --git a/src/test/java/com/phenoml/api/ConstrueCodeSystemsWireTest.java b/src/test/java/com/phenoml/api/ConstrueCodeSystemsWireTest.java index 55823ca0..cacca2cb 100644 --- a/src/test/java/com/phenoml/api/ConstrueCodeSystemsWireTest.java +++ b/src/test/java/com/phenoml/api/ConstrueCodeSystemsWireTest.java @@ -9,6 +9,13 @@ import com.phenoml.api.resources.construe.codesystems.requests.UploadRequest; import com.phenoml.api.resources.construe.codesystems.types.UploadRequestFormat; import com.phenoml.api.resources.construe.codesystems.types.UploadResponse; +import com.phenoml.api.resources.construe.errors.BadRequestError; +import com.phenoml.api.resources.construe.errors.ConflictError; +import com.phenoml.api.resources.construe.errors.FailedDependencyError; +import com.phenoml.api.resources.construe.errors.ForbiddenError; +import com.phenoml.api.resources.construe.errors.InternalServerError; +import com.phenoml.api.resources.construe.errors.NotFoundError; +import com.phenoml.api.resources.construe.errors.UnauthorizedError; import com.phenoml.api.resources.construe.types.CodeResponse; import com.phenoml.api.resources.construe.types.DeleteCodeSystemResponse; import com.phenoml.api.resources.construe.types.ExportCodeSystemResponse; @@ -35,6 +42,7 @@ public void setup() throws Exception { server.start(); client = PhenomlClient.withCredentials("test-client-id", "test-client-secret") .url(server.url("/").toString()) + .maxRetries(0) .build(); } @@ -50,7 +58,7 @@ public void testUpload() throws Exception { .setResponseCode(200) .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); server.enqueue(new MockResponse() - .setResponseCode(200) + .setResponseCode(202) .setBody("{\"status\":\"processing\",\"name\":\"CUSTOM_CODES\",\"version\":\"1.0\"}")); UploadResponse response = client.construe() .codeSystems() @@ -165,32 +173,82 @@ else if (actualResponseNode.has("kind")) } @Test - public void testList() throws Exception { + public void testUploadCsvFileBase64Encoded() throws Exception { // OAuth: enqueue token response (client fetches token before API call) server.enqueue(new MockResponse() .setResponseCode(200) .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); server.enqueue(new MockResponse() - .setResponseCode(200) - .setBody(TestResources.loadResource("/wire-tests/ConstrueCodeSystemsWireTest_testList_response.json"))); - ListCodeSystemsResponse response = client.construe().codeSystems().list(); + .setResponseCode(202) + .setBody("{\"status\":\"processing\",\"name\":\"CUSTOM_CODES\",\"version\":\"1.0\"}")); + UploadResponse response = client.construe() + .codeSystems() + .upload(UploadRequest.builder() + .name("CUSTOM_CODES") + .version("1.0") + .format(UploadRequestFormat.CSV) + .file("Y29kZSxkZXNjcmlwdGlvbgpYMDAxLEV4YW1wbGUgY3VzdG9tIGNvZGUgMQo=") + .codeCol("code") + .descCol("description") + .build()); // OAuth: consume the token request server.takeRequest(); RecordedRequest request = server.takeRequest(); Assertions.assertNotNull(request); - Assertions.assertEquals("GET", request.getMethod()); + Assertions.assertEquals("POST", request.getMethod()); // Validate OAuth Authorization header Assertions.assertEquals( "Bearer test-token", request.getHeader("Authorization"), "OAuth Authorization header should contain Bearer token from OAuth flow"); + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = "" + + "{\n" + + " \"name\": \"CUSTOM_CODES\",\n" + + " \"version\": \"1.0\",\n" + + " \"format\": \"csv\",\n" + + " \"file\": \"Y29kZSxkZXNjcmlwdGlvbgpYMDAxLEV4YW1wbGUgY3VzdG9tIGNvZGUgMQo=\",\n" + + " \"code_col\": \"code\",\n" + + " \"desc_col\": \"description\"\n" + + "}"; + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { + String discriminator = null; + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualJson.isNull()) { + Assertions.assertTrue( + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); + } + + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); + } + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); + } // Validate response body Assertions.assertNotNull(response, "Response should not be null"); String actualResponseJson = objectMapper.writeValueAsString(response); - String expectedResponseBody = - TestResources.loadResource("/wire-tests/ConstrueCodeSystemsWireTest_testList_response.json"); + String expectedResponseBody = "" + + "{\n" + + " \"status\": \"processing\",\n" + + " \"name\": \"CUSTOM_CODES\",\n" + + " \"version\": \"1.0\"\n" + + "}"; JsonNode actualResponseNode = objectMapper.readTree(actualResponseJson); JsonNode expectedResponseNode = objectMapper.readTree(expectedResponseBody); Assertions.assertTrue( @@ -223,213 +281,1241 @@ else if (actualResponseNode.has("kind")) } @Test - public void testFind() throws Exception { + public void testUploadThrowsBadRequestError() throws Exception { // OAuth: enqueue token response (client fetches token before API call) server.enqueue(new MockResponse() .setResponseCode(200) .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); - server.enqueue( - new MockResponse() - .setResponseCode(200) - .setBody( - "{\"name\":\"ICD-10-CM\",\"version\":\"2025\",\"code_count\":97584,\"builtin\":true,\"status\":\"ready\",\"created_at\":\"2026-02-10T18:33:23Z\",\"updated_at\":\"2026-02-10T18:33:23Z\"}")); - GetCodeSystemDetailResponse response = client.construe() + server.enqueue(new MockResponse().setResponseCode(400).setBody("{\"key\":\"value\"}")); + BadRequestError exception = Assertions.assertThrows(BadRequestError.class, () -> client.construe() .codeSystems() - .find("ICD-10-CM", FindRequest.builder().version("2025").build()); + .upload(UploadRequest.builder() + .name("name") + .version("version") + .format(UploadRequestFormat.CSV) + .build())); // OAuth: consume the token request server.takeRequest(); RecordedRequest request = server.takeRequest(); Assertions.assertNotNull(request); - Assertions.assertEquals("GET", request.getMethod()); + Assertions.assertEquals("POST", request.getMethod()); // Validate OAuth Authorization header Assertions.assertEquals( "Bearer test-token", request.getHeader("Authorization"), "OAuth Authorization header should contain Bearer token from OAuth flow"); - - // Validate response body - Assertions.assertNotNull(response, "Response should not be null"); - String actualResponseJson = objectMapper.writeValueAsString(response); - String expectedResponseBody = "" + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = "" + "{\n" - + " \"name\": \"ICD-10-CM\",\n" - + " \"version\": \"2025\",\n" - + " \"code_count\": 97584,\n" - + " \"builtin\": true,\n" - + " \"status\": \"ready\",\n" - + " \"created_at\": \"2026-02-10T18:33:23Z\",\n" - + " \"updated_at\": \"2026-02-10T18:33:23Z\"\n" + + " \"name\": \"name\",\n" + + " \"version\": \"version\",\n" + + " \"format\": \"csv\"\n" + "}"; - JsonNode actualResponseNode = objectMapper.readTree(actualResponseJson); - JsonNode expectedResponseNode = objectMapper.readTree(expectedResponseBody); - Assertions.assertTrue( - jsonEquals(expectedResponseNode, actualResponseNode), - "Response body structure does not match expected"); - if (actualResponseNode.has("type") || actualResponseNode.has("_type") || actualResponseNode.has("kind")) { + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { String discriminator = null; - if (actualResponseNode.has("type")) - discriminator = actualResponseNode.get("type").asText(); - else if (actualResponseNode.has("_type")) - discriminator = actualResponseNode.get("_type").asText(); - else if (actualResponseNode.has("kind")) - discriminator = actualResponseNode.get("kind").asText(); + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); } - if (!actualResponseNode.isNull()) { + if (!actualJson.isNull()) { Assertions.assertTrue( - actualResponseNode.isObject() || actualResponseNode.isArray() || actualResponseNode.isValueNode(), - "response should be a valid JSON value"); + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); } - if (actualResponseNode.isArray()) { - Assertions.assertTrue(actualResponseNode.size() >= 0, "Array should have valid size"); + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); } - if (actualResponseNode.isObject()) { - Assertions.assertTrue(actualResponseNode.size() >= 0, "Object should have valid field count"); + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); } + + // Validate error response + Assertions.assertEquals(400, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); } @Test - public void testDelete() throws Exception { + public void testUploadThrowsUnauthorizedError() throws Exception { // OAuth: enqueue token response (client fetches token before API call) server.enqueue(new MockResponse() .setResponseCode(200) .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); - server.enqueue( - new MockResponse().setResponseCode(200).setBody("{\"message\":\"code system deleted successfully\"}")); - DeleteCodeSystemResponse response = client.construe() + server.enqueue(new MockResponse().setResponseCode(401).setBody("{\"key\":\"value\"}")); + UnauthorizedError exception = Assertions.assertThrows(UnauthorizedError.class, () -> client.construe() .codeSystems() - .delete( - "CUSTOM_CODES", - DeleteRequest.builder().version("version").build()); + .upload(UploadRequest.builder() + .name("name") + .version("version") + .format(UploadRequestFormat.CSV) + .build())); // OAuth: consume the token request server.takeRequest(); RecordedRequest request = server.takeRequest(); Assertions.assertNotNull(request); - Assertions.assertEquals("DELETE", request.getMethod()); + Assertions.assertEquals("POST", request.getMethod()); // Validate OAuth Authorization header Assertions.assertEquals( "Bearer test-token", request.getHeader("Authorization"), "OAuth Authorization header should contain Bearer token from OAuth flow"); - - // Validate response body - Assertions.assertNotNull(response, "Response should not be null"); - String actualResponseJson = objectMapper.writeValueAsString(response); - String expectedResponseBody = "" + "{\n" + " \"message\": \"code system deleted successfully\"\n" + "}"; - JsonNode actualResponseNode = objectMapper.readTree(actualResponseJson); - JsonNode expectedResponseNode = objectMapper.readTree(expectedResponseBody); - Assertions.assertTrue( - jsonEquals(expectedResponseNode, actualResponseNode), - "Response body structure does not match expected"); - if (actualResponseNode.has("type") || actualResponseNode.has("_type") || actualResponseNode.has("kind")) { + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = "" + + "{\n" + + " \"name\": \"name\",\n" + + " \"version\": \"version\",\n" + + " \"format\": \"csv\"\n" + + "}"; + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { String discriminator = null; - if (actualResponseNode.has("type")) - discriminator = actualResponseNode.get("type").asText(); - else if (actualResponseNode.has("_type")) - discriminator = actualResponseNode.get("_type").asText(); - else if (actualResponseNode.has("kind")) - discriminator = actualResponseNode.get("kind").asText(); + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); } - if (!actualResponseNode.isNull()) { + if (!actualJson.isNull()) { Assertions.assertTrue( - actualResponseNode.isObject() || actualResponseNode.isArray() || actualResponseNode.isValueNode(), - "response should be a valid JSON value"); + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); } - if (actualResponseNode.isArray()) { - Assertions.assertTrue(actualResponseNode.size() >= 0, "Array should have valid size"); + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); } - if (actualResponseNode.isObject()) { - Assertions.assertTrue(actualResponseNode.size() >= 0, "Object should have valid field count"); + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); } + + // Validate error response + Assertions.assertEquals(401, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); } @Test - public void testExport() throws Exception { + public void testUploadThrowsForbiddenError() throws Exception { // OAuth: enqueue token response (client fetches token before API call) server.enqueue(new MockResponse() .setResponseCode(200) .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); - server.enqueue( - new MockResponse() - .setResponseCode(200) - .setBody( - "{\"name\":\"CUSTOM_CODES\",\"version\":\"1.0\",\"format\":\"json\",\"codes\":[{\"code\":\"X001\",\"description\":\"Example custom code 1\",\"definition\":\"definition\"},{\"code\":\"X002\",\"description\":\"Example custom code 2\",\"definition\":\"definition\"}]}")); - ExportCodeSystemResponse response = client.construe() + server.enqueue(new MockResponse().setResponseCode(403).setBody("{\"key\":\"value\"}")); + ForbiddenError exception = Assertions.assertThrows(ForbiddenError.class, () -> client.construe() .codeSystems() - .export( - "CUSTOM_CODES", - ExportRequest.builder().version("version").build()); + .upload(UploadRequest.builder() + .name("name") + .version("version") + .format(UploadRequestFormat.CSV) + .build())); // OAuth: consume the token request server.takeRequest(); RecordedRequest request = server.takeRequest(); Assertions.assertNotNull(request); - Assertions.assertEquals("GET", request.getMethod()); + Assertions.assertEquals("POST", request.getMethod()); // Validate OAuth Authorization header Assertions.assertEquals( "Bearer test-token", request.getHeader("Authorization"), "OAuth Authorization header should contain Bearer token from OAuth flow"); - - // Validate response body - Assertions.assertNotNull(response, "Response should not be null"); - String actualResponseJson = objectMapper.writeValueAsString(response); - String expectedResponseBody = "" + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = "" + "{\n" - + " \"name\": \"CUSTOM_CODES\",\n" - + " \"version\": \"1.0\",\n" - + " \"format\": \"json\",\n" - + " \"codes\": [\n" - + " {\n" - + " \"code\": \"X001\",\n" - + " \"description\": \"Example custom code 1\",\n" - + " \"definition\": \"definition\"\n" - + " },\n" - + " {\n" - + " \"code\": \"X002\",\n" - + " \"description\": \"Example custom code 2\",\n" - + " \"definition\": \"definition\"\n" - + " }\n" - + " ]\n" + + " \"name\": \"name\",\n" + + " \"version\": \"version\",\n" + + " \"format\": \"csv\"\n" + "}"; - JsonNode actualResponseNode = objectMapper.readTree(actualResponseJson); - JsonNode expectedResponseNode = objectMapper.readTree(expectedResponseBody); - Assertions.assertTrue( - jsonEquals(expectedResponseNode, actualResponseNode), - "Response body structure does not match expected"); - if (actualResponseNode.has("type") || actualResponseNode.has("_type") || actualResponseNode.has("kind")) { + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { String discriminator = null; - if (actualResponseNode.has("type")) - discriminator = actualResponseNode.get("type").asText(); - else if (actualResponseNode.has("_type")) - discriminator = actualResponseNode.get("_type").asText(); - else if (actualResponseNode.has("kind")) - discriminator = actualResponseNode.get("kind").asText(); + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); } - if (!actualResponseNode.isNull()) { + if (!actualJson.isNull()) { Assertions.assertTrue( - actualResponseNode.isObject() || actualResponseNode.isArray() || actualResponseNode.isValueNode(), - "response should be a valid JSON value"); + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); } - if (actualResponseNode.isArray()) { - Assertions.assertTrue(actualResponseNode.size() >= 0, "Array should have valid size"); + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); } - if (actualResponseNode.isObject()) { - Assertions.assertTrue(actualResponseNode.size() >= 0, "Object should have valid field count"); + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); } + + // Validate error response + Assertions.assertEquals(403, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testUploadThrowsConflictError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(409).setBody("{\"key\":\"value\"}")); + ConflictError exception = Assertions.assertThrows(ConflictError.class, () -> client.construe() + .codeSystems() + .upload(UploadRequest.builder() + .name("name") + .version("version") + .format(UploadRequestFormat.CSV) + .build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("POST", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = "" + + "{\n" + + " \"name\": \"name\",\n" + + " \"version\": \"version\",\n" + + " \"format\": \"csv\"\n" + + "}"; + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { + String discriminator = null; + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualJson.isNull()) { + Assertions.assertTrue( + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); + } + + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); + } + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); + } + + // Validate error response + Assertions.assertEquals(409, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testUploadThrowsFailedDependencyError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(424).setBody("{\"key\":\"value\"}")); + FailedDependencyError exception = Assertions.assertThrows(FailedDependencyError.class, () -> client.construe() + .codeSystems() + .upload(UploadRequest.builder() + .name("name") + .version("version") + .format(UploadRequestFormat.CSV) + .build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("POST", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = "" + + "{\n" + + " \"name\": \"name\",\n" + + " \"version\": \"version\",\n" + + " \"format\": \"csv\"\n" + + "}"; + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { + String discriminator = null; + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualJson.isNull()) { + Assertions.assertTrue( + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); + } + + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); + } + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); + } + + // Validate error response + Assertions.assertEquals(424, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testUploadThrowsInternalServerError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(500).setBody("{\"key\":\"value\"}")); + InternalServerError exception = Assertions.assertThrows(InternalServerError.class, () -> client.construe() + .codeSystems() + .upload(UploadRequest.builder() + .name("name") + .version("version") + .format(UploadRequestFormat.CSV) + .build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("POST", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = "" + + "{\n" + + " \"name\": \"name\",\n" + + " \"version\": \"version\",\n" + + " \"format\": \"csv\"\n" + + "}"; + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { + String discriminator = null; + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualJson.isNull()) { + Assertions.assertTrue( + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); + } + + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); + } + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); + } + + // Validate error response + Assertions.assertEquals(500, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testList() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody(TestResources.loadResource("/wire-tests/ConstrueCodeSystemsWireTest_testList_response.json"))); + ListCodeSystemsResponse response = client.construe().codeSystems().list(); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("GET", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate response body + Assertions.assertNotNull(response, "Response should not be null"); + String actualResponseJson = objectMapper.writeValueAsString(response); + String expectedResponseBody = + TestResources.loadResource("/wire-tests/ConstrueCodeSystemsWireTest_testList_response.json"); + JsonNode actualResponseNode = objectMapper.readTree(actualResponseJson); + JsonNode expectedResponseNode = objectMapper.readTree(expectedResponseBody); + Assertions.assertTrue( + jsonEquals(expectedResponseNode, actualResponseNode), + "Response body structure does not match expected"); + if (actualResponseNode.has("type") || actualResponseNode.has("_type") || actualResponseNode.has("kind")) { + String discriminator = null; + if (actualResponseNode.has("type")) + discriminator = actualResponseNode.get("type").asText(); + else if (actualResponseNode.has("_type")) + discriminator = actualResponseNode.get("_type").asText(); + else if (actualResponseNode.has("kind")) + discriminator = actualResponseNode.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualResponseNode.isNull()) { + Assertions.assertTrue( + actualResponseNode.isObject() || actualResponseNode.isArray() || actualResponseNode.isValueNode(), + "response should be a valid JSON value"); + } + + if (actualResponseNode.isArray()) { + Assertions.assertTrue(actualResponseNode.size() >= 0, "Array should have valid size"); + } + if (actualResponseNode.isObject()) { + Assertions.assertTrue(actualResponseNode.size() >= 0, "Object should have valid field count"); + } + } + + @Test + public void testListThrowsUnauthorizedError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(401).setBody("{\"key\":\"value\"}")); + UnauthorizedError exception = Assertions.assertThrows( + UnauthorizedError.class, () -> client.construe().codeSystems().list()); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("GET", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate error response + Assertions.assertEquals(401, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testListThrowsInternalServerError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(500).setBody("{\"key\":\"value\"}")); + InternalServerError exception = Assertions.assertThrows( + InternalServerError.class, () -> client.construe().codeSystems().list()); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("GET", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate error response + Assertions.assertEquals(500, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testFind() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue( + new MockResponse() + .setResponseCode(200) + .setBody( + "{\"name\":\"ICD-10-CM\",\"version\":\"2025\",\"code_count\":97584,\"builtin\":true,\"status\":\"ready\",\"created_at\":\"2026-02-10T18:33:23Z\",\"updated_at\":\"2026-02-10T18:33:23Z\"}")); + GetCodeSystemDetailResponse response = client.construe() + .codeSystems() + .find("ICD-10-CM", FindRequest.builder().version("2025").build()); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("GET", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate response body + Assertions.assertNotNull(response, "Response should not be null"); + String actualResponseJson = objectMapper.writeValueAsString(response); + String expectedResponseBody = "" + + "{\n" + + " \"name\": \"ICD-10-CM\",\n" + + " \"version\": \"2025\",\n" + + " \"code_count\": 97584,\n" + + " \"builtin\": true,\n" + + " \"status\": \"ready\",\n" + + " \"created_at\": \"2026-02-10T18:33:23Z\",\n" + + " \"updated_at\": \"2026-02-10T18:33:23Z\"\n" + + "}"; + JsonNode actualResponseNode = objectMapper.readTree(actualResponseJson); + JsonNode expectedResponseNode = objectMapper.readTree(expectedResponseBody); + Assertions.assertTrue( + jsonEquals(expectedResponseNode, actualResponseNode), + "Response body structure does not match expected"); + if (actualResponseNode.has("type") || actualResponseNode.has("_type") || actualResponseNode.has("kind")) { + String discriminator = null; + if (actualResponseNode.has("type")) + discriminator = actualResponseNode.get("type").asText(); + else if (actualResponseNode.has("_type")) + discriminator = actualResponseNode.get("_type").asText(); + else if (actualResponseNode.has("kind")) + discriminator = actualResponseNode.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualResponseNode.isNull()) { + Assertions.assertTrue( + actualResponseNode.isObject() || actualResponseNode.isArray() || actualResponseNode.isValueNode(), + "response should be a valid JSON value"); + } + + if (actualResponseNode.isArray()) { + Assertions.assertTrue(actualResponseNode.size() >= 0, "Array should have valid size"); + } + if (actualResponseNode.isObject()) { + Assertions.assertTrue(actualResponseNode.size() >= 0, "Object should have valid field count"); + } + } + + @Test + public void testFindThrowsBadRequestError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(400).setBody("{\"key\":\"value\"}")); + BadRequestError exception = Assertions.assertThrows(BadRequestError.class, () -> client.construe() + .codeSystems() + .find("codesystem", FindRequest.builder().build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("GET", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate error response + Assertions.assertEquals(400, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testFindThrowsUnauthorizedError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(401).setBody("{\"key\":\"value\"}")); + UnauthorizedError exception = Assertions.assertThrows(UnauthorizedError.class, () -> client.construe() + .codeSystems() + .find("codesystem", FindRequest.builder().build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("GET", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate error response + Assertions.assertEquals(401, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testFindThrowsNotFoundError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(404).setBody("{\"key\":\"value\"}")); + NotFoundError exception = Assertions.assertThrows(NotFoundError.class, () -> client.construe() + .codeSystems() + .find("codesystem", FindRequest.builder().build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("GET", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate error response + Assertions.assertEquals(404, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testFindThrowsInternalServerError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(500).setBody("{\"key\":\"value\"}")); + InternalServerError exception = Assertions.assertThrows(InternalServerError.class, () -> client.construe() + .codeSystems() + .find("codesystem", FindRequest.builder().build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("GET", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate error response + Assertions.assertEquals(500, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testDelete() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue( + new MockResponse().setResponseCode(200).setBody("{\"message\":\"code system deleted successfully\"}")); + DeleteCodeSystemResponse response = client.construe() + .codeSystems() + .delete( + "CUSTOM_CODES", + DeleteRequest.builder().version("version").build()); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("DELETE", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate response body + Assertions.assertNotNull(response, "Response should not be null"); + String actualResponseJson = objectMapper.writeValueAsString(response); + String expectedResponseBody = "" + "{\n" + " \"message\": \"code system deleted successfully\"\n" + "}"; + JsonNode actualResponseNode = objectMapper.readTree(actualResponseJson); + JsonNode expectedResponseNode = objectMapper.readTree(expectedResponseBody); + Assertions.assertTrue( + jsonEquals(expectedResponseNode, actualResponseNode), + "Response body structure does not match expected"); + if (actualResponseNode.has("type") || actualResponseNode.has("_type") || actualResponseNode.has("kind")) { + String discriminator = null; + if (actualResponseNode.has("type")) + discriminator = actualResponseNode.get("type").asText(); + else if (actualResponseNode.has("_type")) + discriminator = actualResponseNode.get("_type").asText(); + else if (actualResponseNode.has("kind")) + discriminator = actualResponseNode.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualResponseNode.isNull()) { + Assertions.assertTrue( + actualResponseNode.isObject() || actualResponseNode.isArray() || actualResponseNode.isValueNode(), + "response should be a valid JSON value"); + } + + if (actualResponseNode.isArray()) { + Assertions.assertTrue(actualResponseNode.size() >= 0, "Array should have valid size"); + } + if (actualResponseNode.isObject()) { + Assertions.assertTrue(actualResponseNode.size() >= 0, "Object should have valid field count"); + } + } + + @Test + public void testDeleteThrowsBadRequestError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(400).setBody("{\"key\":\"value\"}")); + BadRequestError exception = Assertions.assertThrows(BadRequestError.class, () -> client.construe() + .codeSystems() + .delete("codesystem", DeleteRequest.builder().build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("DELETE", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate error response + Assertions.assertEquals(400, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testDeleteThrowsUnauthorizedError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(401).setBody("{\"key\":\"value\"}")); + UnauthorizedError exception = Assertions.assertThrows(UnauthorizedError.class, () -> client.construe() + .codeSystems() + .delete("codesystem", DeleteRequest.builder().build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("DELETE", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate error response + Assertions.assertEquals(401, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testDeleteThrowsForbiddenError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(403).setBody("{\"key\":\"value\"}")); + ForbiddenError exception = Assertions.assertThrows(ForbiddenError.class, () -> client.construe() + .codeSystems() + .delete("codesystem", DeleteRequest.builder().build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("DELETE", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate error response + Assertions.assertEquals(403, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testDeleteThrowsNotFoundError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(404).setBody("{\"key\":\"value\"}")); + NotFoundError exception = Assertions.assertThrows(NotFoundError.class, () -> client.construe() + .codeSystems() + .delete("codesystem", DeleteRequest.builder().build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("DELETE", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate error response + Assertions.assertEquals(404, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testDeleteThrowsInternalServerError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(500).setBody("{\"key\":\"value\"}")); + InternalServerError exception = Assertions.assertThrows(InternalServerError.class, () -> client.construe() + .codeSystems() + .delete("codesystem", DeleteRequest.builder().build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("DELETE", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate error response + Assertions.assertEquals(500, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testExport() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue( + new MockResponse() + .setResponseCode(200) + .setBody( + "{\"name\":\"CUSTOM_CODES\",\"version\":\"1.0\",\"format\":\"json\",\"codes\":[{\"code\":\"X001\",\"description\":\"Example custom code 1\",\"definition\":\"definition\"},{\"code\":\"X002\",\"description\":\"Example custom code 2\",\"definition\":\"definition\"}]}")); + ExportCodeSystemResponse response = client.construe() + .codeSystems() + .export( + "CUSTOM_CODES", + ExportRequest.builder().version("version").build()); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("GET", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate response body + Assertions.assertNotNull(response, "Response should not be null"); + String actualResponseJson = objectMapper.writeValueAsString(response); + String expectedResponseBody = "" + + "{\n" + + " \"name\": \"CUSTOM_CODES\",\n" + + " \"version\": \"1.0\",\n" + + " \"format\": \"json\",\n" + + " \"codes\": [\n" + + " {\n" + + " \"code\": \"X001\",\n" + + " \"description\": \"Example custom code 1\",\n" + + " \"definition\": \"definition\"\n" + + " },\n" + + " {\n" + + " \"code\": \"X002\",\n" + + " \"description\": \"Example custom code 2\",\n" + + " \"definition\": \"definition\"\n" + + " }\n" + + " ]\n" + + "}"; + JsonNode actualResponseNode = objectMapper.readTree(actualResponseJson); + JsonNode expectedResponseNode = objectMapper.readTree(expectedResponseBody); + Assertions.assertTrue( + jsonEquals(expectedResponseNode, actualResponseNode), + "Response body structure does not match expected"); + if (actualResponseNode.has("type") || actualResponseNode.has("_type") || actualResponseNode.has("kind")) { + String discriminator = null; + if (actualResponseNode.has("type")) + discriminator = actualResponseNode.get("type").asText(); + else if (actualResponseNode.has("_type")) + discriminator = actualResponseNode.get("_type").asText(); + else if (actualResponseNode.has("kind")) + discriminator = actualResponseNode.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualResponseNode.isNull()) { + Assertions.assertTrue( + actualResponseNode.isObject() || actualResponseNode.isArray() || actualResponseNode.isValueNode(), + "response should be a valid JSON value"); + } + + if (actualResponseNode.isArray()) { + Assertions.assertTrue(actualResponseNode.size() >= 0, "Array should have valid size"); + } + if (actualResponseNode.isObject()) { + Assertions.assertTrue(actualResponseNode.size() >= 0, "Object should have valid field count"); + } + } + + @Test + public void testExportThrowsBadRequestError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(400).setBody("{\"key\":\"value\"}")); + BadRequestError exception = Assertions.assertThrows(BadRequestError.class, () -> client.construe() + .codeSystems() + .export("codesystem", ExportRequest.builder().build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("GET", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate error response + Assertions.assertEquals(400, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testExportThrowsUnauthorizedError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(401).setBody("{\"key\":\"value\"}")); + UnauthorizedError exception = Assertions.assertThrows(UnauthorizedError.class, () -> client.construe() + .codeSystems() + .export("codesystem", ExportRequest.builder().build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("GET", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate error response + Assertions.assertEquals(401, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testExportThrowsForbiddenError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(403).setBody("{\"key\":\"value\"}")); + ForbiddenError exception = Assertions.assertThrows(ForbiddenError.class, () -> client.construe() + .codeSystems() + .export("codesystem", ExportRequest.builder().build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("GET", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate error response + Assertions.assertEquals(403, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testExportThrowsNotFoundError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(404).setBody("{\"key\":\"value\"}")); + NotFoundError exception = Assertions.assertThrows(NotFoundError.class, () -> client.construe() + .codeSystems() + .export("codesystem", ExportRequest.builder().build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("GET", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate error response + Assertions.assertEquals(404, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testExportThrowsConflictError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(409).setBody("{\"key\":\"value\"}")); + ConflictError exception = Assertions.assertThrows(ConflictError.class, () -> client.construe() + .codeSystems() + .export("codesystem", ExportRequest.builder().build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("GET", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate error response + Assertions.assertEquals(409, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testExportThrowsFailedDependencyError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(424).setBody("{\"key\":\"value\"}")); + FailedDependencyError exception = Assertions.assertThrows(FailedDependencyError.class, () -> client.construe() + .codeSystems() + .export("codesystem", ExportRequest.builder().build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("GET", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate error response + Assertions.assertEquals(424, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testExportThrowsInternalServerError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(500).setBody("{\"key\":\"value\"}")); + InternalServerError exception = Assertions.assertThrows(InternalServerError.class, () -> client.construe() + .codeSystems() + .export("codesystem", ExportRequest.builder().build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("GET", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate error response + Assertions.assertEquals(500, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); } /** diff --git a/src/test/java/com/phenoml/api/ConstrueCodesWireTest.java b/src/test/java/com/phenoml/api/ConstrueCodesWireTest.java index b8555bca..e847d2ef 100644 --- a/src/test/java/com/phenoml/api/ConstrueCodesWireTest.java +++ b/src/test/java/com/phenoml/api/ConstrueCodesWireTest.java @@ -10,8 +10,18 @@ import com.phenoml.api.resources.construe.codes.requests.PhenoCrRequest; import com.phenoml.api.resources.construe.codes.requests.SearchSemanticRequest; import com.phenoml.api.resources.construe.codes.requests.SearchTextRequest; +import com.phenoml.api.resources.construe.errors.BadRequestError; +import com.phenoml.api.resources.construe.errors.GatewayTimeoutError; +import com.phenoml.api.resources.construe.errors.InternalServerError; +import com.phenoml.api.resources.construe.errors.NotFoundError; +import com.phenoml.api.resources.construe.errors.NotImplementedError; +import com.phenoml.api.resources.construe.errors.ServiceUnavailableError; +import com.phenoml.api.resources.construe.errors.UnauthorizedError; import com.phenoml.api.resources.construe.types.CrosswalkResponse; import com.phenoml.api.resources.construe.types.ExtractCodesResult; +import com.phenoml.api.resources.construe.types.ExtractRequestConfig; +import com.phenoml.api.resources.construe.types.ExtractRequestConfigChunkingMethod; +import com.phenoml.api.resources.construe.types.ExtractRequestConfigValidationMethod; import com.phenoml.api.resources.construe.types.ExtractRequestSystem; import com.phenoml.api.resources.construe.types.GetCodeResponse; import com.phenoml.api.resources.construe.types.ListCodesResponse; @@ -38,6 +48,7 @@ public void setup() throws Exception { server.start(); client = PhenomlClient.withCredentials("test-client-id", "test-client-secret") .url(server.url("/").toString()) + .maxRetries(0) .build(); } @@ -150,24 +161,25 @@ else if (actualResponseNode.has("kind")) } @Test - public void testPhenocr() throws Exception { + public void testExtractIcd10CmUmls2026Aa() throws Exception { // OAuth: enqueue token response (client fetches token before API call) server.enqueue(new MockResponse() .setResponseCode(200) .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); - server.enqueue( - new MockResponse() - .setResponseCode(200) - .setBody( - "{\"system\":{\"name\":\"SNOMED_CT_US_LITE\",\"version\":\"umls-2026aa\"},\"codes\":[{\"code\":\"195967001\",\"description\":\"Asthma\",\"valid\":true,\"reason\":\"reason\",\"is_ancestor\":true,\"citations\":[{\"text\":\"Patient has type 2 diabetes\",\"begin_offset\":0,\"end_offset\":27}],\"categories\":[{\"uri\":\"HP:0025142\",\"label\":\"Constitutional symptom\"}]}]}")); + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody(TestResources.loadResource( + "/wire-tests/ConstrueCodesWireTest_testExtractIcd10CmUmls2026Aa_response.json"))); ExtractCodesResult response = client.construe() .codes() - .phenocr(PhenoCrRequest.builder() - .text( - "5-year-old male with seizures, severe intellectual disability, microcephaly, and hypotonia.") - .system(PhenocrExtractRequestSystem.builder() - .name("HPO") - .version("umls-2026AA") + .extract(ExtractRequest.builder() + .text("Patient has type 2 diabetes mellitus with diabetic chronic kidney disease.") + .system(ExtractRequestSystem.builder() + .name("ICD-10-CM") + .version("umls-2026aa") + .build()) + .config(ExtractRequestConfig.builder() + .validationMethod(ExtractRequestConfigValidationMethod.SIMPLE) .build()) .build()); // OAuth: consume the token request @@ -185,10 +197,13 @@ public void testPhenocr() throws Exception { String actualRequestBody = request.getBody().readUtf8(); String expectedRequestBody = "" + "{\n" - + " \"text\": \"5-year-old male with seizures, severe intellectual disability, microcephaly, and hypotonia.\",\n" + + " \"text\": \"Patient has type 2 diabetes mellitus with diabetic chronic kidney disease.\",\n" + " \"system\": {\n" - + " \"name\": \"HPO\",\n" - + " \"version\": \"umls-2026AA\"\n" + + " \"name\": \"ICD-10-CM\",\n" + + " \"version\": \"umls-2026aa\"\n" + + " },\n" + + " \"config\": {\n" + + " \"validation_method\": \"simple\"\n" + " }\n" + "}"; JsonNode actualJson = objectMapper.readTree(actualRequestBody); @@ -221,35 +236,8 @@ else if (actualJson.has("kind")) // Validate response body Assertions.assertNotNull(response, "Response should not be null"); String actualResponseJson = objectMapper.writeValueAsString(response); - String expectedResponseBody = "" - + "{\n" - + " \"system\": {\n" - + " \"name\": \"SNOMED_CT_US_LITE\",\n" - + " \"version\": \"umls-2026aa\"\n" - + " },\n" - + " \"codes\": [\n" - + " {\n" - + " \"code\": \"195967001\",\n" - + " \"description\": \"Asthma\",\n" - + " \"valid\": true,\n" - + " \"reason\": \"reason\",\n" - + " \"is_ancestor\": true,\n" - + " \"citations\": [\n" - + " {\n" - + " \"text\": \"Patient has type 2 diabetes\",\n" - + " \"begin_offset\": 0,\n" - + " \"end_offset\": 27\n" - + " }\n" - + " ],\n" - + " \"categories\": [\n" - + " {\n" - + " \"uri\": \"HP:0025142\",\n" - + " \"label\": \"Constitutional symptom\"\n" - + " }\n" - + " ]\n" - + " }\n" - + " ]\n" - + "}"; + String expectedResponseBody = TestResources.loadResource( + "/wire-tests/ConstrueCodesWireTest_testExtractIcd10CmUmls2026Aa_response.json"); JsonNode actualResponseNode = objectMapper.readTree(actualResponseJson); JsonNode expectedResponseNode = objectMapper.readTree(expectedResponseBody); Assertions.assertTrue( @@ -282,22 +270,30 @@ else if (actualResponseNode.has("kind")) } @Test - public void testCrosswalk() throws Exception { + public void testExtractWithSourceCitations() throws Exception { // OAuth: enqueue token response (client fetches token before API call) server.enqueue(new MockResponse() .setResponseCode(200) .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); - server.enqueue( - new MockResponse() - .setResponseCode(200) - .setBody( - "{\"resolved_umls_release\":\"2026AA\",\"system\":\"http://hl7.org/fhir/sid/icd-10-cm\",\"code\":\"A02.24\",\"reason_code\":\"NO_SOURCE_CUI\",\"targets\":[{\"system\":\"http://human-phenotype-ontology.org\",\"reason_code\":\"NO_TARGET_MATCHES\",\"matches\":[{\"code\":\"HP:0005661\",\"display\":\"Salmonella infection\",\"cui\":\"C0036111\",\"suppress\":\"N\"}]}]}")); - CrosswalkResponse response = client.construe() + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody(TestResources.loadResource( + "/wire-tests/ConstrueCodesWireTest_testExtractWithSourceCitations_response.json"))); + ExtractCodesResult response = client.construe() .codes() - .crosswalk(CrosswalkRequest.builder() - .system("http://hl7.org/fhir/sid/icd-10-cm") - .code("A02.24") - .targets(Arrays.asList("http://human-phenotype-ontology.org")) + .extract(ExtractRequest.builder() + .text( + "45-year-old male presents with chest pain radiating to left arm, shortness of breath, and diaphoresis. ECG shows ST elevation in leads II, III, and aVF.") + .system(ExtractRequestSystem.builder() + .name("SNOMED_CT_US_LITE") + .version("20240901") + .build()) + .config(ExtractRequestConfig.builder() + .chunkingMethod(ExtractRequestConfigChunkingMethod.SENTENCES) + .validationMethod(ExtractRequestConfigValidationMethod.SIMPLE) + .includeRationale(true) + .includeCitations(true) + .build()) .build()); // OAuth: consume the token request server.takeRequest(); @@ -314,11 +310,17 @@ public void testCrosswalk() throws Exception { String actualRequestBody = request.getBody().readUtf8(); String expectedRequestBody = "" + "{\n" - + " \"system\": \"http://hl7.org/fhir/sid/icd-10-cm\",\n" - + " \"code\": \"A02.24\",\n" - + " \"targets\": [\n" - + " \"http://human-phenotype-ontology.org\"\n" - + " ]\n" + + " \"text\": \"45-year-old male presents with chest pain radiating to left arm, shortness of breath, and diaphoresis. ECG shows ST elevation in leads II, III, and aVF.\",\n" + + " \"system\": {\n" + + " \"name\": \"SNOMED_CT_US_LITE\",\n" + + " \"version\": \"20240901\"\n" + + " },\n" + + " \"config\": {\n" + + " \"chunking_method\": \"sentences\",\n" + + " \"validation_method\": \"simple\",\n" + + " \"include_rationale\": true,\n" + + " \"include_citations\": true\n" + + " }\n" + "}"; JsonNode actualJson = objectMapper.readTree(actualRequestBody); JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); @@ -350,27 +352,8 @@ else if (actualJson.has("kind")) // Validate response body Assertions.assertNotNull(response, "Response should not be null"); String actualResponseJson = objectMapper.writeValueAsString(response); - String expectedResponseBody = "" - + "{\n" - + " \"resolved_umls_release\": \"2026AA\",\n" - + " \"system\": \"http://hl7.org/fhir/sid/icd-10-cm\",\n" - + " \"code\": \"A02.24\",\n" - + " \"reason_code\": \"NO_SOURCE_CUI\",\n" - + " \"targets\": [\n" - + " {\n" - + " \"system\": \"http://human-phenotype-ontology.org\",\n" - + " \"reason_code\": \"NO_TARGET_MATCHES\",\n" - + " \"matches\": [\n" - + " {\n" - + " \"code\": \"HP:0005661\",\n" - + " \"display\": \"Salmonella infection\",\n" - + " \"cui\": \"C0036111\",\n" - + " \"suppress\": \"N\"\n" - + " }\n" - + " ]\n" - + " }\n" - + " ]\n" - + "}"; + String expectedResponseBody = TestResources.loadResource( + "/wire-tests/ConstrueCodesWireTest_testExtractWithSourceCitations_response.json"); JsonNode actualResponseNode = objectMapper.readTree(actualResponseJson); JsonNode expectedResponseNode = objectMapper.readTree(expectedResponseBody); Assertions.assertTrue( @@ -403,76 +386,86 @@ else if (actualResponseNode.has("kind")) } @Test - public void testList() throws Exception { + public void testExtractMedicationValidation() throws Exception { // OAuth: enqueue token response (client fetches token before API call) server.enqueue(new MockResponse() .setResponseCode(200) .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); - server.enqueue( - new MockResponse() - .setResponseCode(200) - .setBody( - "{\"system\":{\"name\":\"ICD-10-CM\",\"version\":\"2025\"},\"codes\":[{\"code\":\"A00\",\"description\":\"Cholera\",\"definition\":\"definition\"},{\"code\":\"A000\",\"description\":\"Cholera due to Vibrio cholerae 01, biovar cholerae\",\"definition\":\"definition\"},{\"code\":\"A001\",\"description\":\"Cholera due to Vibrio cholerae 01, biovar eltor\",\"definition\":\"definition\"},{\"code\":\"A009\",\"description\":\"Cholera, unspecified\",\"definition\":\"definition\"},{\"code\":\"A01\",\"description\":\"Typhoid and paratyphoid fevers\",\"definition\":\"definition\"}],\"next_cursor\":\"QTAx\",\"has_more\":true}")); - ListCodesResponse response = client.construe() + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody(TestResources.loadResource( + "/wire-tests/ConstrueCodesWireTest_testExtractMedicationValidation_response.json"))); + ExtractCodesResult response = client.construe() .codes() - .list( - "ICD-10-CM", - CodesListRequest.builder() - .version("2025") - .cursor("cursor") - .limit(1) - .build()); + .extract(ExtractRequest.builder() + .text( + "Start Metformin 500mg twice daily for type 2 diabetes. Continue Lisinopril 10mg daily for hypertension. Add Atorvastatin 20mg at bedtime.") + .system(ExtractRequestSystem.builder() + .name("RXNORM") + .version("11042024") + .build()) + .config(ExtractRequestConfig.builder() + .validationMethod(ExtractRequestConfigValidationMethod.MEDICATION_SEARCH) + .includeRationale(true) + .build()) + .build()); // OAuth: consume the token request server.takeRequest(); RecordedRequest request = server.takeRequest(); Assertions.assertNotNull(request); - Assertions.assertEquals("GET", request.getMethod()); + Assertions.assertEquals("POST", request.getMethod()); // Validate OAuth Authorization header Assertions.assertEquals( "Bearer test-token", request.getHeader("Authorization"), "OAuth Authorization header should contain Bearer token from OAuth flow"); - - // Validate response body - Assertions.assertNotNull(response, "Response should not be null"); - String actualResponseJson = objectMapper.writeValueAsString(response); - String expectedResponseBody = "" + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = "" + "{\n" + + " \"text\": \"Start Metformin 500mg twice daily for type 2 diabetes. Continue Lisinopril 10mg daily for hypertension. Add Atorvastatin 20mg at bedtime.\",\n" + " \"system\": {\n" - + " \"name\": \"ICD-10-CM\",\n" - + " \"version\": \"2025\"\n" + + " \"name\": \"RXNORM\",\n" + + " \"version\": \"11042024\"\n" + " },\n" - + " \"codes\": [\n" - + " {\n" - + " \"code\": \"A00\",\n" - + " \"description\": \"Cholera\",\n" - + " \"definition\": \"definition\"\n" - + " },\n" - + " {\n" - + " \"code\": \"A000\",\n" - + " \"description\": \"Cholera due to Vibrio cholerae 01, biovar cholerae\",\n" - + " \"definition\": \"definition\"\n" - + " },\n" - + " {\n" - + " \"code\": \"A001\",\n" - + " \"description\": \"Cholera due to Vibrio cholerae 01, biovar eltor\",\n" - + " \"definition\": \"definition\"\n" - + " },\n" - + " {\n" - + " \"code\": \"A009\",\n" - + " \"description\": \"Cholera, unspecified\",\n" - + " \"definition\": \"definition\"\n" - + " },\n" - + " {\n" - + " \"code\": \"A01\",\n" - + " \"description\": \"Typhoid and paratyphoid fevers\",\n" - + " \"definition\": \"definition\"\n" - + " }\n" - + " ],\n" - + " \"next_cursor\": \"QTAx\",\n" - + " \"has_more\": true\n" + + " \"config\": {\n" + + " \"validation_method\": \"medication_search\",\n" + + " \"include_rationale\": true\n" + + " }\n" + "}"; + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { + String discriminator = null; + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualJson.isNull()) { + Assertions.assertTrue( + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); + } + + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); + } + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); + } + + // Validate response body + Assertions.assertNotNull(response, "Response should not be null"); + String actualResponseJson = objectMapper.writeValueAsString(response); + String expectedResponseBody = TestResources.loadResource( + "/wire-tests/ConstrueCodesWireTest_testExtractMedicationValidation_response.json"); JsonNode actualResponseNode = objectMapper.readTree(actualResponseJson); JsonNode expectedResponseNode = objectMapper.readTree(expectedResponseBody); Assertions.assertTrue( @@ -505,65 +498,2267 @@ else if (actualResponseNode.has("kind")) } @Test - public void testLookup() throws Exception { + public void testExtractWithAncestorCodes() throws Exception { // OAuth: enqueue token response (client fetches token before API call) server.enqueue(new MockResponse() .setResponseCode(200) .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); - server.enqueue( - new MockResponse() - .setResponseCode(200) - .setBody( - "{\"system\":{\"name\":\"ICD-10-CM\",\"version\":\"2025\"},\"code\":\"E1165\",\"description\":\"Type 2 diabetes mellitus with hyperglycemia\",\"definition\":\"definition\"}")); - GetCodeResponse response = client.construe() + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody(TestResources.loadResource( + "/wire-tests/ConstrueCodesWireTest_testExtractWithAncestorCodes_response.json"))); + ExtractCodesResult response = client.construe() .codes() - .lookup( - "ICD-10-CM", - "E1165", - LookupRequest.builder().version("version").build()); + .extract(ExtractRequest.builder() + .text( + "Patient diagnosed with Type 2 diabetes mellitus with diabetic chronic kidney disease, stage 3.") + .system(ExtractRequestSystem.builder() + .name("ICD-10-CM") + .version("2025") + .build()) + .config(ExtractRequestConfig.builder() + .validationMethod(ExtractRequestConfigValidationMethod.SIMPLE) + .includeAncestors(true) + .build()) + .build()); // OAuth: consume the token request server.takeRequest(); RecordedRequest request = server.takeRequest(); Assertions.assertNotNull(request); - Assertions.assertEquals("GET", request.getMethod()); + Assertions.assertEquals("POST", request.getMethod()); // Validate OAuth Authorization header Assertions.assertEquals( "Bearer test-token", request.getHeader("Authorization"), "OAuth Authorization header should contain Bearer token from OAuth flow"); - - // Validate response body - Assertions.assertNotNull(response, "Response should not be null"); - String actualResponseJson = objectMapper.writeValueAsString(response); - String expectedResponseBody = "" + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = "" + "{\n" + + " \"text\": \"Patient diagnosed with Type 2 diabetes mellitus with diabetic chronic kidney disease, stage 3.\",\n" + " \"system\": {\n" + " \"name\": \"ICD-10-CM\",\n" + " \"version\": \"2025\"\n" + " },\n" - + " \"code\": \"E1165\",\n" - + " \"description\": \"Type 2 diabetes mellitus with hyperglycemia\",\n" - + " \"definition\": \"definition\"\n" + + " \"config\": {\n" + + " \"validation_method\": \"simple\",\n" + + " \"include_ancestors\": true\n" + + " }\n" + "}"; - JsonNode actualResponseNode = objectMapper.readTree(actualResponseJson); - JsonNode expectedResponseNode = objectMapper.readTree(expectedResponseBody); - Assertions.assertTrue( - jsonEquals(expectedResponseNode, actualResponseNode), - "Response body structure does not match expected"); - if (actualResponseNode.has("type") || actualResponseNode.has("_type") || actualResponseNode.has("kind")) { + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { String discriminator = null; - if (actualResponseNode.has("type")) - discriminator = actualResponseNode.get("type").asText(); - else if (actualResponseNode.has("_type")) - discriminator = actualResponseNode.get("_type").asText(); - else if (actualResponseNode.has("kind")) - discriminator = actualResponseNode.get("kind").asText(); + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); } - if (!actualResponseNode.isNull()) { + if (!actualJson.isNull()) { + Assertions.assertTrue( + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); + } + + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); + } + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); + } + + // Validate response body + Assertions.assertNotNull(response, "Response should not be null"); + String actualResponseJson = objectMapper.writeValueAsString(response); + String expectedResponseBody = TestResources.loadResource( + "/wire-tests/ConstrueCodesWireTest_testExtractWithAncestorCodes_response.json"); + JsonNode actualResponseNode = objectMapper.readTree(actualResponseJson); + JsonNode expectedResponseNode = objectMapper.readTree(expectedResponseBody); + Assertions.assertTrue( + jsonEquals(expectedResponseNode, actualResponseNode), + "Response body structure does not match expected"); + if (actualResponseNode.has("type") || actualResponseNode.has("_type") || actualResponseNode.has("kind")) { + String discriminator = null; + if (actualResponseNode.has("type")) + discriminator = actualResponseNode.get("type").asText(); + else if (actualResponseNode.has("_type")) + discriminator = actualResponseNode.get("_type").asText(); + else if (actualResponseNode.has("kind")) + discriminator = actualResponseNode.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualResponseNode.isNull()) { + Assertions.assertTrue( + actualResponseNode.isObject() || actualResponseNode.isArray() || actualResponseNode.isValueNode(), + "response should be a valid JSON value"); + } + + if (actualResponseNode.isArray()) { + Assertions.assertTrue(actualResponseNode.size() >= 0, "Array should have valid size"); + } + if (actualResponseNode.isObject()) { + Assertions.assertTrue(actualResponseNode.size() >= 0, "Object should have valid field count"); + } + } + + @Test + public void testExtractHpoPhenotypeExtraction() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody(TestResources.loadResource( + "/wire-tests/ConstrueCodesWireTest_testExtractHpoPhenotypeExtraction_response.json"))); + ExtractCodesResult response = client.construe() + .codes() + .extract(ExtractRequest.builder() + .text( + "5-year-old male with seizures, severe intellectual disability, microcephaly, and hypotonia.") + .system(ExtractRequestSystem.builder() + .name("HPO") + .version("2025") + .build()) + .config(ExtractRequestConfig.builder() + .chunkingMethod(ExtractRequestConfigChunkingMethod.FASTHPOCR) + .validationMethod(ExtractRequestConfigValidationMethod.NONE) + .includeRationale(false) + .build()) + .build()); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("POST", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = "" + + "{\n" + + " \"text\": \"5-year-old male with seizures, severe intellectual disability, microcephaly, and hypotonia.\",\n" + + " \"system\": {\n" + + " \"name\": \"HPO\",\n" + + " \"version\": \"2025\"\n" + + " },\n" + + " \"config\": {\n" + + " \"chunking_method\": \"fasthpocr\",\n" + + " \"validation_method\": \"none\",\n" + + " \"include_rationale\": false\n" + + " }\n" + + "}"; + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { + String discriminator = null; + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualJson.isNull()) { + Assertions.assertTrue( + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); + } + + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); + } + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); + } + + // Validate response body + Assertions.assertNotNull(response, "Response should not be null"); + String actualResponseJson = objectMapper.writeValueAsString(response); + String expectedResponseBody = TestResources.loadResource( + "/wire-tests/ConstrueCodesWireTest_testExtractHpoPhenotypeExtraction_response.json"); + JsonNode actualResponseNode = objectMapper.readTree(actualResponseJson); + JsonNode expectedResponseNode = objectMapper.readTree(expectedResponseBody); + Assertions.assertTrue( + jsonEquals(expectedResponseNode, actualResponseNode), + "Response body structure does not match expected"); + if (actualResponseNode.has("type") || actualResponseNode.has("_type") || actualResponseNode.has("kind")) { + String discriminator = null; + if (actualResponseNode.has("type")) + discriminator = actualResponseNode.get("type").asText(); + else if (actualResponseNode.has("_type")) + discriminator = actualResponseNode.get("_type").asText(); + else if (actualResponseNode.has("kind")) + discriminator = actualResponseNode.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualResponseNode.isNull()) { + Assertions.assertTrue( + actualResponseNode.isObject() || actualResponseNode.isArray() || actualResponseNode.isValueNode(), + "response should be a valid JSON value"); + } + + if (actualResponseNode.isArray()) { + Assertions.assertTrue(actualResponseNode.size() >= 0, "Array should have valid size"); + } + if (actualResponseNode.isObject()) { + Assertions.assertTrue(actualResponseNode.size() >= 0, "Object should have valid field count"); + } + } + + @Test + public void testExtractThrowsBadRequestError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(400).setBody("{\"key\":\"value\"}")); + BadRequestError exception = Assertions.assertThrows(BadRequestError.class, () -> client.construe() + .codes() + .extract(ExtractRequest.builder().text("text").build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("POST", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = "" + "{\n" + " \"text\": \"text\"\n" + "}"; + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { + String discriminator = null; + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualJson.isNull()) { + Assertions.assertTrue( + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); + } + + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); + } + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); + } + + // Validate error response + Assertions.assertEquals(400, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testExtractThrowsUnauthorizedError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(401).setBody("{\"key\":\"value\"}")); + UnauthorizedError exception = Assertions.assertThrows(UnauthorizedError.class, () -> client.construe() + .codes() + .extract(ExtractRequest.builder().text("text").build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("POST", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = "" + "{\n" + " \"text\": \"text\"\n" + "}"; + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { + String discriminator = null; + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualJson.isNull()) { + Assertions.assertTrue( + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); + } + + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); + } + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); + } + + // Validate error response + Assertions.assertEquals(401, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testExtractThrowsNotFoundError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(404).setBody("{\"key\":\"value\"}")); + NotFoundError exception = Assertions.assertThrows(NotFoundError.class, () -> client.construe() + .codes() + .extract(ExtractRequest.builder().text("text").build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("POST", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = "" + "{\n" + " \"text\": \"text\"\n" + "}"; + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { + String discriminator = null; + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualJson.isNull()) { + Assertions.assertTrue( + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); + } + + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); + } + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); + } + + // Validate error response + Assertions.assertEquals(404, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testExtractThrowsInternalServerError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(500).setBody("{\"key\":\"value\"}")); + InternalServerError exception = Assertions.assertThrows(InternalServerError.class, () -> client.construe() + .codes() + .extract(ExtractRequest.builder().text("text").build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("POST", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = "" + "{\n" + " \"text\": \"text\"\n" + "}"; + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { + String discriminator = null; + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualJson.isNull()) { + Assertions.assertTrue( + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); + } + + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); + } + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); + } + + // Validate error response + Assertions.assertEquals(500, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testExtractThrowsServiceUnavailableError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(503).setBody("{\"key\":\"value\"}")); + ServiceUnavailableError exception = + Assertions.assertThrows(ServiceUnavailableError.class, () -> client.construe() + .codes() + .extract(ExtractRequest.builder().text("text").build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("POST", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = "" + "{\n" + " \"text\": \"text\"\n" + "}"; + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { + String discriminator = null; + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualJson.isNull()) { + Assertions.assertTrue( + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); + } + + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); + } + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); + } + + // Validate error response + Assertions.assertEquals(503, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testExtractThrowsGatewayTimeoutError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(504).setBody("{\"key\":\"value\"}")); + GatewayTimeoutError exception = Assertions.assertThrows(GatewayTimeoutError.class, () -> client.construe() + .codes() + .extract(ExtractRequest.builder().text("text").build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("POST", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = "" + "{\n" + " \"text\": \"text\"\n" + "}"; + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { + String discriminator = null; + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualJson.isNull()) { + Assertions.assertTrue( + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); + } + + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); + } + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); + } + + // Validate error response + Assertions.assertEquals(504, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testPhenocr() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue( + new MockResponse() + .setResponseCode(200) + .setBody( + "{\"system\":{\"name\":\"SNOMED_CT_US_LITE\",\"version\":\"umls-2026aa\"},\"codes\":[{\"code\":\"195967001\",\"description\":\"Asthma\",\"valid\":true,\"reason\":\"reason\",\"is_ancestor\":true,\"citations\":[{\"text\":\"Patient has type 2 diabetes\",\"begin_offset\":0,\"end_offset\":27}],\"categories\":[{\"uri\":\"HP:0025142\",\"label\":\"Constitutional symptom\"}]}]}")); + ExtractCodesResult response = client.construe() + .codes() + .phenocr(PhenoCrRequest.builder() + .text( + "5-year-old male with seizures, severe intellectual disability, microcephaly, and hypotonia.") + .system(PhenocrExtractRequestSystem.builder() + .name("HPO") + .version("umls-2026AA") + .build()) + .build()); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("POST", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = "" + + "{\n" + + " \"text\": \"5-year-old male with seizures, severe intellectual disability, microcephaly, and hypotonia.\",\n" + + " \"system\": {\n" + + " \"name\": \"HPO\",\n" + + " \"version\": \"umls-2026AA\"\n" + + " }\n" + + "}"; + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { + String discriminator = null; + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualJson.isNull()) { + Assertions.assertTrue( + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); + } + + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); + } + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); + } + + // Validate response body + Assertions.assertNotNull(response, "Response should not be null"); + String actualResponseJson = objectMapper.writeValueAsString(response); + String expectedResponseBody = "" + + "{\n" + + " \"system\": {\n" + + " \"name\": \"SNOMED_CT_US_LITE\",\n" + + " \"version\": \"umls-2026aa\"\n" + + " },\n" + + " \"codes\": [\n" + + " {\n" + + " \"code\": \"195967001\",\n" + + " \"description\": \"Asthma\",\n" + + " \"valid\": true,\n" + + " \"reason\": \"reason\",\n" + + " \"is_ancestor\": true,\n" + + " \"citations\": [\n" + + " {\n" + + " \"text\": \"Patient has type 2 diabetes\",\n" + + " \"begin_offset\": 0,\n" + + " \"end_offset\": 27\n" + + " }\n" + + " ],\n" + + " \"categories\": [\n" + + " {\n" + + " \"uri\": \"HP:0025142\",\n" + + " \"label\": \"Constitutional symptom\"\n" + + " }\n" + + " ]\n" + + " }\n" + + " ]\n" + + "}"; + JsonNode actualResponseNode = objectMapper.readTree(actualResponseJson); + JsonNode expectedResponseNode = objectMapper.readTree(expectedResponseBody); + Assertions.assertTrue( + jsonEquals(expectedResponseNode, actualResponseNode), + "Response body structure does not match expected"); + if (actualResponseNode.has("type") || actualResponseNode.has("_type") || actualResponseNode.has("kind")) { + String discriminator = null; + if (actualResponseNode.has("type")) + discriminator = actualResponseNode.get("type").asText(); + else if (actualResponseNode.has("_type")) + discriminator = actualResponseNode.get("_type").asText(); + else if (actualResponseNode.has("kind")) + discriminator = actualResponseNode.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualResponseNode.isNull()) { + Assertions.assertTrue( + actualResponseNode.isObject() || actualResponseNode.isArray() || actualResponseNode.isValueNode(), + "response should be a valid JSON value"); + } + + if (actualResponseNode.isArray()) { + Assertions.assertTrue(actualResponseNode.size() >= 0, "Array should have valid size"); + } + if (actualResponseNode.isObject()) { + Assertions.assertTrue(actualResponseNode.size() >= 0, "Object should have valid field count"); + } + } + + @Test + public void testPhenocrThrowsBadRequestError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(400).setBody("{\"key\":\"value\"}")); + BadRequestError exception = Assertions.assertThrows(BadRequestError.class, () -> client.construe() + .codes() + .phenocr(PhenoCrRequest.builder() + .text("text") + .system(PhenocrExtractRequestSystem.builder() + .name("name") + .version("version") + .build()) + .build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("POST", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = "" + + "{\n" + + " \"text\": \"text\",\n" + + " \"system\": {\n" + + " \"name\": \"name\",\n" + + " \"version\": \"version\"\n" + + " }\n" + + "}"; + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { + String discriminator = null; + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualJson.isNull()) { + Assertions.assertTrue( + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); + } + + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); + } + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); + } + + // Validate error response + Assertions.assertEquals(400, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testPhenocrThrowsUnauthorizedError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(401).setBody("{\"key\":\"value\"}")); + UnauthorizedError exception = Assertions.assertThrows(UnauthorizedError.class, () -> client.construe() + .codes() + .phenocr(PhenoCrRequest.builder() + .text("text") + .system(PhenocrExtractRequestSystem.builder() + .name("name") + .version("version") + .build()) + .build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("POST", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = "" + + "{\n" + + " \"text\": \"text\",\n" + + " \"system\": {\n" + + " \"name\": \"name\",\n" + + " \"version\": \"version\"\n" + + " }\n" + + "}"; + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { + String discriminator = null; + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualJson.isNull()) { + Assertions.assertTrue( + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); + } + + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); + } + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); + } + + // Validate error response + Assertions.assertEquals(401, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testPhenocrThrowsNotFoundError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(404).setBody("{\"key\":\"value\"}")); + NotFoundError exception = Assertions.assertThrows(NotFoundError.class, () -> client.construe() + .codes() + .phenocr(PhenoCrRequest.builder() + .text("text") + .system(PhenocrExtractRequestSystem.builder() + .name("name") + .version("version") + .build()) + .build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("POST", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = "" + + "{\n" + + " \"text\": \"text\",\n" + + " \"system\": {\n" + + " \"name\": \"name\",\n" + + " \"version\": \"version\"\n" + + " }\n" + + "}"; + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { + String discriminator = null; + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualJson.isNull()) { + Assertions.assertTrue( + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); + } + + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); + } + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); + } + + // Validate error response + Assertions.assertEquals(404, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testPhenocrThrowsInternalServerError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(500).setBody("{\"key\":\"value\"}")); + InternalServerError exception = Assertions.assertThrows(InternalServerError.class, () -> client.construe() + .codes() + .phenocr(PhenoCrRequest.builder() + .text("text") + .system(PhenocrExtractRequestSystem.builder() + .name("name") + .version("version") + .build()) + .build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("POST", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = "" + + "{\n" + + " \"text\": \"text\",\n" + + " \"system\": {\n" + + " \"name\": \"name\",\n" + + " \"version\": \"version\"\n" + + " }\n" + + "}"; + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { + String discriminator = null; + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualJson.isNull()) { + Assertions.assertTrue( + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); + } + + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); + } + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); + } + + // Validate error response + Assertions.assertEquals(500, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testPhenocrThrowsServiceUnavailableError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(503).setBody("{\"key\":\"value\"}")); + ServiceUnavailableError exception = + Assertions.assertThrows(ServiceUnavailableError.class, () -> client.construe() + .codes() + .phenocr(PhenoCrRequest.builder() + .text("text") + .system(PhenocrExtractRequestSystem.builder() + .name("name") + .version("version") + .build()) + .build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("POST", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = "" + + "{\n" + + " \"text\": \"text\",\n" + + " \"system\": {\n" + + " \"name\": \"name\",\n" + + " \"version\": \"version\"\n" + + " }\n" + + "}"; + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { + String discriminator = null; + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualJson.isNull()) { + Assertions.assertTrue( + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); + } + + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); + } + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); + } + + // Validate error response + Assertions.assertEquals(503, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testPhenocrThrowsGatewayTimeoutError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(504).setBody("{\"key\":\"value\"}")); + GatewayTimeoutError exception = Assertions.assertThrows(GatewayTimeoutError.class, () -> client.construe() + .codes() + .phenocr(PhenoCrRequest.builder() + .text("text") + .system(PhenocrExtractRequestSystem.builder() + .name("name") + .version("version") + .build()) + .build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("POST", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = "" + + "{\n" + + " \"text\": \"text\",\n" + + " \"system\": {\n" + + " \"name\": \"name\",\n" + + " \"version\": \"version\"\n" + + " }\n" + + "}"; + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { + String discriminator = null; + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualJson.isNull()) { + Assertions.assertTrue( + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); + } + + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); + } + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); + } + + // Validate error response + Assertions.assertEquals(504, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testCrosswalk() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue( + new MockResponse() + .setResponseCode(200) + .setBody( + "{\"resolved_umls_release\":\"2026AA\",\"system\":\"http://hl7.org/fhir/sid/icd-10-cm\",\"code\":\"A02.24\",\"reason_code\":\"NO_SOURCE_CUI\",\"targets\":[{\"system\":\"http://human-phenotype-ontology.org\",\"reason_code\":\"NO_TARGET_MATCHES\",\"matches\":[{\"code\":\"HP:0005661\",\"display\":\"Salmonella infection\",\"cui\":\"C0036111\",\"suppress\":\"N\"}]}]}")); + CrosswalkResponse response = client.construe() + .codes() + .crosswalk(CrosswalkRequest.builder() + .system("http://hl7.org/fhir/sid/icd-10-cm") + .code("A02.24") + .targets(Arrays.asList("http://human-phenotype-ontology.org")) + .build()); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("POST", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = "" + + "{\n" + + " \"system\": \"http://hl7.org/fhir/sid/icd-10-cm\",\n" + + " \"code\": \"A02.24\",\n" + + " \"targets\": [\n" + + " \"http://human-phenotype-ontology.org\"\n" + + " ]\n" + + "}"; + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { + String discriminator = null; + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualJson.isNull()) { + Assertions.assertTrue( + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); + } + + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); + } + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); + } + + // Validate response body + Assertions.assertNotNull(response, "Response should not be null"); + String actualResponseJson = objectMapper.writeValueAsString(response); + String expectedResponseBody = "" + + "{\n" + + " \"resolved_umls_release\": \"2026AA\",\n" + + " \"system\": \"http://hl7.org/fhir/sid/icd-10-cm\",\n" + + " \"code\": \"A02.24\",\n" + + " \"reason_code\": \"NO_SOURCE_CUI\",\n" + + " \"targets\": [\n" + + " {\n" + + " \"system\": \"http://human-phenotype-ontology.org\",\n" + + " \"reason_code\": \"NO_TARGET_MATCHES\",\n" + + " \"matches\": [\n" + + " {\n" + + " \"code\": \"HP:0005661\",\n" + + " \"display\": \"Salmonella infection\",\n" + + " \"cui\": \"C0036111\",\n" + + " \"suppress\": \"N\"\n" + + " }\n" + + " ]\n" + + " }\n" + + " ]\n" + + "}"; + JsonNode actualResponseNode = objectMapper.readTree(actualResponseJson); + JsonNode expectedResponseNode = objectMapper.readTree(expectedResponseBody); + Assertions.assertTrue( + jsonEquals(expectedResponseNode, actualResponseNode), + "Response body structure does not match expected"); + if (actualResponseNode.has("type") || actualResponseNode.has("_type") || actualResponseNode.has("kind")) { + String discriminator = null; + if (actualResponseNode.has("type")) + discriminator = actualResponseNode.get("type").asText(); + else if (actualResponseNode.has("_type")) + discriminator = actualResponseNode.get("_type").asText(); + else if (actualResponseNode.has("kind")) + discriminator = actualResponseNode.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualResponseNode.isNull()) { + Assertions.assertTrue( + actualResponseNode.isObject() || actualResponseNode.isArray() || actualResponseNode.isValueNode(), + "response should be a valid JSON value"); + } + + if (actualResponseNode.isArray()) { + Assertions.assertTrue(actualResponseNode.size() >= 0, "Array should have valid size"); + } + if (actualResponseNode.isObject()) { + Assertions.assertTrue(actualResponseNode.size() >= 0, "Object should have valid field count"); + } + } + + @Test + public void testCrosswalkThrowsBadRequestError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(400).setBody("{\"key\":\"value\"}")); + BadRequestError exception = Assertions.assertThrows(BadRequestError.class, () -> client.construe() + .codes() + .crosswalk(CrosswalkRequest.builder() + .system("system") + .code("code") + .targets(Arrays.asList("targets", "targets")) + .build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("POST", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = "" + + "{\n" + + " \"system\": \"system\",\n" + + " \"code\": \"code\",\n" + + " \"targets\": [\n" + + " \"targets\",\n" + + " \"targets\"\n" + + " ]\n" + + "}"; + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { + String discriminator = null; + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualJson.isNull()) { + Assertions.assertTrue( + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); + } + + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); + } + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); + } + + // Validate error response + Assertions.assertEquals(400, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testCrosswalkThrowsUnauthorizedError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(401).setBody("{\"key\":\"value\"}")); + UnauthorizedError exception = Assertions.assertThrows(UnauthorizedError.class, () -> client.construe() + .codes() + .crosswalk(CrosswalkRequest.builder() + .system("system") + .code("code") + .targets(Arrays.asList("targets", "targets")) + .build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("POST", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = "" + + "{\n" + + " \"system\": \"system\",\n" + + " \"code\": \"code\",\n" + + " \"targets\": [\n" + + " \"targets\",\n" + + " \"targets\"\n" + + " ]\n" + + "}"; + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { + String discriminator = null; + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualJson.isNull()) { + Assertions.assertTrue( + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); + } + + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); + } + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); + } + + // Validate error response + Assertions.assertEquals(401, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testCrosswalkThrowsInternalServerError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(500).setBody("{\"key\":\"value\"}")); + InternalServerError exception = Assertions.assertThrows(InternalServerError.class, () -> client.construe() + .codes() + .crosswalk(CrosswalkRequest.builder() + .system("system") + .code("code") + .targets(Arrays.asList("targets", "targets")) + .build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("POST", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = "" + + "{\n" + + " \"system\": \"system\",\n" + + " \"code\": \"code\",\n" + + " \"targets\": [\n" + + " \"targets\",\n" + + " \"targets\"\n" + + " ]\n" + + "}"; + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { + String discriminator = null; + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualJson.isNull()) { + Assertions.assertTrue( + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); + } + + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); + } + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); + } + + // Validate error response + Assertions.assertEquals(500, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testList() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue( + new MockResponse() + .setResponseCode(200) + .setBody( + "{\"system\":{\"name\":\"ICD-10-CM\",\"version\":\"2025\"},\"codes\":[{\"code\":\"A00\",\"description\":\"Cholera\",\"definition\":\"definition\"},{\"code\":\"A000\",\"description\":\"Cholera due to Vibrio cholerae 01, biovar cholerae\",\"definition\":\"definition\"},{\"code\":\"A001\",\"description\":\"Cholera due to Vibrio cholerae 01, biovar eltor\",\"definition\":\"definition\"},{\"code\":\"A009\",\"description\":\"Cholera, unspecified\",\"definition\":\"definition\"},{\"code\":\"A01\",\"description\":\"Typhoid and paratyphoid fevers\",\"definition\":\"definition\"}],\"next_cursor\":\"QTAx\",\"has_more\":true}")); + ListCodesResponse response = client.construe() + .codes() + .list( + "ICD-10-CM", + CodesListRequest.builder() + .version("2025") + .cursor("cursor") + .limit(1) + .build()); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("GET", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate response body + Assertions.assertNotNull(response, "Response should not be null"); + String actualResponseJson = objectMapper.writeValueAsString(response); + String expectedResponseBody = "" + + "{\n" + + " \"system\": {\n" + + " \"name\": \"ICD-10-CM\",\n" + + " \"version\": \"2025\"\n" + + " },\n" + + " \"codes\": [\n" + + " {\n" + + " \"code\": \"A00\",\n" + + " \"description\": \"Cholera\",\n" + + " \"definition\": \"definition\"\n" + + " },\n" + + " {\n" + + " \"code\": \"A000\",\n" + + " \"description\": \"Cholera due to Vibrio cholerae 01, biovar cholerae\",\n" + + " \"definition\": \"definition\"\n" + + " },\n" + + " {\n" + + " \"code\": \"A001\",\n" + + " \"description\": \"Cholera due to Vibrio cholerae 01, biovar eltor\",\n" + + " \"definition\": \"definition\"\n" + + " },\n" + + " {\n" + + " \"code\": \"A009\",\n" + + " \"description\": \"Cholera, unspecified\",\n" + + " \"definition\": \"definition\"\n" + + " },\n" + + " {\n" + + " \"code\": \"A01\",\n" + + " \"description\": \"Typhoid and paratyphoid fevers\",\n" + + " \"definition\": \"definition\"\n" + + " }\n" + + " ],\n" + + " \"next_cursor\": \"QTAx\",\n" + + " \"has_more\": true\n" + + "}"; + JsonNode actualResponseNode = objectMapper.readTree(actualResponseJson); + JsonNode expectedResponseNode = objectMapper.readTree(expectedResponseBody); + Assertions.assertTrue( + jsonEquals(expectedResponseNode, actualResponseNode), + "Response body structure does not match expected"); + if (actualResponseNode.has("type") || actualResponseNode.has("_type") || actualResponseNode.has("kind")) { + String discriminator = null; + if (actualResponseNode.has("type")) + discriminator = actualResponseNode.get("type").asText(); + else if (actualResponseNode.has("_type")) + discriminator = actualResponseNode.get("_type").asText(); + else if (actualResponseNode.has("kind")) + discriminator = actualResponseNode.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualResponseNode.isNull()) { + Assertions.assertTrue( + actualResponseNode.isObject() || actualResponseNode.isArray() || actualResponseNode.isValueNode(), + "response should be a valid JSON value"); + } + + if (actualResponseNode.isArray()) { + Assertions.assertTrue(actualResponseNode.size() >= 0, "Array should have valid size"); + } + if (actualResponseNode.isObject()) { + Assertions.assertTrue(actualResponseNode.size() >= 0, "Object should have valid field count"); + } + } + + @Test + public void testListBrowseSnomed() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue( + new MockResponse() + .setResponseCode(200) + .setBody( + "{\"system\":{\"name\":\"SNOMED_CT_US_LITE\",\"version\":\"20240901\"},\"codes\":[{\"code\":\"10000006\",\"description\":\"Radiating chest pain (finding)\",\"definition\":\"definition\"},{\"code\":\"10001005\",\"description\":\"Bacterial septicemia (disorder)\",\"definition\":\"definition\"},{\"code\":\"10007009\",\"description\":\"Coffin-Siris syndrome (disorder)\",\"definition\":\"definition\"},{\"code\":\"1001000119102\",\"description\":\"Pulmonary embolism with pulmonary infarction (disorder)\",\"definition\":\"definition\"},{\"code\":\"10017004\",\"description\":\"Occlusal wear of teeth (disorder)\",\"definition\":\"definition\"}],\"next_cursor\":\"MTAwMTcwMDQ\",\"has_more\":true}")); + ListCodesResponse response = client.construe() + .codes() + .list( + "ICD-10-CM", + CodesListRequest.builder() + .version("2025") + .cursor("cursor") + .limit(1) + .build()); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("GET", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate response body + Assertions.assertNotNull(response, "Response should not be null"); + String actualResponseJson = objectMapper.writeValueAsString(response); + String expectedResponseBody = "" + + "{\n" + + " \"system\": {\n" + + " \"name\": \"SNOMED_CT_US_LITE\",\n" + + " \"version\": \"20240901\"\n" + + " },\n" + + " \"codes\": [\n" + + " {\n" + + " \"code\": \"10000006\",\n" + + " \"description\": \"Radiating chest pain (finding)\",\n" + + " \"definition\": \"definition\"\n" + + " },\n" + + " {\n" + + " \"code\": \"10001005\",\n" + + " \"description\": \"Bacterial septicemia (disorder)\",\n" + + " \"definition\": \"definition\"\n" + + " },\n" + + " {\n" + + " \"code\": \"10007009\",\n" + + " \"description\": \"Coffin-Siris syndrome (disorder)\",\n" + + " \"definition\": \"definition\"\n" + + " },\n" + + " {\n" + + " \"code\": \"1001000119102\",\n" + + " \"description\": \"Pulmonary embolism with pulmonary infarction (disorder)\",\n" + + " \"definition\": \"definition\"\n" + + " },\n" + + " {\n" + + " \"code\": \"10017004\",\n" + + " \"description\": \"Occlusal wear of teeth (disorder)\",\n" + + " \"definition\": \"definition\"\n" + + " }\n" + + " ],\n" + + " \"next_cursor\": \"MTAwMTcwMDQ\",\n" + + " \"has_more\": true\n" + + "}"; + JsonNode actualResponseNode = objectMapper.readTree(actualResponseJson); + JsonNode expectedResponseNode = objectMapper.readTree(expectedResponseBody); + Assertions.assertTrue( + jsonEquals(expectedResponseNode, actualResponseNode), + "Response body structure does not match expected"); + if (actualResponseNode.has("type") || actualResponseNode.has("_type") || actualResponseNode.has("kind")) { + String discriminator = null; + if (actualResponseNode.has("type")) + discriminator = actualResponseNode.get("type").asText(); + else if (actualResponseNode.has("_type")) + discriminator = actualResponseNode.get("_type").asText(); + else if (actualResponseNode.has("kind")) + discriminator = actualResponseNode.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualResponseNode.isNull()) { + Assertions.assertTrue( + actualResponseNode.isObject() || actualResponseNode.isArray() || actualResponseNode.isValueNode(), + "response should be a valid JSON value"); + } + + if (actualResponseNode.isArray()) { + Assertions.assertTrue(actualResponseNode.size() >= 0, "Array should have valid size"); + } + if (actualResponseNode.isObject()) { + Assertions.assertTrue(actualResponseNode.size() >= 0, "Object should have valid field count"); + } + } + + @Test + public void testListThrowsBadRequestError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(400).setBody("{\"key\":\"value\"}")); + BadRequestError exception = Assertions.assertThrows(BadRequestError.class, () -> client.construe() + .codes() + .list("codesystem", CodesListRequest.builder().build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("GET", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate error response + Assertions.assertEquals(400, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testListThrowsUnauthorizedError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(401).setBody("{\"key\":\"value\"}")); + UnauthorizedError exception = Assertions.assertThrows(UnauthorizedError.class, () -> client.construe() + .codes() + .list("codesystem", CodesListRequest.builder().build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("GET", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate error response + Assertions.assertEquals(401, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testListThrowsNotFoundError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(404).setBody("{\"key\":\"value\"}")); + NotFoundError exception = Assertions.assertThrows(NotFoundError.class, () -> client.construe() + .codes() + .list("codesystem", CodesListRequest.builder().build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("GET", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate error response + Assertions.assertEquals(404, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testListThrowsInternalServerError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(500).setBody("{\"key\":\"value\"}")); + InternalServerError exception = Assertions.assertThrows(InternalServerError.class, () -> client.construe() + .codes() + .list("codesystem", CodesListRequest.builder().build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("GET", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate error response + Assertions.assertEquals(500, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testLookup() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue( + new MockResponse() + .setResponseCode(200) + .setBody( + "{\"system\":{\"name\":\"ICD-10-CM\",\"version\":\"2025\"},\"code\":\"E1165\",\"description\":\"Type 2 diabetes mellitus with hyperglycemia\",\"definition\":\"definition\"}")); + GetCodeResponse response = client.construe() + .codes() + .lookup( + "ICD-10-CM", + "E1165", + LookupRequest.builder().version("version").build()); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("GET", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate response body + Assertions.assertNotNull(response, "Response should not be null"); + String actualResponseJson = objectMapper.writeValueAsString(response); + String expectedResponseBody = "" + + "{\n" + + " \"system\": {\n" + + " \"name\": \"ICD-10-CM\",\n" + + " \"version\": \"2025\"\n" + + " },\n" + + " \"code\": \"E1165\",\n" + + " \"description\": \"Type 2 diabetes mellitus with hyperglycemia\",\n" + + " \"definition\": \"definition\"\n" + + "}"; + JsonNode actualResponseNode = objectMapper.readTree(actualResponseJson); + JsonNode expectedResponseNode = objectMapper.readTree(expectedResponseBody); + Assertions.assertTrue( + jsonEquals(expectedResponseNode, actualResponseNode), + "Response body structure does not match expected"); + if (actualResponseNode.has("type") || actualResponseNode.has("_type") || actualResponseNode.has("kind")) { + String discriminator = null; + if (actualResponseNode.has("type")) + discriminator = actualResponseNode.get("type").asText(); + else if (actualResponseNode.has("_type")) + discriminator = actualResponseNode.get("_type").asText(); + else if (actualResponseNode.has("kind")) + discriminator = actualResponseNode.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualResponseNode.isNull()) { + Assertions.assertTrue( + actualResponseNode.isObject() || actualResponseNode.isArray() || actualResponseNode.isValueNode(), + "response should be a valid JSON value"); + } + + if (actualResponseNode.isArray()) { + Assertions.assertTrue(actualResponseNode.size() >= 0, "Array should have valid size"); + } + if (actualResponseNode.isObject()) { + Assertions.assertTrue(actualResponseNode.size() >= 0, "Object should have valid field count"); + } + } + + @Test + public void testLookupLookUpSnomedConcept() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue( + new MockResponse() + .setResponseCode(200) + .setBody( + "{\"system\":{\"name\":\"SNOMED_CT_US_LITE\",\"version\":\"20240901\"},\"code\":\"73211009\",\"description\":\"Diabetes mellitus (disorder)\",\"definition\":\"definition\"}")); + GetCodeResponse response = client.construe() + .codes() + .lookup( + "ICD-10-CM", + "E1165", + LookupRequest.builder().version("version").build()); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("GET", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate response body + Assertions.assertNotNull(response, "Response should not be null"); + String actualResponseJson = objectMapper.writeValueAsString(response); + String expectedResponseBody = "" + + "{\n" + + " \"system\": {\n" + + " \"name\": \"SNOMED_CT_US_LITE\",\n" + + " \"version\": \"20240901\"\n" + + " },\n" + + " \"code\": \"73211009\",\n" + + " \"description\": \"Diabetes mellitus (disorder)\",\n" + + " \"definition\": \"definition\"\n" + + "}"; + JsonNode actualResponseNode = objectMapper.readTree(actualResponseJson); + JsonNode expectedResponseNode = objectMapper.readTree(expectedResponseBody); + Assertions.assertTrue( + jsonEquals(expectedResponseNode, actualResponseNode), + "Response body structure does not match expected"); + if (actualResponseNode.has("type") || actualResponseNode.has("_type") || actualResponseNode.has("kind")) { + String discriminator = null; + if (actualResponseNode.has("type")) + discriminator = actualResponseNode.get("type").asText(); + else if (actualResponseNode.has("_type")) + discriminator = actualResponseNode.get("_type").asText(); + else if (actualResponseNode.has("kind")) + discriminator = actualResponseNode.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualResponseNode.isNull()) { + Assertions.assertTrue( + actualResponseNode.isObject() || actualResponseNode.isArray() || actualResponseNode.isValueNode(), + "response should be a valid JSON value"); + } + + if (actualResponseNode.isArray()) { + Assertions.assertTrue(actualResponseNode.size() >= 0, "Array should have valid size"); + } + if (actualResponseNode.isObject()) { + Assertions.assertTrue(actualResponseNode.size() >= 0, "Object should have valid field count"); + } + } + + @Test + public void testLookupThrowsBadRequestError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(400).setBody("{\"key\":\"value\"}")); + BadRequestError exception = Assertions.assertThrows(BadRequestError.class, () -> client.construe() + .codes() + .lookup("codesystem", "codeID", LookupRequest.builder().build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("GET", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate error response + Assertions.assertEquals(400, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testLookupThrowsUnauthorizedError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(401).setBody("{\"key\":\"value\"}")); + UnauthorizedError exception = Assertions.assertThrows(UnauthorizedError.class, () -> client.construe() + .codes() + .lookup("codesystem", "codeID", LookupRequest.builder().build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("GET", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate error response + Assertions.assertEquals(401, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testLookupThrowsNotFoundError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(404).setBody("{\"key\":\"value\"}")); + NotFoundError exception = Assertions.assertThrows(NotFoundError.class, () -> client.construe() + .codes() + .lookup("codesystem", "codeID", LookupRequest.builder().build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("GET", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate error response + Assertions.assertEquals(404, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testLookupThrowsInternalServerError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(500).setBody("{\"key\":\"value\"}")); + InternalServerError exception = Assertions.assertThrows(InternalServerError.class, () -> client.construe() + .codes() + .lookup("codesystem", "codeID", LookupRequest.builder().build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("GET", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate error response + Assertions.assertEquals(500, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testSearchSemantic() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue( + new MockResponse() + .setResponseCode(200) + .setBody( + "{\"system\":{\"name\":\"ICD-10-CM\",\"version\":\"2025\"},\"results\":[{\"code\":\"R06.00\",\"description\":\"Dyspnea, unspecified\"},{\"code\":\"R06.01\",\"description\":\"Orthopnea\"},{\"code\":\"G47.33\",\"description\":\"Obstructive sleep apnea\"},{\"code\":\"R06.83\",\"description\":\"Snoring\"},{\"code\":\"J45.20\",\"description\":\"Mild intermittent asthma, uncomplicated\"}]}")); + SemanticSearchResponse response = client.construe() + .codes() + .searchSemantic( + "ICD-10-CM", + SearchSemanticRequest.builder() + .text("patient has trouble breathing at night and wakes up gasping") + .version("version") + .limit(1) + .build()); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("GET", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate response body + Assertions.assertNotNull(response, "Response should not be null"); + String actualResponseJson = objectMapper.writeValueAsString(response); + String expectedResponseBody = "" + + "{\n" + + " \"system\": {\n" + + " \"name\": \"ICD-10-CM\",\n" + + " \"version\": \"2025\"\n" + + " },\n" + + " \"results\": [\n" + + " {\n" + + " \"code\": \"R06.00\",\n" + + " \"description\": \"Dyspnea, unspecified\"\n" + + " },\n" + + " {\n" + + " \"code\": \"R06.01\",\n" + + " \"description\": \"Orthopnea\"\n" + + " },\n" + + " {\n" + + " \"code\": \"G47.33\",\n" + + " \"description\": \"Obstructive sleep apnea\"\n" + + " },\n" + + " {\n" + + " \"code\": \"R06.83\",\n" + + " \"description\": \"Snoring\"\n" + + " },\n" + + " {\n" + + " \"code\": \"J45.20\",\n" + + " \"description\": \"Mild intermittent asthma, uncomplicated\"\n" + + " }\n" + + " ]\n" + + "}"; + JsonNode actualResponseNode = objectMapper.readTree(actualResponseJson); + JsonNode expectedResponseNode = objectMapper.readTree(expectedResponseBody); + Assertions.assertTrue( + jsonEquals(expectedResponseNode, actualResponseNode), + "Response body structure does not match expected"); + if (actualResponseNode.has("type") || actualResponseNode.has("_type") || actualResponseNode.has("kind")) { + String discriminator = null; + if (actualResponseNode.has("type")) + discriminator = actualResponseNode.get("type").asText(); + else if (actualResponseNode.has("_type")) + discriminator = actualResponseNode.get("_type").asText(); + else if (actualResponseNode.has("kind")) + discriminator = actualResponseNode.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualResponseNode.isNull()) { + Assertions.assertTrue( + actualResponseNode.isObject() || actualResponseNode.isArray() || actualResponseNode.isValueNode(), + "response should be a valid JSON value"); + } + + if (actualResponseNode.isArray()) { + Assertions.assertTrue(actualResponseNode.size() >= 0, "Array should have valid size"); + } + if (actualResponseNode.isObject()) { + Assertions.assertTrue(actualResponseNode.size() >= 0, "Object should have valid field count"); + } + } + + @Test + public void testSearchSemanticDiabetesComplications() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue( + new MockResponse() + .setResponseCode(200) + .setBody( + "{\"system\":{\"name\":\"ICD-10-CM\",\"version\":\"2025\"},\"results\":[{\"code\":\"E11.22\",\"description\":\"Type 2 diabetes mellitus with diabetic chronic kidney disease\"},{\"code\":\"E11.311\",\"description\":\"Type 2 diabetes mellitus with unspecified diabetic retinopathy with macular edema\"},{\"code\":\"E11.65\",\"description\":\"Type 2 diabetes mellitus with hyperglycemia\"}]}")); + SemanticSearchResponse response = client.construe() + .codes() + .searchSemantic( + "ICD-10-CM", + SearchSemanticRequest.builder() + .text("patient has trouble breathing at night and wakes up gasping") + .version("version") + .limit(1) + .build()); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("GET", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate response body + Assertions.assertNotNull(response, "Response should not be null"); + String actualResponseJson = objectMapper.writeValueAsString(response); + String expectedResponseBody = "" + + "{\n" + + " \"system\": {\n" + + " \"name\": \"ICD-10-CM\",\n" + + " \"version\": \"2025\"\n" + + " },\n" + + " \"results\": [\n" + + " {\n" + + " \"code\": \"E11.22\",\n" + + " \"description\": \"Type 2 diabetes mellitus with diabetic chronic kidney disease\"\n" + + " },\n" + + " {\n" + + " \"code\": \"E11.311\",\n" + + " \"description\": \"Type 2 diabetes mellitus with unspecified diabetic retinopathy with macular edema\"\n" + + " },\n" + + " {\n" + + " \"code\": \"E11.65\",\n" + + " \"description\": \"Type 2 diabetes mellitus with hyperglycemia\"\n" + + " }\n" + + " ]\n" + + "}"; + JsonNode actualResponseNode = objectMapper.readTree(actualResponseJson); + JsonNode expectedResponseNode = objectMapper.readTree(expectedResponseBody); + Assertions.assertTrue( + jsonEquals(expectedResponseNode, actualResponseNode), + "Response body structure does not match expected"); + if (actualResponseNode.has("type") || actualResponseNode.has("_type") || actualResponseNode.has("kind")) { + String discriminator = null; + if (actualResponseNode.has("type")) + discriminator = actualResponseNode.get("type").asText(); + else if (actualResponseNode.has("_type")) + discriminator = actualResponseNode.get("_type").asText(); + else if (actualResponseNode.has("kind")) + discriminator = actualResponseNode.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualResponseNode.isNull()) { Assertions.assertTrue( actualResponseNode.isObject() || actualResponseNode.isArray() || actualResponseNode.isValueNode(), "response should be a valid JSON value"); @@ -578,7 +2773,139 @@ else if (actualResponseNode.has("kind")) } @Test - public void testSearchSemantic() throws Exception { + public void testSearchSemanticThrowsBadRequestError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(400).setBody("{\"key\":\"value\"}")); + BadRequestError exception = Assertions.assertThrows(BadRequestError.class, () -> client.construe() + .codes() + .searchSemantic( + "codesystem", + SearchSemanticRequest.builder().text("text").build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("GET", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate error response + Assertions.assertEquals(400, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testSearchSemanticThrowsUnauthorizedError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(401).setBody("{\"key\":\"value\"}")); + UnauthorizedError exception = Assertions.assertThrows(UnauthorizedError.class, () -> client.construe() + .codes() + .searchSemantic( + "codesystem", + SearchSemanticRequest.builder().text("text").build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("GET", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate error response + Assertions.assertEquals(401, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testSearchSemanticThrowsNotFoundError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(404).setBody("{\"key\":\"value\"}")); + NotFoundError exception = Assertions.assertThrows(NotFoundError.class, () -> client.construe() + .codes() + .searchSemantic( + "codesystem", + SearchSemanticRequest.builder().text("text").build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("GET", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate error response + Assertions.assertEquals(404, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testSearchSemanticThrowsInternalServerError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(500).setBody("{\"key\":\"value\"}")); + InternalServerError exception = Assertions.assertThrows(InternalServerError.class, () -> client.construe() + .codes() + .searchSemantic( + "codesystem", + SearchSemanticRequest.builder().text("text").build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("GET", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate error response + Assertions.assertEquals(500, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testSearchText() throws Exception { // OAuth: enqueue token response (client fetches token before API call) server.enqueue(new MockResponse() .setResponseCode(200) @@ -587,13 +2914,13 @@ public void testSearchSemantic() throws Exception { new MockResponse() .setResponseCode(200) .setBody( - "{\"system\":{\"name\":\"ICD-10-CM\",\"version\":\"2025\"},\"results\":[{\"code\":\"R06.00\",\"description\":\"Dyspnea, unspecified\"},{\"code\":\"R06.01\",\"description\":\"Orthopnea\"},{\"code\":\"G47.33\",\"description\":\"Obstructive sleep apnea\"},{\"code\":\"R06.83\",\"description\":\"Snoring\"},{\"code\":\"J45.20\",\"description\":\"Mild intermittent asthma, uncomplicated\"}]}")); - SemanticSearchResponse response = client.construe() + "{\"system\":{\"name\":\"ICD-10-CM\",\"version\":\"2025\"},\"results\":[{\"code\":\"E11.65\",\"description\":\"Type 2 diabetes mellitus with hyperglycemia\"},{\"code\":\"E11.649\",\"description\":\"Type 2 diabetes mellitus with hypoglycemia without coma\"},{\"code\":\"E11.69\",\"description\":\"Type 2 diabetes mellitus with other specified complication\"}],\"found\":3}")); + TextSearchResponse response = client.construe() .codes() - .searchSemantic( + .searchText( "ICD-10-CM", - SearchSemanticRequest.builder() - .text("patient has trouble breathing at night and wakes up gasping") + SearchTextRequest.builder() + .q("E11.65") .version("version") .limit(1) .build()); @@ -620,26 +2947,19 @@ public void testSearchSemantic() throws Exception { + " },\n" + " \"results\": [\n" + " {\n" - + " \"code\": \"R06.00\",\n" - + " \"description\": \"Dyspnea, unspecified\"\n" - + " },\n" - + " {\n" - + " \"code\": \"R06.01\",\n" - + " \"description\": \"Orthopnea\"\n" - + " },\n" - + " {\n" - + " \"code\": \"G47.33\",\n" - + " \"description\": \"Obstructive sleep apnea\"\n" + + " \"code\": \"E11.65\",\n" + + " \"description\": \"Type 2 diabetes mellitus with hyperglycemia\"\n" + " },\n" + " {\n" - + " \"code\": \"R06.83\",\n" - + " \"description\": \"Snoring\"\n" + + " \"code\": \"E11.649\",\n" + + " \"description\": \"Type 2 diabetes mellitus with hypoglycemia without coma\"\n" + " },\n" + " {\n" - + " \"code\": \"J45.20\",\n" - + " \"description\": \"Mild intermittent asthma, uncomplicated\"\n" + + " \"code\": \"E11.69\",\n" + + " \"description\": \"Type 2 diabetes mellitus with other specified complication\"\n" + " }\n" - + " ]\n" + + " ],\n" + + " \"found\": 3\n" + "}"; JsonNode actualResponseNode = objectMapper.readTree(actualResponseJson); JsonNode expectedResponseNode = objectMapper.readTree(expectedResponseBody); @@ -673,7 +2993,7 @@ else if (actualResponseNode.has("kind")) } @Test - public void testSearchText() throws Exception { + public void testSearchTextSearchByDescription() throws Exception { // OAuth: enqueue token response (client fetches token before API call) server.enqueue(new MockResponse() .setResponseCode(200) @@ -682,7 +3002,7 @@ public void testSearchText() throws Exception { new MockResponse() .setResponseCode(200) .setBody( - "{\"system\":{\"name\":\"ICD-10-CM\",\"version\":\"2025\"},\"results\":[{\"code\":\"E11.65\",\"description\":\"Type 2 diabetes mellitus with hyperglycemia\"},{\"code\":\"E11.649\",\"description\":\"Type 2 diabetes mellitus with hypoglycemia without coma\"},{\"code\":\"E11.69\",\"description\":\"Type 2 diabetes mellitus with other specified complication\"}],\"found\":3}")); + "{\"system\":{\"name\":\"ICD-10-CM\",\"version\":\"2025\"},\"results\":[{\"code\":\"I10\",\"description\":\"Essential (primary) hypertension\"},{\"code\":\"I11.9\",\"description\":\"Hypertensive heart disease without heart failure\"},{\"code\":\"I12.9\",\"description\":\"Hypertensive chronic kidney disease with stage 1-4 or unspecified CKD\"}],\"found\":3}")); TextSearchResponse response = client.construe() .codes() .searchText( @@ -715,16 +3035,16 @@ public void testSearchText() throws Exception { + " },\n" + " \"results\": [\n" + " {\n" - + " \"code\": \"E11.65\",\n" - + " \"description\": \"Type 2 diabetes mellitus with hyperglycemia\"\n" + + " \"code\": \"I10\",\n" + + " \"description\": \"Essential (primary) hypertension\"\n" + " },\n" + " {\n" - + " \"code\": \"E11.649\",\n" - + " \"description\": \"Type 2 diabetes mellitus with hypoglycemia without coma\"\n" + + " \"code\": \"I11.9\",\n" + + " \"description\": \"Hypertensive heart disease without heart failure\"\n" + " },\n" + " {\n" - + " \"code\": \"E11.69\",\n" - + " \"description\": \"Type 2 diabetes mellitus with other specified complication\"\n" + + " \"code\": \"I12.9\",\n" + + " \"description\": \"Hypertensive chronic kidney disease with stage 1-4 or unspecified CKD\"\n" + " }\n" + " ],\n" + " \"found\": 3\n" @@ -760,6 +3080,161 @@ else if (actualResponseNode.has("kind")) } } + @Test + public void testSearchTextThrowsBadRequestError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(400).setBody("{\"key\":\"value\"}")); + BadRequestError exception = Assertions.assertThrows(BadRequestError.class, () -> client.construe() + .codes() + .searchText("codesystem", SearchTextRequest.builder().q("q").build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("GET", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate error response + Assertions.assertEquals(400, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testSearchTextThrowsUnauthorizedError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(401).setBody("{\"key\":\"value\"}")); + UnauthorizedError exception = Assertions.assertThrows(UnauthorizedError.class, () -> client.construe() + .codes() + .searchText("codesystem", SearchTextRequest.builder().q("q").build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("GET", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate error response + Assertions.assertEquals(401, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testSearchTextThrowsNotFoundError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(404).setBody("{\"key\":\"value\"}")); + NotFoundError exception = Assertions.assertThrows(NotFoundError.class, () -> client.construe() + .codes() + .searchText("codesystem", SearchTextRequest.builder().q("q").build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("GET", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate error response + Assertions.assertEquals(404, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testSearchTextThrowsInternalServerError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(500).setBody("{\"key\":\"value\"}")); + InternalServerError exception = Assertions.assertThrows(InternalServerError.class, () -> client.construe() + .codes() + .searchText("codesystem", SearchTextRequest.builder().q("q").build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("GET", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate error response + Assertions.assertEquals(500, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testSearchTextThrowsNotImplementedError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(501).setBody("{\"key\":\"value\"}")); + NotImplementedError exception = Assertions.assertThrows(NotImplementedError.class, () -> client.construe() + .codes() + .searchText("codesystem", SearchTextRequest.builder().q("q").build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("GET", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate error response + Assertions.assertEquals(501, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + /** * Compares two JsonNodes with numeric equivalence and null safety. * For objects, checks that all fields in 'expected' exist in 'actual' with matching values. diff --git a/src/test/java/com/phenoml/api/ConstrueWireTest.java b/src/test/java/com/phenoml/api/ConstrueWireTest.java index 69711a89..469ac03f 100644 --- a/src/test/java/com/phenoml/api/ConstrueWireTest.java +++ b/src/test/java/com/phenoml/api/ConstrueWireTest.java @@ -3,6 +3,10 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.phenoml.api.core.ObjectMappers; +import com.phenoml.api.resources.construe.errors.BadRequestError; +import com.phenoml.api.resources.construe.errors.InternalServerError; +import com.phenoml.api.resources.construe.errors.ServiceUnavailableError; +import com.phenoml.api.resources.construe.errors.UnauthorizedError; import com.phenoml.api.resources.construe.requests.FeedbackRequest; import com.phenoml.api.resources.construe.types.ExtractCodesResult; import com.phenoml.api.resources.construe.types.ExtractRequestSystem; @@ -28,6 +32,7 @@ public void setup() throws Exception { server.start(); client = PhenomlClient.withCredentials("test-client-id", "test-client-secret") .url(server.url("/").toString()) + .maxRetries(0) .build(); } @@ -42,7 +47,7 @@ public void testSubmitFeedback() throws Exception { server.enqueue(new MockResponse() .setResponseCode(200) .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); - server.enqueue(new MockResponse().setResponseCode(200).setBody("{\"id\":\"abc123def456\"}")); + server.enqueue(new MockResponse().setResponseCode(201).setBody("{\"id\":\"abc123def456\"}")); FeedbackResponse response = client.construe() .submitFeedback(FeedbackRequest.builder() .text("Patient has type 2 diabetes with hyperglycemia") @@ -176,6 +181,495 @@ else if (actualResponseNode.has("kind")) } } + @Test + public void testSubmitFeedbackThrowsBadRequestError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(400).setBody("{\"key\":\"value\"}")); + BadRequestError exception = Assertions.assertThrows(BadRequestError.class, () -> client.construe() + .submitFeedback(FeedbackRequest.builder() + .text("text") + .receivedResult(ExtractCodesResult.builder() + .system(ExtractRequestSystem.builder().build()) + .codes(Arrays.asList( + ExtractedCodeResult.builder() + .code("code") + .description("description") + .valid(true) + .build(), + ExtractedCodeResult.builder() + .code("code") + .description("description") + .valid(true) + .build())) + .build()) + .expectedResult(ExtractCodesResult.builder() + .system(ExtractRequestSystem.builder().build()) + .codes(Arrays.asList( + ExtractedCodeResult.builder() + .code("code") + .description("description") + .valid(true) + .build(), + ExtractedCodeResult.builder() + .code("code") + .description("description") + .valid(true) + .build())) + .build()) + .build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("POST", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = "" + + "{\n" + + " \"text\": \"text\",\n" + + " \"received_result\": {\n" + + " \"system\": {},\n" + + " \"codes\": [\n" + + " {\n" + + " \"code\": \"code\",\n" + + " \"description\": \"description\",\n" + + " \"valid\": true\n" + + " },\n" + + " {\n" + + " \"code\": \"code\",\n" + + " \"description\": \"description\",\n" + + " \"valid\": true\n" + + " }\n" + + " ]\n" + + " },\n" + + " \"expected_result\": {\n" + + " \"system\": {},\n" + + " \"codes\": [\n" + + " {\n" + + " \"code\": \"code\",\n" + + " \"description\": \"description\",\n" + + " \"valid\": true\n" + + " },\n" + + " {\n" + + " \"code\": \"code\",\n" + + " \"description\": \"description\",\n" + + " \"valid\": true\n" + + " }\n" + + " ]\n" + + " }\n" + + "}"; + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { + String discriminator = null; + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualJson.isNull()) { + Assertions.assertTrue( + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); + } + + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); + } + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); + } + + // Validate error response + Assertions.assertEquals(400, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testSubmitFeedbackThrowsUnauthorizedError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(401).setBody("{\"key\":\"value\"}")); + UnauthorizedError exception = Assertions.assertThrows(UnauthorizedError.class, () -> client.construe() + .submitFeedback(FeedbackRequest.builder() + .text("text") + .receivedResult(ExtractCodesResult.builder() + .system(ExtractRequestSystem.builder().build()) + .codes(Arrays.asList( + ExtractedCodeResult.builder() + .code("code") + .description("description") + .valid(true) + .build(), + ExtractedCodeResult.builder() + .code("code") + .description("description") + .valid(true) + .build())) + .build()) + .expectedResult(ExtractCodesResult.builder() + .system(ExtractRequestSystem.builder().build()) + .codes(Arrays.asList( + ExtractedCodeResult.builder() + .code("code") + .description("description") + .valid(true) + .build(), + ExtractedCodeResult.builder() + .code("code") + .description("description") + .valid(true) + .build())) + .build()) + .build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("POST", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = "" + + "{\n" + + " \"text\": \"text\",\n" + + " \"received_result\": {\n" + + " \"system\": {},\n" + + " \"codes\": [\n" + + " {\n" + + " \"code\": \"code\",\n" + + " \"description\": \"description\",\n" + + " \"valid\": true\n" + + " },\n" + + " {\n" + + " \"code\": \"code\",\n" + + " \"description\": \"description\",\n" + + " \"valid\": true\n" + + " }\n" + + " ]\n" + + " },\n" + + " \"expected_result\": {\n" + + " \"system\": {},\n" + + " \"codes\": [\n" + + " {\n" + + " \"code\": \"code\",\n" + + " \"description\": \"description\",\n" + + " \"valid\": true\n" + + " },\n" + + " {\n" + + " \"code\": \"code\",\n" + + " \"description\": \"description\",\n" + + " \"valid\": true\n" + + " }\n" + + " ]\n" + + " }\n" + + "}"; + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { + String discriminator = null; + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualJson.isNull()) { + Assertions.assertTrue( + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); + } + + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); + } + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); + } + + // Validate error response + Assertions.assertEquals(401, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testSubmitFeedbackThrowsInternalServerError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(500).setBody("{\"key\":\"value\"}")); + InternalServerError exception = Assertions.assertThrows(InternalServerError.class, () -> client.construe() + .submitFeedback(FeedbackRequest.builder() + .text("text") + .receivedResult(ExtractCodesResult.builder() + .system(ExtractRequestSystem.builder().build()) + .codes(Arrays.asList( + ExtractedCodeResult.builder() + .code("code") + .description("description") + .valid(true) + .build(), + ExtractedCodeResult.builder() + .code("code") + .description("description") + .valid(true) + .build())) + .build()) + .expectedResult(ExtractCodesResult.builder() + .system(ExtractRequestSystem.builder().build()) + .codes(Arrays.asList( + ExtractedCodeResult.builder() + .code("code") + .description("description") + .valid(true) + .build(), + ExtractedCodeResult.builder() + .code("code") + .description("description") + .valid(true) + .build())) + .build()) + .build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("POST", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = "" + + "{\n" + + " \"text\": \"text\",\n" + + " \"received_result\": {\n" + + " \"system\": {},\n" + + " \"codes\": [\n" + + " {\n" + + " \"code\": \"code\",\n" + + " \"description\": \"description\",\n" + + " \"valid\": true\n" + + " },\n" + + " {\n" + + " \"code\": \"code\",\n" + + " \"description\": \"description\",\n" + + " \"valid\": true\n" + + " }\n" + + " ]\n" + + " },\n" + + " \"expected_result\": {\n" + + " \"system\": {},\n" + + " \"codes\": [\n" + + " {\n" + + " \"code\": \"code\",\n" + + " \"description\": \"description\",\n" + + " \"valid\": true\n" + + " },\n" + + " {\n" + + " \"code\": \"code\",\n" + + " \"description\": \"description\",\n" + + " \"valid\": true\n" + + " }\n" + + " ]\n" + + " }\n" + + "}"; + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { + String discriminator = null; + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualJson.isNull()) { + Assertions.assertTrue( + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); + } + + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); + } + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); + } + + // Validate error response + Assertions.assertEquals(500, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testSubmitFeedbackThrowsServiceUnavailableError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(503).setBody("{\"key\":\"value\"}")); + ServiceUnavailableError exception = + Assertions.assertThrows(ServiceUnavailableError.class, () -> client.construe() + .submitFeedback(FeedbackRequest.builder() + .text("text") + .receivedResult(ExtractCodesResult.builder() + .system(ExtractRequestSystem.builder().build()) + .codes(Arrays.asList( + ExtractedCodeResult.builder() + .code("code") + .description("description") + .valid(true) + .build(), + ExtractedCodeResult.builder() + .code("code") + .description("description") + .valid(true) + .build())) + .build()) + .expectedResult(ExtractCodesResult.builder() + .system(ExtractRequestSystem.builder().build()) + .codes(Arrays.asList( + ExtractedCodeResult.builder() + .code("code") + .description("description") + .valid(true) + .build(), + ExtractedCodeResult.builder() + .code("code") + .description("description") + .valid(true) + .build())) + .build()) + .build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("POST", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = "" + + "{\n" + + " \"text\": \"text\",\n" + + " \"received_result\": {\n" + + " \"system\": {},\n" + + " \"codes\": [\n" + + " {\n" + + " \"code\": \"code\",\n" + + " \"description\": \"description\",\n" + + " \"valid\": true\n" + + " },\n" + + " {\n" + + " \"code\": \"code\",\n" + + " \"description\": \"description\",\n" + + " \"valid\": true\n" + + " }\n" + + " ]\n" + + " },\n" + + " \"expected_result\": {\n" + + " \"system\": {},\n" + + " \"codes\": [\n" + + " {\n" + + " \"code\": \"code\",\n" + + " \"description\": \"description\",\n" + + " \"valid\": true\n" + + " },\n" + + " {\n" + + " \"code\": \"code\",\n" + + " \"description\": \"description\",\n" + + " \"valid\": true\n" + + " }\n" + + " ]\n" + + " }\n" + + "}"; + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { + String discriminator = null; + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualJson.isNull()) { + Assertions.assertTrue( + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); + } + + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); + } + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); + } + + // Validate error response + Assertions.assertEquals(503, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + /** * Compares two JsonNodes with numeric equivalence and null safety. * For objects, checks that all fields in 'expected' exist in 'actual' with matching values. diff --git a/src/test/java/com/phenoml/api/Fhir2OmopWireTest.java b/src/test/java/com/phenoml/api/Fhir2OmopWireTest.java index 0afb6bae..9e082351 100644 --- a/src/test/java/com/phenoml/api/Fhir2OmopWireTest.java +++ b/src/test/java/com/phenoml/api/Fhir2OmopWireTest.java @@ -3,6 +3,9 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.phenoml.api.core.ObjectMappers; +import com.phenoml.api.resources.fhir2omop.errors.BadRequestError; +import com.phenoml.api.resources.fhir2omop.errors.InternalServerError; +import com.phenoml.api.resources.fhir2omop.errors.UnauthorizedError; import com.phenoml.api.resources.fhir2omop.requests.CreateOmopRequest; import com.phenoml.api.resources.fhir2omop.types.CreateOmopResponse; import java.util.ArrayList; @@ -27,6 +30,7 @@ public void setup() throws Exception { server.start(); client = PhenomlClient.withCredentials("test-client-id", "test-client-secret") .url(server.url("/").toString()) + .maxRetries(0) .build(); } @@ -250,6 +254,425 @@ else if (actualResponseNode.has("kind")) } } + @Test + public void testCreateOidFormLoincCoding() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody(TestResources.loadResource( + "/wire-tests/Fhir2OmopWireTest_testCreateOidFormLoincCoding_response.json"))); + CreateOmopResponse response = client.fhir2Omop() + .create(CreateOmopRequest.builder() + .fhirResources(new HashMap() { + { + put("resourceType", "Bundle"); + put("type", "collection"); + put( + "entry", + new ArrayList(Arrays.asList( + new HashMap() { + { + put("resource", new HashMap() { + { + put("resourceType", "Patient"); + put("id", "patient-1"); + } + }); + } + }, + new HashMap() { + { + put("resource", new HashMap() { + { + put("resourceType", "Observation"); + put("id", "hemoglobin-1"); + put("status", "final"); + put("subject", new HashMap() { + { + put("reference", "Patient/patient-1"); + } + }); + put("code", new HashMap() { + { + put( + "coding", + new ArrayList( + Arrays.asList( + new HashMap< + String, + Object>() { + { + put( + "system", + "urn:oid:2.16.840.1.113883.6.1"); + put( + "code", + "718-7"); + put( + "display", + "Hemoglobin"); + } + }))); + } + }); + put("valueQuantity", new HashMap() { + { + put("value", 13.5); + put("unit", "g/dL"); + } + }); + } + }); + } + }))); + } + }) + .build()); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("POST", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = "" + + "{\n" + + " \"fhir_resources\": {\n" + + " \"resourceType\": \"Bundle\",\n" + + " \"type\": \"collection\",\n" + + " \"entry\": [\n" + + " {\n" + + " \"resource\": {\n" + + " \"resourceType\": \"Patient\",\n" + + " \"id\": \"patient-1\"\n" + + " }\n" + + " },\n" + + " {\n" + + " \"resource\": {\n" + + " \"resourceType\": \"Observation\",\n" + + " \"id\": \"hemoglobin-1\",\n" + + " \"status\": \"final\",\n" + + " \"subject\": {\n" + + " \"reference\": \"Patient/patient-1\"\n" + + " },\n" + + " \"code\": {\n" + + " \"coding\": [\n" + + " {\n" + + " \"system\": \"urn:oid:2.16.840.1.113883.6.1\",\n" + + " \"code\": \"718-7\",\n" + + " \"display\": \"Hemoglobin\"\n" + + " }\n" + + " ]\n" + + " },\n" + + " \"valueQuantity\": {\n" + + " \"value\": 13.5,\n" + + " \"unit\": \"g/dL\"\n" + + " }\n" + + " }\n" + + " }\n" + + " ]\n" + + " }\n" + + "}"; + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { + String discriminator = null; + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualJson.isNull()) { + Assertions.assertTrue( + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); + } + + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); + } + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); + } + + // Validate response body + Assertions.assertNotNull(response, "Response should not be null"); + String actualResponseJson = objectMapper.writeValueAsString(response); + String expectedResponseBody = + TestResources.loadResource("/wire-tests/Fhir2OmopWireTest_testCreateOidFormLoincCoding_response.json"); + JsonNode actualResponseNode = objectMapper.readTree(actualResponseJson); + JsonNode expectedResponseNode = objectMapper.readTree(expectedResponseBody); + Assertions.assertTrue( + jsonEquals(expectedResponseNode, actualResponseNode), + "Response body structure does not match expected"); + if (actualResponseNode.has("type") || actualResponseNode.has("_type") || actualResponseNode.has("kind")) { + String discriminator = null; + if (actualResponseNode.has("type")) + discriminator = actualResponseNode.get("type").asText(); + else if (actualResponseNode.has("_type")) + discriminator = actualResponseNode.get("_type").asText(); + else if (actualResponseNode.has("kind")) + discriminator = actualResponseNode.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualResponseNode.isNull()) { + Assertions.assertTrue( + actualResponseNode.isObject() || actualResponseNode.isArray() || actualResponseNode.isValueNode(), + "response should be a valid JSON value"); + } + + if (actualResponseNode.isArray()) { + Assertions.assertTrue(actualResponseNode.size() >= 0, "Array should have valid size"); + } + if (actualResponseNode.isObject()) { + Assertions.assertTrue(actualResponseNode.size() >= 0, "Object should have valid field count"); + } + } + + @Test + public void testCreateThrowsBadRequestError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(400).setBody("{\"key\":\"value\"}")); + BadRequestError exception = Assertions.assertThrows(BadRequestError.class, () -> client.fhir2Omop() + .create(CreateOmopRequest.builder() + .fhirResources(new HashMap() { + { + put("fhir_resources", new HashMap() { + { + put("key", "value"); + } + }); + } + }) + .build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("POST", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = "" + + "{\n" + + " \"fhir_resources\": {\n" + + " \"fhir_resources\": {\n" + + " \"key\": \"value\"\n" + + " }\n" + + " }\n" + + "}"; + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { + String discriminator = null; + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualJson.isNull()) { + Assertions.assertTrue( + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); + } + + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); + } + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); + } + + // Validate error response + Assertions.assertEquals(400, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testCreateThrowsUnauthorizedError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(401).setBody("{\"key\":\"value\"}")); + UnauthorizedError exception = Assertions.assertThrows(UnauthorizedError.class, () -> client.fhir2Omop() + .create(CreateOmopRequest.builder() + .fhirResources(new HashMap() { + { + put("fhir_resources", new HashMap() { + { + put("key", "value"); + } + }); + } + }) + .build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("POST", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = "" + + "{\n" + + " \"fhir_resources\": {\n" + + " \"fhir_resources\": {\n" + + " \"key\": \"value\"\n" + + " }\n" + + " }\n" + + "}"; + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { + String discriminator = null; + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualJson.isNull()) { + Assertions.assertTrue( + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); + } + + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); + } + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); + } + + // Validate error response + Assertions.assertEquals(401, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testCreateThrowsInternalServerError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(500).setBody("{\"key\":\"value\"}")); + InternalServerError exception = Assertions.assertThrows(InternalServerError.class, () -> client.fhir2Omop() + .create(CreateOmopRequest.builder() + .fhirResources(new HashMap() { + { + put("fhir_resources", new HashMap() { + { + put("key", "value"); + } + }); + } + }) + .build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("POST", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = "" + + "{\n" + + " \"fhir_resources\": {\n" + + " \"fhir_resources\": {\n" + + " \"key\": \"value\"\n" + + " }\n" + + " }\n" + + "}"; + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { + String discriminator = null; + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualJson.isNull()) { + Assertions.assertTrue( + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); + } + + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); + } + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); + } + + // Validate error response + Assertions.assertEquals(500, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + /** * Compares two JsonNodes with numeric equivalence and null safety. * For objects, checks that all fields in 'expected' exist in 'actual' with matching values. diff --git a/src/test/java/com/phenoml/api/FhirProviderAuthConfigWireTest.java b/src/test/java/com/phenoml/api/FhirProviderAuthConfigWireTest.java index beb16def..6616f0cf 100644 --- a/src/test/java/com/phenoml/api/FhirProviderAuthConfigWireTest.java +++ b/src/test/java/com/phenoml/api/FhirProviderAuthConfigWireTest.java @@ -6,9 +6,15 @@ import com.phenoml.api.resources.fhirprovider.authconfig.requests.FhirProviderRemoveAuthConfigRequest; import com.phenoml.api.resources.fhirprovider.authconfig.requests.FhirProviderSetActiveAuthConfigRequest; import com.phenoml.api.resources.fhirprovider.authconfig.types.RemoveResponse; +import com.phenoml.api.resources.fhirprovider.errors.BadRequestError; +import com.phenoml.api.resources.fhirprovider.errors.ForbiddenError; +import com.phenoml.api.resources.fhirprovider.errors.InternalServerError; +import com.phenoml.api.resources.fhirprovider.errors.NotFoundError; +import com.phenoml.api.resources.fhirprovider.errors.UnauthorizedError; import com.phenoml.api.resources.fhirprovider.types.ClientSecretAuth; import com.phenoml.api.resources.fhirprovider.types.FhirProviderAddAuthConfigRequest; import com.phenoml.api.resources.fhirprovider.types.FhirProviderResponse; +import com.phenoml.api.resources.fhirprovider.types.JwtAuth; import okhttp3.mockwebserver.MockResponse; import okhttp3.mockwebserver.MockWebServer; import okhttp3.mockwebserver.RecordedRequest; @@ -28,6 +34,7 @@ public void setup() throws Exception { server.start(); client = PhenomlClient.withCredentials("test-client-id", "test-client-secret") .url(server.url("/").toString()) + .maxRetries(0) .build(); } @@ -136,6 +143,326 @@ else if (actualResponseNode.has("kind")) } } + @Test + public void testAddThrowsBadRequestError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(400).setBody("{\"key\":\"value\"}")); + BadRequestError exception = Assertions.assertThrows(BadRequestError.class, () -> client.fhirProvider() + .authConfig() + .add( + "fhir_provider_id", + FhirProviderAddAuthConfigRequest.jwt( + JwtAuth.builder().clientId("client_id").build()))); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("PATCH", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = + "" + "{\n" + " \"auth_method\": \"jwt\",\n" + " \"client_id\": \"client_id\"\n" + "}"; + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { + String discriminator = null; + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualJson.isNull()) { + Assertions.assertTrue( + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); + } + + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); + } + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); + } + + // Validate error response + Assertions.assertEquals(400, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testAddThrowsUnauthorizedError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(401).setBody("{\"key\":\"value\"}")); + UnauthorizedError exception = Assertions.assertThrows(UnauthorizedError.class, () -> client.fhirProvider() + .authConfig() + .add( + "fhir_provider_id", + FhirProviderAddAuthConfigRequest.jwt( + JwtAuth.builder().clientId("client_id").build()))); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("PATCH", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = + "" + "{\n" + " \"auth_method\": \"jwt\",\n" + " \"client_id\": \"client_id\"\n" + "}"; + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { + String discriminator = null; + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualJson.isNull()) { + Assertions.assertTrue( + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); + } + + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); + } + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); + } + + // Validate error response + Assertions.assertEquals(401, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testAddThrowsForbiddenError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(403).setBody("{\"key\":\"value\"}")); + ForbiddenError exception = Assertions.assertThrows(ForbiddenError.class, () -> client.fhirProvider() + .authConfig() + .add( + "fhir_provider_id", + FhirProviderAddAuthConfigRequest.jwt( + JwtAuth.builder().clientId("client_id").build()))); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("PATCH", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = + "" + "{\n" + " \"auth_method\": \"jwt\",\n" + " \"client_id\": \"client_id\"\n" + "}"; + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { + String discriminator = null; + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualJson.isNull()) { + Assertions.assertTrue( + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); + } + + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); + } + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); + } + + // Validate error response + Assertions.assertEquals(403, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testAddThrowsNotFoundError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(404).setBody("{\"key\":\"value\"}")); + NotFoundError exception = Assertions.assertThrows(NotFoundError.class, () -> client.fhirProvider() + .authConfig() + .add( + "fhir_provider_id", + FhirProviderAddAuthConfigRequest.jwt( + JwtAuth.builder().clientId("client_id").build()))); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("PATCH", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = + "" + "{\n" + " \"auth_method\": \"jwt\",\n" + " \"client_id\": \"client_id\"\n" + "}"; + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { + String discriminator = null; + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualJson.isNull()) { + Assertions.assertTrue( + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); + } + + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); + } + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); + } + + // Validate error response + Assertions.assertEquals(404, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testAddThrowsInternalServerError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(500).setBody("{\"key\":\"value\"}")); + InternalServerError exception = Assertions.assertThrows(InternalServerError.class, () -> client.fhirProvider() + .authConfig() + .add( + "fhir_provider_id", + FhirProviderAddAuthConfigRequest.jwt( + JwtAuth.builder().clientId("client_id").build()))); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("PATCH", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = + "" + "{\n" + " \"auth_method\": \"jwt\",\n" + " \"client_id\": \"client_id\"\n" + "}"; + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { + String discriminator = null; + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualJson.isNull()) { + Assertions.assertTrue( + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); + } + + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); + } + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); + } + + // Validate error response + Assertions.assertEquals(500, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + @Test public void testSetActive() throws Exception { // OAuth: enqueue token response (client fetches token before API call) @@ -145,12 +472,426 @@ public void testSetActive() throws Exception { server.enqueue(new MockResponse() .setResponseCode(200) .setBody(TestResources.loadResource( - "/wire-tests/FhirProviderAuthConfigWireTest_testSetActive_response.json"))); - FhirProviderResponse response = client.fhirProvider() + "/wire-tests/FhirProviderAuthConfigWireTest_testSetActive_response.json"))); + FhirProviderResponse response = client.fhirProvider() + .authConfig() + .setActive( + "1716d214-de93-43a4-aa6b-a878d864e2ad", + FhirProviderSetActiveAuthConfigRequest.builder() + .authConfigId("auth-config-456") + .build()); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("PATCH", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = "" + "{\n" + " \"auth_config_id\": \"auth-config-456\"\n" + "}"; + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { + String discriminator = null; + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualJson.isNull()) { + Assertions.assertTrue( + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); + } + + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); + } + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); + } + + // Validate response body + Assertions.assertNotNull(response, "Response should not be null"); + String actualResponseJson = objectMapper.writeValueAsString(response); + String expectedResponseBody = + TestResources.loadResource("/wire-tests/FhirProviderAuthConfigWireTest_testSetActive_response.json"); + JsonNode actualResponseNode = objectMapper.readTree(actualResponseJson); + JsonNode expectedResponseNode = objectMapper.readTree(expectedResponseBody); + Assertions.assertTrue( + jsonEquals(expectedResponseNode, actualResponseNode), + "Response body structure does not match expected"); + if (actualResponseNode.has("type") || actualResponseNode.has("_type") || actualResponseNode.has("kind")) { + String discriminator = null; + if (actualResponseNode.has("type")) + discriminator = actualResponseNode.get("type").asText(); + else if (actualResponseNode.has("_type")) + discriminator = actualResponseNode.get("_type").asText(); + else if (actualResponseNode.has("kind")) + discriminator = actualResponseNode.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualResponseNode.isNull()) { + Assertions.assertTrue( + actualResponseNode.isObject() || actualResponseNode.isArray() || actualResponseNode.isValueNode(), + "response should be a valid JSON value"); + } + + if (actualResponseNode.isArray()) { + Assertions.assertTrue(actualResponseNode.size() >= 0, "Array should have valid size"); + } + if (actualResponseNode.isObject()) { + Assertions.assertTrue(actualResponseNode.size() >= 0, "Object should have valid field count"); + } + } + + @Test + public void testSetActiveThrowsBadRequestError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(400).setBody("{\"key\":\"value\"}")); + BadRequestError exception = Assertions.assertThrows(BadRequestError.class, () -> client.fhirProvider() + .authConfig() + .setActive( + "fhir_provider_id", + FhirProviderSetActiveAuthConfigRequest.builder() + .authConfigId("auth_config_id") + .build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("PATCH", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = "" + "{\n" + " \"auth_config_id\": \"auth_config_id\"\n" + "}"; + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { + String discriminator = null; + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualJson.isNull()) { + Assertions.assertTrue( + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); + } + + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); + } + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); + } + + // Validate error response + Assertions.assertEquals(400, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testSetActiveThrowsUnauthorizedError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(401).setBody("{\"key\":\"value\"}")); + UnauthorizedError exception = Assertions.assertThrows(UnauthorizedError.class, () -> client.fhirProvider() + .authConfig() + .setActive( + "fhir_provider_id", + FhirProviderSetActiveAuthConfigRequest.builder() + .authConfigId("auth_config_id") + .build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("PATCH", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = "" + "{\n" + " \"auth_config_id\": \"auth_config_id\"\n" + "}"; + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { + String discriminator = null; + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualJson.isNull()) { + Assertions.assertTrue( + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); + } + + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); + } + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); + } + + // Validate error response + Assertions.assertEquals(401, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testSetActiveThrowsForbiddenError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(403).setBody("{\"key\":\"value\"}")); + ForbiddenError exception = Assertions.assertThrows(ForbiddenError.class, () -> client.fhirProvider() + .authConfig() + .setActive( + "fhir_provider_id", + FhirProviderSetActiveAuthConfigRequest.builder() + .authConfigId("auth_config_id") + .build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("PATCH", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = "" + "{\n" + " \"auth_config_id\": \"auth_config_id\"\n" + "}"; + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { + String discriminator = null; + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualJson.isNull()) { + Assertions.assertTrue( + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); + } + + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); + } + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); + } + + // Validate error response + Assertions.assertEquals(403, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testSetActiveThrowsNotFoundError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(404).setBody("{\"key\":\"value\"}")); + NotFoundError exception = Assertions.assertThrows(NotFoundError.class, () -> client.fhirProvider() + .authConfig() + .setActive( + "fhir_provider_id", + FhirProviderSetActiveAuthConfigRequest.builder() + .authConfigId("auth_config_id") + .build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("PATCH", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = "" + "{\n" + " \"auth_config_id\": \"auth_config_id\"\n" + "}"; + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { + String discriminator = null; + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualJson.isNull()) { + Assertions.assertTrue( + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); + } + + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); + } + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); + } + + // Validate error response + Assertions.assertEquals(404, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testSetActiveThrowsInternalServerError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(500).setBody("{\"key\":\"value\"}")); + InternalServerError exception = Assertions.assertThrows(InternalServerError.class, () -> client.fhirProvider() + .authConfig() + .setActive( + "fhir_provider_id", + FhirProviderSetActiveAuthConfigRequest.builder() + .authConfigId("auth_config_id") + .build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("PATCH", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = "" + "{\n" + " \"auth_config_id\": \"auth_config_id\"\n" + "}"; + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { + String discriminator = null; + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualJson.isNull()) { + Assertions.assertTrue( + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); + } + + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); + } + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); + } + + // Validate error response + Assertions.assertEquals(500, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testRemove() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody(TestResources.loadResource( + "/wire-tests/FhirProviderAuthConfigWireTest_testRemove_response.json"))); + RemoveResponse response = client.fhirProvider() .authConfig() - .setActive( + .remove( "1716d214-de93-43a4-aa6b-a878d864e2ad", - FhirProviderSetActiveAuthConfigRequest.builder() + FhirProviderRemoveAuthConfigRequest.builder() .authConfigId("auth-config-456") .build()); // OAuth: consume the token request @@ -198,7 +939,7 @@ else if (actualJson.has("kind")) Assertions.assertNotNull(response, "Response should not be null"); String actualResponseJson = objectMapper.writeValueAsString(response); String expectedResponseBody = - TestResources.loadResource("/wire-tests/FhirProviderAuthConfigWireTest_testSetActive_response.json"); + TestResources.loadResource("/wire-tests/FhirProviderAuthConfigWireTest_testRemove_response.json"); JsonNode actualResponseNode = objectMapper.readTree(actualResponseJson); JsonNode expectedResponseNode = objectMapper.readTree(expectedResponseBody); Assertions.assertTrue( @@ -231,22 +972,83 @@ else if (actualResponseNode.has("kind")) } @Test - public void testRemove() throws Exception { + public void testRemoveThrowsBadRequestError() throws Exception { // OAuth: enqueue token response (client fetches token before API call) server.enqueue(new MockResponse() .setResponseCode(200) .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(400).setBody("{\"key\":\"value\"}")); + BadRequestError exception = Assertions.assertThrows(BadRequestError.class, () -> client.fhirProvider() + .authConfig() + .remove( + "fhir_provider_id", + FhirProviderRemoveAuthConfigRequest.builder() + .authConfigId("auth_config_id") + .build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("PATCH", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = "" + "{\n" + " \"auth_config_id\": \"auth_config_id\"\n" + "}"; + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { + String discriminator = null; + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualJson.isNull()) { + Assertions.assertTrue( + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); + } + + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); + } + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); + } + + // Validate error response + Assertions.assertEquals(400, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testRemoveThrowsUnauthorizedError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) server.enqueue(new MockResponse() .setResponseCode(200) - .setBody(TestResources.loadResource( - "/wire-tests/FhirProviderAuthConfigWireTest_testRemove_response.json"))); - RemoveResponse response = client.fhirProvider() + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(401).setBody("{\"key\":\"value\"}")); + UnauthorizedError exception = Assertions.assertThrows(UnauthorizedError.class, () -> client.fhirProvider() .authConfig() .remove( - "1716d214-de93-43a4-aa6b-a878d864e2ad", + "fhir_provider_id", FhirProviderRemoveAuthConfigRequest.builder() - .authConfigId("auth-config-456") - .build()); + .authConfigId("auth_config_id") + .build())); // OAuth: consume the token request server.takeRequest(); RecordedRequest request = server.takeRequest(); @@ -260,7 +1062,7 @@ public void testRemove() throws Exception { "OAuth Authorization header should contain Bearer token from OAuth flow"); // Validate request body String actualRequestBody = request.getBody().readUtf8(); - String expectedRequestBody = "" + "{\n" + " \"auth_config_id\": \"auth-config-456\"\n" + "}"; + String expectedRequestBody = "" + "{\n" + " \"auth_config_id\": \"auth_config_id\"\n" + "}"; JsonNode actualJson = objectMapper.readTree(actualRequestBody); JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); @@ -288,40 +1090,205 @@ else if (actualJson.has("kind")) Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); } - // Validate response body - Assertions.assertNotNull(response, "Response should not be null"); - String actualResponseJson = objectMapper.writeValueAsString(response); - String expectedResponseBody = - TestResources.loadResource("/wire-tests/FhirProviderAuthConfigWireTest_testRemove_response.json"); - JsonNode actualResponseNode = objectMapper.readTree(actualResponseJson); - JsonNode expectedResponseNode = objectMapper.readTree(expectedResponseBody); - Assertions.assertTrue( - jsonEquals(expectedResponseNode, actualResponseNode), - "Response body structure does not match expected"); - if (actualResponseNode.has("type") || actualResponseNode.has("_type") || actualResponseNode.has("kind")) { + // Validate error response + Assertions.assertEquals(401, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testRemoveThrowsForbiddenError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(403).setBody("{\"key\":\"value\"}")); + ForbiddenError exception = Assertions.assertThrows(ForbiddenError.class, () -> client.fhirProvider() + .authConfig() + .remove( + "fhir_provider_id", + FhirProviderRemoveAuthConfigRequest.builder() + .authConfigId("auth_config_id") + .build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("PATCH", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = "" + "{\n" + " \"auth_config_id\": \"auth_config_id\"\n" + "}"; + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { String discriminator = null; - if (actualResponseNode.has("type")) - discriminator = actualResponseNode.get("type").asText(); - else if (actualResponseNode.has("_type")) - discriminator = actualResponseNode.get("_type").asText(); - else if (actualResponseNode.has("kind")) - discriminator = actualResponseNode.get("kind").asText(); + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); } - if (!actualResponseNode.isNull()) { + if (!actualJson.isNull()) { Assertions.assertTrue( - actualResponseNode.isObject() || actualResponseNode.isArray() || actualResponseNode.isValueNode(), - "response should be a valid JSON value"); + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); } - if (actualResponseNode.isArray()) { - Assertions.assertTrue(actualResponseNode.size() >= 0, "Array should have valid size"); + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); } - if (actualResponseNode.isObject()) { - Assertions.assertTrue(actualResponseNode.size() >= 0, "Object should have valid field count"); + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); + } + + // Validate error response + Assertions.assertEquals(403, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testRemoveThrowsNotFoundError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(404).setBody("{\"key\":\"value\"}")); + NotFoundError exception = Assertions.assertThrows(NotFoundError.class, () -> client.fhirProvider() + .authConfig() + .remove( + "fhir_provider_id", + FhirProviderRemoveAuthConfigRequest.builder() + .authConfigId("auth_config_id") + .build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("PATCH", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = "" + "{\n" + " \"auth_config_id\": \"auth_config_id\"\n" + "}"; + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { + String discriminator = null; + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualJson.isNull()) { + Assertions.assertTrue( + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); + } + + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); + } + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); + } + + // Validate error response + Assertions.assertEquals(404, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testRemoveThrowsInternalServerError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(500).setBody("{\"key\":\"value\"}")); + InternalServerError exception = Assertions.assertThrows(InternalServerError.class, () -> client.fhirProvider() + .authConfig() + .remove( + "fhir_provider_id", + FhirProviderRemoveAuthConfigRequest.builder() + .authConfigId("auth_config_id") + .build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("PATCH", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = "" + "{\n" + " \"auth_config_id\": \"auth_config_id\"\n" + "}"; + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { + String discriminator = null; + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualJson.isNull()) { + Assertions.assertTrue( + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); } + + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); + } + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); + } + + // Validate error response + Assertions.assertEquals(500, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); } /** diff --git a/src/test/java/com/phenoml/api/FhirProviderWireTest.java b/src/test/java/com/phenoml/api/FhirProviderWireTest.java index 0b2f73e6..7a0a84c9 100644 --- a/src/test/java/com/phenoml/api/FhirProviderWireTest.java +++ b/src/test/java/com/phenoml/api/FhirProviderWireTest.java @@ -3,12 +3,18 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.phenoml.api.core.ObjectMappers; +import com.phenoml.api.resources.fhirprovider.errors.BadRequestError; +import com.phenoml.api.resources.fhirprovider.errors.ForbiddenError; +import com.phenoml.api.resources.fhirprovider.errors.InternalServerError; +import com.phenoml.api.resources.fhirprovider.errors.NotFoundError; +import com.phenoml.api.resources.fhirprovider.errors.UnauthorizedError; import com.phenoml.api.resources.fhirprovider.requests.FhirProviderCreateRequest; import com.phenoml.api.resources.fhirprovider.types.ClientSecretAuth; import com.phenoml.api.resources.fhirprovider.types.DeleteResponse; import com.phenoml.api.resources.fhirprovider.types.FhirProviderCreateRequestAuth; import com.phenoml.api.resources.fhirprovider.types.FhirProviderListResponse; import com.phenoml.api.resources.fhirprovider.types.FhirProviderResponse; +import com.phenoml.api.resources.fhirprovider.types.JwtAuth; import com.phenoml.api.resources.fhirprovider.types.Provider; import okhttp3.mockwebserver.MockResponse; import okhttp3.mockwebserver.MockWebServer; @@ -29,6 +35,7 @@ public void setup() throws Exception { server.start(); client = PhenomlClient.withCredentials("test-client-id", "test-client-secret") .url(server.url("/").toString()) + .maxRetries(0) .build(); } @@ -44,7 +51,7 @@ public void testCreate() throws Exception { .setResponseCode(200) .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); server.enqueue(new MockResponse() - .setResponseCode(200) + .setResponseCode(201) .setBody(TestResources.loadResource("/wire-tests/FhirProviderWireTest_testCreate_response.json"))); FhirProviderResponse response = client.fhirProvider() .create(FhirProviderCreateRequest.builder() @@ -145,6 +152,302 @@ else if (actualResponseNode.has("kind")) } } + @Test + public void testCreateThrowsBadRequestError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(400).setBody("{\"key\":\"value\"}")); + BadRequestError exception = Assertions.assertThrows(BadRequestError.class, () -> client.fhirProvider() + .create(FhirProviderCreateRequest.builder() + .name("x") + .provider(Provider.AIDBOX) + .baseUrl("base_url") + .auth(FhirProviderCreateRequestAuth.jwt( + JwtAuth.builder().clientId("client_id").build())) + .build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("POST", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = "" + + "{\n" + + " \"name\": \"x\",\n" + + " \"provider\": \"aidbox\",\n" + + " \"base_url\": \"base_url\",\n" + + " \"auth\": {\n" + + " \"auth_method\": \"jwt\",\n" + + " \"client_id\": \"client_id\"\n" + + " }\n" + + "}"; + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { + String discriminator = null; + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualJson.isNull()) { + Assertions.assertTrue( + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); + } + + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); + } + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); + } + + // Validate error response + Assertions.assertEquals(400, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testCreateThrowsUnauthorizedError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(401).setBody("{\"key\":\"value\"}")); + UnauthorizedError exception = Assertions.assertThrows(UnauthorizedError.class, () -> client.fhirProvider() + .create(FhirProviderCreateRequest.builder() + .name("x") + .provider(Provider.AIDBOX) + .baseUrl("base_url") + .auth(FhirProviderCreateRequestAuth.jwt( + JwtAuth.builder().clientId("client_id").build())) + .build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("POST", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = "" + + "{\n" + + " \"name\": \"x\",\n" + + " \"provider\": \"aidbox\",\n" + + " \"base_url\": \"base_url\",\n" + + " \"auth\": {\n" + + " \"auth_method\": \"jwt\",\n" + + " \"client_id\": \"client_id\"\n" + + " }\n" + + "}"; + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { + String discriminator = null; + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualJson.isNull()) { + Assertions.assertTrue( + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); + } + + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); + } + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); + } + + // Validate error response + Assertions.assertEquals(401, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testCreateThrowsForbiddenError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(403).setBody("{\"key\":\"value\"}")); + ForbiddenError exception = Assertions.assertThrows(ForbiddenError.class, () -> client.fhirProvider() + .create(FhirProviderCreateRequest.builder() + .name("x") + .provider(Provider.AIDBOX) + .baseUrl("base_url") + .auth(FhirProviderCreateRequestAuth.jwt( + JwtAuth.builder().clientId("client_id").build())) + .build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("POST", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = "" + + "{\n" + + " \"name\": \"x\",\n" + + " \"provider\": \"aidbox\",\n" + + " \"base_url\": \"base_url\",\n" + + " \"auth\": {\n" + + " \"auth_method\": \"jwt\",\n" + + " \"client_id\": \"client_id\"\n" + + " }\n" + + "}"; + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { + String discriminator = null; + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualJson.isNull()) { + Assertions.assertTrue( + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); + } + + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); + } + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); + } + + // Validate error response + Assertions.assertEquals(403, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testCreateThrowsInternalServerError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(500).setBody("{\"key\":\"value\"}")); + InternalServerError exception = Assertions.assertThrows(InternalServerError.class, () -> client.fhirProvider() + .create(FhirProviderCreateRequest.builder() + .name("x") + .provider(Provider.AIDBOX) + .baseUrl("base_url") + .auth(FhirProviderCreateRequestAuth.jwt( + JwtAuth.builder().clientId("client_id").build())) + .build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("POST", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = "" + + "{\n" + + " \"name\": \"x\",\n" + + " \"provider\": \"aidbox\",\n" + + " \"base_url\": \"base_url\",\n" + + " \"auth\": {\n" + + " \"auth_method\": \"jwt\",\n" + + " \"client_id\": \"client_id\"\n" + + " }\n" + + "}"; + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { + String discriminator = null; + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualJson.isNull()) { + Assertions.assertTrue( + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); + } + + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); + } + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); + } + + // Validate error response + Assertions.assertEquals(500, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + @Test public void testList() throws Exception { // OAuth: enqueue token response (client fetches token before API call) @@ -203,6 +506,66 @@ else if (actualResponseNode.has("kind")) } } + @Test + public void testListThrowsUnauthorizedError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(401).setBody("{\"key\":\"value\"}")); + UnauthorizedError exception = Assertions.assertThrows( + UnauthorizedError.class, () -> client.fhirProvider().list()); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("GET", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate error response + Assertions.assertEquals(401, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testListThrowsInternalServerError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(500).setBody("{\"key\":\"value\"}")); + InternalServerError exception = Assertions.assertThrows( + InternalServerError.class, () -> client.fhirProvider().list()); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("GET", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate error response + Assertions.assertEquals(500, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + @Test public void testGet() throws Exception { // OAuth: enqueue token response (client fetches token before API call) @@ -261,6 +624,96 @@ else if (actualResponseNode.has("kind")) } } + @Test + public void testGetThrowsUnauthorizedError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(401).setBody("{\"key\":\"value\"}")); + UnauthorizedError exception = Assertions.assertThrows( + UnauthorizedError.class, () -> client.fhirProvider().get("fhir_provider_id")); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("GET", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate error response + Assertions.assertEquals(401, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testGetThrowsNotFoundError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(404).setBody("{\"key\":\"value\"}")); + NotFoundError exception = Assertions.assertThrows( + NotFoundError.class, () -> client.fhirProvider().get("fhir_provider_id")); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("GET", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate error response + Assertions.assertEquals(404, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testGetThrowsInternalServerError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(500).setBody("{\"key\":\"value\"}")); + InternalServerError exception = Assertions.assertThrows( + InternalServerError.class, () -> client.fhirProvider().get("fhir_provider_id")); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("GET", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate error response + Assertions.assertEquals(500, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + @Test public void testDelete() throws Exception { // OAuth: enqueue token response (client fetches token before API call) @@ -319,6 +772,126 @@ else if (actualResponseNode.has("kind")) } } + @Test + public void testDeleteThrowsUnauthorizedError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(401).setBody("{\"key\":\"value\"}")); + UnauthorizedError exception = Assertions.assertThrows( + UnauthorizedError.class, () -> client.fhirProvider().delete("fhir_provider_id")); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("DELETE", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate error response + Assertions.assertEquals(401, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testDeleteThrowsForbiddenError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(403).setBody("{\"key\":\"value\"}")); + ForbiddenError exception = Assertions.assertThrows( + ForbiddenError.class, () -> client.fhirProvider().delete("fhir_provider_id")); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("DELETE", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate error response + Assertions.assertEquals(403, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testDeleteThrowsNotFoundError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(404).setBody("{\"key\":\"value\"}")); + NotFoundError exception = Assertions.assertThrows( + NotFoundError.class, () -> client.fhirProvider().delete("fhir_provider_id")); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("DELETE", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate error response + Assertions.assertEquals(404, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testDeleteThrowsInternalServerError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(500).setBody("{\"key\":\"value\"}")); + InternalServerError exception = Assertions.assertThrows( + InternalServerError.class, () -> client.fhirProvider().delete("fhir_provider_id")); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("DELETE", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate error response + Assertions.assertEquals(500, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + /** * Compares two JsonNodes with numeric equivalence and null safety. * For objects, checks that all fields in 'expected' exist in 'actual' with matching values. diff --git a/src/test/java/com/phenoml/api/FhirWireTest.java b/src/test/java/com/phenoml/api/FhirWireTest.java index e19d3f54..1faafd04 100644 --- a/src/test/java/com/phenoml/api/FhirWireTest.java +++ b/src/test/java/com/phenoml/api/FhirWireTest.java @@ -33,6 +33,7 @@ public void setup() throws Exception { server.start(); client = PhenomlClient.withCredentials("test-client-id", "test-client-secret") .url(server.url("/").toString()) + .maxRetries(0) .build(); } @@ -138,6 +139,95 @@ else if (actualResponseNode.has("kind")) } } + @Test + public void testSearchSingleResourceResponse() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue( + new MockResponse() + .setResponseCode(200) + .setBody( + "{\"resourceType\":\"Patient\",\"id\":\"123\",\"name\":[{\"family\":\"Doe\",\"given\":[\"John\"]}]}")); + Object response = client.fhir() + .search( + "550e8400-e29b-41d4-a716-446655440000", + "Patient", + SearchRequest.builder() + .phenomlOnBehalfOf("Patient/550e8400-e29b-41d4-a716-446655440000") + .phenomlFhirProvider( + "550e8400-e29b-41d4-a716-446655440000:eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c...") + .build()); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("GET", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate headers + Assertions.assertEquals( + "Patient/550e8400-e29b-41d4-a716-446655440000", + request.getHeader("X-Phenoml-On-Behalf-Of"), + "Header 'X-Phenoml-On-Behalf-Of' should match expected value"); + Assertions.assertEquals( + "550e8400-e29b-41d4-a716-446655440000:eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c...", + request.getHeader("X-Phenoml-Fhir-Provider"), + "Header 'X-Phenoml-Fhir-Provider' should match expected value"); + + // Validate response body + Assertions.assertNotNull(response, "Response should not be null"); + String actualResponseJson = objectMapper.writeValueAsString(response); + String expectedResponseBody = "" + + "{\n" + + " \"resourceType\": \"Patient\",\n" + + " \"id\": \"123\",\n" + + " \"name\": [\n" + + " {\n" + + " \"family\": \"Doe\",\n" + + " \"given\": [\n" + + " \"John\"\n" + + " ]\n" + + " }\n" + + " ]\n" + + "}"; + JsonNode actualResponseNode = objectMapper.readTree(actualResponseJson); + JsonNode expectedResponseNode = objectMapper.readTree(expectedResponseBody); + Assertions.assertTrue( + jsonEquals(expectedResponseNode, actualResponseNode), + "Response body structure does not match expected"); + if (actualResponseNode.has("type") || actualResponseNode.has("_type") || actualResponseNode.has("kind")) { + String discriminator = null; + if (actualResponseNode.has("type")) + discriminator = actualResponseNode.get("type").asText(); + else if (actualResponseNode.has("_type")) + discriminator = actualResponseNode.get("_type").asText(); + else if (actualResponseNode.has("kind")) + discriminator = actualResponseNode.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualResponseNode.isNull()) { + Assertions.assertTrue( + actualResponseNode.isObject() || actualResponseNode.isArray() || actualResponseNode.isValueNode(), + "response should be a valid JSON value"); + } + + if (actualResponseNode.isArray()) { + Assertions.assertTrue(actualResponseNode.size() >= 0, "Array should have valid size"); + } + if (actualResponseNode.isObject()) { + Assertions.assertTrue(actualResponseNode.size() >= 0, "Object should have valid field count"); + } + } + @Test public void testCreate() throws Exception { // OAuth: enqueue token response (client fetches token before API call) @@ -592,6 +682,432 @@ else if (actualResponseNode.has("kind")) } } + @Test + public void testPatchAddPhoneNumberToPatient() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue( + new MockResponse() + .setResponseCode(200) + .setBody( + "{\"resourceType\":\"Patient\",\"id\":\"123\",\"meta\":{\"versionId\":\"3\",\"lastUpdated\":\"2024-02-20T11:30:00Z\"}}")); + Object response = client.fhir() + .patch( + "550e8400-e29b-41d4-a716-446655440000", + "Patient", + PatchRequest.builder() + .body(Arrays.asList(PatchRequestBodyItem.builder() + .op(PatchRequestBodyItemOp.ADD) + .path("/telecom/-") + .value(new HashMap() { + { + put("system", "phone"); + put("value", "+1-555-123-4567"); + put("use", "home"); + } + }) + .build())) + .phenomlOnBehalfOf("Patient/550e8400-e29b-41d4-a716-446655440000") + .phenomlFhirProvider( + "550e8400-e29b-41d4-a716-446655440000:eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c...") + .build()); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("PATCH", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate headers + Assertions.assertEquals( + "Patient/550e8400-e29b-41d4-a716-446655440000", + request.getHeader("X-Phenoml-On-Behalf-Of"), + "Header 'X-Phenoml-On-Behalf-Of' should match expected value"); + Assertions.assertEquals( + "550e8400-e29b-41d4-a716-446655440000:eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c...", + request.getHeader("X-Phenoml-Fhir-Provider"), + "Header 'X-Phenoml-Fhir-Provider' should match expected value"); + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = "" + + "[\n" + + " {\n" + + " \"op\": \"add\",\n" + + " \"path\": \"/telecom/-\",\n" + + " \"value\": {\n" + + " \"system\": \"phone\",\n" + + " \"value\": \"+1-555-123-4567\",\n" + + " \"use\": \"home\"\n" + + " }\n" + + " }\n" + + "]"; + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { + String discriminator = null; + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualJson.isNull()) { + Assertions.assertTrue( + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); + } + + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); + } + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); + } + + // Validate response body + Assertions.assertNotNull(response, "Response should not be null"); + String actualResponseJson = objectMapper.writeValueAsString(response); + String expectedResponseBody = "" + + "{\n" + + " \"resourceType\": \"Patient\",\n" + + " \"id\": \"123\",\n" + + " \"meta\": {\n" + + " \"versionId\": \"3\",\n" + + " \"lastUpdated\": \"2024-02-20T11:30:00Z\"\n" + + " }\n" + + "}"; + JsonNode actualResponseNode = objectMapper.readTree(actualResponseJson); + JsonNode expectedResponseNode = objectMapper.readTree(expectedResponseBody); + Assertions.assertTrue( + jsonEquals(expectedResponseNode, actualResponseNode), + "Response body structure does not match expected"); + if (actualResponseNode.has("type") || actualResponseNode.has("_type") || actualResponseNode.has("kind")) { + String discriminator = null; + if (actualResponseNode.has("type")) + discriminator = actualResponseNode.get("type").asText(); + else if (actualResponseNode.has("_type")) + discriminator = actualResponseNode.get("_type").asText(); + else if (actualResponseNode.has("kind")) + discriminator = actualResponseNode.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualResponseNode.isNull()) { + Assertions.assertTrue( + actualResponseNode.isObject() || actualResponseNode.isArray() || actualResponseNode.isValueNode(), + "response should be a valid JSON value"); + } + + if (actualResponseNode.isArray()) { + Assertions.assertTrue(actualResponseNode.size() >= 0, "Array should have valid size"); + } + if (actualResponseNode.isObject()) { + Assertions.assertTrue(actualResponseNode.size() >= 0, "Object should have valid field count"); + } + } + + @Test + public void testPatchMultiplePatchOperations() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue( + new MockResponse() + .setResponseCode(200) + .setBody( + "{\"resourceType\":\"Patient\",\"id\":\"123\",\"meta\":{\"versionId\":\"3\",\"lastUpdated\":\"2024-02-20T11:30:00Z\"}}")); + Object response = client.fhir() + .patch( + "550e8400-e29b-41d4-a716-446655440000", + "Patient", + PatchRequest.builder() + .body(Arrays.asList( + PatchRequestBodyItem.builder() + .op(PatchRequestBodyItemOp.REPLACE) + .path("/name/0/family") + .value("Smith") + .build(), + PatchRequestBodyItem.builder() + .op(PatchRequestBodyItemOp.ADD) + .path("/telecom/-") + .value(new HashMap() { + { + put("system", "email"); + put("value", "john.smith@example.com"); + } + }) + .build(), + PatchRequestBodyItem.builder() + .op(PatchRequestBodyItemOp.REMOVE) + .path("/address/0") + .build())) + .phenomlOnBehalfOf("Patient/550e8400-e29b-41d4-a716-446655440000") + .phenomlFhirProvider( + "550e8400-e29b-41d4-a716-446655440000:eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c...") + .build()); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("PATCH", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate headers + Assertions.assertEquals( + "Patient/550e8400-e29b-41d4-a716-446655440000", + request.getHeader("X-Phenoml-On-Behalf-Of"), + "Header 'X-Phenoml-On-Behalf-Of' should match expected value"); + Assertions.assertEquals( + "550e8400-e29b-41d4-a716-446655440000:eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c...", + request.getHeader("X-Phenoml-Fhir-Provider"), + "Header 'X-Phenoml-Fhir-Provider' should match expected value"); + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = "" + + "[\n" + + " {\n" + + " \"op\": \"replace\",\n" + + " \"path\": \"/name/0/family\",\n" + + " \"value\": \"Smith\"\n" + + " },\n" + + " {\n" + + " \"op\": \"add\",\n" + + " \"path\": \"/telecom/-\",\n" + + " \"value\": {\n" + + " \"system\": \"email\",\n" + + " \"value\": \"john.smith@example.com\"\n" + + " }\n" + + " },\n" + + " {\n" + + " \"op\": \"remove\",\n" + + " \"path\": \"/address/0\"\n" + + " }\n" + + "]"; + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { + String discriminator = null; + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualJson.isNull()) { + Assertions.assertTrue( + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); + } + + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); + } + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); + } + + // Validate response body + Assertions.assertNotNull(response, "Response should not be null"); + String actualResponseJson = objectMapper.writeValueAsString(response); + String expectedResponseBody = "" + + "{\n" + + " \"resourceType\": \"Patient\",\n" + + " \"id\": \"123\",\n" + + " \"meta\": {\n" + + " \"versionId\": \"3\",\n" + + " \"lastUpdated\": \"2024-02-20T11:30:00Z\"\n" + + " }\n" + + "}"; + JsonNode actualResponseNode = objectMapper.readTree(actualResponseJson); + JsonNode expectedResponseNode = objectMapper.readTree(expectedResponseBody); + Assertions.assertTrue( + jsonEquals(expectedResponseNode, actualResponseNode), + "Response body structure does not match expected"); + if (actualResponseNode.has("type") || actualResponseNode.has("_type") || actualResponseNode.has("kind")) { + String discriminator = null; + if (actualResponseNode.has("type")) + discriminator = actualResponseNode.get("type").asText(); + else if (actualResponseNode.has("_type")) + discriminator = actualResponseNode.get("_type").asText(); + else if (actualResponseNode.has("kind")) + discriminator = actualResponseNode.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualResponseNode.isNull()) { + Assertions.assertTrue( + actualResponseNode.isObject() || actualResponseNode.isArray() || actualResponseNode.isValueNode(), + "response should be a valid JSON value"); + } + + if (actualResponseNode.isArray()) { + Assertions.assertTrue(actualResponseNode.size() >= 0, "Array should have valid size"); + } + if (actualResponseNode.isObject()) { + Assertions.assertTrue(actualResponseNode.size() >= 0, "Object should have valid field count"); + } + } + + @Test + public void testPatchTestCurrentValueBeforeReplacing() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue( + new MockResponse() + .setResponseCode(200) + .setBody( + "{\"resourceType\":\"Patient\",\"id\":\"123\",\"meta\":{\"versionId\":\"3\",\"lastUpdated\":\"2024-02-20T11:30:00Z\"}}")); + Object response = client.fhir() + .patch( + "550e8400-e29b-41d4-a716-446655440000", + "Patient", + PatchRequest.builder() + .body(Arrays.asList( + PatchRequestBodyItem.builder() + .op(PatchRequestBodyItemOp.TEST) + .path("/gender") + .value("male") + .build(), + PatchRequestBodyItem.builder() + .op(PatchRequestBodyItemOp.REPLACE) + .path("/gender") + .value("female") + .build())) + .phenomlOnBehalfOf("Patient/550e8400-e29b-41d4-a716-446655440000") + .phenomlFhirProvider( + "550e8400-e29b-41d4-a716-446655440000:eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c...") + .build()); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("PATCH", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate headers + Assertions.assertEquals( + "Patient/550e8400-e29b-41d4-a716-446655440000", + request.getHeader("X-Phenoml-On-Behalf-Of"), + "Header 'X-Phenoml-On-Behalf-Of' should match expected value"); + Assertions.assertEquals( + "550e8400-e29b-41d4-a716-446655440000:eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c...", + request.getHeader("X-Phenoml-Fhir-Provider"), + "Header 'X-Phenoml-Fhir-Provider' should match expected value"); + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = "" + + "[\n" + + " {\n" + + " \"op\": \"test\",\n" + + " \"path\": \"/gender\",\n" + + " \"value\": \"male\"\n" + + " },\n" + + " {\n" + + " \"op\": \"replace\",\n" + + " \"path\": \"/gender\",\n" + + " \"value\": \"female\"\n" + + " }\n" + + "]"; + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { + String discriminator = null; + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualJson.isNull()) { + Assertions.assertTrue( + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); + } + + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); + } + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); + } + + // Validate response body + Assertions.assertNotNull(response, "Response should not be null"); + String actualResponseJson = objectMapper.writeValueAsString(response); + String expectedResponseBody = "" + + "{\n" + + " \"resourceType\": \"Patient\",\n" + + " \"id\": \"123\",\n" + + " \"meta\": {\n" + + " \"versionId\": \"3\",\n" + + " \"lastUpdated\": \"2024-02-20T11:30:00Z\"\n" + + " }\n" + + "}"; + JsonNode actualResponseNode = objectMapper.readTree(actualResponseJson); + JsonNode expectedResponseNode = objectMapper.readTree(expectedResponseBody); + Assertions.assertTrue( + jsonEquals(expectedResponseNode, actualResponseNode), + "Response body structure does not match expected"); + if (actualResponseNode.has("type") || actualResponseNode.has("_type") || actualResponseNode.has("kind")) { + String discriminator = null; + if (actualResponseNode.has("type")) + discriminator = actualResponseNode.get("type").asText(); + else if (actualResponseNode.has("_type")) + discriminator = actualResponseNode.get("_type").asText(); + else if (actualResponseNode.has("kind")) + discriminator = actualResponseNode.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualResponseNode.isNull()) { + Assertions.assertTrue( + actualResponseNode.isObject() || actualResponseNode.isArray() || actualResponseNode.isValueNode(), + "response should be a valid JSON value"); + } + + if (actualResponseNode.isArray()) { + Assertions.assertTrue(actualResponseNode.size() >= 0, "Array should have valid size"); + } + if (actualResponseNode.isObject()) { + Assertions.assertTrue(actualResponseNode.size() >= 0, "Object should have valid field count"); + } + } + @Test public void testExecuteBundle() throws Exception { // OAuth: enqueue token response (client fetches token before API call) diff --git a/src/test/java/com/phenoml/api/ImplementationGuidesImplementationGuidesWireTest.java b/src/test/java/com/phenoml/api/ImplementationGuidesImplementationGuidesWireTest.java index 533d016f..c7517f35 100644 --- a/src/test/java/com/phenoml/api/ImplementationGuidesImplementationGuidesWireTest.java +++ b/src/test/java/com/phenoml/api/ImplementationGuidesImplementationGuidesWireTest.java @@ -3,6 +3,12 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.phenoml.api.core.ObjectMappers; +import com.phenoml.api.resources.implementationguides.errors.BadRequestError; +import com.phenoml.api.resources.implementationguides.errors.ConflictError; +import com.phenoml.api.resources.implementationguides.errors.ForbiddenError; +import com.phenoml.api.resources.implementationguides.errors.InternalServerError; +import com.phenoml.api.resources.implementationguides.errors.NotFoundError; +import com.phenoml.api.resources.implementationguides.errors.UnauthorizedError; import com.phenoml.api.resources.implementationguides.implementationguides.requests.CreateCanonicalImplementationGuideRequest; import com.phenoml.api.resources.implementationguides.implementationguides.requests.UpdateImplementationGuideRequest; import com.phenoml.api.resources.implementationguides.types.FhirImplementationGuide; @@ -30,6 +36,7 @@ public void setup() throws Exception { server.start(); client = PhenomlClient.withCredentials("test-client-id", "test-client-secret") .url(server.url("/").toString()) + .maxRetries(0) .build(); } @@ -111,6 +118,99 @@ else if (actualResponseNode.has("kind")) } } + @Test + public void testListThrowsUnauthorizedError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(401).setBody("{\"key\":\"value\"}")); + UnauthorizedError exception = Assertions.assertThrows( + UnauthorizedError.class, + () -> client.implementationGuides().implementationGuides().list()); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("GET", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate error response + Assertions.assertEquals(401, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testListThrowsForbiddenError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(403).setBody("{\"key\":\"value\"}")); + ForbiddenError exception = Assertions.assertThrows( + ForbiddenError.class, + () -> client.implementationGuides().implementationGuides().list()); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("GET", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate error response + Assertions.assertEquals(403, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testListThrowsInternalServerError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(500).setBody("{\"key\":\"value\"}")); + InternalServerError exception = Assertions.assertThrows( + InternalServerError.class, + () -> client.implementationGuides().implementationGuides().list()); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("GET", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate error response + Assertions.assertEquals(500, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + @Test public void testGet() throws Exception { // OAuth: enqueue token response (client fetches token before API call) @@ -185,26 +285,710 @@ else if (actualResponseNode.has("kind")) } @Test - public void testUpdate() throws Exception { + public void testGetThrowsBadRequestError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(400).setBody("{\"key\":\"value\"}")); + BadRequestError exception = Assertions.assertThrows( + BadRequestError.class, + () -> client.implementationGuides().implementationGuides().get("name")); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("GET", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate error response + Assertions.assertEquals(400, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testGetThrowsUnauthorizedError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(401).setBody("{\"key\":\"value\"}")); + UnauthorizedError exception = Assertions.assertThrows( + UnauthorizedError.class, + () -> client.implementationGuides().implementationGuides().get("name")); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("GET", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate error response + Assertions.assertEquals(401, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testGetThrowsForbiddenError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(403).setBody("{\"key\":\"value\"}")); + ForbiddenError exception = Assertions.assertThrows( + ForbiddenError.class, + () -> client.implementationGuides().implementationGuides().get("name")); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("GET", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate error response + Assertions.assertEquals(403, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testGetThrowsNotFoundError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(404).setBody("{\"key\":\"value\"}")); + NotFoundError exception = Assertions.assertThrows( + NotFoundError.class, + () -> client.implementationGuides().implementationGuides().get("name")); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("GET", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate error response + Assertions.assertEquals(404, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testGetThrowsInternalServerError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(500).setBody("{\"key\":\"value\"}")); + InternalServerError exception = Assertions.assertThrows( + InternalServerError.class, + () -> client.implementationGuides().implementationGuides().get("name")); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("GET", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate error response + Assertions.assertEquals(500, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testUpdate() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue( + new MockResponse() + .setResponseCode(200) + .setBody( + "{\"name\":\"acme-cardiology\",\"profile_context\":\"When the text mentions phenotypic features, prefer the hpo-observation profile over Condition.\",\"profile_count\":3,\"canonical_url\":\"canonical_url\",\"version_count\":1,\"created_at\":\"2024-01-15T09:30:00Z\",\"updated_at\":\"2024-01-15T09:30:00Z\"}")); + ImplementationGuideSummary response = client.implementationGuides() + .implementationGuides() + .update( + "acme-cardiology", + UpdateImplementationGuideRequest.builder().build()); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("PUT", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = "" + "{}"; + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { + String discriminator = null; + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualJson.isNull()) { + Assertions.assertTrue( + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); + } + + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); + } + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); + } + + // Validate response body + Assertions.assertNotNull(response, "Response should not be null"); + String actualResponseJson = objectMapper.writeValueAsString(response); + String expectedResponseBody = "" + + "{\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" + + "}"; + JsonNode actualResponseNode = objectMapper.readTree(actualResponseJson); + JsonNode expectedResponseNode = objectMapper.readTree(expectedResponseBody); + Assertions.assertTrue( + jsonEquals(expectedResponseNode, actualResponseNode), + "Response body structure does not match expected"); + if (actualResponseNode.has("type") || actualResponseNode.has("_type") || actualResponseNode.has("kind")) { + String discriminator = null; + if (actualResponseNode.has("type")) + discriminator = actualResponseNode.get("type").asText(); + else if (actualResponseNode.has("_type")) + discriminator = actualResponseNode.get("_type").asText(); + else if (actualResponseNode.has("kind")) + discriminator = actualResponseNode.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualResponseNode.isNull()) { + Assertions.assertTrue( + actualResponseNode.isObject() || actualResponseNode.isArray() || actualResponseNode.isValueNode(), + "response should be a valid JSON value"); + } + + if (actualResponseNode.isArray()) { + Assertions.assertTrue(actualResponseNode.size() >= 0, "Array should have valid size"); + } + if (actualResponseNode.isObject()) { + Assertions.assertTrue(actualResponseNode.size() >= 0, "Object should have valid field count"); + } + } + + @Test + public void testUpdateThrowsBadRequestError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(400).setBody("{\"key\":\"value\"}")); + BadRequestError exception = Assertions.assertThrows(BadRequestError.class, () -> client.implementationGuides() + .implementationGuides() + .update("name", UpdateImplementationGuideRequest.builder().build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("PUT", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = "" + "{}"; + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { + String discriminator = null; + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualJson.isNull()) { + Assertions.assertTrue( + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); + } + + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); + } + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); + } + + // Validate error response + Assertions.assertEquals(400, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testUpdateThrowsUnauthorizedError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(401).setBody("{\"key\":\"value\"}")); + UnauthorizedError exception = + Assertions.assertThrows(UnauthorizedError.class, () -> client.implementationGuides() + .implementationGuides() + .update( + "name", + UpdateImplementationGuideRequest.builder().build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("PUT", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = "" + "{}"; + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { + String discriminator = null; + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualJson.isNull()) { + Assertions.assertTrue( + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); + } + + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); + } + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); + } + + // Validate error response + Assertions.assertEquals(401, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testUpdateThrowsForbiddenError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(403).setBody("{\"key\":\"value\"}")); + ForbiddenError exception = Assertions.assertThrows(ForbiddenError.class, () -> client.implementationGuides() + .implementationGuides() + .update("name", UpdateImplementationGuideRequest.builder().build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("PUT", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = "" + "{}"; + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { + String discriminator = null; + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualJson.isNull()) { + Assertions.assertTrue( + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); + } + + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); + } + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); + } + + // Validate error response + Assertions.assertEquals(403, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testUpdateThrowsInternalServerError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(500).setBody("{\"key\":\"value\"}")); + InternalServerError exception = + Assertions.assertThrows(InternalServerError.class, () -> client.implementationGuides() + .implementationGuides() + .update( + "name", + UpdateImplementationGuideRequest.builder().build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("PUT", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = "" + "{}"; + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { + String discriminator = null; + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualJson.isNull()) { + Assertions.assertTrue( + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); + } + + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); + } + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); + } + + // Validate error response + Assertions.assertEquals(500, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testDelete() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(200).setBody("{}")); + client.implementationGuides().implementationGuides().delete("acme-cardiology"); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("DELETE", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + } + + @Test + public void testDeleteThrowsBadRequestError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(400).setBody("{\"key\":\"value\"}")); + BadRequestError exception = Assertions.assertThrows( + BadRequestError.class, + () -> client.implementationGuides().implementationGuides().delete("name")); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("DELETE", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate error response + Assertions.assertEquals(400, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testDeleteThrowsUnauthorizedError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(401).setBody("{\"key\":\"value\"}")); + UnauthorizedError exception = Assertions.assertThrows( + UnauthorizedError.class, + () -> client.implementationGuides().implementationGuides().delete("name")); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("DELETE", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate error response + Assertions.assertEquals(401, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testDeleteThrowsForbiddenError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(403).setBody("{\"key\":\"value\"}")); + ForbiddenError exception = Assertions.assertThrows( + ForbiddenError.class, + () -> client.implementationGuides().implementationGuides().delete("name")); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("DELETE", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate error response + Assertions.assertEquals(403, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testDeleteThrowsNotFoundError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(404).setBody("{\"key\":\"value\"}")); + NotFoundError exception = Assertions.assertThrows( + NotFoundError.class, + () -> client.implementationGuides().implementationGuides().delete("name")); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("DELETE", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate error response + Assertions.assertEquals(404, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testDeleteThrowsInternalServerError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(500).setBody("{\"key\":\"value\"}")); + InternalServerError exception = Assertions.assertThrows( + InternalServerError.class, + () -> client.implementationGuides().implementationGuides().delete("name")); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("DELETE", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate error response + Assertions.assertEquals(500, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testCreateVersion() throws Exception { // OAuth: enqueue token response (client fetches token before API call) server.enqueue(new MockResponse() .setResponseCode(200) .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); server.enqueue( new MockResponse() - .setResponseCode(200) + .setResponseCode(201) .setBody( - "{\"name\":\"acme-cardiology\",\"profile_context\":\"When the text mentions phenotypic features, prefer the hpo-observation profile over Condition.\",\"profile_count\":3,\"canonical_url\":\"canonical_url\",\"version_count\":1,\"created_at\":\"2024-01-15T09:30:00Z\",\"updated_at\":\"2024-01-15T09:30:00Z\"}")); - ImplementationGuideSummary response = client.implementationGuides() + "{\"name\":\"name\",\"url\":\"url\",\"version\":\"version\",\"profile_context\":\"profile_context\",\"profiles\":[\"profiles\"],\"profile_refs\":[\"profile_refs\"],\"implementation_guide\":{\"resourceType\":\"ImplementationGuide\",\"id\":\"id\",\"url\":\"url\",\"version\":\"version\",\"name\":\"name\",\"status\":\"status\",\"packageId\":\"packageId\",\"fhirVersion\":[\"fhirVersion\"]},\"created_at\":\"2024-01-15T09:30:00Z\",\"updated_at\":\"2024-01-15T09:30:00Z\"}")); + ImplementationGuideVersionDetail response = client.implementationGuides() .implementationGuides() - .update( - "acme-cardiology", - UpdateImplementationGuideRequest.builder().build()); + .createVersion( + "name", + CreateCanonicalImplementationGuideRequest.builder() + .implementationGuide(FhirImplementationGuide.builder() + .url("url") + .version("version") + .build()) + .profileRefs(Arrays.asList("profile_refs")) + .build()); // OAuth: consume the token request server.takeRequest(); RecordedRequest request = server.takeRequest(); Assertions.assertNotNull(request); - Assertions.assertEquals("PUT", request.getMethod()); + Assertions.assertEquals("POST", request.getMethod()); // Validate OAuth Authorization header Assertions.assertEquals( @@ -213,7 +997,17 @@ public void testUpdate() throws Exception { "OAuth Authorization header should contain Bearer token from OAuth flow"); // Validate request body String actualRequestBody = request.getBody().readUtf8(); - String expectedRequestBody = "" + "{}"; + String expectedRequestBody = "" + + "{\n" + + " \"implementation_guide\": {\n" + + " \"resourceType\": \"ImplementationGuide\",\n" + + " \"url\": \"url\",\n" + + " \"version\": \"version\"\n" + + " },\n" + + " \"profile_refs\": [\n" + + " \"profile_refs\"\n" + + " ]\n" + + "}"; JsonNode actualJson = objectMapper.readTree(actualRequestBody); JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); @@ -246,11 +1040,28 @@ else if (actualJson.has("kind")) String actualResponseJson = objectMapper.writeValueAsString(response); String expectedResponseBody = "" + "{\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" + + " \"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" + "}"; @@ -286,38 +1097,92 @@ else if (actualResponseNode.has("kind")) } @Test - public void testDelete() throws Exception { + public void testCreateVersionThrowsBadRequestError() throws Exception { // OAuth: enqueue token response (client fetches token before API call) server.enqueue(new MockResponse() .setResponseCode(200) .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); - server.enqueue(new MockResponse().setResponseCode(200).setBody("{}")); - client.implementationGuides().implementationGuides().delete("acme-cardiology"); + server.enqueue(new MockResponse().setResponseCode(400).setBody("{\"key\":\"value\"}")); + BadRequestError exception = Assertions.assertThrows(BadRequestError.class, () -> client.implementationGuides() + .implementationGuides() + .createVersion( + "name", + CreateCanonicalImplementationGuideRequest.builder() + .implementationGuide(FhirImplementationGuide.builder() + .url("url") + .version("version") + .build()) + .profileRefs(Arrays.asList("profile_refs", "profile_refs")) + .build())); // OAuth: consume the token request server.takeRequest(); RecordedRequest request = server.takeRequest(); Assertions.assertNotNull(request); - Assertions.assertEquals("DELETE", request.getMethod()); + Assertions.assertEquals("POST", request.getMethod()); // Validate OAuth Authorization header Assertions.assertEquals( "Bearer test-token", request.getHeader("Authorization"), "OAuth Authorization header should contain Bearer token from OAuth flow"); + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = "" + + "{\n" + + " \"implementation_guide\": {\n" + + " \"resourceType\": \"ImplementationGuide\",\n" + + " \"url\": \"url\",\n" + + " \"version\": \"version\"\n" + + " },\n" + + " \"profile_refs\": [\n" + + " \"profile_refs\",\n" + + " \"profile_refs\"\n" + + " ]\n" + + "}"; + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { + String discriminator = null; + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualJson.isNull()) { + Assertions.assertTrue( + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); + } + + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); + } + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); + } + + // Validate error response + Assertions.assertEquals(400, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); } @Test - public void testCreateVersion() throws Exception { + public void testCreateVersionThrowsNotFoundError() throws Exception { // OAuth: enqueue token response (client fetches token before API call) server.enqueue(new MockResponse() .setResponseCode(200) .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); - server.enqueue( - new MockResponse() - .setResponseCode(200) - .setBody( - "{\"name\":\"name\",\"url\":\"url\",\"version\":\"version\",\"profile_context\":\"profile_context\",\"profiles\":[\"profiles\"],\"profile_refs\":[\"profile_refs\"],\"implementation_guide\":{\"resourceType\":\"ImplementationGuide\",\"id\":\"id\",\"url\":\"url\",\"version\":\"version\",\"name\":\"name\",\"status\":\"status\",\"packageId\":\"packageId\",\"fhirVersion\":[\"fhirVersion\"]},\"created_at\":\"2024-01-15T09:30:00Z\",\"updated_at\":\"2024-01-15T09:30:00Z\"}")); - ImplementationGuideVersionDetail response = client.implementationGuides() + server.enqueue(new MockResponse().setResponseCode(404).setBody("{\"key\":\"value\"}")); + NotFoundError exception = Assertions.assertThrows(NotFoundError.class, () -> client.implementationGuides() .implementationGuides() .createVersion( "name", @@ -326,8 +1191,8 @@ public void testCreateVersion() throws Exception { .url("url") .version("version") .build()) - .profileRefs(Arrays.asList("profile_refs")) - .build()); + .profileRefs(Arrays.asList("profile_refs", "profile_refs")) + .build())); // OAuth: consume the token request server.takeRequest(); RecordedRequest request = server.takeRequest(); @@ -349,6 +1214,7 @@ public void testCreateVersion() throws Exception { + " \"version\": \"version\"\n" + " },\n" + " \"profile_refs\": [\n" + + " \"profile_refs\",\n" + " \"profile_refs\"\n" + " ]\n" + "}"; @@ -379,65 +1245,92 @@ else if (actualJson.has("kind")) Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); } - // Validate response body - Assertions.assertNotNull(response, "Response should not be null"); - String actualResponseJson = objectMapper.writeValueAsString(response); - String expectedResponseBody = "" + // Validate error response + Assertions.assertEquals(404, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testCreateVersionThrowsConflictError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(409).setBody("{\"key\":\"value\"}")); + ConflictError exception = Assertions.assertThrows(ConflictError.class, () -> client.implementationGuides() + .implementationGuides() + .createVersion( + "name", + CreateCanonicalImplementationGuideRequest.builder() + .implementationGuide(FhirImplementationGuide.builder() + .url("url") + .version("version") + .build()) + .profileRefs(Arrays.asList("profile_refs", "profile_refs")) + .build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("POST", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = "" + "{\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" + + " \"version\": \"version\"\n" + " },\n" - + " \"created_at\": \"2024-01-15T09:30:00Z\",\n" - + " \"updated_at\": \"2024-01-15T09:30:00Z\"\n" + + " \"profile_refs\": [\n" + + " \"profile_refs\",\n" + + " \"profile_refs\"\n" + + " ]\n" + "}"; - JsonNode actualResponseNode = objectMapper.readTree(actualResponseJson); - JsonNode expectedResponseNode = objectMapper.readTree(expectedResponseBody); - Assertions.assertTrue( - jsonEquals(expectedResponseNode, actualResponseNode), - "Response body structure does not match expected"); - if (actualResponseNode.has("type") || actualResponseNode.has("_type") || actualResponseNode.has("kind")) { + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { String discriminator = null; - if (actualResponseNode.has("type")) - discriminator = actualResponseNode.get("type").asText(); - else if (actualResponseNode.has("_type")) - discriminator = actualResponseNode.get("_type").asText(); - else if (actualResponseNode.has("kind")) - discriminator = actualResponseNode.get("kind").asText(); + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); } - if (!actualResponseNode.isNull()) { + if (!actualJson.isNull()) { Assertions.assertTrue( - actualResponseNode.isObject() || actualResponseNode.isArray() || actualResponseNode.isValueNode(), - "response should be a valid JSON value"); + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); } - if (actualResponseNode.isArray()) { - Assertions.assertTrue(actualResponseNode.size() >= 0, "Array should have valid size"); + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); } - if (actualResponseNode.isObject()) { - Assertions.assertTrue(actualResponseNode.size() >= 0, "Object should have valid field count"); + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); } + + // Validate error response + Assertions.assertEquals(409, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); } @Test @@ -526,6 +1419,68 @@ else if (actualResponseNode.has("kind")) } } + @Test + public void testGetVersionThrowsBadRequestError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(400).setBody("{\"key\":\"value\"}")); + BadRequestError exception = Assertions.assertThrows( + BadRequestError.class, + () -> client.implementationGuides().implementationGuides().getVersion("name", "version")); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("GET", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate error response + Assertions.assertEquals(400, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testGetVersionThrowsNotFoundError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(404).setBody("{\"key\":\"value\"}")); + NotFoundError exception = Assertions.assertThrows( + NotFoundError.class, + () -> client.implementationGuides().implementationGuides().getVersion("name", "version")); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("GET", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate error response + Assertions.assertEquals(404, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + /** * Compares two JsonNodes with numeric equivalence and null safety. * For objects, checks that all fields in 'expected' exist in 'actual' with matching values. diff --git a/src/test/java/com/phenoml/api/Lang2FhirBatchWireTest.java b/src/test/java/com/phenoml/api/Lang2FhirBatchWireTest.java index c7b885f3..dea8ea6f 100644 --- a/src/test/java/com/phenoml/api/Lang2FhirBatchWireTest.java +++ b/src/test/java/com/phenoml/api/Lang2FhirBatchWireTest.java @@ -3,6 +3,13 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.phenoml.api.core.ObjectMappers; +import com.phenoml.api.resources.lang2fhirbatch.errors.BadRequestError; +import com.phenoml.api.resources.lang2fhirbatch.errors.ClientClosedRequestError; +import com.phenoml.api.resources.lang2fhirbatch.errors.ConflictError; +import com.phenoml.api.resources.lang2fhirbatch.errors.GatewayTimeoutError; +import com.phenoml.api.resources.lang2fhirbatch.errors.InternalServerError; +import com.phenoml.api.resources.lang2fhirbatch.errors.NotFoundError; +import com.phenoml.api.resources.lang2fhirbatch.errors.UnauthorizedError; import com.phenoml.api.resources.lang2fhirbatch.requests.CreateBatchRequest; import com.phenoml.api.resources.lang2fhirbatch.requests.GetRequest; import com.phenoml.api.resources.lang2fhirbatch.requests.GetResultsRequest; @@ -31,6 +38,7 @@ public void setup() throws Exception { server.start(); client = PhenomlClient.withCredentials("test-client-id", "test-client-secret") .url(server.url("/").toString()) + .maxRetries(0) .build(); } @@ -120,6 +128,157 @@ else if (actualResponseNode.has("kind")) } } + @Test + public void testListThrowsBadRequestError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(400).setBody("{\"key\":\"value\"}")); + BadRequestError exception = Assertions.assertThrows(BadRequestError.class, () -> client.lang2FhirBatch() + .list(ListRequest.builder().build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("GET", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate error response + Assertions.assertEquals(400, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testListThrowsUnauthorizedError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(401).setBody("{\"key\":\"value\"}")); + UnauthorizedError exception = Assertions.assertThrows(UnauthorizedError.class, () -> client.lang2FhirBatch() + .list(ListRequest.builder().build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("GET", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate error response + Assertions.assertEquals(401, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testListThrowsClientClosedRequestError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(499).setBody("{\"key\":\"value\"}")); + ClientClosedRequestError exception = + Assertions.assertThrows(ClientClosedRequestError.class, () -> client.lang2FhirBatch() + .list(ListRequest.builder().build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("GET", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate error response + Assertions.assertEquals(499, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testListThrowsInternalServerError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(500).setBody("{\"key\":\"value\"}")); + InternalServerError exception = Assertions.assertThrows(InternalServerError.class, () -> client.lang2FhirBatch() + .list(ListRequest.builder().build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("GET", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate error response + Assertions.assertEquals(500, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testListThrowsGatewayTimeoutError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(504).setBody("{\"key\":\"value\"}")); + GatewayTimeoutError exception = Assertions.assertThrows(GatewayTimeoutError.class, () -> client.lang2FhirBatch() + .list(ListRequest.builder().build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("GET", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate error response + Assertions.assertEquals(504, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + @Test public void testCreate() throws Exception { // OAuth: enqueue token response (client fetches token before API call) @@ -128,7 +287,7 @@ public void testCreate() throws Exception { .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); server.enqueue( new MockResponse() - .setResponseCode(200) + .setResponseCode(202) .setBody( "{\"job_id\":\"l2f_batch_6f1d2c3a-8b4e-4f5a-9c7d-0e1f2a3b4c5d\",\"request_id\":\"submit-2025-09-02-batch-001\",\"status\":\"pending\",\"finalized\":false,\"total_items\":12,\"error\":{\"kind\":\"processing_failed\",\"message\":\"the item could not be converted\"},\"created_at\":\"2024-01-15T09:30:00Z\",\"updated_at\":\"2024-01-15T09:30:00Z\",\"completed_at\":\"2024-01-15T09:30:00Z\",\"expires_at\":\"2024-01-15T09:30:00Z\"}")); BatchJob response = client.lang2FhirBatch() @@ -227,17 +386,14 @@ else if (actualResponseNode.has("kind")) } @Test - public void testFinalize() throws Exception { + public void testCreateThrowsBadRequestError() throws Exception { // OAuth: enqueue token response (client fetches token before API call) server.enqueue(new MockResponse() .setResponseCode(200) .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); - server.enqueue( - new MockResponse() - .setResponseCode(200) - .setBody( - "{\"job_id\":\"l2f_batch_6f1d2c3a-8b4e-4f5a-9c7d-0e1f2a3b4c5d\",\"request_id\":\"submit-2025-09-02-batch-001\",\"status\":\"pending\",\"finalized\":false,\"total_items\":12,\"error\":{\"kind\":\"processing_failed\",\"message\":\"the item could not be converted\"},\"created_at\":\"2024-01-15T09:30:00Z\",\"updated_at\":\"2024-01-15T09:30:00Z\",\"completed_at\":\"2024-01-15T09:30:00Z\",\"expires_at\":\"2024-01-15T09:30:00Z\"}")); - BatchJob response = client.lang2FhirBatch().finalize("job_id"); + server.enqueue(new MockResponse().setResponseCode(400).setBody("{\"key\":\"value\"}")); + BadRequestError exception = Assertions.assertThrows(BadRequestError.class, () -> client.lang2FhirBatch() + .create(CreateBatchRequest.builder().build())); // OAuth: consume the token request server.takeRequest(); RecordedRequest request = server.takeRequest(); @@ -249,69 +405,54 @@ public void testFinalize() throws Exception { "Bearer test-token", request.getHeader("Authorization"), "OAuth Authorization header should contain Bearer token from OAuth flow"); - - // Validate response body - Assertions.assertNotNull(response, "Response should not be null"); - String actualResponseJson = objectMapper.writeValueAsString(response); - String expectedResponseBody = "" - + "{\n" - + " \"job_id\": \"l2f_batch_6f1d2c3a-8b4e-4f5a-9c7d-0e1f2a3b4c5d\",\n" - + " \"request_id\": \"submit-2025-09-02-batch-001\",\n" - + " \"status\": \"pending\",\n" - + " \"finalized\": false,\n" - + " \"total_items\": 12,\n" - + " \"error\": {\n" - + " \"kind\": \"processing_failed\",\n" - + " \"message\": \"the item could not be converted\"\n" - + " },\n" - + " \"created_at\": \"2024-01-15T09:30:00Z\",\n" - + " \"updated_at\": \"2024-01-15T09:30:00Z\",\n" - + " \"completed_at\": \"2024-01-15T09:30:00Z\",\n" - + " \"expires_at\": \"2024-01-15T09:30:00Z\"\n" - + "}"; - JsonNode actualResponseNode = objectMapper.readTree(actualResponseJson); - JsonNode expectedResponseNode = objectMapper.readTree(expectedResponseBody); - Assertions.assertTrue( - jsonEquals(expectedResponseNode, actualResponseNode), - "Response body structure does not match expected"); - if (actualResponseNode.has("type") || actualResponseNode.has("_type") || actualResponseNode.has("kind")) { + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = "" + "{}"; + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { String discriminator = null; - if (actualResponseNode.has("type")) - discriminator = actualResponseNode.get("type").asText(); - else if (actualResponseNode.has("_type")) - discriminator = actualResponseNode.get("_type").asText(); - else if (actualResponseNode.has("kind")) - discriminator = actualResponseNode.get("kind").asText(); + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); } - if (!actualResponseNode.isNull()) { + if (!actualJson.isNull()) { Assertions.assertTrue( - actualResponseNode.isObject() || actualResponseNode.isArray() || actualResponseNode.isValueNode(), - "response should be a valid JSON value"); + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); } - if (actualResponseNode.isArray()) { - Assertions.assertTrue(actualResponseNode.size() >= 0, "Array should have valid size"); + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); } - if (actualResponseNode.isObject()) { - Assertions.assertTrue(actualResponseNode.size() >= 0, "Object should have valid field count"); + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); } + + // Validate error response + Assertions.assertEquals(400, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); } @Test - public void testCancel() throws Exception { + public void testCreateThrowsUnauthorizedError() throws Exception { // OAuth: enqueue token response (client fetches token before API call) server.enqueue(new MockResponse() .setResponseCode(200) .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); - server.enqueue( - new MockResponse() - .setResponseCode(200) - .setBody( - "{\"job_id\":\"l2f_batch_6f1d2c3a-8b4e-4f5a-9c7d-0e1f2a3b4c5d\",\"request_id\":\"submit-2025-09-02-batch-001\",\"status\":\"pending\",\"finalized\":false,\"total_items\":12,\"error\":{\"kind\":\"processing_failed\",\"message\":\"the item could not be converted\"},\"created_at\":\"2024-01-15T09:30:00Z\",\"updated_at\":\"2024-01-15T09:30:00Z\",\"completed_at\":\"2024-01-15T09:30:00Z\",\"expires_at\":\"2024-01-15T09:30:00Z\"}")); - BatchJob response = client.lang2FhirBatch().cancel("job_id"); + server.enqueue(new MockResponse().setResponseCode(401).setBody("{\"key\":\"value\"}")); + UnauthorizedError exception = Assertions.assertThrows(UnauthorizedError.class, () -> client.lang2FhirBatch() + .create(CreateBatchRequest.builder().build())); // OAuth: consume the token request server.takeRequest(); RecordedRequest request = server.takeRequest(); @@ -323,87 +464,791 @@ public void testCancel() throws Exception { "Bearer test-token", request.getHeader("Authorization"), "OAuth Authorization header should contain Bearer token from OAuth flow"); - - // Validate response body - Assertions.assertNotNull(response, "Response should not be null"); - String actualResponseJson = objectMapper.writeValueAsString(response); - String expectedResponseBody = "" - + "{\n" - + " \"job_id\": \"l2f_batch_6f1d2c3a-8b4e-4f5a-9c7d-0e1f2a3b4c5d\",\n" - + " \"request_id\": \"submit-2025-09-02-batch-001\",\n" - + " \"status\": \"pending\",\n" - + " \"finalized\": false,\n" - + " \"total_items\": 12,\n" - + " \"error\": {\n" - + " \"kind\": \"processing_failed\",\n" - + " \"message\": \"the item could not be converted\"\n" - + " },\n" - + " \"created_at\": \"2024-01-15T09:30:00Z\",\n" - + " \"updated_at\": \"2024-01-15T09:30:00Z\",\n" - + " \"completed_at\": \"2024-01-15T09:30:00Z\",\n" - + " \"expires_at\": \"2024-01-15T09:30:00Z\"\n" - + "}"; - JsonNode actualResponseNode = objectMapper.readTree(actualResponseJson); - JsonNode expectedResponseNode = objectMapper.readTree(expectedResponseBody); - Assertions.assertTrue( - jsonEquals(expectedResponseNode, actualResponseNode), - "Response body structure does not match expected"); - if (actualResponseNode.has("type") || actualResponseNode.has("_type") || actualResponseNode.has("kind")) { + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = "" + "{}"; + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { String discriminator = null; - if (actualResponseNode.has("type")) - discriminator = actualResponseNode.get("type").asText(); - else if (actualResponseNode.has("_type")) - discriminator = actualResponseNode.get("_type").asText(); - else if (actualResponseNode.has("kind")) - discriminator = actualResponseNode.get("kind").asText(); + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); } - if (!actualResponseNode.isNull()) { + if (!actualJson.isNull()) { Assertions.assertTrue( - actualResponseNode.isObject() || actualResponseNode.isArray() || actualResponseNode.isValueNode(), - "response should be a valid JSON value"); + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); } - if (actualResponseNode.isArray()) { - Assertions.assertTrue(actualResponseNode.size() >= 0, "Array should have valid size"); + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); } - if (actualResponseNode.isObject()) { - Assertions.assertTrue(actualResponseNode.size() >= 0, "Object should have valid field count"); + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); } + + // Validate error response + Assertions.assertEquals(401, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); } @Test - public void testGet() throws Exception { + public void testCreateThrowsClientClosedRequestError() throws Exception { // OAuth: enqueue token response (client fetches token before API call) server.enqueue(new MockResponse() .setResponseCode(200) .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); - server.enqueue( - new MockResponse() - .setResponseCode(200) - .setBody( - "{\"job_id\":\"l2f_batch_6f1d2c3a-8b4e-4f5a-9c7d-0e1f2a3b4c5d\",\"request_id\":\"submit-2025-09-02-batch-001\",\"status\":\"pending\",\"finalized\":false,\"total_items\":12,\"error\":{\"kind\":\"processing_failed\",\"message\":\"the item could not be converted\"},\"created_at\":\"2024-01-15T09:30:00Z\",\"updated_at\":\"2024-01-15T09:30:00Z\",\"completed_at\":\"2024-01-15T09:30:00Z\",\"expires_at\":\"2024-01-15T09:30:00Z\",\"counts\":{\"total\":12,\"pending\":3,\"processing\":2,\"succeeded\":6,\"failed\":1},\"items\":[{\"item_id\":\"9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08\",\"id\":\"chart-note-0042\",\"status\":\"pending\",\"attempts\":1,\"detect_retries\":1,\"result_size\":20482,\"error\":{\"kind\":\"processing_failed\",\"message\":\"the item could not be converted\"},\"completed_at\":\"2024-01-15T09:30:00Z\"}],\"next_cursor\":\"next_cursor\",\"has_more\":false}")); - JobDetailResponse response = client.lang2FhirBatch() - .get("job_id", GetRequest.builder().cursor("cursor").limit(1).build()); + server.enqueue(new MockResponse().setResponseCode(499).setBody("{\"key\":\"value\"}")); + ClientClosedRequestError exception = + Assertions.assertThrows(ClientClosedRequestError.class, () -> client.lang2FhirBatch() + .create(CreateBatchRequest.builder().build())); // OAuth: consume the token request server.takeRequest(); RecordedRequest request = server.takeRequest(); Assertions.assertNotNull(request); - Assertions.assertEquals("GET", request.getMethod()); + Assertions.assertEquals("POST", request.getMethod()); // Validate OAuth Authorization header Assertions.assertEquals( "Bearer test-token", request.getHeader("Authorization"), "OAuth Authorization header should contain Bearer token from OAuth flow"); + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = "" + "{}"; + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { + String discriminator = null; + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } - // Validate response body - Assertions.assertNotNull(response, "Response should not be null"); - String actualResponseJson = objectMapper.writeValueAsString(response); - String expectedResponseBody = "" - + "{\n" + if (!actualJson.isNull()) { + Assertions.assertTrue( + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); + } + + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); + } + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); + } + + // Validate error response + Assertions.assertEquals(499, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testCreateThrowsInternalServerError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(500).setBody("{\"key\":\"value\"}")); + InternalServerError exception = Assertions.assertThrows(InternalServerError.class, () -> client.lang2FhirBatch() + .create(CreateBatchRequest.builder().build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("POST", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = "" + "{}"; + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { + String discriminator = null; + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualJson.isNull()) { + Assertions.assertTrue( + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); + } + + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); + } + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); + } + + // Validate error response + Assertions.assertEquals(500, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testCreateThrowsGatewayTimeoutError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(504).setBody("{\"key\":\"value\"}")); + GatewayTimeoutError exception = Assertions.assertThrows(GatewayTimeoutError.class, () -> client.lang2FhirBatch() + .create(CreateBatchRequest.builder().build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("POST", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = "" + "{}"; + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { + String discriminator = null; + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualJson.isNull()) { + Assertions.assertTrue( + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); + } + + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); + } + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); + } + + // Validate error response + Assertions.assertEquals(504, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testFinalize() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue( + new MockResponse() + .setResponseCode(202) + .setBody( + "{\"job_id\":\"l2f_batch_6f1d2c3a-8b4e-4f5a-9c7d-0e1f2a3b4c5d\",\"request_id\":\"submit-2025-09-02-batch-001\",\"status\":\"pending\",\"finalized\":false,\"total_items\":12,\"error\":{\"kind\":\"processing_failed\",\"message\":\"the item could not be converted\"},\"created_at\":\"2024-01-15T09:30:00Z\",\"updated_at\":\"2024-01-15T09:30:00Z\",\"completed_at\":\"2024-01-15T09:30:00Z\",\"expires_at\":\"2024-01-15T09:30:00Z\"}")); + BatchJob response = client.lang2FhirBatch().finalize("job_id"); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("POST", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate response body + Assertions.assertNotNull(response, "Response should not be null"); + String actualResponseJson = objectMapper.writeValueAsString(response); + String expectedResponseBody = "" + + "{\n" + + " \"job_id\": \"l2f_batch_6f1d2c3a-8b4e-4f5a-9c7d-0e1f2a3b4c5d\",\n" + + " \"request_id\": \"submit-2025-09-02-batch-001\",\n" + + " \"status\": \"pending\",\n" + + " \"finalized\": false,\n" + + " \"total_items\": 12,\n" + + " \"error\": {\n" + + " \"kind\": \"processing_failed\",\n" + + " \"message\": \"the item could not be converted\"\n" + + " },\n" + + " \"created_at\": \"2024-01-15T09:30:00Z\",\n" + + " \"updated_at\": \"2024-01-15T09:30:00Z\",\n" + + " \"completed_at\": \"2024-01-15T09:30:00Z\",\n" + + " \"expires_at\": \"2024-01-15T09:30:00Z\"\n" + + "}"; + JsonNode actualResponseNode = objectMapper.readTree(actualResponseJson); + JsonNode expectedResponseNode = objectMapper.readTree(expectedResponseBody); + Assertions.assertTrue( + jsonEquals(expectedResponseNode, actualResponseNode), + "Response body structure does not match expected"); + if (actualResponseNode.has("type") || actualResponseNode.has("_type") || actualResponseNode.has("kind")) { + String discriminator = null; + if (actualResponseNode.has("type")) + discriminator = actualResponseNode.get("type").asText(); + else if (actualResponseNode.has("_type")) + discriminator = actualResponseNode.get("_type").asText(); + else if (actualResponseNode.has("kind")) + discriminator = actualResponseNode.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualResponseNode.isNull()) { + Assertions.assertTrue( + actualResponseNode.isObject() || actualResponseNode.isArray() || actualResponseNode.isValueNode(), + "response should be a valid JSON value"); + } + + if (actualResponseNode.isArray()) { + Assertions.assertTrue(actualResponseNode.size() >= 0, "Array should have valid size"); + } + if (actualResponseNode.isObject()) { + Assertions.assertTrue(actualResponseNode.size() >= 0, "Object should have valid field count"); + } + } + + @Test + public void testFinalizeThrowsBadRequestError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(400).setBody("{\"key\":\"value\"}")); + BadRequestError exception = Assertions.assertThrows( + BadRequestError.class, () -> client.lang2FhirBatch().finalize("job_id")); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("POST", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate error response + Assertions.assertEquals(400, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testFinalizeThrowsUnauthorizedError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(401).setBody("{\"key\":\"value\"}")); + UnauthorizedError exception = Assertions.assertThrows( + UnauthorizedError.class, () -> client.lang2FhirBatch().finalize("job_id")); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("POST", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate error response + Assertions.assertEquals(401, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testFinalizeThrowsNotFoundError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(404).setBody("{\"key\":\"value\"}")); + NotFoundError exception = Assertions.assertThrows( + NotFoundError.class, () -> client.lang2FhirBatch().finalize("job_id")); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("POST", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate error response + Assertions.assertEquals(404, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testFinalizeThrowsConflictError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(409).setBody("{\"key\":\"value\"}")); + ConflictError exception = Assertions.assertThrows( + ConflictError.class, () -> client.lang2FhirBatch().finalize("job_id")); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("POST", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate error response + Assertions.assertEquals(409, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testFinalizeThrowsClientClosedRequestError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(499).setBody("{\"key\":\"value\"}")); + ClientClosedRequestError exception = Assertions.assertThrows( + ClientClosedRequestError.class, () -> client.lang2FhirBatch().finalize("job_id")); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("POST", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate error response + Assertions.assertEquals(499, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testFinalizeThrowsInternalServerError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(500).setBody("{\"key\":\"value\"}")); + InternalServerError exception = Assertions.assertThrows( + InternalServerError.class, () -> client.lang2FhirBatch().finalize("job_id")); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("POST", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate error response + Assertions.assertEquals(500, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testFinalizeThrowsGatewayTimeoutError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(504).setBody("{\"key\":\"value\"}")); + GatewayTimeoutError exception = Assertions.assertThrows( + GatewayTimeoutError.class, () -> client.lang2FhirBatch().finalize("job_id")); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("POST", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate error response + Assertions.assertEquals(504, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testCancel() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue( + new MockResponse() + .setResponseCode(200) + .setBody( + "{\"job_id\":\"l2f_batch_6f1d2c3a-8b4e-4f5a-9c7d-0e1f2a3b4c5d\",\"request_id\":\"submit-2025-09-02-batch-001\",\"status\":\"pending\",\"finalized\":false,\"total_items\":12,\"error\":{\"kind\":\"processing_failed\",\"message\":\"the item could not be converted\"},\"created_at\":\"2024-01-15T09:30:00Z\",\"updated_at\":\"2024-01-15T09:30:00Z\",\"completed_at\":\"2024-01-15T09:30:00Z\",\"expires_at\":\"2024-01-15T09:30:00Z\"}")); + BatchJob response = client.lang2FhirBatch().cancel("job_id"); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("POST", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate response body + Assertions.assertNotNull(response, "Response should not be null"); + String actualResponseJson = objectMapper.writeValueAsString(response); + String expectedResponseBody = "" + + "{\n" + + " \"job_id\": \"l2f_batch_6f1d2c3a-8b4e-4f5a-9c7d-0e1f2a3b4c5d\",\n" + + " \"request_id\": \"submit-2025-09-02-batch-001\",\n" + + " \"status\": \"pending\",\n" + + " \"finalized\": false,\n" + + " \"total_items\": 12,\n" + + " \"error\": {\n" + + " \"kind\": \"processing_failed\",\n" + + " \"message\": \"the item could not be converted\"\n" + + " },\n" + + " \"created_at\": \"2024-01-15T09:30:00Z\",\n" + + " \"updated_at\": \"2024-01-15T09:30:00Z\",\n" + + " \"completed_at\": \"2024-01-15T09:30:00Z\",\n" + + " \"expires_at\": \"2024-01-15T09:30:00Z\"\n" + + "}"; + JsonNode actualResponseNode = objectMapper.readTree(actualResponseJson); + JsonNode expectedResponseNode = objectMapper.readTree(expectedResponseBody); + Assertions.assertTrue( + jsonEquals(expectedResponseNode, actualResponseNode), + "Response body structure does not match expected"); + if (actualResponseNode.has("type") || actualResponseNode.has("_type") || actualResponseNode.has("kind")) { + String discriminator = null; + if (actualResponseNode.has("type")) + discriminator = actualResponseNode.get("type").asText(); + else if (actualResponseNode.has("_type")) + discriminator = actualResponseNode.get("_type").asText(); + else if (actualResponseNode.has("kind")) + discriminator = actualResponseNode.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualResponseNode.isNull()) { + Assertions.assertTrue( + actualResponseNode.isObject() || actualResponseNode.isArray() || actualResponseNode.isValueNode(), + "response should be a valid JSON value"); + } + + if (actualResponseNode.isArray()) { + Assertions.assertTrue(actualResponseNode.size() >= 0, "Array should have valid size"); + } + if (actualResponseNode.isObject()) { + Assertions.assertTrue(actualResponseNode.size() >= 0, "Object should have valid field count"); + } + } + + @Test + public void testCancelThrowsUnauthorizedError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(401).setBody("{\"key\":\"value\"}")); + UnauthorizedError exception = Assertions.assertThrows( + UnauthorizedError.class, () -> client.lang2FhirBatch().cancel("job_id")); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("POST", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate error response + Assertions.assertEquals(401, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testCancelThrowsNotFoundError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(404).setBody("{\"key\":\"value\"}")); + NotFoundError exception = Assertions.assertThrows( + NotFoundError.class, () -> client.lang2FhirBatch().cancel("job_id")); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("POST", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate error response + Assertions.assertEquals(404, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testCancelThrowsConflictError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(409).setBody("{\"key\":\"value\"}")); + ConflictError exception = Assertions.assertThrows( + ConflictError.class, () -> client.lang2FhirBatch().cancel("job_id")); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("POST", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate error response + Assertions.assertEquals(409, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testCancelThrowsClientClosedRequestError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(499).setBody("{\"key\":\"value\"}")); + ClientClosedRequestError exception = Assertions.assertThrows( + ClientClosedRequestError.class, () -> client.lang2FhirBatch().cancel("job_id")); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("POST", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate error response + Assertions.assertEquals(499, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testCancelThrowsInternalServerError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(500).setBody("{\"key\":\"value\"}")); + InternalServerError exception = Assertions.assertThrows( + InternalServerError.class, () -> client.lang2FhirBatch().cancel("job_id")); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("POST", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate error response + Assertions.assertEquals(500, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testCancelThrowsGatewayTimeoutError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(504).setBody("{\"key\":\"value\"}")); + GatewayTimeoutError exception = Assertions.assertThrows( + GatewayTimeoutError.class, () -> client.lang2FhirBatch().cancel("job_id")); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("POST", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate error response + Assertions.assertEquals(504, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testGet() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue( + new MockResponse() + .setResponseCode(200) + .setBody( + "{\"job_id\":\"l2f_batch_6f1d2c3a-8b4e-4f5a-9c7d-0e1f2a3b4c5d\",\"request_id\":\"submit-2025-09-02-batch-001\",\"status\":\"pending\",\"finalized\":false,\"total_items\":12,\"error\":{\"kind\":\"processing_failed\",\"message\":\"the item could not be converted\"},\"created_at\":\"2024-01-15T09:30:00Z\",\"updated_at\":\"2024-01-15T09:30:00Z\",\"completed_at\":\"2024-01-15T09:30:00Z\",\"expires_at\":\"2024-01-15T09:30:00Z\",\"counts\":{\"total\":12,\"pending\":3,\"processing\":2,\"succeeded\":6,\"failed\":1},\"items\":[{\"item_id\":\"9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08\",\"id\":\"chart-note-0042\",\"status\":\"pending\",\"attempts\":1,\"detect_retries\":1,\"result_size\":20482,\"error\":{\"kind\":\"processing_failed\",\"message\":\"the item could not be converted\"},\"completed_at\":\"2024-01-15T09:30:00Z\"}],\"next_cursor\":\"next_cursor\",\"has_more\":false}")); + JobDetailResponse response = client.lang2FhirBatch() + .get("job_id", GetRequest.builder().cursor("cursor").limit(1).build()); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("GET", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate response body + Assertions.assertNotNull(response, "Response should not be null"); + String actualResponseJson = objectMapper.writeValueAsString(response); + String expectedResponseBody = "" + + "{\n" + " \"job_id\": \"l2f_batch_6f1d2c3a-8b4e-4f5a-9c7d-0e1f2a3b4c5d\",\n" + " \"request_id\": \"submit-2025-09-02-batch-001\",\n" + " \"status\": \"pending\",\n" @@ -465,12 +1310,193 @@ else if (actualResponseNode.has("kind")) "response should be a valid JSON value"); } - if (actualResponseNode.isArray()) { - Assertions.assertTrue(actualResponseNode.size() >= 0, "Array should have valid size"); - } - if (actualResponseNode.isObject()) { - Assertions.assertTrue(actualResponseNode.size() >= 0, "Object should have valid field count"); - } + if (actualResponseNode.isArray()) { + Assertions.assertTrue(actualResponseNode.size() >= 0, "Array should have valid size"); + } + if (actualResponseNode.isObject()) { + Assertions.assertTrue(actualResponseNode.size() >= 0, "Object should have valid field count"); + } + } + + @Test + public void testGetThrowsBadRequestError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(400).setBody("{\"key\":\"value\"}")); + BadRequestError exception = Assertions.assertThrows(BadRequestError.class, () -> client.lang2FhirBatch() + .get("job_id", GetRequest.builder().build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("GET", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate error response + Assertions.assertEquals(400, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testGetThrowsUnauthorizedError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(401).setBody("{\"key\":\"value\"}")); + UnauthorizedError exception = Assertions.assertThrows(UnauthorizedError.class, () -> client.lang2FhirBatch() + .get("job_id", GetRequest.builder().build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("GET", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate error response + Assertions.assertEquals(401, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testGetThrowsNotFoundError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(404).setBody("{\"key\":\"value\"}")); + NotFoundError exception = Assertions.assertThrows(NotFoundError.class, () -> client.lang2FhirBatch() + .get("job_id", GetRequest.builder().build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("GET", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate error response + Assertions.assertEquals(404, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testGetThrowsClientClosedRequestError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(499).setBody("{\"key\":\"value\"}")); + ClientClosedRequestError exception = + Assertions.assertThrows(ClientClosedRequestError.class, () -> client.lang2FhirBatch() + .get("job_id", GetRequest.builder().build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("GET", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate error response + Assertions.assertEquals(499, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testGetThrowsInternalServerError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(500).setBody("{\"key\":\"value\"}")); + InternalServerError exception = Assertions.assertThrows(InternalServerError.class, () -> client.lang2FhirBatch() + .get("job_id", GetRequest.builder().build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("GET", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate error response + Assertions.assertEquals(500, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testGetThrowsGatewayTimeoutError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(504).setBody("{\"key\":\"value\"}")); + GatewayTimeoutError exception = Assertions.assertThrows(GatewayTimeoutError.class, () -> client.lang2FhirBatch() + .get("job_id", GetRequest.builder().build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("GET", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate error response + Assertions.assertEquals(504, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); } @Test @@ -554,6 +1580,187 @@ else if (actualResponseNode.has("kind")) } } + @Test + public void testGetResultsThrowsBadRequestError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(400).setBody("{\"key\":\"value\"}")); + BadRequestError exception = Assertions.assertThrows(BadRequestError.class, () -> client.lang2FhirBatch() + .getResults("job_id", GetResultsRequest.builder().build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("GET", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate error response + Assertions.assertEquals(400, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testGetResultsThrowsUnauthorizedError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(401).setBody("{\"key\":\"value\"}")); + UnauthorizedError exception = Assertions.assertThrows(UnauthorizedError.class, () -> client.lang2FhirBatch() + .getResults("job_id", GetResultsRequest.builder().build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("GET", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate error response + Assertions.assertEquals(401, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testGetResultsThrowsNotFoundError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(404).setBody("{\"key\":\"value\"}")); + NotFoundError exception = Assertions.assertThrows(NotFoundError.class, () -> client.lang2FhirBatch() + .getResults("job_id", GetResultsRequest.builder().build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("GET", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate error response + Assertions.assertEquals(404, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testGetResultsThrowsClientClosedRequestError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(499).setBody("{\"key\":\"value\"}")); + ClientClosedRequestError exception = + Assertions.assertThrows(ClientClosedRequestError.class, () -> client.lang2FhirBatch() + .getResults("job_id", GetResultsRequest.builder().build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("GET", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate error response + Assertions.assertEquals(499, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testGetResultsThrowsInternalServerError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(500).setBody("{\"key\":\"value\"}")); + InternalServerError exception = Assertions.assertThrows(InternalServerError.class, () -> client.lang2FhirBatch() + .getResults("job_id", GetResultsRequest.builder().build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("GET", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate error response + Assertions.assertEquals(500, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testGetResultsThrowsGatewayTimeoutError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(504).setBody("{\"key\":\"value\"}")); + GatewayTimeoutError exception = Assertions.assertThrows(GatewayTimeoutError.class, () -> client.lang2FhirBatch() + .getResults("job_id", GetResultsRequest.builder().build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("GET", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate error response + Assertions.assertEquals(504, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + @Test public void testGetResult() throws Exception { // OAuth: enqueue token response (client fetches token before API call) @@ -609,6 +1816,216 @@ else if (actualResponseNode.has("kind")) } } + @Test + public void testGetResultThrowsBadRequestError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(400).setBody("{\"key\":\"value\"}")); + BadRequestError exception = Assertions.assertThrows( + BadRequestError.class, () -> client.lang2FhirBatch().getResult("job_id", "item_id")); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("GET", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate error response + Assertions.assertEquals(400, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testGetResultThrowsUnauthorizedError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(401).setBody("{\"key\":\"value\"}")); + UnauthorizedError exception = Assertions.assertThrows( + UnauthorizedError.class, () -> client.lang2FhirBatch().getResult("job_id", "item_id")); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("GET", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate error response + Assertions.assertEquals(401, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testGetResultThrowsNotFoundError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(404).setBody("{\"key\":\"value\"}")); + NotFoundError exception = Assertions.assertThrows( + NotFoundError.class, () -> client.lang2FhirBatch().getResult("job_id", "item_id")); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("GET", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate error response + Assertions.assertEquals(404, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testGetResultThrowsConflictError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(409).setBody("{\"key\":\"value\"}")); + ConflictError exception = Assertions.assertThrows( + ConflictError.class, () -> client.lang2FhirBatch().getResult("job_id", "item_id")); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("GET", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate error response + Assertions.assertEquals(409, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testGetResultThrowsClientClosedRequestError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(499).setBody("{\"key\":\"value\"}")); + ClientClosedRequestError exception = Assertions.assertThrows( + ClientClosedRequestError.class, () -> client.lang2FhirBatch().getResult("job_id", "item_id")); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("GET", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate error response + Assertions.assertEquals(499, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testGetResultThrowsInternalServerError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(500).setBody("{\"key\":\"value\"}")); + InternalServerError exception = Assertions.assertThrows( + InternalServerError.class, () -> client.lang2FhirBatch().getResult("job_id", "item_id")); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("GET", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate error response + Assertions.assertEquals(500, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testGetResultThrowsGatewayTimeoutError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(504).setBody("{\"key\":\"value\"}")); + GatewayTimeoutError exception = Assertions.assertThrows( + GatewayTimeoutError.class, () -> client.lang2FhirBatch().getResult("job_id", "item_id")); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("GET", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate error response + Assertions.assertEquals(504, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + /** * Compares two JsonNodes with numeric equivalence and null safety. * For objects, checks that all fields in 'expected' exist in 'actual' with matching values. diff --git a/src/test/java/com/phenoml/api/Lang2FhirWireTest.java b/src/test/java/com/phenoml/api/Lang2FhirWireTest.java index 647398d5..70e26323 100644 --- a/src/test/java/com/phenoml/api/Lang2FhirWireTest.java +++ b/src/test/java/com/phenoml/api/Lang2FhirWireTest.java @@ -3,6 +3,14 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.phenoml.api.core.ObjectMappers; +import com.phenoml.api.resources.lang2fhir.errors.BadRequestError; +import com.phenoml.api.resources.lang2fhir.errors.ClientClosedRequestError; +import com.phenoml.api.resources.lang2fhir.errors.ForbiddenError; +import com.phenoml.api.resources.lang2fhir.errors.GatewayTimeoutError; +import com.phenoml.api.resources.lang2fhir.errors.InternalServerError; +import com.phenoml.api.resources.lang2fhir.errors.NotFoundError; +import com.phenoml.api.resources.lang2fhir.errors.UnauthorizedError; +import com.phenoml.api.resources.lang2fhir.errors.UnprocessableEntityError; import com.phenoml.api.resources.lang2fhir.requests.CreateMultiRequest; import com.phenoml.api.resources.lang2fhir.requests.CreateRequest; import com.phenoml.api.resources.lang2fhir.requests.DocumentMultiRequest; @@ -39,6 +47,7 @@ public void setup() throws Exception { server.start(); client = PhenomlClient.withCredentials("test-client-id", "test-client-secret") .url(server.url("/").toString()) + .maxRetries(0) .build(); } @@ -176,19 +185,21 @@ else if (actualResponseNode.has("kind")) } @Test - public void testCreateMulti() throws Exception { + public void testCreateMedicationRequest() throws Exception { // OAuth: enqueue token response (client fetches token before API call) server.enqueue(new MockResponse() .setResponseCode(200) .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); - server.enqueue(new MockResponse() - .setResponseCode(200) - .setBody(TestResources.loadResource("/wire-tests/Lang2FhirWireTest_testCreateMulti_response.json"))); - CreateMultiResponse response = client.lang2Fhir() - .createMulti(CreateMultiRequest.builder() - .text( - "John Smith, 45-year-old male, diagnosed with Type 2 Diabetes. Prescribed Metformin 500mg twice daily. Blood pressure 140/90.") + server.enqueue( + new MockResponse() + .setResponseCode(200) + .setBody( + "{\"resourceType\":\"MedicationRequest\",\"status\":\"active\",\"intent\":\"order\",\"medicationCodeableConcept\":{\"coding\":[{\"system\":\"http://www.nlm.nih.gov/research/umls/rxnorm\",\"code\":\"308182\",\"display\":\"Amoxicillin 500 MG Oral Capsule\"}]},\"dosageInstruction\":[{\"text\":\"Take one capsule three times daily\",\"timing\":{\"repeat\":{\"frequency\":3,\"period\":1,\"periodUnit\":\"d\"}},\"doseAndRate\":[{\"doseQuantity\":{\"value\":500,\"unit\":\"mg\"}}]}],\"dispenseRequest\":{\"expectedSupplyDuration\":{\"value\":10,\"unit\":\"days\"}}}")); + Map response = client.lang2Fhir() + .create(CreateRequest.builder() .version("R4") + .resource(CreateRequestResource.MEDICATIONREQUEST) + .text("Prescribe Amoxicillin 500mg capsules, take one capsule three times daily for 10 days") .build()); // OAuth: consume the token request server.takeRequest(); @@ -205,8 +216,9 @@ public void testCreateMulti() throws Exception { String actualRequestBody = request.getBody().readUtf8(); String expectedRequestBody = "" + "{\n" - + " \"text\": \"John Smith, 45-year-old male, diagnosed with Type 2 Diabetes. Prescribed Metformin 500mg twice daily. Blood pressure 140/90.\",\n" - + " \"version\": \"R4\"\n" + + " \"version\": \"R4\",\n" + + " \"resource\": \"medicationrequest\",\n" + + " \"text\": \"Prescribe Amoxicillin 500mg capsules, take one capsule three times daily for 10 days\"\n" + "}"; JsonNode actualJson = objectMapper.readTree(actualRequestBody); JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); @@ -238,8 +250,47 @@ else if (actualJson.has("kind")) // Validate response body Assertions.assertNotNull(response, "Response should not be null"); String actualResponseJson = objectMapper.writeValueAsString(response); - String expectedResponseBody = - TestResources.loadResource("/wire-tests/Lang2FhirWireTest_testCreateMulti_response.json"); + String expectedResponseBody = "" + + "{\n" + + " \"resourceType\": \"MedicationRequest\",\n" + + " \"status\": \"active\",\n" + + " \"intent\": \"order\",\n" + + " \"medicationCodeableConcept\": {\n" + + " \"coding\": [\n" + + " {\n" + + " \"system\": \"http://www.nlm.nih.gov/research/umls/rxnorm\",\n" + + " \"code\": \"308182\",\n" + + " \"display\": \"Amoxicillin 500 MG Oral Capsule\"\n" + + " }\n" + + " ]\n" + + " },\n" + + " \"dosageInstruction\": [\n" + + " {\n" + + " \"text\": \"Take one capsule three times daily\",\n" + + " \"timing\": {\n" + + " \"repeat\": {\n" + + " \"frequency\": 3,\n" + + " \"period\": 1,\n" + + " \"periodUnit\": \"d\"\n" + + " }\n" + + " },\n" + + " \"doseAndRate\": [\n" + + " {\n" + + " \"doseQuantity\": {\n" + + " \"value\": 500,\n" + + " \"unit\": \"mg\"\n" + + " }\n" + + " }\n" + + " ]\n" + + " }\n" + + " ],\n" + + " \"dispenseRequest\": {\n" + + " \"expectedSupplyDuration\": {\n" + + " \"value\": 10,\n" + + " \"unit\": \"days\"\n" + + " }\n" + + " }\n" + + "}"; JsonNode actualResponseNode = objectMapper.readTree(actualResponseJson); JsonNode expectedResponseNode = objectMapper.readTree(expectedResponseBody); Assertions.assertTrue( @@ -272,20 +323,18 @@ else if (actualResponseNode.has("kind")) } @Test - public void testSearch() throws Exception { + public void testCreateThrowsBadRequestError() throws Exception { // OAuth: enqueue token response (client fetches token before API call) server.enqueue(new MockResponse() .setResponseCode(200) .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); - server.enqueue( - new MockResponse() - .setResponseCode(200) - .setBody( - "{\"resource_type\":\"Appointment\",\"search_params\":\"date=ge2025-03-02&date=le2025-03-09\"}")); - SearchResponse response = client.lang2Fhir() - .search(SearchRequest.builder() - .text("Appointments between March 2-9, 2025") - .build()); + server.enqueue(new MockResponse().setResponseCode(400).setBody("{\"key\":\"value\"}")); + BadRequestError exception = Assertions.assertThrows(BadRequestError.class, () -> client.lang2Fhir() + .create(CreateRequest.builder() + .version("R4") + .resource(CreateRequestResource.AUTO) + .text("text") + .build())); // OAuth: consume the token request server.takeRequest(); RecordedRequest request = server.takeRequest(); @@ -299,7 +348,12 @@ public void testSearch() throws Exception { "OAuth Authorization header should contain Bearer token from OAuth flow"); // Validate request body String actualRequestBody = request.getBody().readUtf8(); - String expectedRequestBody = "" + "{\n" + " \"text\": \"Appointments between March 2-9, 2025\"\n" + "}"; + String expectedRequestBody = "" + + "{\n" + + " \"version\": \"R4\",\n" + + " \"resource\": \"auto\",\n" + + " \"text\": \"text\"\n" + + "}"; JsonNode actualJson = objectMapper.readTree(actualRequestBody); JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); @@ -327,64 +381,28 @@ else if (actualJson.has("kind")) Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); } - // Validate response body - Assertions.assertNotNull(response, "Response should not be null"); - String actualResponseJson = objectMapper.writeValueAsString(response); - String expectedResponseBody = "" - + "{\n" - + " \"resource_type\": \"Appointment\",\n" - + " \"search_params\": \"date=ge2025-03-02&date=le2025-03-09\"\n" - + "}"; - JsonNode actualResponseNode = objectMapper.readTree(actualResponseJson); - JsonNode expectedResponseNode = objectMapper.readTree(expectedResponseBody); - Assertions.assertTrue( - jsonEquals(expectedResponseNode, actualResponseNode), - "Response body structure does not match expected"); - if (actualResponseNode.has("type") || actualResponseNode.has("_type") || actualResponseNode.has("kind")) { - String discriminator = null; - if (actualResponseNode.has("type")) - discriminator = actualResponseNode.get("type").asText(); - else if (actualResponseNode.has("_type")) - discriminator = actualResponseNode.get("_type").asText(); - else if (actualResponseNode.has("kind")) - discriminator = actualResponseNode.get("kind").asText(); - Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); - Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); - } - - if (!actualResponseNode.isNull()) { - Assertions.assertTrue( - actualResponseNode.isObject() || actualResponseNode.isArray() || actualResponseNode.isValueNode(), - "response should be a valid JSON value"); - } - - if (actualResponseNode.isArray()) { - Assertions.assertTrue(actualResponseNode.size() >= 0, "Array should have valid size"); - } - if (actualResponseNode.isObject()) { - Assertions.assertTrue(actualResponseNode.size() >= 0, "Object should have valid field count"); - } + // Validate error response + Assertions.assertEquals(400, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); } @Test - public void testUploadProfile() throws Exception { + public void testCreateThrowsUnauthorizedError() throws Exception { // OAuth: enqueue token response (client fetches token before API call) server.enqueue(new MockResponse() .setResponseCode(200) .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); - server.enqueue( - new MockResponse() - .setResponseCode(200) - .setBody( - "{\"message\":\"Profile uploaded successfully\",\"id\":\"custom-patient\",\"type\":\"Patient\",\"url\":\"http://phenoml.com/fhir/StructureDefinition/custom-patient\"}")); - UploadProfileResponse response = client.lang2Fhir() - .uploadProfile(ProfileUploadRequest.builder() - .profile( - "eyJyZXNvdXJjZVR5cGUiOiJTdHJ1Y3R1cmVEZWZpbml0aW9uIiwiaWQiOiJjdXN0b20tcGF0aWVudCIsInVybCI6Imh0dHA6Ly9waGVub21sLmNvbS9maGlyL1N0cnVjdHVyZURlZmluaXRpb24vY3VzdG9tLXBhdGllbnQiLCJuYW1lIjoiQ3VzdG9tUGF0aWVudCIsInN0YXR1cyI6ImFjdGl2ZSIsImZoaXJWZXJzaW9uIjoiNC4wLjEiLCJraW5kIjoicmVzb3VyY2UiLCJhYnN0cmFjdCI6ZmFsc2UsInR5cGUiOiJQYXRpZW50IiwiYmFzZURlZmluaXRpb24iOiJodHRwOi8vaGw3Lm9yZy9maGlyL1N0cnVjdHVyZURlZmluaXRpb24vUGF0aWVudCIsImRlcml2YXRpb24iOiJjb25zdHJhaW50Iiwic25hcHNob3QiOnsiZWxlbWVudCI6W3siaWQiOiJQYXRpZW50IiwicGF0aCI6IlBhdGllbnQiLCJtaW4iOjAsIm1heCI6IioifSx7ImlkIjoiUGF0aWVudC5uYW1lIiwicGF0aCI6IlBhdGllbnQubmFtZSIsIm1pbiI6MSwibWF4IjoiKiJ9XX19Cg==") - .implementationGuide("acme-cardiology") - .profileContext( - "When clinical text describes cardiology-specific findings, prefer this profile over the generic US Core Condition.") - .build()); + server.enqueue(new MockResponse().setResponseCode(401).setBody("{\"key\":\"value\"}")); + UnauthorizedError exception = Assertions.assertThrows(UnauthorizedError.class, () -> client.lang2Fhir() + .create(CreateRequest.builder() + .version("R4") + .resource(CreateRequestResource.AUTO) + .text("text") + .build())); // OAuth: consume the token request server.takeRequest(); RecordedRequest request = server.takeRequest(); @@ -400,9 +418,9 @@ public void testUploadProfile() throws Exception { String actualRequestBody = request.getBody().readUtf8(); String expectedRequestBody = "" + "{\n" - + " \"profile\": \"eyJyZXNvdXJjZVR5cGUiOiJTdHJ1Y3R1cmVEZWZpbml0aW9uIiwiaWQiOiJjdXN0b20tcGF0aWVudCIsInVybCI6Imh0dHA6Ly9waGVub21sLmNvbS9maGlyL1N0cnVjdHVyZURlZmluaXRpb24vY3VzdG9tLXBhdGllbnQiLCJuYW1lIjoiQ3VzdG9tUGF0aWVudCIsInN0YXR1cyI6ImFjdGl2ZSIsImZoaXJWZXJzaW9uIjoiNC4wLjEiLCJraW5kIjoicmVzb3VyY2UiLCJhYnN0cmFjdCI6ZmFsc2UsInR5cGUiOiJQYXRpZW50IiwiYmFzZURlZmluaXRpb24iOiJodHRwOi8vaGw3Lm9yZy9maGlyL1N0cnVjdHVyZURlZmluaXRpb24vUGF0aWVudCIsImRlcml2YXRpb24iOiJjb25zdHJhaW50Iiwic25hcHNob3QiOnsiZWxlbWVudCI6W3siaWQiOiJQYXRpZW50IiwicGF0aCI6IlBhdGllbnQiLCJtaW4iOjAsIm1heCI6IioifSx7ImlkIjoiUGF0aWVudC5uYW1lIiwicGF0aCI6IlBhdGllbnQubmFtZSIsIm1pbiI6MSwibWF4IjoiKiJ9XX19Cg==\",\n" - + " \"implementation_guide\": \"acme-cardiology\",\n" - + " \"profile_context\": \"When clinical text describes cardiology-specific findings, prefer this profile over the generic US Core Condition.\"\n" + + " \"version\": \"R4\",\n" + + " \"resource\": \"auto\",\n" + + " \"text\": \"text\"\n" + "}"; JsonNode actualJson = objectMapper.readTree(actualRequestBody); JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); @@ -431,64 +449,28 @@ else if (actualJson.has("kind")) Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); } - // Validate response body - Assertions.assertNotNull(response, "Response should not be null"); - String actualResponseJson = objectMapper.writeValueAsString(response); - String expectedResponseBody = "" - + "{\n" - + " \"message\": \"Profile uploaded successfully\",\n" - + " \"id\": \"custom-patient\",\n" - + " \"type\": \"Patient\",\n" - + " \"url\": \"http://phenoml.com/fhir/StructureDefinition/custom-patient\"\n" - + "}"; - JsonNode actualResponseNode = objectMapper.readTree(actualResponseJson); - JsonNode expectedResponseNode = objectMapper.readTree(expectedResponseBody); - Assertions.assertTrue( - jsonEquals(expectedResponseNode, actualResponseNode), - "Response body structure does not match expected"); - if (actualResponseNode.has("type") || actualResponseNode.has("_type") || actualResponseNode.has("kind")) { - String discriminator = null; - if (actualResponseNode.has("type")) - discriminator = actualResponseNode.get("type").asText(); - else if (actualResponseNode.has("_type")) - discriminator = actualResponseNode.get("_type").asText(); - else if (actualResponseNode.has("kind")) - discriminator = actualResponseNode.get("kind").asText(); - Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); - Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); - } - - if (!actualResponseNode.isNull()) { - Assertions.assertTrue( - actualResponseNode.isObject() || actualResponseNode.isArray() || actualResponseNode.isValueNode(), - "response should be a valid JSON value"); - } - - if (actualResponseNode.isArray()) { - Assertions.assertTrue(actualResponseNode.size() >= 0, "Array should have valid size"); - } - if (actualResponseNode.isObject()) { - Assertions.assertTrue(actualResponseNode.size() >= 0, "Object should have valid field count"); - } + // Validate error response + Assertions.assertEquals(401, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); } @Test - public void testDocument() throws Exception { + public void testCreateThrowsNotFoundError() throws Exception { // OAuth: enqueue token response (client fetches token before API call) server.enqueue(new MockResponse() .setResponseCode(200) .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); - server.enqueue( - new MockResponse() - .setResponseCode(200) - .setBody( - "{\"resourceType\":\"Questionnaire\",\"status\":\"active\",\"title\":\"Patient Intake Form\",\"item\":[{\"linkId\":\"1\",\"text\":\"What is your name?\",\"type\":\"string\"},{\"linkId\":\"2\",\"text\":\"What is your date of birth?\",\"type\":\"date\"}]}")); - Map response = client.lang2Fhir() - .document(DocumentRequest.builder() + server.enqueue(new MockResponse().setResponseCode(404).setBody("{\"key\":\"value\"}")); + NotFoundError exception = Assertions.assertThrows(NotFoundError.class, () -> client.lang2Fhir() + .create(CreateRequest.builder() .version("R4") - .resource("questionnaire") - .content("JVBERi0xLjQKJeLjz9MK...(base64-encoded document bytes)") - .build()); + .resource(CreateRequestResource.AUTO) + .text("text") + .build())); // OAuth: consume the token request server.takeRequest(); RecordedRequest request = server.takeRequest(); @@ -505,8 +487,8 @@ public void testDocument() throws Exception { String expectedRequestBody = "" + "{\n" + " \"version\": \"R4\",\n" - + " \"resource\": \"questionnaire\",\n" - + " \"content\": \"JVBERi0xLjQKJeLjz9MK...(base64-encoded document bytes)\"\n" + + " \"resource\": \"auto\",\n" + + " \"text\": \"text\"\n" + "}"; JsonNode actualJson = objectMapper.readTree(actualRequestBody); JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); @@ -535,88 +517,29 @@ else if (actualJson.has("kind")) Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); } - // Validate response body - Assertions.assertNotNull(response, "Response should not be null"); - String actualResponseJson = objectMapper.writeValueAsString(response); - String expectedResponseBody = "" - + "{\n" - + " \"resourceType\": \"Questionnaire\",\n" - + " \"status\": \"active\",\n" - + " \"title\": \"Patient Intake Form\",\n" - + " \"item\": [\n" - + " {\n" - + " \"linkId\": \"1\",\n" - + " \"text\": \"What is your name?\",\n" - + " \"type\": \"string\"\n" - + " },\n" - + " {\n" - + " \"linkId\": \"2\",\n" - + " \"text\": \"What is your date of birth?\",\n" - + " \"type\": \"date\"\n" - + " }\n" - + " ]\n" - + "}"; - JsonNode actualResponseNode = objectMapper.readTree(actualResponseJson); - JsonNode expectedResponseNode = objectMapper.readTree(expectedResponseBody); - Assertions.assertTrue( - jsonEquals(expectedResponseNode, actualResponseNode), - "Response body structure does not match expected"); - if (actualResponseNode.has("type") || actualResponseNode.has("_type") || actualResponseNode.has("kind")) { - String discriminator = null; - if (actualResponseNode.has("type")) - discriminator = actualResponseNode.get("type").asText(); - else if (actualResponseNode.has("_type")) - discriminator = actualResponseNode.get("_type").asText(); - else if (actualResponseNode.has("kind")) - discriminator = actualResponseNode.get("kind").asText(); - Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); - Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); - } - - if (!actualResponseNode.isNull()) { - Assertions.assertTrue( - actualResponseNode.isObject() || actualResponseNode.isArray() || actualResponseNode.isValueNode(), - "response should be a valid JSON value"); - } - - if (actualResponseNode.isArray()) { - Assertions.assertTrue(actualResponseNode.size() >= 0, "Array should have valid size"); - } - if (actualResponseNode.isObject()) { - Assertions.assertTrue(actualResponseNode.size() >= 0, "Object should have valid field count"); - } + // Validate error response + Assertions.assertEquals(404, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); } @Test - public void testDocumentMulti() throws Exception { + public void testCreateThrowsUnprocessableEntityError() throws Exception { // OAuth: enqueue token response (client fetches token before API call) server.enqueue(new MockResponse() .setResponseCode(200) .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); - server.enqueue(new MockResponse() - .setResponseCode(200) - .setBody(TestResources.loadResource("/wire-tests/Lang2FhirWireTest_testDocumentMulti_response.json"))); - DocumentMultiResponse response = client.lang2Fhir() - .documentMulti(DocumentMultiRequest.builder() - .version("R4") - .content("JVBERi0xLjQKJeLjz9MK...(base64-encoded document bytes)") - .provider("medplum") - .config(DocumentConfig.builder() - .splitClassifications(Optional.of(Arrays.asList( - SplitClassification.builder() - .id("clinical") - .description( - "Clinical notes, diagnoses, medications, observations, and patient demographics.") - .operation(SplitClassificationOperation.GROUP) - .build(), - SplitClassification.builder() - .id("admin") - .description( - "Administrative boilerplate, insurance forms, and cover sheets.") - .operation(SplitClassificationOperation.DROP) - .build()))) - .build()) - .build()); + server.enqueue(new MockResponse().setResponseCode(422).setBody("{\"key\":\"value\"}")); + UnprocessableEntityError exception = + Assertions.assertThrows(UnprocessableEntityError.class, () -> client.lang2Fhir() + .create(CreateRequest.builder() + .version("R4") + .resource(CreateRequestResource.AUTO) + .text("text") + .build())); // OAuth: consume the token request server.takeRequest(); RecordedRequest request = server.takeRequest(); @@ -633,22 +556,8 @@ public void testDocumentMulti() throws Exception { String expectedRequestBody = "" + "{\n" + " \"version\": \"R4\",\n" - + " \"content\": \"JVBERi0xLjQKJeLjz9MK...(base64-encoded document bytes)\",\n" - + " \"provider\": \"medplum\",\n" - + " \"config\": {\n" - + " \"split_classifications\": [\n" - + " {\n" - + " \"id\": \"clinical\",\n" - + " \"description\": \"Clinical notes, diagnoses, medications, observations, and patient demographics.\",\n" - + " \"operation\": \"group\"\n" - + " },\n" - + " {\n" - + " \"id\": \"admin\",\n" - + " \"description\": \"Administrative boilerplate, insurance forms, and cover sheets.\",\n" - + " \"operation\": \"drop\"\n" - + " }\n" - + " ]\n" - + " }\n" + + " \"resource\": \"auto\",\n" + + " \"text\": \"text\"\n" + "}"; JsonNode actualJson = objectMapper.readTree(actualRequestBody); JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); @@ -677,11 +586,148 @@ else if (actualJson.has("kind")) Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); } - // Validate response body - Assertions.assertNotNull(response, "Response should not be null"); - String actualResponseJson = objectMapper.writeValueAsString(response); - String expectedResponseBody = - TestResources.loadResource("/wire-tests/Lang2FhirWireTest_testDocumentMulti_response.json"); + // Validate error response + Assertions.assertEquals(422, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testCreateThrowsInternalServerError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(500).setBody("{\"key\":\"value\"}")); + InternalServerError exception = Assertions.assertThrows(InternalServerError.class, () -> client.lang2Fhir() + .create(CreateRequest.builder() + .version("R4") + .resource(CreateRequestResource.AUTO) + .text("text") + .build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("POST", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = "" + + "{\n" + + " \"version\": \"R4\",\n" + + " \"resource\": \"auto\",\n" + + " \"text\": \"text\"\n" + + "}"; + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { + String discriminator = null; + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualJson.isNull()) { + Assertions.assertTrue( + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); + } + + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); + } + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); + } + + // Validate error response + Assertions.assertEquals(500, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testCreateMulti() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody(TestResources.loadResource("/wire-tests/Lang2FhirWireTest_testCreateMulti_response.json"))); + CreateMultiResponse response = client.lang2Fhir() + .createMulti(CreateMultiRequest.builder() + .text( + "John Smith, 45-year-old male, diagnosed with Type 2 Diabetes. Prescribed Metformin 500mg twice daily. Blood pressure 140/90.") + .version("R4") + .build()); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("POST", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = "" + + "{\n" + + " \"text\": \"John Smith, 45-year-old male, diagnosed with Type 2 Diabetes. Prescribed Metformin 500mg twice daily. Blood pressure 140/90.\",\n" + + " \"version\": \"R4\"\n" + + "}"; + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { + String discriminator = null; + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualJson.isNull()) { + Assertions.assertTrue( + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); + } + + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); + } + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); + } + + // Validate response body + Assertions.assertNotNull(response, "Response should not be null"); + String actualResponseJson = objectMapper.writeValueAsString(response); + String expectedResponseBody = + TestResources.loadResource("/wire-tests/Lang2FhirWireTest_testCreateMulti_response.json"); JsonNode actualResponseNode = objectMapper.readTree(actualResponseJson); JsonNode expectedResponseNode = objectMapper.readTree(expectedResponseBody); Assertions.assertTrue( @@ -713,6 +759,2209 @@ else if (actualResponseNode.has("kind")) } } + @Test + public void testCreateMultiThrowsBadRequestError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(400).setBody("{\"key\":\"value\"}")); + BadRequestError exception = Assertions.assertThrows(BadRequestError.class, () -> client.lang2Fhir() + .createMulti(CreateMultiRequest.builder().text("text").build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("POST", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = "" + "{\n" + " \"text\": \"text\"\n" + "}"; + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { + String discriminator = null; + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualJson.isNull()) { + Assertions.assertTrue( + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); + } + + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); + } + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); + } + + // Validate error response + Assertions.assertEquals(400, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testCreateMultiThrowsUnauthorizedError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(401).setBody("{\"key\":\"value\"}")); + UnauthorizedError exception = Assertions.assertThrows(UnauthorizedError.class, () -> client.lang2Fhir() + .createMulti(CreateMultiRequest.builder().text("text").build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("POST", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = "" + "{\n" + " \"text\": \"text\"\n" + "}"; + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { + String discriminator = null; + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualJson.isNull()) { + Assertions.assertTrue( + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); + } + + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); + } + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); + } + + // Validate error response + Assertions.assertEquals(401, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testCreateMultiThrowsNotFoundError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(404).setBody("{\"key\":\"value\"}")); + NotFoundError exception = Assertions.assertThrows(NotFoundError.class, () -> client.lang2Fhir() + .createMulti(CreateMultiRequest.builder().text("text").build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("POST", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = "" + "{\n" + " \"text\": \"text\"\n" + "}"; + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { + String discriminator = null; + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualJson.isNull()) { + Assertions.assertTrue( + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); + } + + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); + } + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); + } + + // Validate error response + Assertions.assertEquals(404, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testCreateMultiThrowsUnprocessableEntityError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(422).setBody("{\"key\":\"value\"}")); + UnprocessableEntityError exception = + Assertions.assertThrows(UnprocessableEntityError.class, () -> client.lang2Fhir() + .createMulti(CreateMultiRequest.builder().text("text").build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("POST", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = "" + "{\n" + " \"text\": \"text\"\n" + "}"; + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { + String discriminator = null; + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualJson.isNull()) { + Assertions.assertTrue( + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); + } + + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); + } + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); + } + + // Validate error response + Assertions.assertEquals(422, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testCreateMultiThrowsInternalServerError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(500).setBody("{\"key\":\"value\"}")); + InternalServerError exception = Assertions.assertThrows(InternalServerError.class, () -> client.lang2Fhir() + .createMulti(CreateMultiRequest.builder().text("text").build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("POST", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = "" + "{\n" + " \"text\": \"text\"\n" + "}"; + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { + String discriminator = null; + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualJson.isNull()) { + Assertions.assertTrue( + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); + } + + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); + } + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); + } + + // Validate error response + Assertions.assertEquals(500, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testSearch() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue( + new MockResponse() + .setResponseCode(200) + .setBody( + "{\"resource_type\":\"Appointment\",\"search_params\":\"date=ge2025-03-02&date=le2025-03-09\"}")); + SearchResponse response = client.lang2Fhir() + .search(SearchRequest.builder() + .text("Appointments between March 2-9, 2025") + .build()); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("POST", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = "" + "{\n" + " \"text\": \"Appointments between March 2-9, 2025\"\n" + "}"; + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { + String discriminator = null; + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualJson.isNull()) { + Assertions.assertTrue( + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); + } + + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); + } + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); + } + + // Validate response body + Assertions.assertNotNull(response, "Response should not be null"); + String actualResponseJson = objectMapper.writeValueAsString(response); + String expectedResponseBody = "" + + "{\n" + + " \"resource_type\": \"Appointment\",\n" + + " \"search_params\": \"date=ge2025-03-02&date=le2025-03-09\"\n" + + "}"; + JsonNode actualResponseNode = objectMapper.readTree(actualResponseJson); + JsonNode expectedResponseNode = objectMapper.readTree(expectedResponseBody); + Assertions.assertTrue( + jsonEquals(expectedResponseNode, actualResponseNode), + "Response body structure does not match expected"); + if (actualResponseNode.has("type") || actualResponseNode.has("_type") || actualResponseNode.has("kind")) { + String discriminator = null; + if (actualResponseNode.has("type")) + discriminator = actualResponseNode.get("type").asText(); + else if (actualResponseNode.has("_type")) + discriminator = actualResponseNode.get("_type").asText(); + else if (actualResponseNode.has("kind")) + discriminator = actualResponseNode.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualResponseNode.isNull()) { + Assertions.assertTrue( + actualResponseNode.isObject() || actualResponseNode.isArray() || actualResponseNode.isValueNode(), + "response should be a valid JSON value"); + } + + if (actualResponseNode.isArray()) { + Assertions.assertTrue(actualResponseNode.size() >= 0, "Array should have valid size"); + } + if (actualResponseNode.isObject()) { + Assertions.assertTrue(actualResponseNode.size() >= 0, "Object should have valid field count"); + } + } + + @Test + public void testSearchThrowsBadRequestError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(400).setBody("{\"key\":\"value\"}")); + BadRequestError exception = Assertions.assertThrows(BadRequestError.class, () -> client.lang2Fhir() + .search(SearchRequest.builder().text("text").build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("POST", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = "" + "{\n" + " \"text\": \"text\"\n" + "}"; + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { + String discriminator = null; + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualJson.isNull()) { + Assertions.assertTrue( + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); + } + + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); + } + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); + } + + // Validate error response + Assertions.assertEquals(400, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testSearchThrowsUnauthorizedError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(401).setBody("{\"key\":\"value\"}")); + UnauthorizedError exception = Assertions.assertThrows(UnauthorizedError.class, () -> client.lang2Fhir() + .search(SearchRequest.builder().text("text").build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("POST", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = "" + "{\n" + " \"text\": \"text\"\n" + "}"; + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { + String discriminator = null; + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualJson.isNull()) { + Assertions.assertTrue( + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); + } + + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); + } + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); + } + + // Validate error response + Assertions.assertEquals(401, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testSearchThrowsInternalServerError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(500).setBody("{\"key\":\"value\"}")); + InternalServerError exception = Assertions.assertThrows(InternalServerError.class, () -> client.lang2Fhir() + .search(SearchRequest.builder().text("text").build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("POST", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = "" + "{\n" + " \"text\": \"text\"\n" + "}"; + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { + String discriminator = null; + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualJson.isNull()) { + Assertions.assertTrue( + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); + } + + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); + } + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); + } + + // Validate error response + Assertions.assertEquals(500, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testUploadProfile() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue( + new MockResponse() + .setResponseCode(201) + .setBody( + "{\"message\":\"Profile uploaded successfully\",\"id\":\"custom-patient\",\"type\":\"Patient\",\"url\":\"http://phenoml.com/fhir/StructureDefinition/custom-patient\"}")); + UploadProfileResponse response = client.lang2Fhir() + .uploadProfile(ProfileUploadRequest.builder() + .profile( + "eyJyZXNvdXJjZVR5cGUiOiJTdHJ1Y3R1cmVEZWZpbml0aW9uIiwiaWQiOiJjdXN0b20tcGF0aWVudCIsInVybCI6Imh0dHA6Ly9waGVub21sLmNvbS9maGlyL1N0cnVjdHVyZURlZmluaXRpb24vY3VzdG9tLXBhdGllbnQiLCJuYW1lIjoiQ3VzdG9tUGF0aWVudCIsInN0YXR1cyI6ImFjdGl2ZSIsImZoaXJWZXJzaW9uIjoiNC4wLjEiLCJraW5kIjoicmVzb3VyY2UiLCJhYnN0cmFjdCI6ZmFsc2UsInR5cGUiOiJQYXRpZW50IiwiYmFzZURlZmluaXRpb24iOiJodHRwOi8vaGw3Lm9yZy9maGlyL1N0cnVjdHVyZURlZmluaXRpb24vUGF0aWVudCIsImRlcml2YXRpb24iOiJjb25zdHJhaW50Iiwic25hcHNob3QiOnsiZWxlbWVudCI6W3siaWQiOiJQYXRpZW50IiwicGF0aCI6IlBhdGllbnQiLCJtaW4iOjAsIm1heCI6IioifSx7ImlkIjoiUGF0aWVudC5uYW1lIiwicGF0aCI6IlBhdGllbnQubmFtZSIsIm1pbiI6MSwibWF4IjoiKiJ9XX19Cg==") + .implementationGuide("acme-cardiology") + .profileContext( + "When clinical text describes cardiology-specific findings, prefer this profile over the generic US Core Condition.") + .build()); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("POST", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = "" + + "{\n" + + " \"profile\": \"eyJyZXNvdXJjZVR5cGUiOiJTdHJ1Y3R1cmVEZWZpbml0aW9uIiwiaWQiOiJjdXN0b20tcGF0aWVudCIsInVybCI6Imh0dHA6Ly9waGVub21sLmNvbS9maGlyL1N0cnVjdHVyZURlZmluaXRpb24vY3VzdG9tLXBhdGllbnQiLCJuYW1lIjoiQ3VzdG9tUGF0aWVudCIsInN0YXR1cyI6ImFjdGl2ZSIsImZoaXJWZXJzaW9uIjoiNC4wLjEiLCJraW5kIjoicmVzb3VyY2UiLCJhYnN0cmFjdCI6ZmFsc2UsInR5cGUiOiJQYXRpZW50IiwiYmFzZURlZmluaXRpb24iOiJodHRwOi8vaGw3Lm9yZy9maGlyL1N0cnVjdHVyZURlZmluaXRpb24vUGF0aWVudCIsImRlcml2YXRpb24iOiJjb25zdHJhaW50Iiwic25hcHNob3QiOnsiZWxlbWVudCI6W3siaWQiOiJQYXRpZW50IiwicGF0aCI6IlBhdGllbnQiLCJtaW4iOjAsIm1heCI6IioifSx7ImlkIjoiUGF0aWVudC5uYW1lIiwicGF0aCI6IlBhdGllbnQubmFtZSIsIm1pbiI6MSwibWF4IjoiKiJ9XX19Cg==\",\n" + + " \"implementation_guide\": \"acme-cardiology\",\n" + + " \"profile_context\": \"When clinical text describes cardiology-specific findings, prefer this profile over the generic US Core Condition.\"\n" + + "}"; + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { + String discriminator = null; + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualJson.isNull()) { + Assertions.assertTrue( + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); + } + + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); + } + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); + } + + // Validate response body + Assertions.assertNotNull(response, "Response should not be null"); + String actualResponseJson = objectMapper.writeValueAsString(response); + String expectedResponseBody = "" + + "{\n" + + " \"message\": \"Profile uploaded successfully\",\n" + + " \"id\": \"custom-patient\",\n" + + " \"type\": \"Patient\",\n" + + " \"url\": \"http://phenoml.com/fhir/StructureDefinition/custom-patient\"\n" + + "}"; + JsonNode actualResponseNode = objectMapper.readTree(actualResponseJson); + JsonNode expectedResponseNode = objectMapper.readTree(expectedResponseBody); + Assertions.assertTrue( + jsonEquals(expectedResponseNode, actualResponseNode), + "Response body structure does not match expected"); + if (actualResponseNode.has("type") || actualResponseNode.has("_type") || actualResponseNode.has("kind")) { + String discriminator = null; + if (actualResponseNode.has("type")) + discriminator = actualResponseNode.get("type").asText(); + else if (actualResponseNode.has("_type")) + discriminator = actualResponseNode.get("_type").asText(); + else if (actualResponseNode.has("kind")) + discriminator = actualResponseNode.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualResponseNode.isNull()) { + Assertions.assertTrue( + actualResponseNode.isObject() || actualResponseNode.isArray() || actualResponseNode.isValueNode(), + "response should be a valid JSON value"); + } + + if (actualResponseNode.isArray()) { + Assertions.assertTrue(actualResponseNode.size() >= 0, "Array should have valid size"); + } + if (actualResponseNode.isObject()) { + Assertions.assertTrue(actualResponseNode.size() >= 0, "Object should have valid field count"); + } + } + + @Test + public void testUploadProfileThrowsBadRequestError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(400).setBody("{\"key\":\"value\"}")); + BadRequestError exception = Assertions.assertThrows(BadRequestError.class, () -> client.lang2Fhir() + .uploadProfile(ProfileUploadRequest.builder().profile("profile").build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("POST", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = "" + "{\n" + " \"profile\": \"profile\"\n" + "}"; + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { + String discriminator = null; + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualJson.isNull()) { + Assertions.assertTrue( + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); + } + + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); + } + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); + } + + // Validate error response + Assertions.assertEquals(400, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testUploadProfileThrowsUnauthorizedError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(401).setBody("{\"key\":\"value\"}")); + UnauthorizedError exception = Assertions.assertThrows(UnauthorizedError.class, () -> client.lang2Fhir() + .uploadProfile(ProfileUploadRequest.builder().profile("profile").build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("POST", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = "" + "{\n" + " \"profile\": \"profile\"\n" + "}"; + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { + String discriminator = null; + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualJson.isNull()) { + Assertions.assertTrue( + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); + } + + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); + } + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); + } + + // Validate error response + Assertions.assertEquals(401, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testUploadProfileThrowsForbiddenError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(403).setBody("{\"key\":\"value\"}")); + ForbiddenError exception = Assertions.assertThrows(ForbiddenError.class, () -> client.lang2Fhir() + .uploadProfile(ProfileUploadRequest.builder().profile("profile").build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("POST", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = "" + "{\n" + " \"profile\": \"profile\"\n" + "}"; + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { + String discriminator = null; + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualJson.isNull()) { + Assertions.assertTrue( + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); + } + + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); + } + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); + } + + // Validate error response + Assertions.assertEquals(403, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testUploadProfileThrowsInternalServerError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(500).setBody("{\"key\":\"value\"}")); + InternalServerError exception = Assertions.assertThrows(InternalServerError.class, () -> client.lang2Fhir() + .uploadProfile(ProfileUploadRequest.builder().profile("profile").build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("POST", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = "" + "{\n" + " \"profile\": \"profile\"\n" + "}"; + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { + String discriminator = null; + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualJson.isNull()) { + Assertions.assertTrue( + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); + } + + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); + } + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); + } + + // Validate error response + Assertions.assertEquals(500, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testDocument() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue( + new MockResponse() + .setResponseCode(200) + .setBody( + "{\"resourceType\":\"Questionnaire\",\"status\":\"active\",\"title\":\"Patient Intake Form\",\"item\":[{\"linkId\":\"1\",\"text\":\"What is your name?\",\"type\":\"string\"},{\"linkId\":\"2\",\"text\":\"What is your date of birth?\",\"type\":\"date\"}]}")); + Map response = client.lang2Fhir() + .document(DocumentRequest.builder() + .version("R4") + .resource("questionnaire") + .content("JVBERi0xLjQKJeLjz9MK...(base64-encoded document bytes)") + .build()); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("POST", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = "" + + "{\n" + + " \"version\": \"R4\",\n" + + " \"resource\": \"questionnaire\",\n" + + " \"content\": \"JVBERi0xLjQKJeLjz9MK...(base64-encoded document bytes)\"\n" + + "}"; + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { + String discriminator = null; + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualJson.isNull()) { + Assertions.assertTrue( + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); + } + + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); + } + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); + } + + // Validate response body + Assertions.assertNotNull(response, "Response should not be null"); + String actualResponseJson = objectMapper.writeValueAsString(response); + String expectedResponseBody = "" + + "{\n" + + " \"resourceType\": \"Questionnaire\",\n" + + " \"status\": \"active\",\n" + + " \"title\": \"Patient Intake Form\",\n" + + " \"item\": [\n" + + " {\n" + + " \"linkId\": \"1\",\n" + + " \"text\": \"What is your name?\",\n" + + " \"type\": \"string\"\n" + + " },\n" + + " {\n" + + " \"linkId\": \"2\",\n" + + " \"text\": \"What is your date of birth?\",\n" + + " \"type\": \"date\"\n" + + " }\n" + + " ]\n" + + "}"; + JsonNode actualResponseNode = objectMapper.readTree(actualResponseJson); + JsonNode expectedResponseNode = objectMapper.readTree(expectedResponseBody); + Assertions.assertTrue( + jsonEquals(expectedResponseNode, actualResponseNode), + "Response body structure does not match expected"); + if (actualResponseNode.has("type") || actualResponseNode.has("_type") || actualResponseNode.has("kind")) { + String discriminator = null; + if (actualResponseNode.has("type")) + discriminator = actualResponseNode.get("type").asText(); + else if (actualResponseNode.has("_type")) + discriminator = actualResponseNode.get("_type").asText(); + else if (actualResponseNode.has("kind")) + discriminator = actualResponseNode.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualResponseNode.isNull()) { + Assertions.assertTrue( + actualResponseNode.isObject() || actualResponseNode.isArray() || actualResponseNode.isValueNode(), + "response should be a valid JSON value"); + } + + if (actualResponseNode.isArray()) { + Assertions.assertTrue(actualResponseNode.size() >= 0, "Array should have valid size"); + } + if (actualResponseNode.isObject()) { + Assertions.assertTrue(actualResponseNode.size() >= 0, "Object should have valid field count"); + } + } + + @Test + public void testDocumentThrowsBadRequestError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(400).setBody("{\"key\":\"value\"}")); + BadRequestError exception = Assertions.assertThrows(BadRequestError.class, () -> client.lang2Fhir() + .document(DocumentRequest.builder() + .version("version") + .resource("resource") + .content("content") + .build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("POST", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = "" + + "{\n" + + " \"version\": \"version\",\n" + + " \"resource\": \"resource\",\n" + + " \"content\": \"content\"\n" + + "}"; + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { + String discriminator = null; + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualJson.isNull()) { + Assertions.assertTrue( + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); + } + + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); + } + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); + } + + // Validate error response + Assertions.assertEquals(400, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testDocumentThrowsUnauthorizedError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(401).setBody("{\"key\":\"value\"}")); + UnauthorizedError exception = Assertions.assertThrows(UnauthorizedError.class, () -> client.lang2Fhir() + .document(DocumentRequest.builder() + .version("version") + .resource("resource") + .content("content") + .build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("POST", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = "" + + "{\n" + + " \"version\": \"version\",\n" + + " \"resource\": \"resource\",\n" + + " \"content\": \"content\"\n" + + "}"; + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { + String discriminator = null; + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualJson.isNull()) { + Assertions.assertTrue( + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); + } + + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); + } + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); + } + + // Validate error response + Assertions.assertEquals(401, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testDocumentThrowsForbiddenError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(403).setBody("{\"key\":\"value\"}")); + ForbiddenError exception = Assertions.assertThrows(ForbiddenError.class, () -> client.lang2Fhir() + .document(DocumentRequest.builder() + .version("version") + .resource("resource") + .content("content") + .build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("POST", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = "" + + "{\n" + + " \"version\": \"version\",\n" + + " \"resource\": \"resource\",\n" + + " \"content\": \"content\"\n" + + "}"; + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { + String discriminator = null; + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualJson.isNull()) { + Assertions.assertTrue( + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); + } + + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); + } + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); + } + + // Validate error response + Assertions.assertEquals(403, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testDocumentThrowsNotFoundError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(404).setBody("{\"key\":\"value\"}")); + NotFoundError exception = Assertions.assertThrows(NotFoundError.class, () -> client.lang2Fhir() + .document(DocumentRequest.builder() + .version("version") + .resource("resource") + .content("content") + .build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("POST", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = "" + + "{\n" + + " \"version\": \"version\",\n" + + " \"resource\": \"resource\",\n" + + " \"content\": \"content\"\n" + + "}"; + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { + String discriminator = null; + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualJson.isNull()) { + Assertions.assertTrue( + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); + } + + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); + } + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); + } + + // Validate error response + Assertions.assertEquals(404, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testDocumentThrowsUnprocessableEntityError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(422).setBody("{\"key\":\"value\"}")); + UnprocessableEntityError exception = + Assertions.assertThrows(UnprocessableEntityError.class, () -> client.lang2Fhir() + .document(DocumentRequest.builder() + .version("version") + .resource("resource") + .content("content") + .build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("POST", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = "" + + "{\n" + + " \"version\": \"version\",\n" + + " \"resource\": \"resource\",\n" + + " \"content\": \"content\"\n" + + "}"; + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { + String discriminator = null; + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualJson.isNull()) { + Assertions.assertTrue( + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); + } + + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); + } + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); + } + + // Validate error response + Assertions.assertEquals(422, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testDocumentThrowsClientClosedRequestError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(499).setBody("{\"key\":\"value\"}")); + ClientClosedRequestError exception = + Assertions.assertThrows(ClientClosedRequestError.class, () -> client.lang2Fhir() + .document(DocumentRequest.builder() + .version("version") + .resource("resource") + .content("content") + .build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("POST", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = "" + + "{\n" + + " \"version\": \"version\",\n" + + " \"resource\": \"resource\",\n" + + " \"content\": \"content\"\n" + + "}"; + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { + String discriminator = null; + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualJson.isNull()) { + Assertions.assertTrue( + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); + } + + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); + } + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); + } + + // Validate error response + Assertions.assertEquals(499, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testDocumentThrowsInternalServerError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(500).setBody("{\"key\":\"value\"}")); + InternalServerError exception = Assertions.assertThrows(InternalServerError.class, () -> client.lang2Fhir() + .document(DocumentRequest.builder() + .version("version") + .resource("resource") + .content("content") + .build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("POST", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = "" + + "{\n" + + " \"version\": \"version\",\n" + + " \"resource\": \"resource\",\n" + + " \"content\": \"content\"\n" + + "}"; + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { + String discriminator = null; + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualJson.isNull()) { + Assertions.assertTrue( + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); + } + + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); + } + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); + } + + // Validate error response + Assertions.assertEquals(500, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testDocumentThrowsGatewayTimeoutError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(504).setBody("{\"key\":\"value\"}")); + GatewayTimeoutError exception = Assertions.assertThrows(GatewayTimeoutError.class, () -> client.lang2Fhir() + .document(DocumentRequest.builder() + .version("version") + .resource("resource") + .content("content") + .build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("POST", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = "" + + "{\n" + + " \"version\": \"version\",\n" + + " \"resource\": \"resource\",\n" + + " \"content\": \"content\"\n" + + "}"; + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { + String discriminator = null; + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualJson.isNull()) { + Assertions.assertTrue( + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); + } + + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); + } + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); + } + + // Validate error response + Assertions.assertEquals(504, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testDocumentMulti() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody(TestResources.loadResource("/wire-tests/Lang2FhirWireTest_testDocumentMulti_response.json"))); + DocumentMultiResponse response = client.lang2Fhir() + .documentMulti(DocumentMultiRequest.builder() + .version("R4") + .content("JVBERi0xLjQKJeLjz9MK...(base64-encoded document bytes)") + .provider("medplum") + .config(DocumentConfig.builder() + .splitClassifications(Optional.of(Arrays.asList( + SplitClassification.builder() + .id("clinical") + .description( + "Clinical notes, diagnoses, medications, observations, and patient demographics.") + .operation(SplitClassificationOperation.GROUP) + .build(), + SplitClassification.builder() + .id("admin") + .description( + "Administrative boilerplate, insurance forms, and cover sheets.") + .operation(SplitClassificationOperation.DROP) + .build()))) + .build()) + .build()); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("POST", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = "" + + "{\n" + + " \"version\": \"R4\",\n" + + " \"content\": \"JVBERi0xLjQKJeLjz9MK...(base64-encoded document bytes)\",\n" + + " \"provider\": \"medplum\",\n" + + " \"config\": {\n" + + " \"split_classifications\": [\n" + + " {\n" + + " \"id\": \"clinical\",\n" + + " \"description\": \"Clinical notes, diagnoses, medications, observations, and patient demographics.\",\n" + + " \"operation\": \"group\"\n" + + " },\n" + + " {\n" + + " \"id\": \"admin\",\n" + + " \"description\": \"Administrative boilerplate, insurance forms, and cover sheets.\",\n" + + " \"operation\": \"drop\"\n" + + " }\n" + + " ]\n" + + " }\n" + + "}"; + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { + String discriminator = null; + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualJson.isNull()) { + Assertions.assertTrue( + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); + } + + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); + } + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); + } + + // Validate response body + Assertions.assertNotNull(response, "Response should not be null"); + String actualResponseJson = objectMapper.writeValueAsString(response); + String expectedResponseBody = + TestResources.loadResource("/wire-tests/Lang2FhirWireTest_testDocumentMulti_response.json"); + JsonNode actualResponseNode = objectMapper.readTree(actualResponseJson); + JsonNode expectedResponseNode = objectMapper.readTree(expectedResponseBody); + Assertions.assertTrue( + jsonEquals(expectedResponseNode, actualResponseNode), + "Response body structure does not match expected"); + if (actualResponseNode.has("type") || actualResponseNode.has("_type") || actualResponseNode.has("kind")) { + String discriminator = null; + if (actualResponseNode.has("type")) + discriminator = actualResponseNode.get("type").asText(); + else if (actualResponseNode.has("_type")) + discriminator = actualResponseNode.get("_type").asText(); + else if (actualResponseNode.has("kind")) + discriminator = actualResponseNode.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualResponseNode.isNull()) { + Assertions.assertTrue( + actualResponseNode.isObject() || actualResponseNode.isArray() || actualResponseNode.isValueNode(), + "response should be a valid JSON value"); + } + + if (actualResponseNode.isArray()) { + Assertions.assertTrue(actualResponseNode.size() >= 0, "Array should have valid size"); + } + if (actualResponseNode.isObject()) { + Assertions.assertTrue(actualResponseNode.size() >= 0, "Object should have valid field count"); + } + } + + @Test + public void testDocumentMultiThrowsBadRequestError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(400).setBody("{\"key\":\"value\"}")); + BadRequestError exception = Assertions.assertThrows(BadRequestError.class, () -> client.lang2Fhir() + .documentMulti(DocumentMultiRequest.builder() + .version("version") + .content("content") + .build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("POST", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = + "" + "{\n" + " \"version\": \"version\",\n" + " \"content\": \"content\"\n" + "}"; + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { + String discriminator = null; + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualJson.isNull()) { + Assertions.assertTrue( + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); + } + + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); + } + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); + } + + // Validate error response + Assertions.assertEquals(400, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testDocumentMultiThrowsUnauthorizedError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(401).setBody("{\"key\":\"value\"}")); + UnauthorizedError exception = Assertions.assertThrows(UnauthorizedError.class, () -> client.lang2Fhir() + .documentMulti(DocumentMultiRequest.builder() + .version("version") + .content("content") + .build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("POST", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = + "" + "{\n" + " \"version\": \"version\",\n" + " \"content\": \"content\"\n" + "}"; + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { + String discriminator = null; + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualJson.isNull()) { + Assertions.assertTrue( + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); + } + + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); + } + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); + } + + // Validate error response + Assertions.assertEquals(401, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testDocumentMultiThrowsForbiddenError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(403).setBody("{\"key\":\"value\"}")); + ForbiddenError exception = Assertions.assertThrows(ForbiddenError.class, () -> client.lang2Fhir() + .documentMulti(DocumentMultiRequest.builder() + .version("version") + .content("content") + .build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("POST", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = + "" + "{\n" + " \"version\": \"version\",\n" + " \"content\": \"content\"\n" + "}"; + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { + String discriminator = null; + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualJson.isNull()) { + Assertions.assertTrue( + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); + } + + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); + } + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); + } + + // Validate error response + Assertions.assertEquals(403, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testDocumentMultiThrowsNotFoundError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(404).setBody("{\"key\":\"value\"}")); + NotFoundError exception = Assertions.assertThrows(NotFoundError.class, () -> client.lang2Fhir() + .documentMulti(DocumentMultiRequest.builder() + .version("version") + .content("content") + .build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("POST", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = + "" + "{\n" + " \"version\": \"version\",\n" + " \"content\": \"content\"\n" + "}"; + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { + String discriminator = null; + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualJson.isNull()) { + Assertions.assertTrue( + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); + } + + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); + } + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); + } + + // Validate error response + Assertions.assertEquals(404, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testDocumentMultiThrowsUnprocessableEntityError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(422).setBody("{\"key\":\"value\"}")); + UnprocessableEntityError exception = + Assertions.assertThrows(UnprocessableEntityError.class, () -> client.lang2Fhir() + .documentMulti(DocumentMultiRequest.builder() + .version("version") + .content("content") + .build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("POST", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = + "" + "{\n" + " \"version\": \"version\",\n" + " \"content\": \"content\"\n" + "}"; + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { + String discriminator = null; + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualJson.isNull()) { + Assertions.assertTrue( + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); + } + + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); + } + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); + } + + // Validate error response + Assertions.assertEquals(422, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testDocumentMultiThrowsClientClosedRequestError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(499).setBody("{\"key\":\"value\"}")); + ClientClosedRequestError exception = + Assertions.assertThrows(ClientClosedRequestError.class, () -> client.lang2Fhir() + .documentMulti(DocumentMultiRequest.builder() + .version("version") + .content("content") + .build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("POST", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = + "" + "{\n" + " \"version\": \"version\",\n" + " \"content\": \"content\"\n" + "}"; + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { + String discriminator = null; + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualJson.isNull()) { + Assertions.assertTrue( + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); + } + + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); + } + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); + } + + // Validate error response + Assertions.assertEquals(499, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testDocumentMultiThrowsInternalServerError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(500).setBody("{\"key\":\"value\"}")); + InternalServerError exception = Assertions.assertThrows(InternalServerError.class, () -> client.lang2Fhir() + .documentMulti(DocumentMultiRequest.builder() + .version("version") + .content("content") + .build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("POST", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = + "" + "{\n" + " \"version\": \"version\",\n" + " \"content\": \"content\"\n" + "}"; + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { + String discriminator = null; + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualJson.isNull()) { + Assertions.assertTrue( + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); + } + + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); + } + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); + } + + // Validate error response + Assertions.assertEquals(500, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testDocumentMultiThrowsGatewayTimeoutError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(504).setBody("{\"key\":\"value\"}")); + GatewayTimeoutError exception = Assertions.assertThrows(GatewayTimeoutError.class, () -> client.lang2Fhir() + .documentMulti(DocumentMultiRequest.builder() + .version("version") + .content("content") + .build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("POST", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = + "" + "{\n" + " \"version\": \"version\",\n" + " \"content\": \"content\"\n" + "}"; + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { + String discriminator = null; + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualJson.isNull()) { + Assertions.assertTrue( + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); + } + + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); + } + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); + } + + // Validate error response + Assertions.assertEquals(504, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + /** * Compares two JsonNodes with numeric equivalence and null safety. * For objects, checks that all fields in 'expected' exist in 'actual' with matching values. diff --git a/src/test/java/com/phenoml/api/ProfilesProfilesWireTest.java b/src/test/java/com/phenoml/api/ProfilesProfilesWireTest.java index 25290314..3dab266e 100644 --- a/src/test/java/com/phenoml/api/ProfilesProfilesWireTest.java +++ b/src/test/java/com/phenoml/api/ProfilesProfilesWireTest.java @@ -3,6 +3,12 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.phenoml.api.core.ObjectMappers; +import com.phenoml.api.resources.profiles.errors.BadRequestError; +import com.phenoml.api.resources.profiles.errors.ConflictError; +import com.phenoml.api.resources.profiles.errors.ForbiddenError; +import com.phenoml.api.resources.profiles.errors.InternalServerError; +import com.phenoml.api.resources.profiles.errors.NotFoundError; +import com.phenoml.api.resources.profiles.errors.UnauthorizedError; import com.phenoml.api.resources.profiles.profiles.requests.ListRequest; import com.phenoml.api.resources.profiles.types.ProfileGetResponse; import com.phenoml.api.resources.profiles.types.ProfileListResponse; @@ -30,6 +36,7 @@ public void setup() throws Exception { server.start(); client = PhenomlClient.withCredentials("test-client-id", "test-client-secret") .url(server.url("/").toString()) + .maxRetries(0) .build(); } @@ -119,6 +126,99 @@ else if (actualResponseNode.has("kind")) } } + @Test + public void testListThrowsUnauthorizedError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(401).setBody("{\"key\":\"value\"}")); + UnauthorizedError exception = Assertions.assertThrows( + UnauthorizedError.class, + () -> client.profiles().profiles().list(ListRequest.builder().build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("GET", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate error response + Assertions.assertEquals(401, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testListThrowsForbiddenError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(403).setBody("{\"key\":\"value\"}")); + ForbiddenError exception = Assertions.assertThrows( + ForbiddenError.class, + () -> client.profiles().profiles().list(ListRequest.builder().build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("GET", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate error response + Assertions.assertEquals(403, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testListThrowsInternalServerError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(500).setBody("{\"key\":\"value\"}")); + InternalServerError exception = Assertions.assertThrows( + InternalServerError.class, + () -> client.profiles().profiles().list(ListRequest.builder().build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("GET", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate error response + Assertions.assertEquals(500, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + @Test public void testCreate() throws Exception { // OAuth: enqueue token response (client fetches token before API call) @@ -127,7 +227,7 @@ public void testCreate() throws Exception { .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); server.enqueue( new MockResponse() - .setResponseCode(200) + .setResponseCode(201) .setBody( "{\"id\":\"custom-patient\",\"source\":\"custom\",\"resource_type\":\"Patient\",\"url\":\"http://phenoml.com/fhir/StructureDefinition/custom-patient\",\"version\":\"1.0.0\",\"status\":\"active\",\"date\":\"2026-08-24\",\"canonical\":\"http://phenoml.com/fhir/StructureDefinition/custom-patient|1.0.0\",\"fhir_version\":\"4.0.1\",\"implementation_guide\":\"acme-cardiology\",\"created_at\":\"2026-08-24T15:04:05Z\",\"updated_at\":\"2026-08-25T16:04:05Z\"}")); ProfileSummary response = client.profiles() @@ -281,155 +381,107 @@ else if (actualResponseNode.has("kind")) } @Test - public void testGet() throws Exception { + public void testCreateThrowsBadRequestError() throws Exception { // OAuth: enqueue token response (client fetches token before API call) server.enqueue(new MockResponse() .setResponseCode(200) .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); - server.enqueue( - new MockResponse() - .setResponseCode(200) - .setBody( - "{\"id\":\"custom-patient\",\"source\":\"custom\",\"resource_type\":\"Patient\",\"url\":\"http://phenoml.com/fhir/StructureDefinition/custom-patient\",\"version\":\"1.0.0\",\"status\":\"active\",\"date\":\"2026-08-24\",\"canonical\":\"http://phenoml.com/fhir/StructureDefinition/custom-patient|1.0.0\",\"fhir_version\":\"4.0.1\",\"implementation_guide\":\"acme-cardiology\",\"created_at\":\"2026-08-24T15:04:05Z\",\"updated_at\":\"2026-08-25T16:04:05Z\",\"structure_definition\":{\"resourceType\":\"StructureDefinition\",\"id\":\"custom-patient\",\"url\":\"http://phenoml.com/fhir/StructureDefinition/custom-patient\",\"version\":\"1.0.0\",\"name\":\"CustomPatient\",\"status\":\"active\",\"fhirVersion\":\"4.0.1\",\"kind\":\"resource\",\"abstract\":false,\"type\":\"Patient\",\"baseDefinition\":\"http://hl7.org/fhir/StructureDefinition/Patient\",\"derivation\":\"constraint\",\"snapshot\":{\"element\":[{\"id\":\"Patient\",\"path\":\"Patient\",\"min\":0,\"max\":\"*\"}]}}}")); - ProfileGetResponse response = client.profiles().profiles().get("custom-patient"); + server.enqueue(new MockResponse().setResponseCode(400).setBody("{\"key\":\"value\"}")); + BadRequestError exception = Assertions.assertThrows(BadRequestError.class, () -> client.profiles() + .profiles() + .create(ProfileUploadRequest.builder() + .structureDefinition(new HashMap() { + { + put("structure_definition", new HashMap() { + { + put("key", "value"); + } + }); + } + }) + .build())); // OAuth: consume the token request server.takeRequest(); RecordedRequest request = server.takeRequest(); Assertions.assertNotNull(request); - Assertions.assertEquals("GET", request.getMethod()); + Assertions.assertEquals("POST", request.getMethod()); // Validate OAuth Authorization header Assertions.assertEquals( "Bearer test-token", request.getHeader("Authorization"), "OAuth Authorization header should contain Bearer token from OAuth flow"); - - // Validate response body - Assertions.assertNotNull(response, "Response should not be null"); - String actualResponseJson = objectMapper.writeValueAsString(response); - String expectedResponseBody = "" + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = "" + "{\n" - + " \"id\": \"custom-patient\",\n" - + " \"source\": \"custom\",\n" - + " \"resource_type\": \"Patient\",\n" - + " \"url\": \"http://phenoml.com/fhir/StructureDefinition/custom-patient\",\n" - + " \"version\": \"1.0.0\",\n" - + " \"status\": \"active\",\n" - + " \"date\": \"2026-08-24\",\n" - + " \"canonical\": \"http://phenoml.com/fhir/StructureDefinition/custom-patient|1.0.0\",\n" - + " \"fhir_version\": \"4.0.1\",\n" - + " \"implementation_guide\": \"acme-cardiology\",\n" - + " \"created_at\": \"2026-08-24T15:04:05Z\",\n" - + " \"updated_at\": \"2026-08-25T16:04:05Z\",\n" + " \"structure_definition\": {\n" - + " \"resourceType\": \"StructureDefinition\",\n" - + " \"id\": \"custom-patient\",\n" - + " \"url\": \"http://phenoml.com/fhir/StructureDefinition/custom-patient\",\n" - + " \"version\": \"1.0.0\",\n" - + " \"name\": \"CustomPatient\",\n" - + " \"status\": \"active\",\n" - + " \"fhirVersion\": \"4.0.1\",\n" - + " \"kind\": \"resource\",\n" - + " \"abstract\": false,\n" - + " \"type\": \"Patient\",\n" - + " \"baseDefinition\": \"http://hl7.org/fhir/StructureDefinition/Patient\",\n" - + " \"derivation\": \"constraint\",\n" - + " \"snapshot\": {\n" - + " \"element\": [\n" - + " {\n" - + " \"id\": \"Patient\",\n" - + " \"path\": \"Patient\",\n" - + " \"min\": 0,\n" - + " \"max\": \"*\"\n" - + " }\n" - + " ]\n" + + " \"structure_definition\": {\n" + + " \"key\": \"value\"\n" + " }\n" + " }\n" + "}"; - JsonNode actualResponseNode = objectMapper.readTree(actualResponseJson); - JsonNode expectedResponseNode = objectMapper.readTree(expectedResponseBody); - Assertions.assertTrue( - jsonEquals(expectedResponseNode, actualResponseNode), - "Response body structure does not match expected"); - if (actualResponseNode.has("type") || actualResponseNode.has("_type") || actualResponseNode.has("kind")) { + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { String discriminator = null; - if (actualResponseNode.has("type")) - discriminator = actualResponseNode.get("type").asText(); - else if (actualResponseNode.has("_type")) - discriminator = actualResponseNode.get("_type").asText(); - else if (actualResponseNode.has("kind")) - discriminator = actualResponseNode.get("kind").asText(); + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); } - if (!actualResponseNode.isNull()) { + if (!actualJson.isNull()) { Assertions.assertTrue( - actualResponseNode.isObject() || actualResponseNode.isArray() || actualResponseNode.isValueNode(), - "response should be a valid JSON value"); + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); } - if (actualResponseNode.isArray()) { - Assertions.assertTrue(actualResponseNode.size() >= 0, "Array should have valid size"); + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); } - if (actualResponseNode.isObject()) { - Assertions.assertTrue(actualResponseNode.size() >= 0, "Object should have valid field count"); + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); } + + // Validate error response + Assertions.assertEquals(400, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); } @Test - public void testUpdate() throws Exception { + public void testCreateThrowsUnauthorizedError() throws Exception { // OAuth: enqueue token response (client fetches token before API call) server.enqueue(new MockResponse() .setResponseCode(200) .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); - server.enqueue( - new MockResponse() - .setResponseCode(200) - .setBody( - "{\"id\":\"custom-patient\",\"source\":\"custom\",\"resource_type\":\"Patient\",\"url\":\"http://phenoml.com/fhir/StructureDefinition/custom-patient\",\"version\":\"1.0.0\",\"status\":\"active\",\"date\":\"2026-08-24\",\"canonical\":\"http://phenoml.com/fhir/StructureDefinition/custom-patient|1.0.0\",\"fhir_version\":\"4.0.1\",\"implementation_guide\":\"acme-cardiology\",\"created_at\":\"2026-08-24T15:04:05Z\",\"updated_at\":\"2026-08-25T16:04:05Z\"}")); - ProfileSummary response = client.profiles() + server.enqueue(new MockResponse().setResponseCode(401).setBody("{\"key\":\"value\"}")); + UnauthorizedError exception = Assertions.assertThrows(UnauthorizedError.class, () -> client.profiles() .profiles() - .update( - "custom-patient", - ProfileUploadRequest.builder() - .structureDefinition(new HashMap() { + .create(ProfileUploadRequest.builder() + .structureDefinition(new HashMap() { + { + put("structure_definition", new HashMap() { { - put("resourceType", "StructureDefinition"); - put("id", "custom-patient"); - put("url", "http://phenoml.com/fhir/StructureDefinition/custom-patient"); - put("name", "CustomPatient"); - put("status", "active"); - put("fhirVersion", "4.0.1"); - put("kind", "resource"); - put("abstract", false); - put("type", "Patient"); - put("baseDefinition", "http://hl7.org/fhir/StructureDefinition/Patient"); - put("derivation", "constraint"); - put("snapshot", new HashMap() { - { - put( - "element", - new ArrayList( - Arrays.asList(new HashMap() { - { - put("id", "Patient"); - put("path", "Patient"); - put("min", 0); - put("max", "*"); - } - }))); - } - }); + put("key", "value"); } - }) - .implementationGuide("acme-cardiology") - .build()); + }); + } + }) + .build())); // OAuth: consume the token request server.takeRequest(); RecordedRequest request = server.takeRequest(); Assertions.assertNotNull(request); - Assertions.assertEquals("PUT", request.getMethod()); + Assertions.assertEquals("POST", request.getMethod()); // Validate OAuth Authorization header Assertions.assertEquals( @@ -441,29 +493,10 @@ public void testUpdate() throws Exception { String expectedRequestBody = "" + "{\n" + " \"structure_definition\": {\n" - + " \"resourceType\": \"StructureDefinition\",\n" - + " \"id\": \"custom-patient\",\n" - + " \"url\": \"http://phenoml.com/fhir/StructureDefinition/custom-patient\",\n" - + " \"name\": \"CustomPatient\",\n" - + " \"status\": \"active\",\n" - + " \"fhirVersion\": \"4.0.1\",\n" - + " \"kind\": \"resource\",\n" - + " \"abstract\": false,\n" - + " \"type\": \"Patient\",\n" - + " \"baseDefinition\": \"http://hl7.org/fhir/StructureDefinition/Patient\",\n" - + " \"derivation\": \"constraint\",\n" - + " \"snapshot\": {\n" - + " \"element\": [\n" - + " {\n" - + " \"id\": \"Patient\",\n" - + " \"path\": \"Patient\",\n" - + " \"min\": 0,\n" - + " \"max\": \"*\"\n" - + " }\n" - + " ]\n" + + " \"structure_definition\": {\n" + + " \"key\": \"value\"\n" + " }\n" - + " },\n" - + " \"implementation_guide\": \"acme-cardiology\"\n" + + " }\n" + "}"; JsonNode actualJson = objectMapper.readTree(actualRequestBody); JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); @@ -492,38 +525,249 @@ else if (actualJson.has("kind")) Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); } - // Validate response body - Assertions.assertNotNull(response, "Response should not be null"); - String actualResponseJson = objectMapper.writeValueAsString(response); - String expectedResponseBody = "" - + "{\n" - + " \"id\": \"custom-patient\",\n" - + " \"source\": \"custom\",\n" - + " \"resource_type\": \"Patient\",\n" - + " \"url\": \"http://phenoml.com/fhir/StructureDefinition/custom-patient\",\n" - + " \"version\": \"1.0.0\",\n" - + " \"status\": \"active\",\n" - + " \"date\": \"2026-08-24\",\n" - + " \"canonical\": \"http://phenoml.com/fhir/StructureDefinition/custom-patient|1.0.0\",\n" - + " \"fhir_version\": \"4.0.1\",\n" - + " \"implementation_guide\": \"acme-cardiology\",\n" - + " \"created_at\": \"2026-08-24T15:04:05Z\",\n" - + " \"updated_at\": \"2026-08-25T16:04:05Z\"\n" - + "}"; - JsonNode actualResponseNode = objectMapper.readTree(actualResponseJson); - JsonNode expectedResponseNode = objectMapper.readTree(expectedResponseBody); - Assertions.assertTrue( - jsonEquals(expectedResponseNode, actualResponseNode), - "Response body structure does not match expected"); - if (actualResponseNode.has("type") || actualResponseNode.has("_type") || actualResponseNode.has("kind")) { - String discriminator = null; - if (actualResponseNode.has("type")) - discriminator = actualResponseNode.get("type").asText(); - else if (actualResponseNode.has("_type")) - discriminator = actualResponseNode.get("_type").asText(); - else if (actualResponseNode.has("kind")) - discriminator = actualResponseNode.get("kind").asText(); - Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + // Validate error response + Assertions.assertEquals(401, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testCreateThrowsForbiddenError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(403).setBody("{\"key\":\"value\"}")); + ForbiddenError exception = Assertions.assertThrows(ForbiddenError.class, () -> client.profiles() + .profiles() + .create(ProfileUploadRequest.builder() + .structureDefinition(new HashMap() { + { + put("structure_definition", new HashMap() { + { + put("key", "value"); + } + }); + } + }) + .build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("POST", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = "" + + "{\n" + + " \"structure_definition\": {\n" + + " \"structure_definition\": {\n" + + " \"key\": \"value\"\n" + + " }\n" + + " }\n" + + "}"; + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { + String discriminator = null; + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualJson.isNull()) { + Assertions.assertTrue( + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); + } + + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); + } + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); + } + + // Validate error response + Assertions.assertEquals(403, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testCreateThrowsInternalServerError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(500).setBody("{\"key\":\"value\"}")); + InternalServerError exception = Assertions.assertThrows(InternalServerError.class, () -> client.profiles() + .profiles() + .create(ProfileUploadRequest.builder() + .structureDefinition(new HashMap() { + { + put("structure_definition", new HashMap() { + { + put("key", "value"); + } + }); + } + }) + .build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("POST", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = "" + + "{\n" + + " \"structure_definition\": {\n" + + " \"structure_definition\": {\n" + + " \"key\": \"value\"\n" + + " }\n" + + " }\n" + + "}"; + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { + String discriminator = null; + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualJson.isNull()) { + Assertions.assertTrue( + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); + } + + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); + } + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); + } + + // Validate error response + Assertions.assertEquals(500, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testGet() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue( + new MockResponse() + .setResponseCode(200) + .setBody( + "{\"id\":\"custom-patient\",\"source\":\"custom\",\"resource_type\":\"Patient\",\"url\":\"http://phenoml.com/fhir/StructureDefinition/custom-patient\",\"version\":\"1.0.0\",\"status\":\"active\",\"date\":\"2026-08-24\",\"canonical\":\"http://phenoml.com/fhir/StructureDefinition/custom-patient|1.0.0\",\"fhir_version\":\"4.0.1\",\"implementation_guide\":\"acme-cardiology\",\"created_at\":\"2026-08-24T15:04:05Z\",\"updated_at\":\"2026-08-25T16:04:05Z\",\"structure_definition\":{\"resourceType\":\"StructureDefinition\",\"id\":\"custom-patient\",\"url\":\"http://phenoml.com/fhir/StructureDefinition/custom-patient\",\"version\":\"1.0.0\",\"name\":\"CustomPatient\",\"status\":\"active\",\"fhirVersion\":\"4.0.1\",\"kind\":\"resource\",\"abstract\":false,\"type\":\"Patient\",\"baseDefinition\":\"http://hl7.org/fhir/StructureDefinition/Patient\",\"derivation\":\"constraint\",\"snapshot\":{\"element\":[{\"id\":\"Patient\",\"path\":\"Patient\",\"min\":0,\"max\":\"*\"}]}}}")); + ProfileGetResponse response = client.profiles().profiles().get("custom-patient"); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("GET", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate response body + Assertions.assertNotNull(response, "Response should not be null"); + String actualResponseJson = objectMapper.writeValueAsString(response); + String expectedResponseBody = "" + + "{\n" + + " \"id\": \"custom-patient\",\n" + + " \"source\": \"custom\",\n" + + " \"resource_type\": \"Patient\",\n" + + " \"url\": \"http://phenoml.com/fhir/StructureDefinition/custom-patient\",\n" + + " \"version\": \"1.0.0\",\n" + + " \"status\": \"active\",\n" + + " \"date\": \"2026-08-24\",\n" + + " \"canonical\": \"http://phenoml.com/fhir/StructureDefinition/custom-patient|1.0.0\",\n" + + " \"fhir_version\": \"4.0.1\",\n" + + " \"implementation_guide\": \"acme-cardiology\",\n" + + " \"created_at\": \"2026-08-24T15:04:05Z\",\n" + + " \"updated_at\": \"2026-08-25T16:04:05Z\",\n" + + " \"structure_definition\": {\n" + + " \"resourceType\": \"StructureDefinition\",\n" + + " \"id\": \"custom-patient\",\n" + + " \"url\": \"http://phenoml.com/fhir/StructureDefinition/custom-patient\",\n" + + " \"version\": \"1.0.0\",\n" + + " \"name\": \"CustomPatient\",\n" + + " \"status\": \"active\",\n" + + " \"fhirVersion\": \"4.0.1\",\n" + + " \"kind\": \"resource\",\n" + + " \"abstract\": false,\n" + + " \"type\": \"Patient\",\n" + + " \"baseDefinition\": \"http://hl7.org/fhir/StructureDefinition/Patient\",\n" + + " \"derivation\": \"constraint\",\n" + + " \"snapshot\": {\n" + + " \"element\": [\n" + + " {\n" + + " \"id\": \"Patient\",\n" + + " \"path\": \"Patient\",\n" + + " \"min\": 0,\n" + + " \"max\": \"*\"\n" + + " }\n" + + " ]\n" + + " }\n" + + " }\n" + + "}"; + JsonNode actualResponseNode = objectMapper.readTree(actualResponseJson); + JsonNode expectedResponseNode = objectMapper.readTree(expectedResponseBody); + Assertions.assertTrue( + jsonEquals(expectedResponseNode, actualResponseNode), + "Response body structure does not match expected"); + if (actualResponseNode.has("type") || actualResponseNode.has("_type") || actualResponseNode.has("kind")) { + String discriminator = null; + if (actualResponseNode.has("type")) + discriminator = actualResponseNode.get("type").asText(); + else if (actualResponseNode.has("_type")) + discriminator = actualResponseNode.get("_type").asText(); + else if (actualResponseNode.has("kind")) + discriminator = actualResponseNode.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); } @@ -542,24 +786,992 @@ else if (actualResponseNode.has("kind")) } @Test - public void testDelete() throws Exception { + public void testGetThrowsBadRequestError() throws Exception { // OAuth: enqueue token response (client fetches token before API call) server.enqueue(new MockResponse() .setResponseCode(200) .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); - server.enqueue(new MockResponse().setResponseCode(200).setBody("{}")); - client.profiles().profiles().delete("custom-patient"); + server.enqueue(new MockResponse().setResponseCode(400).setBody("{\"key\":\"value\"}")); + BadRequestError exception = Assertions.assertThrows( + BadRequestError.class, () -> client.profiles().profiles().get("id")); // OAuth: consume the token request server.takeRequest(); RecordedRequest request = server.takeRequest(); Assertions.assertNotNull(request); - Assertions.assertEquals("DELETE", request.getMethod()); + Assertions.assertEquals("GET", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate error response + Assertions.assertEquals(400, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testGetThrowsUnauthorizedError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(401).setBody("{\"key\":\"value\"}")); + UnauthorizedError exception = Assertions.assertThrows( + UnauthorizedError.class, () -> client.profiles().profiles().get("id")); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("GET", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate error response + Assertions.assertEquals(401, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testGetThrowsForbiddenError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(403).setBody("{\"key\":\"value\"}")); + ForbiddenError exception = Assertions.assertThrows( + ForbiddenError.class, () -> client.profiles().profiles().get("id")); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("GET", request.getMethod()); // Validate OAuth Authorization header Assertions.assertEquals( "Bearer test-token", request.getHeader("Authorization"), "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate error response + Assertions.assertEquals(403, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testGetThrowsNotFoundError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(404).setBody("{\"key\":\"value\"}")); + NotFoundError exception = Assertions.assertThrows( + NotFoundError.class, () -> client.profiles().profiles().get("id")); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("GET", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate error response + Assertions.assertEquals(404, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testGetThrowsInternalServerError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(500).setBody("{\"key\":\"value\"}")); + InternalServerError exception = Assertions.assertThrows( + InternalServerError.class, () -> client.profiles().profiles().get("id")); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("GET", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate error response + Assertions.assertEquals(500, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testUpdate() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue( + new MockResponse() + .setResponseCode(200) + .setBody( + "{\"id\":\"custom-patient\",\"source\":\"custom\",\"resource_type\":\"Patient\",\"url\":\"http://phenoml.com/fhir/StructureDefinition/custom-patient\",\"version\":\"1.0.0\",\"status\":\"active\",\"date\":\"2026-08-24\",\"canonical\":\"http://phenoml.com/fhir/StructureDefinition/custom-patient|1.0.0\",\"fhir_version\":\"4.0.1\",\"implementation_guide\":\"acme-cardiology\",\"created_at\":\"2026-08-24T15:04:05Z\",\"updated_at\":\"2026-08-25T16:04:05Z\"}")); + ProfileSummary response = client.profiles() + .profiles() + .update( + "custom-patient", + ProfileUploadRequest.builder() + .structureDefinition(new HashMap() { + { + put("resourceType", "StructureDefinition"); + put("id", "custom-patient"); + put("url", "http://phenoml.com/fhir/StructureDefinition/custom-patient"); + put("name", "CustomPatient"); + put("status", "active"); + put("fhirVersion", "4.0.1"); + put("kind", "resource"); + put("abstract", false); + put("type", "Patient"); + put("baseDefinition", "http://hl7.org/fhir/StructureDefinition/Patient"); + put("derivation", "constraint"); + put("snapshot", new HashMap() { + { + put( + "element", + new ArrayList( + Arrays.asList(new HashMap() { + { + put("id", "Patient"); + put("path", "Patient"); + put("min", 0); + put("max", "*"); + } + }))); + } + }); + } + }) + .implementationGuide("acme-cardiology") + .build()); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("PUT", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = "" + + "{\n" + + " \"structure_definition\": {\n" + + " \"resourceType\": \"StructureDefinition\",\n" + + " \"id\": \"custom-patient\",\n" + + " \"url\": \"http://phenoml.com/fhir/StructureDefinition/custom-patient\",\n" + + " \"name\": \"CustomPatient\",\n" + + " \"status\": \"active\",\n" + + " \"fhirVersion\": \"4.0.1\",\n" + + " \"kind\": \"resource\",\n" + + " \"abstract\": false,\n" + + " \"type\": \"Patient\",\n" + + " \"baseDefinition\": \"http://hl7.org/fhir/StructureDefinition/Patient\",\n" + + " \"derivation\": \"constraint\",\n" + + " \"snapshot\": {\n" + + " \"element\": [\n" + + " {\n" + + " \"id\": \"Patient\",\n" + + " \"path\": \"Patient\",\n" + + " \"min\": 0,\n" + + " \"max\": \"*\"\n" + + " }\n" + + " ]\n" + + " }\n" + + " },\n" + + " \"implementation_guide\": \"acme-cardiology\"\n" + + "}"; + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { + String discriminator = null; + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualJson.isNull()) { + Assertions.assertTrue( + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); + } + + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); + } + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); + } + + // Validate response body + Assertions.assertNotNull(response, "Response should not be null"); + String actualResponseJson = objectMapper.writeValueAsString(response); + String expectedResponseBody = "" + + "{\n" + + " \"id\": \"custom-patient\",\n" + + " \"source\": \"custom\",\n" + + " \"resource_type\": \"Patient\",\n" + + " \"url\": \"http://phenoml.com/fhir/StructureDefinition/custom-patient\",\n" + + " \"version\": \"1.0.0\",\n" + + " \"status\": \"active\",\n" + + " \"date\": \"2026-08-24\",\n" + + " \"canonical\": \"http://phenoml.com/fhir/StructureDefinition/custom-patient|1.0.0\",\n" + + " \"fhir_version\": \"4.0.1\",\n" + + " \"implementation_guide\": \"acme-cardiology\",\n" + + " \"created_at\": \"2026-08-24T15:04:05Z\",\n" + + " \"updated_at\": \"2026-08-25T16:04:05Z\"\n" + + "}"; + JsonNode actualResponseNode = objectMapper.readTree(actualResponseJson); + JsonNode expectedResponseNode = objectMapper.readTree(expectedResponseBody); + Assertions.assertTrue( + jsonEquals(expectedResponseNode, actualResponseNode), + "Response body structure does not match expected"); + if (actualResponseNode.has("type") || actualResponseNode.has("_type") || actualResponseNode.has("kind")) { + String discriminator = null; + if (actualResponseNode.has("type")) + discriminator = actualResponseNode.get("type").asText(); + else if (actualResponseNode.has("_type")) + discriminator = actualResponseNode.get("_type").asText(); + else if (actualResponseNode.has("kind")) + discriminator = actualResponseNode.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualResponseNode.isNull()) { + Assertions.assertTrue( + actualResponseNode.isObject() || actualResponseNode.isArray() || actualResponseNode.isValueNode(), + "response should be a valid JSON value"); + } + + if (actualResponseNode.isArray()) { + Assertions.assertTrue(actualResponseNode.size() >= 0, "Array should have valid size"); + } + if (actualResponseNode.isObject()) { + Assertions.assertTrue(actualResponseNode.size() >= 0, "Object should have valid field count"); + } + } + + @Test + public void testUpdateThrowsBadRequestError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(400).setBody("{\"key\":\"value\"}")); + BadRequestError exception = Assertions.assertThrows(BadRequestError.class, () -> client.profiles() + .profiles() + .update( + "id", + ProfileUploadRequest.builder() + .structureDefinition(new HashMap() { + { + put("structure_definition", new HashMap() { + { + put("key", "value"); + } + }); + } + }) + .build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("PUT", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = "" + + "{\n" + + " \"structure_definition\": {\n" + + " \"structure_definition\": {\n" + + " \"key\": \"value\"\n" + + " }\n" + + " }\n" + + "}"; + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { + String discriminator = null; + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualJson.isNull()) { + Assertions.assertTrue( + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); + } + + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); + } + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); + } + + // Validate error response + Assertions.assertEquals(400, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testUpdateThrowsUnauthorizedError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(401).setBody("{\"key\":\"value\"}")); + UnauthorizedError exception = Assertions.assertThrows(UnauthorizedError.class, () -> client.profiles() + .profiles() + .update( + "id", + ProfileUploadRequest.builder() + .structureDefinition(new HashMap() { + { + put("structure_definition", new HashMap() { + { + put("key", "value"); + } + }); + } + }) + .build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("PUT", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = "" + + "{\n" + + " \"structure_definition\": {\n" + + " \"structure_definition\": {\n" + + " \"key\": \"value\"\n" + + " }\n" + + " }\n" + + "}"; + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { + String discriminator = null; + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualJson.isNull()) { + Assertions.assertTrue( + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); + } + + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); + } + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); + } + + // Validate error response + Assertions.assertEquals(401, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testUpdateThrowsForbiddenError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(403).setBody("{\"key\":\"value\"}")); + ForbiddenError exception = Assertions.assertThrows(ForbiddenError.class, () -> client.profiles() + .profiles() + .update( + "id", + ProfileUploadRequest.builder() + .structureDefinition(new HashMap() { + { + put("structure_definition", new HashMap() { + { + put("key", "value"); + } + }); + } + }) + .build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("PUT", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = "" + + "{\n" + + " \"structure_definition\": {\n" + + " \"structure_definition\": {\n" + + " \"key\": \"value\"\n" + + " }\n" + + " }\n" + + "}"; + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { + String discriminator = null; + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualJson.isNull()) { + Assertions.assertTrue( + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); + } + + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); + } + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); + } + + // Validate error response + Assertions.assertEquals(403, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testUpdateThrowsNotFoundError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(404).setBody("{\"key\":\"value\"}")); + NotFoundError exception = Assertions.assertThrows(NotFoundError.class, () -> client.profiles() + .profiles() + .update( + "id", + ProfileUploadRequest.builder() + .structureDefinition(new HashMap() { + { + put("structure_definition", new HashMap() { + { + put("key", "value"); + } + }); + } + }) + .build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("PUT", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = "" + + "{\n" + + " \"structure_definition\": {\n" + + " \"structure_definition\": {\n" + + " \"key\": \"value\"\n" + + " }\n" + + " }\n" + + "}"; + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { + String discriminator = null; + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualJson.isNull()) { + Assertions.assertTrue( + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); + } + + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); + } + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); + } + + // Validate error response + Assertions.assertEquals(404, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testUpdateThrowsConflictError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(409).setBody("{\"key\":\"value\"}")); + ConflictError exception = Assertions.assertThrows(ConflictError.class, () -> client.profiles() + .profiles() + .update( + "id", + ProfileUploadRequest.builder() + .structureDefinition(new HashMap() { + { + put("structure_definition", new HashMap() { + { + put("key", "value"); + } + }); + } + }) + .build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("PUT", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = "" + + "{\n" + + " \"structure_definition\": {\n" + + " \"structure_definition\": {\n" + + " \"key\": \"value\"\n" + + " }\n" + + " }\n" + + "}"; + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { + String discriminator = null; + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualJson.isNull()) { + Assertions.assertTrue( + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); + } + + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); + } + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); + } + + // Validate error response + Assertions.assertEquals(409, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testUpdateThrowsInternalServerError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(500).setBody("{\"key\":\"value\"}")); + InternalServerError exception = Assertions.assertThrows(InternalServerError.class, () -> client.profiles() + .profiles() + .update( + "id", + ProfileUploadRequest.builder() + .structureDefinition(new HashMap() { + { + put("structure_definition", new HashMap() { + { + put("key", "value"); + } + }); + } + }) + .build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("PUT", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = "" + + "{\n" + + " \"structure_definition\": {\n" + + " \"structure_definition\": {\n" + + " \"key\": \"value\"\n" + + " }\n" + + " }\n" + + "}"; + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { + String discriminator = null; + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualJson.isNull()) { + Assertions.assertTrue( + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); + } + + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); + } + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); + } + + // Validate error response + Assertions.assertEquals(500, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testDelete() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(200).setBody("{}")); + client.profiles().profiles().delete("custom-patient"); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("DELETE", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + } + + @Test + public void testDeleteThrowsBadRequestError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(400).setBody("{\"key\":\"value\"}")); + BadRequestError exception = Assertions.assertThrows( + BadRequestError.class, () -> client.profiles().profiles().delete("id")); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("DELETE", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate error response + Assertions.assertEquals(400, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testDeleteThrowsUnauthorizedError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(401).setBody("{\"key\":\"value\"}")); + UnauthorizedError exception = Assertions.assertThrows( + UnauthorizedError.class, () -> client.profiles().profiles().delete("id")); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("DELETE", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate error response + Assertions.assertEquals(401, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testDeleteThrowsForbiddenError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(403).setBody("{\"key\":\"value\"}")); + ForbiddenError exception = Assertions.assertThrows( + ForbiddenError.class, () -> client.profiles().profiles().delete("id")); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("DELETE", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate error response + Assertions.assertEquals(403, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testDeleteThrowsNotFoundError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(404).setBody("{\"key\":\"value\"}")); + NotFoundError exception = Assertions.assertThrows( + NotFoundError.class, () -> client.profiles().profiles().delete("id")); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("DELETE", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate error response + Assertions.assertEquals(404, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testDeleteThrowsConflictError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(409).setBody("{\"key\":\"value\"}")); + ConflictError exception = Assertions.assertThrows( + ConflictError.class, () -> client.profiles().profiles().delete("id")); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("DELETE", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate error response + Assertions.assertEquals(409, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testDeleteThrowsInternalServerError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(500).setBody("{\"key\":\"value\"}")); + InternalServerError exception = Assertions.assertThrows( + InternalServerError.class, () -> client.profiles().profiles().delete("id")); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("DELETE", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate error response + Assertions.assertEquals(500, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); } /** diff --git a/src/test/java/com/phenoml/api/ProfilesVersionsWireTest.java b/src/test/java/com/phenoml/api/ProfilesVersionsWireTest.java index ed84618b..b54b0595 100644 --- a/src/test/java/com/phenoml/api/ProfilesVersionsWireTest.java +++ b/src/test/java/com/phenoml/api/ProfilesVersionsWireTest.java @@ -3,6 +3,12 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.phenoml.api.core.ObjectMappers; +import com.phenoml.api.resources.profiles.errors.BadRequestError; +import com.phenoml.api.resources.profiles.errors.ConflictError; +import com.phenoml.api.resources.profiles.errors.ForbiddenError; +import com.phenoml.api.resources.profiles.errors.InternalServerError; +import com.phenoml.api.resources.profiles.errors.NotFoundError; +import com.phenoml.api.resources.profiles.errors.UnauthorizedError; import com.phenoml.api.resources.profiles.types.ProfileGetResponse; import com.phenoml.api.resources.profiles.types.ProfileSummary; import com.phenoml.api.resources.profiles.types.ProfileVersionListResponse; @@ -26,6 +32,7 @@ public void setup() throws Exception { server.start(); client = PhenomlClient.withCredentials("test-client-id", "test-client-secret") .url(server.url("/").toString()) + .maxRetries(0) .build(); } @@ -125,6 +132,156 @@ else if (actualResponseNode.has("kind")) } } + @Test + public void testListThrowsBadRequestError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(400).setBody("{\"key\":\"value\"}")); + BadRequestError exception = Assertions.assertThrows( + BadRequestError.class, () -> client.profiles().versions().list("id")); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("GET", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate error response + Assertions.assertEquals(400, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testListThrowsUnauthorizedError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(401).setBody("{\"key\":\"value\"}")); + UnauthorizedError exception = Assertions.assertThrows( + UnauthorizedError.class, () -> client.profiles().versions().list("id")); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("GET", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate error response + Assertions.assertEquals(401, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testListThrowsForbiddenError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(403).setBody("{\"key\":\"value\"}")); + ForbiddenError exception = Assertions.assertThrows( + ForbiddenError.class, () -> client.profiles().versions().list("id")); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("GET", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate error response + Assertions.assertEquals(403, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testListThrowsNotFoundError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(404).setBody("{\"key\":\"value\"}")); + NotFoundError exception = Assertions.assertThrows( + NotFoundError.class, () -> client.profiles().versions().list("id")); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("GET", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate error response + Assertions.assertEquals(404, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testListThrowsInternalServerError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(500).setBody("{\"key\":\"value\"}")); + InternalServerError exception = Assertions.assertThrows( + InternalServerError.class, () -> client.profiles().versions().list("id")); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("GET", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate error response + Assertions.assertEquals(500, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + @Test public void testCreate() throws Exception { // OAuth: enqueue token response (client fetches token before API call) @@ -133,7 +290,7 @@ public void testCreate() throws Exception { .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); server.enqueue( new MockResponse() - .setResponseCode(200) + .setResponseCode(201) .setBody( "{\"id\":\"custom-patient\",\"source\":\"custom\",\"resource_type\":\"Patient\",\"url\":\"http://phenoml.com/fhir/StructureDefinition/custom-patient\",\"version\":\"1.0.0\",\"status\":\"active\",\"date\":\"2026-08-24\",\"canonical\":\"http://phenoml.com/fhir/StructureDefinition/custom-patient|1.0.0\",\"fhir_version\":\"4.0.1\",\"implementation_guide\":\"acme-cardiology\",\"created_at\":\"2026-08-24T15:04:05Z\",\"updated_at\":\"2026-08-25T16:04:05Z\"}")); ProfileSummary response = client.profiles().versions().create("custom-patient", new HashMap() { @@ -232,121 +389,854 @@ else if (actualResponseNode.has("kind")) } @Test - public void testGet() throws Exception { + public void testCreateThrowsBadRequestError() throws Exception { // OAuth: enqueue token response (client fetches token before API call) server.enqueue(new MockResponse() .setResponseCode(200) .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); - server.enqueue( - new MockResponse() - .setResponseCode(200) - .setBody( - "{\"id\":\"custom-patient\",\"source\":\"custom\",\"resource_type\":\"Patient\",\"url\":\"http://phenoml.com/fhir/StructureDefinition/custom-patient\",\"version\":\"1.0.0\",\"status\":\"active\",\"date\":\"2026-08-24\",\"canonical\":\"http://phenoml.com/fhir/StructureDefinition/custom-patient|1.0.0\",\"fhir_version\":\"4.0.1\",\"implementation_guide\":\"acme-cardiology\",\"created_at\":\"2026-08-24T15:04:05Z\",\"updated_at\":\"2026-08-25T16:04:05Z\",\"structure_definition\":{\"resourceType\":\"StructureDefinition\",\"id\":\"custom-patient\",\"url\":\"http://phenoml.com/fhir/StructureDefinition/custom-patient\",\"version\":\"1.0.0\",\"name\":\"CustomPatient\",\"status\":\"active\",\"fhirVersion\":\"4.0.1\",\"kind\":\"resource\",\"abstract\":false,\"type\":\"Patient\",\"baseDefinition\":\"http://hl7.org/fhir/StructureDefinition/Patient\",\"derivation\":\"constraint\",\"snapshot\":{\"element\":[{\"id\":\"Patient\",\"path\":\"Patient\",\"min\":0,\"max\":\"*\"}]}}}")); - ProfileGetResponse response = client.profiles().versions().get("custom-patient", "2.0.0"); + server.enqueue(new MockResponse().setResponseCode(400).setBody("{\"key\":\"value\"}")); + BadRequestError exception = Assertions.assertThrows( + BadRequestError.class, () -> client.profiles().versions().create("id", new HashMap() { + { + put("string", new HashMap() { + { + put("key", "value"); + } + }); + } + })); // OAuth: consume the token request server.takeRequest(); RecordedRequest request = server.takeRequest(); Assertions.assertNotNull(request); - Assertions.assertEquals("GET", request.getMethod()); + Assertions.assertEquals("POST", request.getMethod()); // Validate OAuth Authorization header Assertions.assertEquals( "Bearer test-token", request.getHeader("Authorization"), "OAuth Authorization header should contain Bearer token from OAuth flow"); + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = "" + "{\n" + " \"string\": {\n" + " \"key\": \"value\"\n" + " }\n" + "}"; + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { + String discriminator = null; + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } - // Validate response body - Assertions.assertNotNull(response, "Response should not be null"); - String actualResponseJson = objectMapper.writeValueAsString(response); - String expectedResponseBody = "" - + "{\n" - + " \"id\": \"custom-patient\",\n" - + " \"source\": \"custom\",\n" - + " \"resource_type\": \"Patient\",\n" - + " \"url\": \"http://phenoml.com/fhir/StructureDefinition/custom-patient\",\n" - + " \"version\": \"1.0.0\",\n" - + " \"status\": \"active\",\n" - + " \"date\": \"2026-08-24\",\n" - + " \"canonical\": \"http://phenoml.com/fhir/StructureDefinition/custom-patient|1.0.0\",\n" - + " \"fhir_version\": \"4.0.1\",\n" - + " \"implementation_guide\": \"acme-cardiology\",\n" - + " \"created_at\": \"2026-08-24T15:04:05Z\",\n" - + " \"updated_at\": \"2026-08-25T16:04:05Z\",\n" - + " \"structure_definition\": {\n" - + " \"resourceType\": \"StructureDefinition\",\n" - + " \"id\": \"custom-patient\",\n" - + " \"url\": \"http://phenoml.com/fhir/StructureDefinition/custom-patient\",\n" - + " \"version\": \"1.0.0\",\n" - + " \"name\": \"CustomPatient\",\n" - + " \"status\": \"active\",\n" - + " \"fhirVersion\": \"4.0.1\",\n" - + " \"kind\": \"resource\",\n" - + " \"abstract\": false,\n" - + " \"type\": \"Patient\",\n" - + " \"baseDefinition\": \"http://hl7.org/fhir/StructureDefinition/Patient\",\n" - + " \"derivation\": \"constraint\",\n" - + " \"snapshot\": {\n" - + " \"element\": [\n" - + " {\n" - + " \"id\": \"Patient\",\n" - + " \"path\": \"Patient\",\n" - + " \"min\": 0,\n" - + " \"max\": \"*\"\n" - + " }\n" - + " ]\n" - + " }\n" - + " }\n" - + "}"; - JsonNode actualResponseNode = objectMapper.readTree(actualResponseJson); - JsonNode expectedResponseNode = objectMapper.readTree(expectedResponseBody); - Assertions.assertTrue( - jsonEquals(expectedResponseNode, actualResponseNode), - "Response body structure does not match expected"); - if (actualResponseNode.has("type") || actualResponseNode.has("_type") || actualResponseNode.has("kind")) { + if (!actualJson.isNull()) { + Assertions.assertTrue( + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); + } + + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); + } + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); + } + + // Validate error response + Assertions.assertEquals(400, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testCreateThrowsUnauthorizedError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(401).setBody("{\"key\":\"value\"}")); + UnauthorizedError exception = Assertions.assertThrows( + UnauthorizedError.class, () -> client.profiles().versions().create("id", new HashMap() { + { + put("string", new HashMap() { + { + put("key", "value"); + } + }); + } + })); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("POST", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = "" + "{\n" + " \"string\": {\n" + " \"key\": \"value\"\n" + " }\n" + "}"; + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { String discriminator = null; - if (actualResponseNode.has("type")) - discriminator = actualResponseNode.get("type").asText(); - else if (actualResponseNode.has("_type")) - discriminator = actualResponseNode.get("_type").asText(); - else if (actualResponseNode.has("kind")) - discriminator = actualResponseNode.get("kind").asText(); + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); } - if (!actualResponseNode.isNull()) { + if (!actualJson.isNull()) { Assertions.assertTrue( - actualResponseNode.isObject() || actualResponseNode.isArray() || actualResponseNode.isValueNode(), - "response should be a valid JSON value"); + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); } - if (actualResponseNode.isArray()) { - Assertions.assertTrue(actualResponseNode.size() >= 0, "Array should have valid size"); + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); } - if (actualResponseNode.isObject()) { - Assertions.assertTrue(actualResponseNode.size() >= 0, "Object should have valid field count"); + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); } + + // Validate error response + Assertions.assertEquals(401, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); } @Test - public void testDelete() throws Exception { + public void testCreateThrowsForbiddenError() throws Exception { // OAuth: enqueue token response (client fetches token before API call) server.enqueue(new MockResponse() .setResponseCode(200) .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); - server.enqueue(new MockResponse().setResponseCode(200).setBody("{}")); - client.profiles().versions().delete("custom-patient", "2.0.0"); + server.enqueue(new MockResponse().setResponseCode(403).setBody("{\"key\":\"value\"}")); + ForbiddenError exception = Assertions.assertThrows( + ForbiddenError.class, () -> client.profiles().versions().create("id", new HashMap() { + { + put("string", new HashMap() { + { + put("key", "value"); + } + }); + } + })); // OAuth: consume the token request server.takeRequest(); RecordedRequest request = server.takeRequest(); Assertions.assertNotNull(request); - Assertions.assertEquals("DELETE", request.getMethod()); + Assertions.assertEquals("POST", request.getMethod()); // Validate OAuth Authorization header Assertions.assertEquals( "Bearer test-token", request.getHeader("Authorization"), "OAuth Authorization header should contain Bearer token from OAuth flow"); + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = "" + "{\n" + " \"string\": {\n" + " \"key\": \"value\"\n" + " }\n" + "}"; + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { + String discriminator = null; + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualJson.isNull()) { + Assertions.assertTrue( + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); + } + + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); + } + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); + } + + // Validate error response + Assertions.assertEquals(403, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testCreateThrowsNotFoundError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(404).setBody("{\"key\":\"value\"}")); + NotFoundError exception = Assertions.assertThrows( + NotFoundError.class, () -> client.profiles().versions().create("id", new HashMap() { + { + put("string", new HashMap() { + { + put("key", "value"); + } + }); + } + })); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("POST", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = "" + "{\n" + " \"string\": {\n" + " \"key\": \"value\"\n" + " }\n" + "}"; + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { + String discriminator = null; + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualJson.isNull()) { + Assertions.assertTrue( + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); + } + + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); + } + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); + } + + // Validate error response + Assertions.assertEquals(404, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testCreateThrowsConflictError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(409).setBody("{\"key\":\"value\"}")); + ConflictError exception = Assertions.assertThrows( + ConflictError.class, () -> client.profiles().versions().create("id", new HashMap() { + { + put("string", new HashMap() { + { + put("key", "value"); + } + }); + } + })); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("POST", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = "" + "{\n" + " \"string\": {\n" + " \"key\": \"value\"\n" + " }\n" + "}"; + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { + String discriminator = null; + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualJson.isNull()) { + Assertions.assertTrue( + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); + } + + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); + } + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); + } + + // Validate error response + Assertions.assertEquals(409, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testCreateThrowsInternalServerError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(500).setBody("{\"key\":\"value\"}")); + InternalServerError exception = Assertions.assertThrows( + InternalServerError.class, + () -> client.profiles().versions().create("id", new HashMap() { + { + put("string", new HashMap() { + { + put("key", "value"); + } + }); + } + })); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("POST", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = "" + "{\n" + " \"string\": {\n" + " \"key\": \"value\"\n" + " }\n" + "}"; + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { + String discriminator = null; + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualJson.isNull()) { + Assertions.assertTrue( + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); + } + + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); + } + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); + } + + // Validate error response + Assertions.assertEquals(500, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testGet() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue( + new MockResponse() + .setResponseCode(200) + .setBody( + "{\"id\":\"custom-patient\",\"source\":\"custom\",\"resource_type\":\"Patient\",\"url\":\"http://phenoml.com/fhir/StructureDefinition/custom-patient\",\"version\":\"1.0.0\",\"status\":\"active\",\"date\":\"2026-08-24\",\"canonical\":\"http://phenoml.com/fhir/StructureDefinition/custom-patient|1.0.0\",\"fhir_version\":\"4.0.1\",\"implementation_guide\":\"acme-cardiology\",\"created_at\":\"2026-08-24T15:04:05Z\",\"updated_at\":\"2026-08-25T16:04:05Z\",\"structure_definition\":{\"resourceType\":\"StructureDefinition\",\"id\":\"custom-patient\",\"url\":\"http://phenoml.com/fhir/StructureDefinition/custom-patient\",\"version\":\"1.0.0\",\"name\":\"CustomPatient\",\"status\":\"active\",\"fhirVersion\":\"4.0.1\",\"kind\":\"resource\",\"abstract\":false,\"type\":\"Patient\",\"baseDefinition\":\"http://hl7.org/fhir/StructureDefinition/Patient\",\"derivation\":\"constraint\",\"snapshot\":{\"element\":[{\"id\":\"Patient\",\"path\":\"Patient\",\"min\":0,\"max\":\"*\"}]}}}")); + ProfileGetResponse response = client.profiles().versions().get("custom-patient", "2.0.0"); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("GET", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate response body + Assertions.assertNotNull(response, "Response should not be null"); + String actualResponseJson = objectMapper.writeValueAsString(response); + String expectedResponseBody = "" + + "{\n" + + " \"id\": \"custom-patient\",\n" + + " \"source\": \"custom\",\n" + + " \"resource_type\": \"Patient\",\n" + + " \"url\": \"http://phenoml.com/fhir/StructureDefinition/custom-patient\",\n" + + " \"version\": \"1.0.0\",\n" + + " \"status\": \"active\",\n" + + " \"date\": \"2026-08-24\",\n" + + " \"canonical\": \"http://phenoml.com/fhir/StructureDefinition/custom-patient|1.0.0\",\n" + + " \"fhir_version\": \"4.0.1\",\n" + + " \"implementation_guide\": \"acme-cardiology\",\n" + + " \"created_at\": \"2026-08-24T15:04:05Z\",\n" + + " \"updated_at\": \"2026-08-25T16:04:05Z\",\n" + + " \"structure_definition\": {\n" + + " \"resourceType\": \"StructureDefinition\",\n" + + " \"id\": \"custom-patient\",\n" + + " \"url\": \"http://phenoml.com/fhir/StructureDefinition/custom-patient\",\n" + + " \"version\": \"1.0.0\",\n" + + " \"name\": \"CustomPatient\",\n" + + " \"status\": \"active\",\n" + + " \"fhirVersion\": \"4.0.1\",\n" + + " \"kind\": \"resource\",\n" + + " \"abstract\": false,\n" + + " \"type\": \"Patient\",\n" + + " \"baseDefinition\": \"http://hl7.org/fhir/StructureDefinition/Patient\",\n" + + " \"derivation\": \"constraint\",\n" + + " \"snapshot\": {\n" + + " \"element\": [\n" + + " {\n" + + " \"id\": \"Patient\",\n" + + " \"path\": \"Patient\",\n" + + " \"min\": 0,\n" + + " \"max\": \"*\"\n" + + " }\n" + + " ]\n" + + " }\n" + + " }\n" + + "}"; + JsonNode actualResponseNode = objectMapper.readTree(actualResponseJson); + JsonNode expectedResponseNode = objectMapper.readTree(expectedResponseBody); + Assertions.assertTrue( + jsonEquals(expectedResponseNode, actualResponseNode), + "Response body structure does not match expected"); + if (actualResponseNode.has("type") || actualResponseNode.has("_type") || actualResponseNode.has("kind")) { + String discriminator = null; + if (actualResponseNode.has("type")) + discriminator = actualResponseNode.get("type").asText(); + else if (actualResponseNode.has("_type")) + discriminator = actualResponseNode.get("_type").asText(); + else if (actualResponseNode.has("kind")) + discriminator = actualResponseNode.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualResponseNode.isNull()) { + Assertions.assertTrue( + actualResponseNode.isObject() || actualResponseNode.isArray() || actualResponseNode.isValueNode(), + "response should be a valid JSON value"); + } + + if (actualResponseNode.isArray()) { + Assertions.assertTrue(actualResponseNode.size() >= 0, "Array should have valid size"); + } + if (actualResponseNode.isObject()) { + Assertions.assertTrue(actualResponseNode.size() >= 0, "Object should have valid field count"); + } + } + + @Test + public void testGetThrowsBadRequestError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(400).setBody("{\"key\":\"value\"}")); + BadRequestError exception = Assertions.assertThrows( + BadRequestError.class, () -> client.profiles().versions().get("id", "version")); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("GET", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate error response + Assertions.assertEquals(400, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testGetThrowsUnauthorizedError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(401).setBody("{\"key\":\"value\"}")); + UnauthorizedError exception = Assertions.assertThrows( + UnauthorizedError.class, () -> client.profiles().versions().get("id", "version")); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("GET", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate error response + Assertions.assertEquals(401, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testGetThrowsForbiddenError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(403).setBody("{\"key\":\"value\"}")); + ForbiddenError exception = Assertions.assertThrows( + ForbiddenError.class, () -> client.profiles().versions().get("id", "version")); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("GET", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate error response + Assertions.assertEquals(403, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testGetThrowsNotFoundError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(404).setBody("{\"key\":\"value\"}")); + NotFoundError exception = Assertions.assertThrows( + NotFoundError.class, () -> client.profiles().versions().get("id", "version")); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("GET", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate error response + Assertions.assertEquals(404, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testGetThrowsInternalServerError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(500).setBody("{\"key\":\"value\"}")); + InternalServerError exception = Assertions.assertThrows( + InternalServerError.class, () -> client.profiles().versions().get("id", "version")); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("GET", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate error response + Assertions.assertEquals(500, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testDelete() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(200).setBody("{}")); + client.profiles().versions().delete("custom-patient", "2.0.0"); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("DELETE", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + } + + @Test + public void testDeleteThrowsBadRequestError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(400).setBody("{\"key\":\"value\"}")); + BadRequestError exception = Assertions.assertThrows( + BadRequestError.class, () -> client.profiles().versions().delete("id", "version")); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("DELETE", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate error response + Assertions.assertEquals(400, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testDeleteThrowsUnauthorizedError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(401).setBody("{\"key\":\"value\"}")); + UnauthorizedError exception = Assertions.assertThrows( + UnauthorizedError.class, () -> client.profiles().versions().delete("id", "version")); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("DELETE", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate error response + Assertions.assertEquals(401, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testDeleteThrowsForbiddenError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(403).setBody("{\"key\":\"value\"}")); + ForbiddenError exception = Assertions.assertThrows( + ForbiddenError.class, () -> client.profiles().versions().delete("id", "version")); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("DELETE", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate error response + Assertions.assertEquals(403, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testDeleteThrowsNotFoundError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(404).setBody("{\"key\":\"value\"}")); + NotFoundError exception = Assertions.assertThrows( + NotFoundError.class, () -> client.profiles().versions().delete("id", "version")); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("DELETE", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate error response + Assertions.assertEquals(404, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testDeleteThrowsConflictError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(409).setBody("{\"key\":\"value\"}")); + ConflictError exception = Assertions.assertThrows( + ConflictError.class, () -> client.profiles().versions().delete("id", "version")); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("DELETE", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate error response + Assertions.assertEquals(409, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testDeleteThrowsInternalServerError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(500).setBody("{\"key\":\"value\"}")); + InternalServerError exception = Assertions.assertThrows( + InternalServerError.class, () -> client.profiles().versions().delete("id", "version")); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("DELETE", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate error response + Assertions.assertEquals(500, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); } /** diff --git a/src/test/java/com/phenoml/api/SummaryTemplatesWireTest.java b/src/test/java/com/phenoml/api/SummaryTemplatesWireTest.java index 1a9c93b6..980afb8b 100644 --- a/src/test/java/com/phenoml/api/SummaryTemplatesWireTest.java +++ b/src/test/java/com/phenoml/api/SummaryTemplatesWireTest.java @@ -3,6 +3,11 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.phenoml.api.core.ObjectMappers; +import com.phenoml.api.resources.summary.errors.BadRequestError; +import com.phenoml.api.resources.summary.errors.ForbiddenError; +import com.phenoml.api.resources.summary.errors.InternalServerError; +import com.phenoml.api.resources.summary.errors.NotFoundError; +import com.phenoml.api.resources.summary.errors.UnauthorizedError; import com.phenoml.api.resources.summary.templates.requests.CreateSummaryTemplateRequest; import com.phenoml.api.resources.summary.templates.requests.UpdateSummaryTemplateRequest; import com.phenoml.api.resources.summary.templates.types.DeleteResponse; @@ -30,6 +35,7 @@ public void setup() throws Exception { server.start(); client = PhenomlClient.withCredentials("test-client-id", "test-client-secret") .url(server.url("/").toString()) + .maxRetries(0) .build(); } @@ -119,6 +125,66 @@ else if (actualResponseNode.has("kind")) } } + @Test + public void testListThrowsUnauthorizedError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(401).setBody("{\"key\":\"value\"}")); + UnauthorizedError exception = Assertions.assertThrows( + UnauthorizedError.class, () -> client.summary().templates().list()); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("GET", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate error response + Assertions.assertEquals(401, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testListThrowsInternalServerError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(500).setBody("{\"key\":\"value\"}")); + InternalServerError exception = Assertions.assertThrows( + InternalServerError.class, () -> client.summary().templates().list()); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("GET", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate error response + Assertions.assertEquals(500, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + @Test public void testCreate() throws Exception { // OAuth: enqueue token response (client fetches token before API call) @@ -248,111 +314,173 @@ else if (actualResponseNode.has("kind")) } @Test - public void testGet() throws Exception { + public void testCreateThrowsBadRequestError() throws Exception { // OAuth: enqueue token response (client fetches token before API call) server.enqueue(new MockResponse() .setResponseCode(200) .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); - server.enqueue( - new MockResponse() - .setResponseCode(200) - .setBody( - "{\"success\":true,\"template\":{\"id\":\"a1b2c3d4-e5f6-7890-abcd-ef1234567890\",\"name\":\"Discharge Summary\",\"description\":\"description\",\"template\":\"Patient <>, age <>, was admitted on <> with <>.\",\"target_resources\":[\"Patient\",\"Condition\",\"MedicationRequest\"],\"mode\":\"narrative\",\"metadata\":{\"key\":\"value\"},\"created_at\":\"2024-01-15T10:30:00Z\",\"updated_at\":\"2024-01-15T10:30:00Z\"}}")); - GetResponse response = client.summary().templates().get("id"); + server.enqueue(new MockResponse().setResponseCode(400).setBody("{\"key\":\"value\"}")); + BadRequestError exception = Assertions.assertThrows(BadRequestError.class, () -> client.summary() + .templates() + .create(CreateSummaryTemplateRequest.builder() + .name("name") + .exampleSummary("example_summary") + .mode("mode") + .targetResources(Arrays.asList("target_resources", "target_resources")) + .build())); // OAuth: consume the token request server.takeRequest(); RecordedRequest request = server.takeRequest(); Assertions.assertNotNull(request); - Assertions.assertEquals("GET", request.getMethod()); + Assertions.assertEquals("POST", request.getMethod()); // Validate OAuth Authorization header Assertions.assertEquals( "Bearer test-token", request.getHeader("Authorization"), "OAuth Authorization header should contain Bearer token from OAuth flow"); + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = "" + + "{\n" + + " \"name\": \"name\",\n" + + " \"example_summary\": \"example_summary\",\n" + + " \"target_resources\": [\n" + + " \"target_resources\",\n" + + " \"target_resources\"\n" + + " ],\n" + + " \"mode\": \"mode\"\n" + + "}"; + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { + String discriminator = null; + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } - // Validate response body - Assertions.assertNotNull(response, "Response should not be null"); - String actualResponseJson = objectMapper.writeValueAsString(response); - String expectedResponseBody = "" + if (!actualJson.isNull()) { + Assertions.assertTrue( + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); + } + + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); + } + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); + } + + // Validate error response + Assertions.assertEquals(400, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testCreateThrowsUnauthorizedError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(401).setBody("{\"key\":\"value\"}")); + UnauthorizedError exception = Assertions.assertThrows(UnauthorizedError.class, () -> client.summary() + .templates() + .create(CreateSummaryTemplateRequest.builder() + .name("name") + .exampleSummary("example_summary") + .mode("mode") + .targetResources(Arrays.asList("target_resources", "target_resources")) + .build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("POST", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = "" + "{\n" - + " \"success\": true,\n" - + " \"template\": {\n" - + " \"id\": \"a1b2c3d4-e5f6-7890-abcd-ef1234567890\",\n" - + " \"name\": \"Discharge Summary\",\n" - + " \"description\": \"description\",\n" - + " \"template\": \"Patient <>, age <>, was admitted on <> with <>.\",\n" - + " \"target_resources\": [\n" - + " \"Patient\",\n" - + " \"Condition\",\n" - + " \"MedicationRequest\"\n" - + " ],\n" - + " \"mode\": \"narrative\",\n" - + " \"metadata\": {\n" - + " \"key\": \"value\"\n" - + " },\n" - + " \"created_at\": \"2024-01-15T10:30:00Z\",\n" - + " \"updated_at\": \"2024-01-15T10:30:00Z\"\n" - + " }\n" + + " \"name\": \"name\",\n" + + " \"example_summary\": \"example_summary\",\n" + + " \"target_resources\": [\n" + + " \"target_resources\",\n" + + " \"target_resources\"\n" + + " ],\n" + + " \"mode\": \"mode\"\n" + "}"; - JsonNode actualResponseNode = objectMapper.readTree(actualResponseJson); - JsonNode expectedResponseNode = objectMapper.readTree(expectedResponseBody); - Assertions.assertTrue( - jsonEquals(expectedResponseNode, actualResponseNode), - "Response body structure does not match expected"); - if (actualResponseNode.has("type") || actualResponseNode.has("_type") || actualResponseNode.has("kind")) { + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { String discriminator = null; - if (actualResponseNode.has("type")) - discriminator = actualResponseNode.get("type").asText(); - else if (actualResponseNode.has("_type")) - discriminator = actualResponseNode.get("_type").asText(); - else if (actualResponseNode.has("kind")) - discriminator = actualResponseNode.get("kind").asText(); + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); } - if (!actualResponseNode.isNull()) { + if (!actualJson.isNull()) { Assertions.assertTrue( - actualResponseNode.isObject() || actualResponseNode.isArray() || actualResponseNode.isValueNode(), - "response should be a valid JSON value"); + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); } - if (actualResponseNode.isArray()) { - Assertions.assertTrue(actualResponseNode.size() >= 0, "Array should have valid size"); + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); } - if (actualResponseNode.isObject()) { - Assertions.assertTrue(actualResponseNode.size() >= 0, "Object should have valid field count"); + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); } + + // Validate error response + Assertions.assertEquals(401, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); } @Test - public void testUpdate() throws Exception { + public void testCreateThrowsInternalServerError() throws Exception { // OAuth: enqueue token response (client fetches token before API call) server.enqueue(new MockResponse() .setResponseCode(200) .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); - server.enqueue( - new MockResponse() - .setResponseCode(200) - .setBody( - "{\"success\":true,\"message\":\"Template updated successfully\",\"template\":{\"id\":\"a1b2c3d4-e5f6-7890-abcd-ef1234567890\",\"name\":\"Discharge Summary\",\"description\":\"description\",\"template\":\"Patient <> was discharged on <> with <> <>.\",\"target_resources\":[\"Patient\",\"Encounter\",\"MedicationRequest\"],\"mode\":\"narrative\",\"metadata\":{\"key\":\"value\"},\"created_at\":\"2024-01-15T10:30:00Z\",\"updated_at\":\"2024-02-20T11:00:00Z\"}}")); - UpdateResponse response = client.summary() + server.enqueue(new MockResponse().setResponseCode(500).setBody("{\"key\":\"value\"}")); + InternalServerError exception = Assertions.assertThrows(InternalServerError.class, () -> client.summary() .templates() - .update( - "id", - UpdateSummaryTemplateRequest.builder() - .name("Discharge Summary") - .template( - "Patient {{Patient.name[0].text}} was discharged on {{Encounter[0].period.end}} with {{MedicationRequest[0].medicationCodeableConcept.coding[0].display}} {{MedicationRequest[0].dosageInstruction[0].text}}.") - .mode("narrative") - .targetResources(Arrays.asList("Patient", "Encounter", "MedicationRequest")) - .build()); + .create(CreateSummaryTemplateRequest.builder() + .name("name") + .exampleSummary("example_summary") + .mode("mode") + .targetResources(Arrays.asList("target_resources", "target_resources")) + .build())); // OAuth: consume the token request server.takeRequest(); RecordedRequest request = server.takeRequest(); Assertions.assertNotNull(request); - Assertions.assertEquals("PUT", request.getMethod()); + Assertions.assertEquals("POST", request.getMethod()); // Validate OAuth Authorization header Assertions.assertEquals( @@ -363,14 +491,13 @@ public void testUpdate() throws Exception { String actualRequestBody = request.getBody().readUtf8(); String expectedRequestBody = "" + "{\n" - + " \"name\": \"Discharge Summary\",\n" - + " \"template\": \"Patient {{Patient.name[0].text}} was discharged on {{Encounter[0].period.end}} with {{MedicationRequest[0].medicationCodeableConcept.coding[0].display}} {{MedicationRequest[0].dosageInstruction[0].text}}.\",\n" + + " \"name\": \"name\",\n" + + " \"example_summary\": \"example_summary\",\n" + " \"target_resources\": [\n" - + " \"Patient\",\n" - + " \"Encounter\",\n" - + " \"MedicationRequest\"\n" + + " \"target_resources\",\n" + + " \"target_resources\"\n" + " ],\n" - + " \"mode\": \"narrative\"\n" + + " \"mode\": \"mode\"\n" + "}"; JsonNode actualJson = objectMapper.readTree(actualRequestBody); JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); @@ -399,21 +526,53 @@ else if (actualJson.has("kind")) Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); } + // Validate error response + Assertions.assertEquals(500, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testGet() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue( + new MockResponse() + .setResponseCode(200) + .setBody( + "{\"success\":true,\"template\":{\"id\":\"a1b2c3d4-e5f6-7890-abcd-ef1234567890\",\"name\":\"Discharge Summary\",\"description\":\"description\",\"template\":\"Patient <>, age <>, was admitted on <> with <>.\",\"target_resources\":[\"Patient\",\"Condition\",\"MedicationRequest\"],\"mode\":\"narrative\",\"metadata\":{\"key\":\"value\"},\"created_at\":\"2024-01-15T10:30:00Z\",\"updated_at\":\"2024-01-15T10:30:00Z\"}}")); + GetResponse response = client.summary().templates().get("id"); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("GET", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + // Validate response body Assertions.assertNotNull(response, "Response should not be null"); String actualResponseJson = objectMapper.writeValueAsString(response); String expectedResponseBody = "" + "{\n" + " \"success\": true,\n" - + " \"message\": \"Template updated successfully\",\n" + " \"template\": {\n" + " \"id\": \"a1b2c3d4-e5f6-7890-abcd-ef1234567890\",\n" + " \"name\": \"Discharge Summary\",\n" + " \"description\": \"description\",\n" - + " \"template\": \"Patient <> was discharged on <> with <> <>.\",\n" + + " \"template\": \"Patient <>, age <>, was admitted on <> with <>.\",\n" + " \"target_resources\": [\n" + " \"Patient\",\n" - + " \"Encounter\",\n" + + " \"Condition\",\n" + " \"MedicationRequest\"\n" + " ],\n" + " \"mode\": \"narrative\",\n" @@ -421,7 +580,7 @@ else if (actualJson.has("kind")) + " \"key\": \"value\"\n" + " },\n" + " \"created_at\": \"2024-01-15T10:30:00Z\",\n" - + " \"updated_at\": \"2024-02-20T11:00:00Z\"\n" + + " \"updated_at\": \"2024-01-15T10:30:00Z\"\n" + " }\n" + "}"; JsonNode actualResponseNode = objectMapper.readTree(actualResponseJson); @@ -456,20 +615,49 @@ else if (actualResponseNode.has("kind")) } @Test - public void testDelete() throws Exception { + public void testGetThrowsUnauthorizedError() throws Exception { // OAuth: enqueue token response (client fetches token before API call) server.enqueue(new MockResponse() .setResponseCode(200) .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(401).setBody("{\"key\":\"value\"}")); + UnauthorizedError exception = Assertions.assertThrows( + UnauthorizedError.class, () -> client.summary().templates().get("id")); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("GET", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate error response + Assertions.assertEquals(401, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testGetThrowsForbiddenError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) server.enqueue(new MockResponse() .setResponseCode(200) - .setBody("{\"success\":true,\"message\":\"Template deleted successfully\"}")); - DeleteResponse response = client.summary().templates().delete("id"); + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(403).setBody("{\"key\":\"value\"}")); + ForbiddenError exception = Assertions.assertThrows( + ForbiddenError.class, () -> client.summary().templates().get("id")); // OAuth: consume the token request server.takeRequest(); RecordedRequest request = server.takeRequest(); Assertions.assertNotNull(request); - Assertions.assertEquals("DELETE", request.getMethod()); + Assertions.assertEquals("GET", request.getMethod()); // Validate OAuth Authorization header Assertions.assertEquals( @@ -477,17 +665,617 @@ public void testDelete() throws Exception { request.getHeader("Authorization"), "OAuth Authorization header should contain Bearer token from OAuth flow"); - // Validate response body - Assertions.assertNotNull(response, "Response should not be null"); - String actualResponseJson = objectMapper.writeValueAsString(response); - String expectedResponseBody = - "" + "{\n" + " \"success\": true,\n" + " \"message\": \"Template deleted successfully\"\n" + "}"; - JsonNode actualResponseNode = objectMapper.readTree(actualResponseJson); - JsonNode expectedResponseNode = objectMapper.readTree(expectedResponseBody); - Assertions.assertTrue( - jsonEquals(expectedResponseNode, actualResponseNode), - "Response body structure does not match expected"); - if (actualResponseNode.has("type") || actualResponseNode.has("_type") || actualResponseNode.has("kind")) { + // Validate error response + Assertions.assertEquals(403, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testGetThrowsNotFoundError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(404).setBody("{\"key\":\"value\"}")); + NotFoundError exception = Assertions.assertThrows( + NotFoundError.class, () -> client.summary().templates().get("id")); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("GET", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate error response + Assertions.assertEquals(404, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testGetThrowsInternalServerError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(500).setBody("{\"key\":\"value\"}")); + InternalServerError exception = Assertions.assertThrows( + InternalServerError.class, () -> client.summary().templates().get("id")); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("GET", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate error response + Assertions.assertEquals(500, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testUpdate() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue( + new MockResponse() + .setResponseCode(200) + .setBody( + "{\"success\":true,\"message\":\"Template updated successfully\",\"template\":{\"id\":\"a1b2c3d4-e5f6-7890-abcd-ef1234567890\",\"name\":\"Discharge Summary\",\"description\":\"description\",\"template\":\"Patient <> was discharged on <> with <> <>.\",\"target_resources\":[\"Patient\",\"Encounter\",\"MedicationRequest\"],\"mode\":\"narrative\",\"metadata\":{\"key\":\"value\"},\"created_at\":\"2024-01-15T10:30:00Z\",\"updated_at\":\"2024-02-20T11:00:00Z\"}}")); + UpdateResponse response = client.summary() + .templates() + .update( + "id", + UpdateSummaryTemplateRequest.builder() + .name("Discharge Summary") + .template( + "Patient {{Patient.name[0].text}} was discharged on {{Encounter[0].period.end}} with {{MedicationRequest[0].medicationCodeableConcept.coding[0].display}} {{MedicationRequest[0].dosageInstruction[0].text}}.") + .mode("narrative") + .targetResources(Arrays.asList("Patient", "Encounter", "MedicationRequest")) + .build()); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("PUT", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = "" + + "{\n" + + " \"name\": \"Discharge Summary\",\n" + + " \"template\": \"Patient {{Patient.name[0].text}} was discharged on {{Encounter[0].period.end}} with {{MedicationRequest[0].medicationCodeableConcept.coding[0].display}} {{MedicationRequest[0].dosageInstruction[0].text}}.\",\n" + + " \"target_resources\": [\n" + + " \"Patient\",\n" + + " \"Encounter\",\n" + + " \"MedicationRequest\"\n" + + " ],\n" + + " \"mode\": \"narrative\"\n" + + "}"; + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { + String discriminator = null; + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualJson.isNull()) { + Assertions.assertTrue( + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); + } + + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); + } + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); + } + + // Validate response body + Assertions.assertNotNull(response, "Response should not be null"); + String actualResponseJson = objectMapper.writeValueAsString(response); + String expectedResponseBody = "" + + "{\n" + + " \"success\": true,\n" + + " \"message\": \"Template updated successfully\",\n" + + " \"template\": {\n" + + " \"id\": \"a1b2c3d4-e5f6-7890-abcd-ef1234567890\",\n" + + " \"name\": \"Discharge Summary\",\n" + + " \"description\": \"description\",\n" + + " \"template\": \"Patient <> was discharged on <> with <> <>.\",\n" + + " \"target_resources\": [\n" + + " \"Patient\",\n" + + " \"Encounter\",\n" + + " \"MedicationRequest\"\n" + + " ],\n" + + " \"mode\": \"narrative\",\n" + + " \"metadata\": {\n" + + " \"key\": \"value\"\n" + + " },\n" + + " \"created_at\": \"2024-01-15T10:30:00Z\",\n" + + " \"updated_at\": \"2024-02-20T11:00:00Z\"\n" + + " }\n" + + "}"; + JsonNode actualResponseNode = objectMapper.readTree(actualResponseJson); + JsonNode expectedResponseNode = objectMapper.readTree(expectedResponseBody); + Assertions.assertTrue( + jsonEquals(expectedResponseNode, actualResponseNode), + "Response body structure does not match expected"); + if (actualResponseNode.has("type") || actualResponseNode.has("_type") || actualResponseNode.has("kind")) { + String discriminator = null; + if (actualResponseNode.has("type")) + discriminator = actualResponseNode.get("type").asText(); + else if (actualResponseNode.has("_type")) + discriminator = actualResponseNode.get("_type").asText(); + else if (actualResponseNode.has("kind")) + discriminator = actualResponseNode.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualResponseNode.isNull()) { + Assertions.assertTrue( + actualResponseNode.isObject() || actualResponseNode.isArray() || actualResponseNode.isValueNode(), + "response should be a valid JSON value"); + } + + if (actualResponseNode.isArray()) { + Assertions.assertTrue(actualResponseNode.size() >= 0, "Array should have valid size"); + } + if (actualResponseNode.isObject()) { + Assertions.assertTrue(actualResponseNode.size() >= 0, "Object should have valid field count"); + } + } + + @Test + public void testUpdateThrowsBadRequestError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(400).setBody("{\"key\":\"value\"}")); + BadRequestError exception = Assertions.assertThrows(BadRequestError.class, () -> client.summary() + .templates() + .update( + "id", + UpdateSummaryTemplateRequest.builder() + .name("name") + .template("template") + .mode("mode") + .targetResources(Arrays.asList("target_resources", "target_resources")) + .build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("PUT", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = "" + + "{\n" + + " \"name\": \"name\",\n" + + " \"template\": \"template\",\n" + + " \"target_resources\": [\n" + + " \"target_resources\",\n" + + " \"target_resources\"\n" + + " ],\n" + + " \"mode\": \"mode\"\n" + + "}"; + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { + String discriminator = null; + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualJson.isNull()) { + Assertions.assertTrue( + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); + } + + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); + } + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); + } + + // Validate error response + Assertions.assertEquals(400, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testUpdateThrowsUnauthorizedError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(401).setBody("{\"key\":\"value\"}")); + UnauthorizedError exception = Assertions.assertThrows(UnauthorizedError.class, () -> client.summary() + .templates() + .update( + "id", + UpdateSummaryTemplateRequest.builder() + .name("name") + .template("template") + .mode("mode") + .targetResources(Arrays.asList("target_resources", "target_resources")) + .build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("PUT", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = "" + + "{\n" + + " \"name\": \"name\",\n" + + " \"template\": \"template\",\n" + + " \"target_resources\": [\n" + + " \"target_resources\",\n" + + " \"target_resources\"\n" + + " ],\n" + + " \"mode\": \"mode\"\n" + + "}"; + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { + String discriminator = null; + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualJson.isNull()) { + Assertions.assertTrue( + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); + } + + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); + } + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); + } + + // Validate error response + Assertions.assertEquals(401, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testUpdateThrowsForbiddenError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(403).setBody("{\"key\":\"value\"}")); + ForbiddenError exception = Assertions.assertThrows(ForbiddenError.class, () -> client.summary() + .templates() + .update( + "id", + UpdateSummaryTemplateRequest.builder() + .name("name") + .template("template") + .mode("mode") + .targetResources(Arrays.asList("target_resources", "target_resources")) + .build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("PUT", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = "" + + "{\n" + + " \"name\": \"name\",\n" + + " \"template\": \"template\",\n" + + " \"target_resources\": [\n" + + " \"target_resources\",\n" + + " \"target_resources\"\n" + + " ],\n" + + " \"mode\": \"mode\"\n" + + "}"; + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { + String discriminator = null; + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualJson.isNull()) { + Assertions.assertTrue( + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); + } + + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); + } + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); + } + + // Validate error response + Assertions.assertEquals(403, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testUpdateThrowsNotFoundError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(404).setBody("{\"key\":\"value\"}")); + NotFoundError exception = Assertions.assertThrows(NotFoundError.class, () -> client.summary() + .templates() + .update( + "id", + UpdateSummaryTemplateRequest.builder() + .name("name") + .template("template") + .mode("mode") + .targetResources(Arrays.asList("target_resources", "target_resources")) + .build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("PUT", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = "" + + "{\n" + + " \"name\": \"name\",\n" + + " \"template\": \"template\",\n" + + " \"target_resources\": [\n" + + " \"target_resources\",\n" + + " \"target_resources\"\n" + + " ],\n" + + " \"mode\": \"mode\"\n" + + "}"; + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { + String discriminator = null; + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualJson.isNull()) { + Assertions.assertTrue( + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); + } + + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); + } + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); + } + + // Validate error response + Assertions.assertEquals(404, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testUpdateThrowsInternalServerError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(500).setBody("{\"key\":\"value\"}")); + InternalServerError exception = Assertions.assertThrows(InternalServerError.class, () -> client.summary() + .templates() + .update( + "id", + UpdateSummaryTemplateRequest.builder() + .name("name") + .template("template") + .mode("mode") + .targetResources(Arrays.asList("target_resources", "target_resources")) + .build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("PUT", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = "" + + "{\n" + + " \"name\": \"name\",\n" + + " \"template\": \"template\",\n" + + " \"target_resources\": [\n" + + " \"target_resources\",\n" + + " \"target_resources\"\n" + + " ],\n" + + " \"mode\": \"mode\"\n" + + "}"; + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { + String discriminator = null; + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualJson.isNull()) { + Assertions.assertTrue( + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); + } + + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); + } + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); + } + + // Validate error response + Assertions.assertEquals(500, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testDelete() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"success\":true,\"message\":\"Template deleted successfully\"}")); + DeleteResponse response = client.summary().templates().delete("id"); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("DELETE", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate response body + Assertions.assertNotNull(response, "Response should not be null"); + String actualResponseJson = objectMapper.writeValueAsString(response); + String expectedResponseBody = + "" + "{\n" + " \"success\": true,\n" + " \"message\": \"Template deleted successfully\"\n" + "}"; + JsonNode actualResponseNode = objectMapper.readTree(actualResponseJson); + JsonNode expectedResponseNode = objectMapper.readTree(expectedResponseBody); + Assertions.assertTrue( + jsonEquals(expectedResponseNode, actualResponseNode), + "Response body structure does not match expected"); + if (actualResponseNode.has("type") || actualResponseNode.has("_type") || actualResponseNode.has("kind")) { String discriminator = null; if (actualResponseNode.has("type")) discriminator = actualResponseNode.get("type").asText(); @@ -513,6 +1301,126 @@ else if (actualResponseNode.has("kind")) } } + @Test + public void testDeleteThrowsUnauthorizedError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(401).setBody("{\"key\":\"value\"}")); + UnauthorizedError exception = Assertions.assertThrows( + UnauthorizedError.class, () -> client.summary().templates().delete("id")); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("DELETE", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate error response + Assertions.assertEquals(401, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testDeleteThrowsForbiddenError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(403).setBody("{\"key\":\"value\"}")); + ForbiddenError exception = Assertions.assertThrows( + ForbiddenError.class, () -> client.summary().templates().delete("id")); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("DELETE", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate error response + Assertions.assertEquals(403, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testDeleteThrowsNotFoundError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(404).setBody("{\"key\":\"value\"}")); + NotFoundError exception = Assertions.assertThrows( + NotFoundError.class, () -> client.summary().templates().delete("id")); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("DELETE", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate error response + Assertions.assertEquals(404, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testDeleteThrowsInternalServerError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(500).setBody("{\"key\":\"value\"}")); + InternalServerError exception = Assertions.assertThrows( + InternalServerError.class, () -> client.summary().templates().delete("id")); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("DELETE", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate error response + Assertions.assertEquals(500, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + /** * Compares two JsonNodes with numeric equivalence and null safety. * For objects, checks that all fields in 'expected' exist in 'actual' with matching values. diff --git a/src/test/java/com/phenoml/api/SummaryWireTest.java b/src/test/java/com/phenoml/api/SummaryWireTest.java index f8986f56..42647513 100644 --- a/src/test/java/com/phenoml/api/SummaryWireTest.java +++ b/src/test/java/com/phenoml/api/SummaryWireTest.java @@ -3,6 +3,11 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.phenoml.api.core.ObjectMappers; +import com.phenoml.api.resources.summary.errors.BadRequestError; +import com.phenoml.api.resources.summary.errors.ForbiddenError; +import com.phenoml.api.resources.summary.errors.InternalServerError; +import com.phenoml.api.resources.summary.errors.NotFoundError; +import com.phenoml.api.resources.summary.errors.UnauthorizedError; import com.phenoml.api.resources.summary.requests.CreateSummaryRequest; import com.phenoml.api.resources.summary.types.CreateSummaryRequestMode; import com.phenoml.api.resources.summary.types.CreateSummaryResponse; @@ -28,6 +33,7 @@ public void setup() throws Exception { server.start(); client = PhenomlClient.withCredentials("test-client-id", "test-client-secret") .url(server.url("/").toString()) + .maxRetries(0) .build(); } @@ -218,6 +224,568 @@ else if (actualResponseNode.has("kind")) } } + @Test + public void testCreateInternationalPatientSummary() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue( + new MockResponse() + .setResponseCode(200) + .setBody( + "{\"success\":true,\"message\":\"message\",\"summary\":\"International Patient Summary for Maria Garcia\\n\\nAllergies and Intolerances:\\n- Penicillin (high criticality)\\n\\nActive Problems: None documented\\nMedications: None documented\\nImmunizations: None documented\",\"warnings\":[\"warnings\"]}")); + CreateSummaryResponse response = client.summary() + .create(CreateSummaryRequest.builder() + .fhirResources(new HashMap() { + { + put("resourceType", "Bundle"); + put("type", "collection"); + put( + "entry", + new ArrayList(Arrays.asList( + new HashMap() { + { + put("resource", new HashMap() { + { + put("resourceType", "Patient"); + put("id", "maria-garcia-001"); + put( + "name", + new ArrayList(Arrays.asList( + new HashMap() { + { + put( + "given", + new ArrayList( + Arrays.asList( + "Maria"))); + put("family", "Garcia"); + } + }))); + put("gender", "female"); + put("birthDate", "1985-07-22"); + } + }); + } + }, + new HashMap() { + { + put("resource", new HashMap() { + { + put("resourceType", "AllergyIntolerance"); + put("code", new HashMap() { + { + put("text", "Penicillin"); + } + }); + put("criticality", "high"); + } + }); + } + }))); + } + }) + .mode(CreateSummaryRequestMode.IPS) + .build()); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("POST", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = "" + + "{\n" + + " \"mode\": \"ips\",\n" + + " \"fhir_resources\": {\n" + + " \"resourceType\": \"Bundle\",\n" + + " \"type\": \"collection\",\n" + + " \"entry\": [\n" + + " {\n" + + " \"resource\": {\n" + + " \"resourceType\": \"Patient\",\n" + + " \"id\": \"maria-garcia-001\",\n" + + " \"name\": [\n" + + " {\n" + + " \"given\": [\n" + + " \"Maria\"\n" + + " ],\n" + + " \"family\": \"Garcia\"\n" + + " }\n" + + " ],\n" + + " \"gender\": \"female\",\n" + + " \"birthDate\": \"1985-07-22\"\n" + + " }\n" + + " },\n" + + " {\n" + + " \"resource\": {\n" + + " \"resourceType\": \"AllergyIntolerance\",\n" + + " \"code\": {\n" + + " \"text\": \"Penicillin\"\n" + + " },\n" + + " \"criticality\": \"high\"\n" + + " }\n" + + " }\n" + + " ]\n" + + " }\n" + + "}"; + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { + String discriminator = null; + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualJson.isNull()) { + Assertions.assertTrue( + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); + } + + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); + } + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); + } + + // Validate response body + Assertions.assertNotNull(response, "Response should not be null"); + String actualResponseJson = objectMapper.writeValueAsString(response); + String expectedResponseBody = "" + + "{\n" + + " \"success\": true,\n" + + " \"message\": \"message\",\n" + + " \"summary\": \"International Patient Summary for Maria Garcia\\n\\nAllergies and Intolerances:\\n- Penicillin (high criticality)\\n\\nActive Problems: None documented\\nMedications: None documented\\nImmunizations: None documented\",\n" + + " \"warnings\": [\n" + + " \"warnings\"\n" + + " ]\n" + + "}"; + JsonNode actualResponseNode = objectMapper.readTree(actualResponseJson); + JsonNode expectedResponseNode = objectMapper.readTree(expectedResponseBody); + Assertions.assertTrue( + jsonEquals(expectedResponseNode, actualResponseNode), + "Response body structure does not match expected"); + if (actualResponseNode.has("type") || actualResponseNode.has("_type") || actualResponseNode.has("kind")) { + String discriminator = null; + if (actualResponseNode.has("type")) + discriminator = actualResponseNode.get("type").asText(); + else if (actualResponseNode.has("_type")) + discriminator = actualResponseNode.get("_type").asText(); + else if (actualResponseNode.has("kind")) + discriminator = actualResponseNode.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualResponseNode.isNull()) { + Assertions.assertTrue( + actualResponseNode.isObject() || actualResponseNode.isArray() || actualResponseNode.isValueNode(), + "response should be a valid JSON value"); + } + + if (actualResponseNode.isArray()) { + Assertions.assertTrue(actualResponseNode.size() >= 0, "Array should have valid size"); + } + if (actualResponseNode.isObject()) { + Assertions.assertTrue(actualResponseNode.size() >= 0, "Object should have valid field count"); + } + } + + @Test + public void testCreateThrowsBadRequestError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(400).setBody("{\"key\":\"value\"}")); + BadRequestError exception = Assertions.assertThrows(BadRequestError.class, () -> client.summary() + .create(CreateSummaryRequest.builder() + .fhirResources(new HashMap() { + { + put("fhir_resources", new HashMap() { + { + put("key", "value"); + } + }); + } + }) + .build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("POST", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = "" + + "{\n" + + " \"fhir_resources\": {\n" + + " \"fhir_resources\": {\n" + + " \"key\": \"value\"\n" + + " }\n" + + " }\n" + + "}"; + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { + String discriminator = null; + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualJson.isNull()) { + Assertions.assertTrue( + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); + } + + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); + } + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); + } + + // Validate error response + Assertions.assertEquals(400, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testCreateThrowsUnauthorizedError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(401).setBody("{\"key\":\"value\"}")); + UnauthorizedError exception = Assertions.assertThrows(UnauthorizedError.class, () -> client.summary() + .create(CreateSummaryRequest.builder() + .fhirResources(new HashMap() { + { + put("fhir_resources", new HashMap() { + { + put("key", "value"); + } + }); + } + }) + .build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("POST", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = "" + + "{\n" + + " \"fhir_resources\": {\n" + + " \"fhir_resources\": {\n" + + " \"key\": \"value\"\n" + + " }\n" + + " }\n" + + "}"; + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { + String discriminator = null; + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualJson.isNull()) { + Assertions.assertTrue( + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); + } + + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); + } + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); + } + + // Validate error response + Assertions.assertEquals(401, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testCreateThrowsForbiddenError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(403).setBody("{\"key\":\"value\"}")); + ForbiddenError exception = Assertions.assertThrows(ForbiddenError.class, () -> client.summary() + .create(CreateSummaryRequest.builder() + .fhirResources(new HashMap() { + { + put("fhir_resources", new HashMap() { + { + put("key", "value"); + } + }); + } + }) + .build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("POST", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = "" + + "{\n" + + " \"fhir_resources\": {\n" + + " \"fhir_resources\": {\n" + + " \"key\": \"value\"\n" + + " }\n" + + " }\n" + + "}"; + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { + String discriminator = null; + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualJson.isNull()) { + Assertions.assertTrue( + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); + } + + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); + } + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); + } + + // Validate error response + Assertions.assertEquals(403, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testCreateThrowsNotFoundError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(404).setBody("{\"key\":\"value\"}")); + NotFoundError exception = Assertions.assertThrows(NotFoundError.class, () -> client.summary() + .create(CreateSummaryRequest.builder() + .fhirResources(new HashMap() { + { + put("fhir_resources", new HashMap() { + { + put("key", "value"); + } + }); + } + }) + .build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("POST", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = "" + + "{\n" + + " \"fhir_resources\": {\n" + + " \"fhir_resources\": {\n" + + " \"key\": \"value\"\n" + + " }\n" + + " }\n" + + "}"; + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { + String discriminator = null; + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualJson.isNull()) { + Assertions.assertTrue( + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); + } + + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); + } + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); + } + + // Validate error response + Assertions.assertEquals(404, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testCreateThrowsInternalServerError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(500).setBody("{\"key\":\"value\"}")); + InternalServerError exception = Assertions.assertThrows(InternalServerError.class, () -> client.summary() + .create(CreateSummaryRequest.builder() + .fhirResources(new HashMap() { + { + put("fhir_resources", new HashMap() { + { + put("key", "value"); + } + }); + } + }) + .build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("POST", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = "" + + "{\n" + + " \"fhir_resources\": {\n" + + " \"fhir_resources\": {\n" + + " \"key\": \"value\"\n" + + " }\n" + + " }\n" + + "}"; + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { + String discriminator = null; + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualJson.isNull()) { + Assertions.assertTrue( + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); + } + + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); + } + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); + } + + // Validate error response + Assertions.assertEquals(500, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + /** * Compares two JsonNodes with numeric equivalence and null safety. * For objects, checks that all fields in 'expected' exist in 'actual' with matching values. diff --git a/src/test/java/com/phenoml/api/ToolsMcpServersWireTest.java b/src/test/java/com/phenoml/api/ToolsMcpServersWireTest.java index cbb6b47c..8a48c8dd 100644 --- a/src/test/java/com/phenoml/api/ToolsMcpServersWireTest.java +++ b/src/test/java/com/phenoml/api/ToolsMcpServersWireTest.java @@ -3,6 +3,11 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.phenoml.api.core.ObjectMappers; +import com.phenoml.api.resources.tools.errors.BadRequestError; +import com.phenoml.api.resources.tools.errors.ForbiddenError; +import com.phenoml.api.resources.tools.errors.InternalServerError; +import com.phenoml.api.resources.tools.errors.NotFoundError; +import com.phenoml.api.resources.tools.errors.UnauthorizedError; import com.phenoml.api.resources.tools.mcpservers.requests.McpServerCreateRequest; import com.phenoml.api.resources.tools.types.McpServerResponse; import okhttp3.mockwebserver.MockResponse; @@ -24,6 +29,7 @@ public void setup() throws Exception { server.start(); client = PhenomlClient.withCredentials("test-client-id", "test-client-secret") .url(server.url("/").toString()) + .maxRetries(0) .build(); } @@ -169,6 +175,262 @@ else if (actualResponseNode.has("kind")) } } + @Test + public void testCreateThrowsBadRequestError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(400).setBody("{\"key\":\"value\"}")); + BadRequestError exception = Assertions.assertThrows(BadRequestError.class, () -> client.tools() + .mcpServers() + .create(McpServerCreateRequest.builder() + .name("name") + .mcpServerUrl("mcp_server_url") + .build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("POST", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = + "" + "{\n" + " \"name\": \"name\",\n" + " \"mcp_server_url\": \"mcp_server_url\"\n" + "}"; + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { + String discriminator = null; + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualJson.isNull()) { + Assertions.assertTrue( + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); + } + + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); + } + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); + } + + // Validate error response + Assertions.assertEquals(400, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testCreateThrowsUnauthorizedError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(401).setBody("{\"key\":\"value\"}")); + UnauthorizedError exception = Assertions.assertThrows(UnauthorizedError.class, () -> client.tools() + .mcpServers() + .create(McpServerCreateRequest.builder() + .name("name") + .mcpServerUrl("mcp_server_url") + .build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("POST", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = + "" + "{\n" + " \"name\": \"name\",\n" + " \"mcp_server_url\": \"mcp_server_url\"\n" + "}"; + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { + String discriminator = null; + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualJson.isNull()) { + Assertions.assertTrue( + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); + } + + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); + } + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); + } + + // Validate error response + Assertions.assertEquals(401, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testCreateThrowsForbiddenError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(403).setBody("{\"key\":\"value\"}")); + ForbiddenError exception = Assertions.assertThrows(ForbiddenError.class, () -> client.tools() + .mcpServers() + .create(McpServerCreateRequest.builder() + .name("name") + .mcpServerUrl("mcp_server_url") + .build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("POST", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = + "" + "{\n" + " \"name\": \"name\",\n" + " \"mcp_server_url\": \"mcp_server_url\"\n" + "}"; + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { + String discriminator = null; + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualJson.isNull()) { + Assertions.assertTrue( + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); + } + + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); + } + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); + } + + // Validate error response + Assertions.assertEquals(403, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testCreateThrowsInternalServerError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(500).setBody("{\"key\":\"value\"}")); + InternalServerError exception = Assertions.assertThrows(InternalServerError.class, () -> client.tools() + .mcpServers() + .create(McpServerCreateRequest.builder() + .name("name") + .mcpServerUrl("mcp_server_url") + .build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("POST", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = + "" + "{\n" + " \"name\": \"name\",\n" + " \"mcp_server_url\": \"mcp_server_url\"\n" + "}"; + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { + String discriminator = null; + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualJson.isNull()) { + Assertions.assertTrue( + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); + } + + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); + } + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); + } + + // Validate error response + Assertions.assertEquals(500, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + @Test public void testList() throws Exception { // OAuth: enqueue token response (client fetches token before API call) @@ -265,6 +527,96 @@ else if (actualResponseNode.has("kind")) } } + @Test + public void testListThrowsUnauthorizedError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(401).setBody("{\"key\":\"value\"}")); + UnauthorizedError exception = Assertions.assertThrows( + UnauthorizedError.class, () -> client.tools().mcpServers().list()); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("GET", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate error response + Assertions.assertEquals(401, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testListThrowsForbiddenError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(403).setBody("{\"key\":\"value\"}")); + ForbiddenError exception = Assertions.assertThrows( + ForbiddenError.class, () -> client.tools().mcpServers().list()); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("GET", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate error response + Assertions.assertEquals(403, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testListThrowsInternalServerError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(500).setBody("{\"key\":\"value\"}")); + InternalServerError exception = Assertions.assertThrows( + InternalServerError.class, () -> client.tools().mcpServers().list()); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("GET", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate error response + Assertions.assertEquals(500, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + @Test public void testGet() throws Exception { // OAuth: enqueue token response (client fetches token before API call) @@ -355,6 +707,126 @@ else if (actualResponseNode.has("kind")) } } + @Test + public void testGetThrowsUnauthorizedError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(401).setBody("{\"key\":\"value\"}")); + UnauthorizedError exception = Assertions.assertThrows( + UnauthorizedError.class, () -> client.tools().mcpServers().get("mcp_server_id")); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("GET", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate error response + Assertions.assertEquals(401, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testGetThrowsForbiddenError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(403).setBody("{\"key\":\"value\"}")); + ForbiddenError exception = Assertions.assertThrows( + ForbiddenError.class, () -> client.tools().mcpServers().get("mcp_server_id")); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("GET", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate error response + Assertions.assertEquals(403, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testGetThrowsNotFoundError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(404).setBody("{\"key\":\"value\"}")); + NotFoundError exception = Assertions.assertThrows( + NotFoundError.class, () -> client.tools().mcpServers().get("mcp_server_id")); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("GET", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate error response + Assertions.assertEquals(404, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testGetThrowsInternalServerError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(500).setBody("{\"key\":\"value\"}")); + InternalServerError exception = Assertions.assertThrows( + InternalServerError.class, () -> client.tools().mcpServers().get("mcp_server_id")); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("GET", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate error response + Assertions.assertEquals(500, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + @Test public void testDelete() throws Exception { // OAuth: enqueue token response (client fetches token before API call) @@ -445,6 +917,126 @@ else if (actualResponseNode.has("kind")) } } + @Test + public void testDeleteThrowsUnauthorizedError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(401).setBody("{\"key\":\"value\"}")); + UnauthorizedError exception = Assertions.assertThrows( + UnauthorizedError.class, () -> client.tools().mcpServers().delete("mcp_server_id")); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("DELETE", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate error response + Assertions.assertEquals(401, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testDeleteThrowsForbiddenError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(403).setBody("{\"key\":\"value\"}")); + ForbiddenError exception = Assertions.assertThrows( + ForbiddenError.class, () -> client.tools().mcpServers().delete("mcp_server_id")); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("DELETE", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate error response + Assertions.assertEquals(403, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testDeleteThrowsNotFoundError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(404).setBody("{\"key\":\"value\"}")); + NotFoundError exception = Assertions.assertThrows( + NotFoundError.class, () -> client.tools().mcpServers().delete("mcp_server_id")); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("DELETE", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate error response + Assertions.assertEquals(404, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testDeleteThrowsInternalServerError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(500).setBody("{\"key\":\"value\"}")); + InternalServerError exception = Assertions.assertThrows( + InternalServerError.class, () -> client.tools().mcpServers().delete("mcp_server_id")); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("DELETE", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate error response + Assertions.assertEquals(500, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + /** * Compares two JsonNodes with numeric equivalence and null safety. * For objects, checks that all fields in 'expected' exist in 'actual' with matching values. diff --git a/src/test/java/com/phenoml/api/ToolsMcpToolsWireTest.java b/src/test/java/com/phenoml/api/ToolsMcpToolsWireTest.java index 70755424..1eabd67b 100644 --- a/src/test/java/com/phenoml/api/ToolsMcpToolsWireTest.java +++ b/src/test/java/com/phenoml/api/ToolsMcpToolsWireTest.java @@ -3,6 +3,10 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.phenoml.api.core.ObjectMappers; +import com.phenoml.api.resources.tools.errors.ForbiddenError; +import com.phenoml.api.resources.tools.errors.InternalServerError; +import com.phenoml.api.resources.tools.errors.NotFoundError; +import com.phenoml.api.resources.tools.errors.UnauthorizedError; import com.phenoml.api.resources.tools.types.McpServerToolResponse; import okhttp3.mockwebserver.MockResponse; import okhttp3.mockwebserver.MockWebServer; @@ -23,6 +27,7 @@ public void setup() throws Exception { server.start(); client = PhenomlClient.withCredentials("test-client-id", "test-client-secret") .url(server.url("/").toString()) + .maxRetries(0) .build(); } @@ -127,6 +132,96 @@ else if (actualResponseNode.has("kind")) } } + @Test + public void testListThrowsUnauthorizedError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(401).setBody("{\"key\":\"value\"}")); + UnauthorizedError exception = Assertions.assertThrows( + UnauthorizedError.class, () -> client.tools().mcpTools().list("mcp_server_id")); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("GET", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate error response + Assertions.assertEquals(401, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testListThrowsForbiddenError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(403).setBody("{\"key\":\"value\"}")); + ForbiddenError exception = Assertions.assertThrows( + ForbiddenError.class, () -> client.tools().mcpTools().list("mcp_server_id")); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("GET", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate error response + Assertions.assertEquals(403, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testListThrowsInternalServerError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(500).setBody("{\"key\":\"value\"}")); + InternalServerError exception = Assertions.assertThrows( + InternalServerError.class, () -> client.tools().mcpTools().list("mcp_server_id")); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("GET", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate error response + Assertions.assertEquals(500, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + @Test public void testGet() throws Exception { // OAuth: enqueue token response (client fetches token before API call) @@ -214,6 +309,126 @@ else if (actualResponseNode.has("kind")) } } + @Test + public void testGetThrowsUnauthorizedError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(401).setBody("{\"key\":\"value\"}")); + UnauthorizedError exception = Assertions.assertThrows( + UnauthorizedError.class, () -> client.tools().mcpTools().get("mcp_server_tool_id")); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("GET", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate error response + Assertions.assertEquals(401, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testGetThrowsForbiddenError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(403).setBody("{\"key\":\"value\"}")); + ForbiddenError exception = Assertions.assertThrows( + ForbiddenError.class, () -> client.tools().mcpTools().get("mcp_server_tool_id")); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("GET", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate error response + Assertions.assertEquals(403, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testGetThrowsNotFoundError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(404).setBody("{\"key\":\"value\"}")); + NotFoundError exception = Assertions.assertThrows( + NotFoundError.class, () -> client.tools().mcpTools().get("mcp_server_tool_id")); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("GET", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate error response + Assertions.assertEquals(404, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testGetThrowsInternalServerError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(500).setBody("{\"key\":\"value\"}")); + InternalServerError exception = Assertions.assertThrows( + InternalServerError.class, () -> client.tools().mcpTools().get("mcp_server_tool_id")); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("GET", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate error response + Assertions.assertEquals(500, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + @Test public void testDelete() throws Exception { // OAuth: enqueue token response (client fetches token before API call) @@ -301,6 +516,126 @@ else if (actualResponseNode.has("kind")) } } + @Test + public void testDeleteThrowsUnauthorizedError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(401).setBody("{\"key\":\"value\"}")); + UnauthorizedError exception = Assertions.assertThrows( + UnauthorizedError.class, () -> client.tools().mcpTools().delete("mcp_server_tool_id")); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("DELETE", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate error response + Assertions.assertEquals(401, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testDeleteThrowsForbiddenError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(403).setBody("{\"key\":\"value\"}")); + ForbiddenError exception = Assertions.assertThrows( + ForbiddenError.class, () -> client.tools().mcpTools().delete("mcp_server_tool_id")); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("DELETE", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate error response + Assertions.assertEquals(403, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testDeleteThrowsNotFoundError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(404).setBody("{\"key\":\"value\"}")); + NotFoundError exception = Assertions.assertThrows( + NotFoundError.class, () -> client.tools().mcpTools().delete("mcp_server_tool_id")); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("DELETE", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate error response + Assertions.assertEquals(404, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testDeleteThrowsInternalServerError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(500).setBody("{\"key\":\"value\"}")); + InternalServerError exception = Assertions.assertThrows( + InternalServerError.class, () -> client.tools().mcpTools().delete("mcp_server_tool_id")); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("DELETE", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate error response + Assertions.assertEquals(500, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + /** * Compares two JsonNodes with numeric equivalence and null safety. * For objects, checks that all fields in 'expected' exist in 'actual' with matching values. diff --git a/src/test/java/com/phenoml/api/ToolsWireTest.java b/src/test/java/com/phenoml/api/ToolsWireTest.java index 39d2cba3..60029343 100644 --- a/src/test/java/com/phenoml/api/ToolsWireTest.java +++ b/src/test/java/com/phenoml/api/ToolsWireTest.java @@ -3,6 +3,10 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.phenoml.api.core.ObjectMappers; +import com.phenoml.api.resources.tools.errors.BadRequestError; +import com.phenoml.api.resources.tools.errors.ForbiddenError; +import com.phenoml.api.resources.tools.errors.InternalServerError; +import com.phenoml.api.resources.tools.errors.UnauthorizedError; import com.phenoml.api.resources.tools.requests.CohortRequest; import com.phenoml.api.resources.tools.requests.Lang2FhirAndCreateMultiRequest; import com.phenoml.api.resources.tools.requests.Lang2FhirAndCreateRequest; @@ -31,6 +35,7 @@ public void setup() throws Exception { server.start(); client = PhenomlClient.withCredentials("test-client-id", "test-client-secret") .url(server.url("/").toString()) + .maxRetries(0) .build(); } @@ -198,6 +203,254 @@ else if (actualResponseNode.has("kind")) } } + @Test + public void testCreateFhirResourceThrowsBadRequestError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(400).setBody("{\"key\":\"value\"}")); + BadRequestError exception = Assertions.assertThrows(BadRequestError.class, () -> client.tools() + .createFhirResource(Lang2FhirAndCreateRequest.builder() + .resource(Lang2FhirAndCreateRequestResource.AUTO) + .text("text") + .build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("POST", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = "" + "{\n" + " \"resource\": \"auto\",\n" + " \"text\": \"text\"\n" + "}"; + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { + String discriminator = null; + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualJson.isNull()) { + Assertions.assertTrue( + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); + } + + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); + } + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); + } + + // Validate error response + Assertions.assertEquals(400, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testCreateFhirResourceThrowsUnauthorizedError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(401).setBody("{\"key\":\"value\"}")); + UnauthorizedError exception = Assertions.assertThrows(UnauthorizedError.class, () -> client.tools() + .createFhirResource(Lang2FhirAndCreateRequest.builder() + .resource(Lang2FhirAndCreateRequestResource.AUTO) + .text("text") + .build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("POST", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = "" + "{\n" + " \"resource\": \"auto\",\n" + " \"text\": \"text\"\n" + "}"; + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { + String discriminator = null; + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualJson.isNull()) { + Assertions.assertTrue( + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); + } + + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); + } + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); + } + + // Validate error response + Assertions.assertEquals(401, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testCreateFhirResourceThrowsForbiddenError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(403).setBody("{\"key\":\"value\"}")); + ForbiddenError exception = Assertions.assertThrows(ForbiddenError.class, () -> client.tools() + .createFhirResource(Lang2FhirAndCreateRequest.builder() + .resource(Lang2FhirAndCreateRequestResource.AUTO) + .text("text") + .build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("POST", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = "" + "{\n" + " \"resource\": \"auto\",\n" + " \"text\": \"text\"\n" + "}"; + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { + String discriminator = null; + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualJson.isNull()) { + Assertions.assertTrue( + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); + } + + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); + } + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); + } + + // Validate error response + Assertions.assertEquals(403, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testCreateFhirResourceThrowsInternalServerError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(500).setBody("{\"key\":\"value\"}")); + InternalServerError exception = Assertions.assertThrows(InternalServerError.class, () -> client.tools() + .createFhirResource(Lang2FhirAndCreateRequest.builder() + .resource(Lang2FhirAndCreateRequestResource.AUTO) + .text("text") + .build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("POST", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = "" + "{\n" + " \"resource\": \"auto\",\n" + " \"text\": \"text\"\n" + "}"; + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { + String discriminator = null; + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualJson.isNull()) { + Assertions.assertTrue( + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); + } + + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); + } + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); + } + + // Validate error response + Assertions.assertEquals(500, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + @Test public void testCreateFhirResourcesMulti() throws Exception { // OAuth: enqueue token response (client fetches token before API call) @@ -356,25 +609,17 @@ else if (actualResponseNode.has("kind")) } @Test - public void testSearchFhirResources() throws Exception { + public void testCreateFhirResourcesMultiThrowsBadRequestError() throws Exception { // OAuth: enqueue token response (client fetches token before API call) server.enqueue(new MockResponse() .setResponseCode(200) .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); - server.enqueue( - new MockResponse() - .setResponseCode(200) - .setBody( - "{\"resource_type\":\"Appointment\",\"search_params\":\"patient=Patient/patient-123&date=ge2024-01-15&date=le2024-01-22\",\"fhir_results\":[{\"resourceType\":\"Appointment\",\"id\":\"appointment-123\",\"status\":\"booked\",\"start\":\"2024-01-16T10:00:00Z\",\"end\":\"2024-01-16T11:00:00Z\",\"participant\":[{\"actor\":{\"reference\":\"Patient/patient-123\",\"display\":\"John Doe\"}}]}],\"success\":true,\"message\":\"Search query generated and executed in FHIR server successfully. Found 1 results.\"}")); - Lang2FhirAndSearchResponse response = client.tools() - .searchFhirResources(Lang2FhirAndSearchRequest.builder() - .text("Find all appointments for patient John Doe next week") - .phenomlOnBehalfOf("Patient/550e8400-e29b-41d4-a716-446655440000") - .phenomlFhirProvider( - "550e8400-e29b-41d4-a716-446655440000:eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c...") - .count(10) - .provider("550e8400-e29b-41d4-a716-446655440000") - .build()); + server.enqueue(new MockResponse().setResponseCode(400).setBody("{\"key\":\"value\"}")); + BadRequestError exception = Assertions.assertThrows(BadRequestError.class, () -> client.tools() + .createFhirResourcesMulti(Lang2FhirAndCreateMultiRequest.builder() + .text("text") + .provider("provider") + .build())); // OAuth: consume the token request server.takeRequest(); RecordedRequest request = server.takeRequest(); @@ -386,24 +631,9 @@ public void testSearchFhirResources() throws Exception { "Bearer test-token", request.getHeader("Authorization"), "OAuth Authorization header should contain Bearer token from OAuth flow"); - - // Validate headers - Assertions.assertEquals( - "Patient/550e8400-e29b-41d4-a716-446655440000", - request.getHeader("X-Phenoml-On-Behalf-Of"), - "Header 'X-Phenoml-On-Behalf-Of' should match expected value"); - Assertions.assertEquals( - "550e8400-e29b-41d4-a716-446655440000:eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c...", - request.getHeader("X-Phenoml-Fhir-Provider"), - "Header 'X-Phenoml-Fhir-Provider' should match expected value"); // Validate request body String actualRequestBody = request.getBody().readUtf8(); - String expectedRequestBody = "" - + "{\n" - + " \"text\": \"Find all appointments for patient John Doe next week\",\n" - + " \"count\": 10,\n" - + " \"provider\": \"550e8400-e29b-41d4-a716-446655440000\"\n" - + "}"; + String expectedRequestBody = "" + "{\n" + " \"text\": \"text\",\n" + " \"provider\": \"provider\"\n" + "}"; JsonNode actualJson = objectMapper.readTree(actualRequestBody); JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); @@ -431,62 +661,573 @@ else if (actualJson.has("kind")) Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); } - // Validate response body - Assertions.assertNotNull(response, "Response should not be null"); - String actualResponseJson = objectMapper.writeValueAsString(response); - String expectedResponseBody = "" - + "{\n" - + " \"resource_type\": \"Appointment\",\n" - + " \"search_params\": \"patient=Patient/patient-123&date=ge2024-01-15&date=le2024-01-22\",\n" - + " \"fhir_results\": [\n" - + " {\n" - + " \"resourceType\": \"Appointment\",\n" - + " \"id\": \"appointment-123\",\n" - + " \"status\": \"booked\",\n" - + " \"start\": \"2024-01-16T10:00:00Z\",\n" - + " \"end\": \"2024-01-16T11:00:00Z\",\n" - + " \"participant\": [\n" - + " {\n" - + " \"actor\": {\n" - + " \"reference\": \"Patient/patient-123\",\n" - + " \"display\": \"John Doe\"\n" - + " }\n" - + " }\n" - + " ]\n" - + " }\n" - + " ],\n" - + " \"success\": true,\n" - + " \"message\": \"Search query generated and executed in FHIR server successfully. Found 1 results.\"\n" - + "}"; - JsonNode actualResponseNode = objectMapper.readTree(actualResponseJson); - JsonNode expectedResponseNode = objectMapper.readTree(expectedResponseBody); - Assertions.assertTrue( - jsonEquals(expectedResponseNode, actualResponseNode), - "Response body structure does not match expected"); + // Validate error response + Assertions.assertEquals(400, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testCreateFhirResourcesMultiThrowsUnauthorizedError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(401).setBody("{\"key\":\"value\"}")); + UnauthorizedError exception = Assertions.assertThrows(UnauthorizedError.class, () -> client.tools() + .createFhirResourcesMulti(Lang2FhirAndCreateMultiRequest.builder() + .text("text") + .provider("provider") + .build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("POST", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = "" + "{\n" + " \"text\": \"text\",\n" + " \"provider\": \"provider\"\n" + "}"; + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { + String discriminator = null; + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualJson.isNull()) { + Assertions.assertTrue( + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); + } + + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); + } + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); + } + + // Validate error response + Assertions.assertEquals(401, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testCreateFhirResourcesMultiThrowsForbiddenError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(403).setBody("{\"key\":\"value\"}")); + ForbiddenError exception = Assertions.assertThrows(ForbiddenError.class, () -> client.tools() + .createFhirResourcesMulti(Lang2FhirAndCreateMultiRequest.builder() + .text("text") + .provider("provider") + .build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("POST", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = "" + "{\n" + " \"text\": \"text\",\n" + " \"provider\": \"provider\"\n" + "}"; + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { + String discriminator = null; + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualJson.isNull()) { + Assertions.assertTrue( + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); + } + + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); + } + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); + } + + // Validate error response + Assertions.assertEquals(403, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testCreateFhirResourcesMultiThrowsInternalServerError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(500).setBody("{\"key\":\"value\"}")); + InternalServerError exception = Assertions.assertThrows(InternalServerError.class, () -> client.tools() + .createFhirResourcesMulti(Lang2FhirAndCreateMultiRequest.builder() + .text("text") + .provider("provider") + .build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("POST", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = "" + "{\n" + " \"text\": \"text\",\n" + " \"provider\": \"provider\"\n" + "}"; + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { + String discriminator = null; + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualJson.isNull()) { + Assertions.assertTrue( + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); + } + + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); + } + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); + } + + // Validate error response + Assertions.assertEquals(500, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testSearchFhirResources() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue( + new MockResponse() + .setResponseCode(200) + .setBody( + "{\"resource_type\":\"Appointment\",\"search_params\":\"patient=Patient/patient-123&date=ge2024-01-15&date=le2024-01-22\",\"fhir_results\":[{\"resourceType\":\"Appointment\",\"id\":\"appointment-123\",\"status\":\"booked\",\"start\":\"2024-01-16T10:00:00Z\",\"end\":\"2024-01-16T11:00:00Z\",\"participant\":[{\"actor\":{\"reference\":\"Patient/patient-123\",\"display\":\"John Doe\"}}]}],\"success\":true,\"message\":\"Search query generated and executed in FHIR server successfully. Found 1 results.\"}")); + Lang2FhirAndSearchResponse response = client.tools() + .searchFhirResources(Lang2FhirAndSearchRequest.builder() + .text("Find all appointments for patient John Doe next week") + .phenomlOnBehalfOf("Patient/550e8400-e29b-41d4-a716-446655440000") + .phenomlFhirProvider( + "550e8400-e29b-41d4-a716-446655440000:eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c...") + .count(10) + .provider("550e8400-e29b-41d4-a716-446655440000") + .build()); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("POST", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate headers + Assertions.assertEquals( + "Patient/550e8400-e29b-41d4-a716-446655440000", + request.getHeader("X-Phenoml-On-Behalf-Of"), + "Header 'X-Phenoml-On-Behalf-Of' should match expected value"); + Assertions.assertEquals( + "550e8400-e29b-41d4-a716-446655440000:eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c...", + request.getHeader("X-Phenoml-Fhir-Provider"), + "Header 'X-Phenoml-Fhir-Provider' should match expected value"); + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = "" + + "{\n" + + " \"text\": \"Find all appointments for patient John Doe next week\",\n" + + " \"count\": 10,\n" + + " \"provider\": \"550e8400-e29b-41d4-a716-446655440000\"\n" + + "}"; + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { + String discriminator = null; + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualJson.isNull()) { + Assertions.assertTrue( + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); + } + + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); + } + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); + } + + // Validate response body + Assertions.assertNotNull(response, "Response should not be null"); + String actualResponseJson = objectMapper.writeValueAsString(response); + String expectedResponseBody = "" + + "{\n" + + " \"resource_type\": \"Appointment\",\n" + + " \"search_params\": \"patient=Patient/patient-123&date=ge2024-01-15&date=le2024-01-22\",\n" + + " \"fhir_results\": [\n" + + " {\n" + + " \"resourceType\": \"Appointment\",\n" + + " \"id\": \"appointment-123\",\n" + + " \"status\": \"booked\",\n" + + " \"start\": \"2024-01-16T10:00:00Z\",\n" + + " \"end\": \"2024-01-16T11:00:00Z\",\n" + + " \"participant\": [\n" + + " {\n" + + " \"actor\": {\n" + + " \"reference\": \"Patient/patient-123\",\n" + + " \"display\": \"John Doe\"\n" + + " }\n" + + " }\n" + + " ]\n" + + " }\n" + + " ],\n" + + " \"success\": true,\n" + + " \"message\": \"Search query generated and executed in FHIR server successfully. Found 1 results.\"\n" + + "}"; + JsonNode actualResponseNode = objectMapper.readTree(actualResponseJson); + JsonNode expectedResponseNode = objectMapper.readTree(expectedResponseBody); + Assertions.assertTrue( + jsonEquals(expectedResponseNode, actualResponseNode), + "Response body structure does not match expected"); if (actualResponseNode.has("type") || actualResponseNode.has("_type") || actualResponseNode.has("kind")) { String discriminator = null; - if (actualResponseNode.has("type")) - discriminator = actualResponseNode.get("type").asText(); - else if (actualResponseNode.has("_type")) - discriminator = actualResponseNode.get("_type").asText(); - else if (actualResponseNode.has("kind")) - discriminator = actualResponseNode.get("kind").asText(); + if (actualResponseNode.has("type")) + discriminator = actualResponseNode.get("type").asText(); + else if (actualResponseNode.has("_type")) + discriminator = actualResponseNode.get("_type").asText(); + else if (actualResponseNode.has("kind")) + discriminator = actualResponseNode.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualResponseNode.isNull()) { + Assertions.assertTrue( + actualResponseNode.isObject() || actualResponseNode.isArray() || actualResponseNode.isValueNode(), + "response should be a valid JSON value"); + } + + if (actualResponseNode.isArray()) { + Assertions.assertTrue(actualResponseNode.size() >= 0, "Array should have valid size"); + } + if (actualResponseNode.isObject()) { + Assertions.assertTrue(actualResponseNode.size() >= 0, "Object should have valid field count"); + } + } + + @Test + public void testSearchFhirResourcesThrowsBadRequestError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(400).setBody("{\"key\":\"value\"}")); + BadRequestError exception = Assertions.assertThrows(BadRequestError.class, () -> client.tools() + .searchFhirResources( + Lang2FhirAndSearchRequest.builder().text("text").build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("POST", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = "" + "{\n" + " \"text\": \"text\"\n" + "}"; + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { + String discriminator = null; + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualJson.isNull()) { + Assertions.assertTrue( + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); + } + + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); + } + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); + } + + // Validate error response + Assertions.assertEquals(400, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testSearchFhirResourcesThrowsUnauthorizedError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(401).setBody("{\"key\":\"value\"}")); + UnauthorizedError exception = Assertions.assertThrows(UnauthorizedError.class, () -> client.tools() + .searchFhirResources( + Lang2FhirAndSearchRequest.builder().text("text").build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("POST", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = "" + "{\n" + " \"text\": \"text\"\n" + "}"; + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { + String discriminator = null; + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualJson.isNull()) { + Assertions.assertTrue( + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); + } + + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); + } + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); + } + + // Validate error response + Assertions.assertEquals(401, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testSearchFhirResourcesThrowsForbiddenError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(403).setBody("{\"key\":\"value\"}")); + ForbiddenError exception = Assertions.assertThrows(ForbiddenError.class, () -> client.tools() + .searchFhirResources( + Lang2FhirAndSearchRequest.builder().text("text").build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("POST", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = "" + "{\n" + " \"text\": \"text\"\n" + "}"; + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { + String discriminator = null; + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualJson.isNull()) { + Assertions.assertTrue( + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); + } + + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); + } + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); + } + + // Validate error response + Assertions.assertEquals(403, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testSearchFhirResourcesThrowsInternalServerError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(500).setBody("{\"key\":\"value\"}")); + InternalServerError exception = Assertions.assertThrows(InternalServerError.class, () -> client.tools() + .searchFhirResources( + Lang2FhirAndSearchRequest.builder().text("text").build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("POST", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = "" + "{\n" + " \"text\": \"text\"\n" + "}"; + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { + String discriminator = null; + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); } - if (!actualResponseNode.isNull()) { + if (!actualJson.isNull()) { Assertions.assertTrue( - actualResponseNode.isObject() || actualResponseNode.isArray() || actualResponseNode.isValueNode(), - "response should be a valid JSON value"); + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); } - if (actualResponseNode.isArray()) { - Assertions.assertTrue(actualResponseNode.size() >= 0, "Array should have valid size"); + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); } - if (actualResponseNode.isObject()) { - Assertions.assertTrue(actualResponseNode.size() >= 0, "Object should have valid field count"); + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); } + + // Validate error response + Assertions.assertEquals(500, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); } @Test @@ -628,6 +1369,254 @@ else if (actualResponseNode.has("kind")) } } + @Test + public void testAnalyzeCohortThrowsBadRequestError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(400).setBody("{\"key\":\"value\"}")); + BadRequestError exception = Assertions.assertThrows(BadRequestError.class, () -> client.tools() + .analyzeCohort(CohortRequest.builder() + .text("text") + .provider("provider") + .build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("POST", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = "" + "{\n" + " \"text\": \"text\",\n" + " \"provider\": \"provider\"\n" + "}"; + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { + String discriminator = null; + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualJson.isNull()) { + Assertions.assertTrue( + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); + } + + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); + } + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); + } + + // Validate error response + Assertions.assertEquals(400, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testAnalyzeCohortThrowsUnauthorizedError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(401).setBody("{\"key\":\"value\"}")); + UnauthorizedError exception = Assertions.assertThrows(UnauthorizedError.class, () -> client.tools() + .analyzeCohort(CohortRequest.builder() + .text("text") + .provider("provider") + .build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("POST", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = "" + "{\n" + " \"text\": \"text\",\n" + " \"provider\": \"provider\"\n" + "}"; + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { + String discriminator = null; + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualJson.isNull()) { + Assertions.assertTrue( + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); + } + + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); + } + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); + } + + // Validate error response + Assertions.assertEquals(401, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testAnalyzeCohortThrowsForbiddenError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(403).setBody("{\"key\":\"value\"}")); + ForbiddenError exception = Assertions.assertThrows(ForbiddenError.class, () -> client.tools() + .analyzeCohort(CohortRequest.builder() + .text("text") + .provider("provider") + .build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("POST", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = "" + "{\n" + " \"text\": \"text\",\n" + " \"provider\": \"provider\"\n" + "}"; + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { + String discriminator = null; + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualJson.isNull()) { + Assertions.assertTrue( + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); + } + + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); + } + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); + } + + // Validate error response + Assertions.assertEquals(403, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testAnalyzeCohortThrowsInternalServerError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(500).setBody("{\"key\":\"value\"}")); + InternalServerError exception = Assertions.assertThrows(InternalServerError.class, () -> client.tools() + .analyzeCohort(CohortRequest.builder() + .text("text") + .provider("provider") + .build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("POST", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = "" + "{\n" + " \"text\": \"text\",\n" + " \"provider\": \"provider\"\n" + "}"; + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { + String discriminator = null; + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualJson.isNull()) { + Assertions.assertTrue( + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); + } + + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); + } + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); + } + + // Validate error response + Assertions.assertEquals(500, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + /** * Compares two JsonNodes with numeric equivalence and null safety. * For objects, checks that all fields in 'expected' exist in 'actual' with matching values. diff --git a/src/test/java/com/phenoml/api/WorkflowsWireTest.java b/src/test/java/com/phenoml/api/WorkflowsWireTest.java index 8ba9c241..39ebcb9d 100644 --- a/src/test/java/com/phenoml/api/WorkflowsWireTest.java +++ b/src/test/java/com/phenoml/api/WorkflowsWireTest.java @@ -3,6 +3,12 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.phenoml.api.core.ObjectMappers; +import com.phenoml.api.resources.workflows.errors.BadRequestError; +import com.phenoml.api.resources.workflows.errors.ForbiddenError; +import com.phenoml.api.resources.workflows.errors.GatewayTimeoutError; +import com.phenoml.api.resources.workflows.errors.InternalServerError; +import com.phenoml.api.resources.workflows.errors.NotFoundError; +import com.phenoml.api.resources.workflows.errors.UnauthorizedError; import com.phenoml.api.resources.workflows.requests.CreateWorkflowRequest; import com.phenoml.api.resources.workflows.requests.ExecuteWorkflowRequest; import com.phenoml.api.resources.workflows.requests.GetRequest; @@ -36,6 +42,7 @@ public void setup() throws Exception { server.start(); client = PhenomlClient.withCredentials("test-client-id", "test-client-secret") .url(server.url("/").toString()) + .maxRetries(0) .build(); } @@ -103,6 +110,96 @@ else if (actualResponseNode.has("kind")) } } + @Test + public void testListThrowsUnauthorizedError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(401).setBody("{\"key\":\"value\"}")); + UnauthorizedError exception = Assertions.assertThrows(UnauthorizedError.class, () -> client.workflows() + .list(ListRequest.builder().build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("GET", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate error response + Assertions.assertEquals(401, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testListThrowsForbiddenError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(403).setBody("{\"key\":\"value\"}")); + ForbiddenError exception = Assertions.assertThrows(ForbiddenError.class, () -> client.workflows() + .list(ListRequest.builder().build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("GET", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate error response + Assertions.assertEquals(403, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testListThrowsInternalServerError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(500).setBody("{\"key\":\"value\"}")); + InternalServerError exception = Assertions.assertThrows(InternalServerError.class, () -> client.workflows() + .list(ListRequest.builder().build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("GET", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate error response + Assertions.assertEquals(500, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + @Test public void testCreate() throws Exception { // OAuth: enqueue token response (client fetches token before API call) @@ -217,96 +314,114 @@ else if (actualResponseNode.has("kind")) } @Test - public void testGet() throws Exception { + public void testCreateThrowsBadRequestError() throws Exception { // OAuth: enqueue token response (client fetches token before API call) server.enqueue(new MockResponse() .setResponseCode(200) .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); - server.enqueue(new MockResponse() - .setResponseCode(200) - .setBody(TestResources.loadResource("/wire-tests/WorkflowsWireTest_testGet_response.json"))); - GetResponse response = - client.workflows().get("id", GetRequest.builder().verbose(true).build()); + server.enqueue(new MockResponse().setResponseCode(400).setBody("{\"key\":\"value\"}")); + BadRequestError exception = Assertions.assertThrows(BadRequestError.class, () -> client.workflows() + .create(CreateWorkflowRequest.builder() + .name("name") + .workflowInstructions("workflow_instructions") + .fhirProviderId(CreateWorkflowRequestFhirProviderId.of("fhir_provider_id")) + .sampleData(new HashMap() { + { + put("sample_data", new HashMap() { + { + put("key", "value"); + } + }); + } + }) + .build())); // OAuth: consume the token request server.takeRequest(); RecordedRequest request = server.takeRequest(); Assertions.assertNotNull(request); - Assertions.assertEquals("GET", request.getMethod()); + Assertions.assertEquals("POST", request.getMethod()); // Validate OAuth Authorization header Assertions.assertEquals( "Bearer test-token", request.getHeader("Authorization"), "OAuth Authorization header should contain Bearer token from OAuth flow"); - - // Validate response body - Assertions.assertNotNull(response, "Response should not be null"); - String actualResponseJson = objectMapper.writeValueAsString(response); - String expectedResponseBody = TestResources.loadResource("/wire-tests/WorkflowsWireTest_testGet_response.json"); - JsonNode actualResponseNode = objectMapper.readTree(actualResponseJson); - JsonNode expectedResponseNode = objectMapper.readTree(expectedResponseBody); - Assertions.assertTrue( - jsonEquals(expectedResponseNode, actualResponseNode), - "Response body structure does not match expected"); - if (actualResponseNode.has("type") || actualResponseNode.has("_type") || actualResponseNode.has("kind")) { + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = "" + + "{\n" + + " \"name\": \"name\",\n" + + " \"workflow_instructions\": \"workflow_instructions\",\n" + + " \"sample_data\": {\n" + + " \"sample_data\": {\n" + + " \"key\": \"value\"\n" + + " }\n" + + " },\n" + + " \"fhir_provider_id\": \"fhir_provider_id\"\n" + + "}"; + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { String discriminator = null; - if (actualResponseNode.has("type")) - discriminator = actualResponseNode.get("type").asText(); - else if (actualResponseNode.has("_type")) - discriminator = actualResponseNode.get("_type").asText(); - else if (actualResponseNode.has("kind")) - discriminator = actualResponseNode.get("kind").asText(); + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); } - if (!actualResponseNode.isNull()) { + if (!actualJson.isNull()) { Assertions.assertTrue( - actualResponseNode.isObject() || actualResponseNode.isArray() || actualResponseNode.isValueNode(), - "response should be a valid JSON value"); + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); } - if (actualResponseNode.isArray()) { - Assertions.assertTrue(actualResponseNode.size() >= 0, "Array should have valid size"); + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); } - if (actualResponseNode.isObject()) { - Assertions.assertTrue(actualResponseNode.size() >= 0, "Object should have valid field count"); + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); } + + // Validate error response + Assertions.assertEquals(400, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); } @Test - public void testUpdate() throws Exception { + public void testCreateThrowsUnauthorizedError() throws Exception { // OAuth: enqueue token response (client fetches token before API call) server.enqueue(new MockResponse() .setResponseCode(200) .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); - server.enqueue(new MockResponse() - .setResponseCode(200) - .setBody(TestResources.loadResource("/wire-tests/WorkflowsWireTest_testUpdate_response.json"))); - UpdateResponse response = client.workflows() - .update( - "id", - UpdateWorkflowRequest.builder() - .name("Patient Data Mapping Workflow (v2)") - .workflowInstructions( - "Given diagnosis data, find the patient and create a condition record linked to their encounter") - .fhirProviderId( - UpdateWorkflowRequestFhirProviderId.of("550e8400-e29b-41d4-a716-446655440000")) - .verbose(true) - .sampleData(new HashMap() { + server.enqueue(new MockResponse().setResponseCode(401).setBody("{\"key\":\"value\"}")); + UnauthorizedError exception = Assertions.assertThrows(UnauthorizedError.class, () -> client.workflows() + .create(CreateWorkflowRequest.builder() + .name("name") + .workflowInstructions("workflow_instructions") + .fhirProviderId(CreateWorkflowRequestFhirProviderId.of("fhir_provider_id")) + .sampleData(new HashMap() { + { + put("sample_data", new HashMap() { { - put("patient_last_name", "Rippin"); - put("patient_first_name", "Clay"); - put("diagnosis_code", "I10"); - put("encounter_date", "2024-01-15"); + put("key", "value"); } - }) - .build()); + }); + } + }) + .build())); // OAuth: consume the token request server.takeRequest(); RecordedRequest request = server.takeRequest(); Assertions.assertNotNull(request); - Assertions.assertEquals("PUT", request.getMethod()); + Assertions.assertEquals("POST", request.getMethod()); // Validate OAuth Authorization header Assertions.assertEquals( @@ -317,15 +432,14 @@ public void testUpdate() throws Exception { String actualRequestBody = request.getBody().readUtf8(); String expectedRequestBody = "" + "{\n" - + " \"name\": \"Patient Data Mapping Workflow (v2)\",\n" - + " \"workflow_instructions\": \"Given diagnosis data, find the patient and create a condition record linked to their encounter\",\n" + + " \"name\": \"name\",\n" + + " \"workflow_instructions\": \"workflow_instructions\",\n" + " \"sample_data\": {\n" - + " \"patient_last_name\": \"Rippin\",\n" - + " \"patient_first_name\": \"Clay\",\n" - + " \"diagnosis_code\": \"I10\",\n" - + " \"encounter_date\": \"2024-01-15\"\n" + + " \"sample_data\": {\n" + + " \"key\": \"value\"\n" + + " }\n" + " },\n" - + " \"fhir_provider_id\": \"550e8400-e29b-41d4-a716-446655440000\"\n" + + " \"fhir_provider_id\": \"fhir_provider_id\"\n" + "}"; JsonNode actualJson = objectMapper.readTree(actualRequestBody); JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); @@ -354,122 +468,119 @@ else if (actualJson.has("kind")) Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); } - // Validate response body - Assertions.assertNotNull(response, "Response should not be null"); - String actualResponseJson = objectMapper.writeValueAsString(response); - String expectedResponseBody = - TestResources.loadResource("/wire-tests/WorkflowsWireTest_testUpdate_response.json"); - JsonNode actualResponseNode = objectMapper.readTree(actualResponseJson); - JsonNode expectedResponseNode = objectMapper.readTree(expectedResponseBody); - Assertions.assertTrue( - jsonEquals(expectedResponseNode, actualResponseNode), - "Response body structure does not match expected"); - if (actualResponseNode.has("type") || actualResponseNode.has("_type") || actualResponseNode.has("kind")) { - String discriminator = null; - if (actualResponseNode.has("type")) - discriminator = actualResponseNode.get("type").asText(); - else if (actualResponseNode.has("_type")) - discriminator = actualResponseNode.get("_type").asText(); - else if (actualResponseNode.has("kind")) - discriminator = actualResponseNode.get("kind").asText(); - Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); - Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); - } - - if (!actualResponseNode.isNull()) { - Assertions.assertTrue( - actualResponseNode.isObject() || actualResponseNode.isArray() || actualResponseNode.isValueNode(), - "response should be a valid JSON value"); - } - - if (actualResponseNode.isArray()) { - Assertions.assertTrue(actualResponseNode.size() >= 0, "Array should have valid size"); - } - if (actualResponseNode.isObject()) { - Assertions.assertTrue(actualResponseNode.size() >= 0, "Object should have valid field count"); - } + // Validate error response + Assertions.assertEquals(401, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); } @Test - public void testDelete() throws Exception { + public void testCreateThrowsForbiddenError() throws Exception { // OAuth: enqueue token response (client fetches token before API call) server.enqueue(new MockResponse() .setResponseCode(200) .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); - server.enqueue(new MockResponse() - .setResponseCode(200) - .setBody("{\"success\":true,\"message\":\"Workflow deleted successfully\"}")); - DeleteResponse response = client.workflows().delete("id"); + server.enqueue(new MockResponse().setResponseCode(403).setBody("{\"key\":\"value\"}")); + ForbiddenError exception = Assertions.assertThrows(ForbiddenError.class, () -> client.workflows() + .create(CreateWorkflowRequest.builder() + .name("name") + .workflowInstructions("workflow_instructions") + .fhirProviderId(CreateWorkflowRequestFhirProviderId.of("fhir_provider_id")) + .sampleData(new HashMap() { + { + put("sample_data", new HashMap() { + { + put("key", "value"); + } + }); + } + }) + .build())); // OAuth: consume the token request server.takeRequest(); RecordedRequest request = server.takeRequest(); Assertions.assertNotNull(request); - Assertions.assertEquals("DELETE", request.getMethod()); + Assertions.assertEquals("POST", request.getMethod()); // Validate OAuth Authorization header Assertions.assertEquals( "Bearer test-token", request.getHeader("Authorization"), "OAuth Authorization header should contain Bearer token from OAuth flow"); - - // Validate response body - Assertions.assertNotNull(response, "Response should not be null"); - String actualResponseJson = objectMapper.writeValueAsString(response); - String expectedResponseBody = - "" + "{\n" + " \"success\": true,\n" + " \"message\": \"Workflow deleted successfully\"\n" + "}"; - JsonNode actualResponseNode = objectMapper.readTree(actualResponseJson); - JsonNode expectedResponseNode = objectMapper.readTree(expectedResponseBody); - Assertions.assertTrue( - jsonEquals(expectedResponseNode, actualResponseNode), - "Response body structure does not match expected"); - if (actualResponseNode.has("type") || actualResponseNode.has("_type") || actualResponseNode.has("kind")) { + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = "" + + "{\n" + + " \"name\": \"name\",\n" + + " \"workflow_instructions\": \"workflow_instructions\",\n" + + " \"sample_data\": {\n" + + " \"sample_data\": {\n" + + " \"key\": \"value\"\n" + + " }\n" + + " },\n" + + " \"fhir_provider_id\": \"fhir_provider_id\"\n" + + "}"; + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { String discriminator = null; - if (actualResponseNode.has("type")) - discriminator = actualResponseNode.get("type").asText(); - else if (actualResponseNode.has("_type")) - discriminator = actualResponseNode.get("_type").asText(); - else if (actualResponseNode.has("kind")) - discriminator = actualResponseNode.get("kind").asText(); + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); } - if (!actualResponseNode.isNull()) { + if (!actualJson.isNull()) { Assertions.assertTrue( - actualResponseNode.isObject() || actualResponseNode.isArray() || actualResponseNode.isValueNode(), - "response should be a valid JSON value"); + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); } - if (actualResponseNode.isArray()) { - Assertions.assertTrue(actualResponseNode.size() >= 0, "Array should have valid size"); + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); } - if (actualResponseNode.isObject()) { - Assertions.assertTrue(actualResponseNode.size() >= 0, "Object should have valid field count"); + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); } + + // Validate error response + Assertions.assertEquals(403, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); } @Test - public void testExecute() throws Exception { + public void testCreateThrowsInternalServerError() throws Exception { // OAuth: enqueue token response (client fetches token before API call) server.enqueue(new MockResponse() .setResponseCode(200) .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); - server.enqueue(new MockResponse() - .setResponseCode(200) - .setBody(TestResources.loadResource("/wire-tests/WorkflowsWireTest_testExecute_response.json"))); - ExecuteWorkflowResponse response = client.workflows() - .execute( - "7a8b9c0d-1234-5678-abcd-ef9876543210", - ExecuteWorkflowRequest.builder() - .inputData(new HashMap() { + server.enqueue(new MockResponse().setResponseCode(500).setBody("{\"key\":\"value\"}")); + InternalServerError exception = Assertions.assertThrows(InternalServerError.class, () -> client.workflows() + .create(CreateWorkflowRequest.builder() + .name("name") + .workflowInstructions("workflow_instructions") + .fhirProviderId(CreateWorkflowRequestFhirProviderId.of("fhir_provider_id")) + .sampleData(new HashMap() { + { + put("sample_data", new HashMap() { { - put("patient_last_name", "Johnson"); - put("patient_first_name", "Mary"); - put("diagnosis_code", "M79.3"); - put("encounter_date", "2024-03-20"); + put("key", "value"); } - }) - .build()); + }); + } + }) + .build())); // OAuth: consume the token request server.takeRequest(); RecordedRequest request = server.takeRequest(); @@ -485,12 +596,14 @@ public void testExecute() throws Exception { String actualRequestBody = request.getBody().readUtf8(); String expectedRequestBody = "" + "{\n" - + " \"input_data\": {\n" - + " \"patient_last_name\": \"Johnson\",\n" - + " \"patient_first_name\": \"Mary\",\n" - + " \"diagnosis_code\": \"M79.3\",\n" - + " \"encounter_date\": \"2024-03-20\"\n" - + " }\n" + + " \"name\": \"name\",\n" + + " \"workflow_instructions\": \"workflow_instructions\",\n" + + " \"sample_data\": {\n" + + " \"sample_data\": {\n" + + " \"key\": \"value\"\n" + + " }\n" + + " },\n" + + " \"fhir_provider_id\": \"fhir_provider_id\"\n" + "}"; JsonNode actualJson = objectMapper.readTree(actualRequestBody); JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); @@ -519,11 +632,42 @@ else if (actualJson.has("kind")) Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); } + // Validate error response + Assertions.assertEquals(500, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testGet() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody(TestResources.loadResource("/wire-tests/WorkflowsWireTest_testGet_response.json"))); + GetResponse response = + client.workflows().get("id", GetRequest.builder().verbose(true).build()); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("GET", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + // Validate response body Assertions.assertNotNull(response, "Response should not be null"); String actualResponseJson = objectMapper.writeValueAsString(response); - String expectedResponseBody = - TestResources.loadResource("/wire-tests/WorkflowsWireTest_testExecute_response.json"); + String expectedResponseBody = TestResources.loadResource("/wire-tests/WorkflowsWireTest_testGet_response.json"); JsonNode actualResponseNode = objectMapper.readTree(actualResponseJson); JsonNode expectedResponseNode = objectMapper.readTree(expectedResponseBody); Assertions.assertTrue( @@ -555,6 +699,1667 @@ else if (actualResponseNode.has("kind")) } } + @Test + public void testGetThrowsUnauthorizedError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(401).setBody("{\"key\":\"value\"}")); + UnauthorizedError exception = Assertions.assertThrows(UnauthorizedError.class, () -> client.workflows() + .get("id", GetRequest.builder().build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("GET", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate error response + Assertions.assertEquals(401, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testGetThrowsForbiddenError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(403).setBody("{\"key\":\"value\"}")); + ForbiddenError exception = Assertions.assertThrows(ForbiddenError.class, () -> client.workflows() + .get("id", GetRequest.builder().build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("GET", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate error response + Assertions.assertEquals(403, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testGetThrowsNotFoundError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(404).setBody("{\"key\":\"value\"}")); + NotFoundError exception = Assertions.assertThrows(NotFoundError.class, () -> client.workflows() + .get("id", GetRequest.builder().build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("GET", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate error response + Assertions.assertEquals(404, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testGetThrowsInternalServerError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(500).setBody("{\"key\":\"value\"}")); + InternalServerError exception = Assertions.assertThrows(InternalServerError.class, () -> client.workflows() + .get("id", GetRequest.builder().build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("GET", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate error response + Assertions.assertEquals(500, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testGetThrowsGatewayTimeoutError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(504).setBody("{\"key\":\"value\"}")); + GatewayTimeoutError exception = Assertions.assertThrows(GatewayTimeoutError.class, () -> client.workflows() + .get("id", GetRequest.builder().build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("GET", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate error response + Assertions.assertEquals(504, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testUpdate() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody(TestResources.loadResource("/wire-tests/WorkflowsWireTest_testUpdate_response.json"))); + UpdateResponse response = client.workflows() + .update( + "id", + UpdateWorkflowRequest.builder() + .name("Patient Data Mapping Workflow (v2)") + .workflowInstructions( + "Given diagnosis data, find the patient and create a condition record linked to their encounter") + .fhirProviderId( + UpdateWorkflowRequestFhirProviderId.of("550e8400-e29b-41d4-a716-446655440000")) + .verbose(true) + .sampleData(new HashMap() { + { + put("patient_last_name", "Rippin"); + put("patient_first_name", "Clay"); + put("diagnosis_code", "I10"); + put("encounter_date", "2024-01-15"); + } + }) + .build()); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("PUT", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = "" + + "{\n" + + " \"name\": \"Patient Data Mapping Workflow (v2)\",\n" + + " \"workflow_instructions\": \"Given diagnosis data, find the patient and create a condition record linked to their encounter\",\n" + + " \"sample_data\": {\n" + + " \"patient_last_name\": \"Rippin\",\n" + + " \"patient_first_name\": \"Clay\",\n" + + " \"diagnosis_code\": \"I10\",\n" + + " \"encounter_date\": \"2024-01-15\"\n" + + " },\n" + + " \"fhir_provider_id\": \"550e8400-e29b-41d4-a716-446655440000\"\n" + + "}"; + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { + String discriminator = null; + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualJson.isNull()) { + Assertions.assertTrue( + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); + } + + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); + } + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); + } + + // Validate response body + Assertions.assertNotNull(response, "Response should not be null"); + String actualResponseJson = objectMapper.writeValueAsString(response); + String expectedResponseBody = + TestResources.loadResource("/wire-tests/WorkflowsWireTest_testUpdate_response.json"); + JsonNode actualResponseNode = objectMapper.readTree(actualResponseJson); + JsonNode expectedResponseNode = objectMapper.readTree(expectedResponseBody); + Assertions.assertTrue( + jsonEquals(expectedResponseNode, actualResponseNode), + "Response body structure does not match expected"); + if (actualResponseNode.has("type") || actualResponseNode.has("_type") || actualResponseNode.has("kind")) { + String discriminator = null; + if (actualResponseNode.has("type")) + discriminator = actualResponseNode.get("type").asText(); + else if (actualResponseNode.has("_type")) + discriminator = actualResponseNode.get("_type").asText(); + else if (actualResponseNode.has("kind")) + discriminator = actualResponseNode.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualResponseNode.isNull()) { + Assertions.assertTrue( + actualResponseNode.isObject() || actualResponseNode.isArray() || actualResponseNode.isValueNode(), + "response should be a valid JSON value"); + } + + if (actualResponseNode.isArray()) { + Assertions.assertTrue(actualResponseNode.size() >= 0, "Array should have valid size"); + } + if (actualResponseNode.isObject()) { + Assertions.assertTrue(actualResponseNode.size() >= 0, "Object should have valid field count"); + } + } + + @Test + public void testUpdateThrowsBadRequestError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(400).setBody("{\"key\":\"value\"}")); + BadRequestError exception = Assertions.assertThrows(BadRequestError.class, () -> client.workflows() + .update( + "id", + UpdateWorkflowRequest.builder() + .name("name") + .workflowInstructions("workflow_instructions") + .fhirProviderId(UpdateWorkflowRequestFhirProviderId.of("fhir_provider_id")) + .sampleData(new HashMap() { + { + put("sample_data", new HashMap() { + { + put("key", "value"); + } + }); + } + }) + .build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("PUT", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = "" + + "{\n" + + " \"name\": \"name\",\n" + + " \"workflow_instructions\": \"workflow_instructions\",\n" + + " \"sample_data\": {\n" + + " \"sample_data\": {\n" + + " \"key\": \"value\"\n" + + " }\n" + + " },\n" + + " \"fhir_provider_id\": \"fhir_provider_id\"\n" + + "}"; + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { + String discriminator = null; + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualJson.isNull()) { + Assertions.assertTrue( + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); + } + + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); + } + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); + } + + // Validate error response + Assertions.assertEquals(400, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testUpdateThrowsUnauthorizedError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(401).setBody("{\"key\":\"value\"}")); + UnauthorizedError exception = Assertions.assertThrows(UnauthorizedError.class, () -> client.workflows() + .update( + "id", + UpdateWorkflowRequest.builder() + .name("name") + .workflowInstructions("workflow_instructions") + .fhirProviderId(UpdateWorkflowRequestFhirProviderId.of("fhir_provider_id")) + .sampleData(new HashMap() { + { + put("sample_data", new HashMap() { + { + put("key", "value"); + } + }); + } + }) + .build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("PUT", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = "" + + "{\n" + + " \"name\": \"name\",\n" + + " \"workflow_instructions\": \"workflow_instructions\",\n" + + " \"sample_data\": {\n" + + " \"sample_data\": {\n" + + " \"key\": \"value\"\n" + + " }\n" + + " },\n" + + " \"fhir_provider_id\": \"fhir_provider_id\"\n" + + "}"; + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { + String discriminator = null; + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualJson.isNull()) { + Assertions.assertTrue( + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); + } + + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); + } + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); + } + + // Validate error response + Assertions.assertEquals(401, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testUpdateThrowsForbiddenError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(403).setBody("{\"key\":\"value\"}")); + ForbiddenError exception = Assertions.assertThrows(ForbiddenError.class, () -> client.workflows() + .update( + "id", + UpdateWorkflowRequest.builder() + .name("name") + .workflowInstructions("workflow_instructions") + .fhirProviderId(UpdateWorkflowRequestFhirProviderId.of("fhir_provider_id")) + .sampleData(new HashMap() { + { + put("sample_data", new HashMap() { + { + put("key", "value"); + } + }); + } + }) + .build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("PUT", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = "" + + "{\n" + + " \"name\": \"name\",\n" + + " \"workflow_instructions\": \"workflow_instructions\",\n" + + " \"sample_data\": {\n" + + " \"sample_data\": {\n" + + " \"key\": \"value\"\n" + + " }\n" + + " },\n" + + " \"fhir_provider_id\": \"fhir_provider_id\"\n" + + "}"; + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { + String discriminator = null; + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualJson.isNull()) { + Assertions.assertTrue( + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); + } + + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); + } + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); + } + + // Validate error response + Assertions.assertEquals(403, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testUpdateThrowsNotFoundError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(404).setBody("{\"key\":\"value\"}")); + NotFoundError exception = Assertions.assertThrows(NotFoundError.class, () -> client.workflows() + .update( + "id", + UpdateWorkflowRequest.builder() + .name("name") + .workflowInstructions("workflow_instructions") + .fhirProviderId(UpdateWorkflowRequestFhirProviderId.of("fhir_provider_id")) + .sampleData(new HashMap() { + { + put("sample_data", new HashMap() { + { + put("key", "value"); + } + }); + } + }) + .build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("PUT", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = "" + + "{\n" + + " \"name\": \"name\",\n" + + " \"workflow_instructions\": \"workflow_instructions\",\n" + + " \"sample_data\": {\n" + + " \"sample_data\": {\n" + + " \"key\": \"value\"\n" + + " }\n" + + " },\n" + + " \"fhir_provider_id\": \"fhir_provider_id\"\n" + + "}"; + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { + String discriminator = null; + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualJson.isNull()) { + Assertions.assertTrue( + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); + } + + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); + } + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); + } + + // Validate error response + Assertions.assertEquals(404, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testUpdateThrowsInternalServerError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(500).setBody("{\"key\":\"value\"}")); + InternalServerError exception = Assertions.assertThrows(InternalServerError.class, () -> client.workflows() + .update( + "id", + UpdateWorkflowRequest.builder() + .name("name") + .workflowInstructions("workflow_instructions") + .fhirProviderId(UpdateWorkflowRequestFhirProviderId.of("fhir_provider_id")) + .sampleData(new HashMap() { + { + put("sample_data", new HashMap() { + { + put("key", "value"); + } + }); + } + }) + .build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("PUT", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = "" + + "{\n" + + " \"name\": \"name\",\n" + + " \"workflow_instructions\": \"workflow_instructions\",\n" + + " \"sample_data\": {\n" + + " \"sample_data\": {\n" + + " \"key\": \"value\"\n" + + " }\n" + + " },\n" + + " \"fhir_provider_id\": \"fhir_provider_id\"\n" + + "}"; + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { + String discriminator = null; + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualJson.isNull()) { + Assertions.assertTrue( + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); + } + + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); + } + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); + } + + // Validate error response + Assertions.assertEquals(500, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testUpdateThrowsGatewayTimeoutError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(504).setBody("{\"key\":\"value\"}")); + GatewayTimeoutError exception = Assertions.assertThrows(GatewayTimeoutError.class, () -> client.workflows() + .update( + "id", + UpdateWorkflowRequest.builder() + .name("name") + .workflowInstructions("workflow_instructions") + .fhirProviderId(UpdateWorkflowRequestFhirProviderId.of("fhir_provider_id")) + .sampleData(new HashMap() { + { + put("sample_data", new HashMap() { + { + put("key", "value"); + } + }); + } + }) + .build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("PUT", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = "" + + "{\n" + + " \"name\": \"name\",\n" + + " \"workflow_instructions\": \"workflow_instructions\",\n" + + " \"sample_data\": {\n" + + " \"sample_data\": {\n" + + " \"key\": \"value\"\n" + + " }\n" + + " },\n" + + " \"fhir_provider_id\": \"fhir_provider_id\"\n" + + "}"; + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { + String discriminator = null; + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualJson.isNull()) { + Assertions.assertTrue( + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); + } + + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); + } + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); + } + + // Validate error response + Assertions.assertEquals(504, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testDelete() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"success\":true,\"message\":\"Workflow deleted successfully\"}")); + DeleteResponse response = client.workflows().delete("id"); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("DELETE", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate response body + Assertions.assertNotNull(response, "Response should not be null"); + String actualResponseJson = objectMapper.writeValueAsString(response); + String expectedResponseBody = + "" + "{\n" + " \"success\": true,\n" + " \"message\": \"Workflow deleted successfully\"\n" + "}"; + JsonNode actualResponseNode = objectMapper.readTree(actualResponseJson); + JsonNode expectedResponseNode = objectMapper.readTree(expectedResponseBody); + Assertions.assertTrue( + jsonEquals(expectedResponseNode, actualResponseNode), + "Response body structure does not match expected"); + if (actualResponseNode.has("type") || actualResponseNode.has("_type") || actualResponseNode.has("kind")) { + String discriminator = null; + if (actualResponseNode.has("type")) + discriminator = actualResponseNode.get("type").asText(); + else if (actualResponseNode.has("_type")) + discriminator = actualResponseNode.get("_type").asText(); + else if (actualResponseNode.has("kind")) + discriminator = actualResponseNode.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualResponseNode.isNull()) { + Assertions.assertTrue( + actualResponseNode.isObject() || actualResponseNode.isArray() || actualResponseNode.isValueNode(), + "response should be a valid JSON value"); + } + + if (actualResponseNode.isArray()) { + Assertions.assertTrue(actualResponseNode.size() >= 0, "Array should have valid size"); + } + if (actualResponseNode.isObject()) { + Assertions.assertTrue(actualResponseNode.size() >= 0, "Object should have valid field count"); + } + } + + @Test + public void testDeleteThrowsUnauthorizedError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(401).setBody("{\"key\":\"value\"}")); + UnauthorizedError exception = Assertions.assertThrows( + UnauthorizedError.class, () -> client.workflows().delete("id")); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("DELETE", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate error response + Assertions.assertEquals(401, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testDeleteThrowsForbiddenError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(403).setBody("{\"key\":\"value\"}")); + ForbiddenError exception = Assertions.assertThrows( + ForbiddenError.class, () -> client.workflows().delete("id")); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("DELETE", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate error response + Assertions.assertEquals(403, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testDeleteThrowsNotFoundError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(404).setBody("{\"key\":\"value\"}")); + NotFoundError exception = Assertions.assertThrows( + NotFoundError.class, () -> client.workflows().delete("id")); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("DELETE", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate error response + Assertions.assertEquals(404, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testDeleteThrowsInternalServerError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(500).setBody("{\"key\":\"value\"}")); + InternalServerError exception = Assertions.assertThrows( + InternalServerError.class, () -> client.workflows().delete("id")); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("DELETE", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate error response + Assertions.assertEquals(500, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testDeleteThrowsGatewayTimeoutError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(504).setBody("{\"key\":\"value\"}")); + GatewayTimeoutError exception = Assertions.assertThrows( + GatewayTimeoutError.class, () -> client.workflows().delete("id")); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("DELETE", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + + // Validate error response + Assertions.assertEquals(504, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testExecute() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody(TestResources.loadResource("/wire-tests/WorkflowsWireTest_testExecute_response.json"))); + ExecuteWorkflowResponse response = client.workflows() + .execute( + "7a8b9c0d-1234-5678-abcd-ef9876543210", + ExecuteWorkflowRequest.builder() + .inputData(new HashMap() { + { + put("patient_last_name", "Johnson"); + put("patient_first_name", "Mary"); + put("diagnosis_code", "M79.3"); + put("encounter_date", "2024-03-20"); + } + }) + .build()); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("POST", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = "" + + "{\n" + + " \"input_data\": {\n" + + " \"patient_last_name\": \"Johnson\",\n" + + " \"patient_first_name\": \"Mary\",\n" + + " \"diagnosis_code\": \"M79.3\",\n" + + " \"encounter_date\": \"2024-03-20\"\n" + + " }\n" + + "}"; + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { + String discriminator = null; + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualJson.isNull()) { + Assertions.assertTrue( + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); + } + + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); + } + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); + } + + // Validate response body + Assertions.assertNotNull(response, "Response should not be null"); + String actualResponseJson = objectMapper.writeValueAsString(response); + String expectedResponseBody = + TestResources.loadResource("/wire-tests/WorkflowsWireTest_testExecute_response.json"); + JsonNode actualResponseNode = objectMapper.readTree(actualResponseJson); + JsonNode expectedResponseNode = objectMapper.readTree(expectedResponseBody); + Assertions.assertTrue( + jsonEquals(expectedResponseNode, actualResponseNode), + "Response body structure does not match expected"); + if (actualResponseNode.has("type") || actualResponseNode.has("_type") || actualResponseNode.has("kind")) { + String discriminator = null; + if (actualResponseNode.has("type")) + discriminator = actualResponseNode.get("type").asText(); + else if (actualResponseNode.has("_type")) + discriminator = actualResponseNode.get("_type").asText(); + else if (actualResponseNode.has("kind")) + discriminator = actualResponseNode.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualResponseNode.isNull()) { + Assertions.assertTrue( + actualResponseNode.isObject() || actualResponseNode.isArray() || actualResponseNode.isValueNode(), + "response should be a valid JSON value"); + } + + if (actualResponseNode.isArray()) { + Assertions.assertTrue(actualResponseNode.size() >= 0, "Array should have valid size"); + } + if (actualResponseNode.isObject()) { + Assertions.assertTrue(actualResponseNode.size() >= 0, "Object should have valid field count"); + } + } + + @Test + public void testExecutePreviewMode() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody(TestResources.loadResource( + "/wire-tests/WorkflowsWireTest_testExecutePreviewMode_response.json"))); + ExecuteWorkflowResponse response = client.workflows() + .execute( + "7a8b9c0d-1234-5678-abcd-ef9876543210", + ExecuteWorkflowRequest.builder() + .inputData(new HashMap() { + { + put("patient_last_name", "Johnson"); + put("patient_first_name", "Mary"); + put("diagnosis_code", "M79.3"); + } + }) + .preview(true) + .build()); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("POST", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = "" + + "{\n" + + " \"input_data\": {\n" + + " \"patient_last_name\": \"Johnson\",\n" + + " \"patient_first_name\": \"Mary\",\n" + + " \"diagnosis_code\": \"M79.3\"\n" + + " },\n" + + " \"preview\": true\n" + + "}"; + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { + String discriminator = null; + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualJson.isNull()) { + Assertions.assertTrue( + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); + } + + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); + } + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); + } + + // Validate response body + Assertions.assertNotNull(response, "Response should not be null"); + String actualResponseJson = objectMapper.writeValueAsString(response); + String expectedResponseBody = + TestResources.loadResource("/wire-tests/WorkflowsWireTest_testExecutePreviewMode_response.json"); + JsonNode actualResponseNode = objectMapper.readTree(actualResponseJson); + JsonNode expectedResponseNode = objectMapper.readTree(expectedResponseBody); + Assertions.assertTrue( + jsonEquals(expectedResponseNode, actualResponseNode), + "Response body structure does not match expected"); + if (actualResponseNode.has("type") || actualResponseNode.has("_type") || actualResponseNode.has("kind")) { + String discriminator = null; + if (actualResponseNode.has("type")) + discriminator = actualResponseNode.get("type").asText(); + else if (actualResponseNode.has("_type")) + discriminator = actualResponseNode.get("_type").asText(); + else if (actualResponseNode.has("kind")) + discriminator = actualResponseNode.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualResponseNode.isNull()) { + Assertions.assertTrue( + actualResponseNode.isObject() || actualResponseNode.isArray() || actualResponseNode.isValueNode(), + "response should be a valid JSON value"); + } + + if (actualResponseNode.isArray()) { + Assertions.assertTrue(actualResponseNode.size() >= 0, "Array should have valid size"); + } + if (actualResponseNode.isObject()) { + Assertions.assertTrue(actualResponseNode.size() >= 0, "Object should have valid field count"); + } + } + + @Test + public void testExecuteThrowsBadRequestError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(400).setBody("{\"key\":\"value\"}")); + BadRequestError exception = Assertions.assertThrows(BadRequestError.class, () -> client.workflows() + .execute( + "id", + ExecuteWorkflowRequest.builder() + .inputData(new HashMap() { + { + put("input_data", new HashMap() { + { + put("key", "value"); + } + }); + } + }) + .build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("POST", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = "" + + "{\n" + + " \"input_data\": {\n" + + " \"input_data\": {\n" + + " \"key\": \"value\"\n" + + " }\n" + + " }\n" + + "}"; + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { + String discriminator = null; + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualJson.isNull()) { + Assertions.assertTrue( + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); + } + + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); + } + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); + } + + // Validate error response + Assertions.assertEquals(400, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testExecuteThrowsUnauthorizedError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(401).setBody("{\"key\":\"value\"}")); + UnauthorizedError exception = Assertions.assertThrows(UnauthorizedError.class, () -> client.workflows() + .execute( + "id", + ExecuteWorkflowRequest.builder() + .inputData(new HashMap() { + { + put("input_data", new HashMap() { + { + put("key", "value"); + } + }); + } + }) + .build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("POST", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = "" + + "{\n" + + " \"input_data\": {\n" + + " \"input_data\": {\n" + + " \"key\": \"value\"\n" + + " }\n" + + " }\n" + + "}"; + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { + String discriminator = null; + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualJson.isNull()) { + Assertions.assertTrue( + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); + } + + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); + } + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); + } + + // Validate error response + Assertions.assertEquals(401, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testExecuteThrowsForbiddenError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(403).setBody("{\"key\":\"value\"}")); + ForbiddenError exception = Assertions.assertThrows(ForbiddenError.class, () -> client.workflows() + .execute( + "id", + ExecuteWorkflowRequest.builder() + .inputData(new HashMap() { + { + put("input_data", new HashMap() { + { + put("key", "value"); + } + }); + } + }) + .build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("POST", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = "" + + "{\n" + + " \"input_data\": {\n" + + " \"input_data\": {\n" + + " \"key\": \"value\"\n" + + " }\n" + + " }\n" + + "}"; + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { + String discriminator = null; + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualJson.isNull()) { + Assertions.assertTrue( + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); + } + + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); + } + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); + } + + // Validate error response + Assertions.assertEquals(403, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testExecuteThrowsNotFoundError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(404).setBody("{\"key\":\"value\"}")); + NotFoundError exception = Assertions.assertThrows(NotFoundError.class, () -> client.workflows() + .execute( + "id", + ExecuteWorkflowRequest.builder() + .inputData(new HashMap() { + { + put("input_data", new HashMap() { + { + put("key", "value"); + } + }); + } + }) + .build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("POST", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = "" + + "{\n" + + " \"input_data\": {\n" + + " \"input_data\": {\n" + + " \"key\": \"value\"\n" + + " }\n" + + " }\n" + + "}"; + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { + String discriminator = null; + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualJson.isNull()) { + Assertions.assertTrue( + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); + } + + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); + } + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); + } + + // Validate error response + Assertions.assertEquals(404, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testExecuteThrowsInternalServerError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(500).setBody("{\"key\":\"value\"}")); + InternalServerError exception = Assertions.assertThrows(InternalServerError.class, () -> client.workflows() + .execute( + "id", + ExecuteWorkflowRequest.builder() + .inputData(new HashMap() { + { + put("input_data", new HashMap() { + { + put("key", "value"); + } + }); + } + }) + .build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("POST", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = "" + + "{\n" + + " \"input_data\": {\n" + + " \"input_data\": {\n" + + " \"key\": \"value\"\n" + + " }\n" + + " }\n" + + "}"; + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { + String discriminator = null; + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualJson.isNull()) { + Assertions.assertTrue( + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); + } + + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); + } + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); + } + + // Validate error response + Assertions.assertEquals(500, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + + @Test + public void testExecuteThrowsGatewayTimeoutError() throws Exception { + // OAuth: enqueue token response (client fetches token before API call) + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.enqueue(new MockResponse().setResponseCode(504).setBody("{\"key\":\"value\"}")); + GatewayTimeoutError exception = Assertions.assertThrows(GatewayTimeoutError.class, () -> client.workflows() + .execute( + "id", + ExecuteWorkflowRequest.builder() + .inputData(new HashMap() { + { + put("input_data", new HashMap() { + { + put("key", "value"); + } + }); + } + }) + .build())); + // OAuth: consume the token request + server.takeRequest(); + RecordedRequest request = server.takeRequest(); + Assertions.assertNotNull(request); + Assertions.assertEquals("POST", request.getMethod()); + + // Validate OAuth Authorization header + Assertions.assertEquals( + "Bearer test-token", + request.getHeader("Authorization"), + "OAuth Authorization header should contain Bearer token from OAuth flow"); + // Validate request body + String actualRequestBody = request.getBody().readUtf8(); + String expectedRequestBody = "" + + "{\n" + + " \"input_data\": {\n" + + " \"input_data\": {\n" + + " \"key\": \"value\"\n" + + " }\n" + + " }\n" + + "}"; + JsonNode actualJson = objectMapper.readTree(actualRequestBody); + JsonNode expectedJson = objectMapper.readTree(expectedRequestBody); + Assertions.assertTrue(jsonEquals(expectedJson, actualJson), "Request body structure does not match expected"); + if (actualJson.has("type") || actualJson.has("_type") || actualJson.has("kind")) { + String discriminator = null; + if (actualJson.has("type")) discriminator = actualJson.get("type").asText(); + else if (actualJson.has("_type")) + discriminator = actualJson.get("_type").asText(); + else if (actualJson.has("kind")) + discriminator = actualJson.get("kind").asText(); + Assertions.assertNotNull(discriminator, "Union type should have a discriminator field"); + Assertions.assertFalse(discriminator.isEmpty(), "Union discriminator should not be empty"); + } + + if (!actualJson.isNull()) { + Assertions.assertTrue( + actualJson.isObject() || actualJson.isArray() || actualJson.isValueNode(), + "request should be a valid JSON value"); + } + + if (actualJson.isArray()) { + Assertions.assertTrue(actualJson.size() >= 0, "Array should have valid size"); + } + if (actualJson.isObject()) { + Assertions.assertTrue(actualJson.size() >= 0, "Object should have valid field count"); + } + + // Validate error response + Assertions.assertEquals(504, exception.statusCode(), "Error status code does not match expected"); + String actualErrorJson = objectMapper.writeValueAsString(exception.body()); + String expectedErrorBody = "" + "{\n" + " \"key\": \"value\"\n" + "}"; + JsonNode actualErrorNode = objectMapper.readTree(actualErrorJson); + JsonNode expectedErrorNode = objectMapper.readTree(expectedErrorBody); + Assertions.assertTrue(jsonEquals(expectedErrorNode, actualErrorNode), "Error body does not match expected"); + } + /** * Compares two JsonNodes with numeric equivalence and null safety. * For objects, checks that all fields in 'expected' exist in 'actual' with matching values. diff --git a/src/test/java/com/phenoml/api/core/PathSegmentsTest.java b/src/test/java/com/phenoml/api/core/PathSegmentsTest.java new file mode 100644 index 00000000..11004c1f --- /dev/null +++ b/src/test/java/com/phenoml/api/core/PathSegmentsTest.java @@ -0,0 +1,36 @@ +/** + * This file was auto-generated by Fern from our API Definition. + */ +package com.phenoml.api.core; + +import okhttp3.HttpUrl; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +public final class PathSegmentsTest { + + @Test + public void testRejectsDotSegments() { + Assertions.assertThrows(IllegalArgumentException.class, () -> PathSegments.validate(".")); + Assertions.assertThrows(IllegalArgumentException.class, () -> PathSegments.validate("..")); + } + + @Test + public void testAllowsValuesContainingDots() { + Assertions.assertEquals("...", PathSegments.validate("...")); + Assertions.assertEquals("v1.2", PathSegments.validate("v1.2")); + Assertions.assertEquals("../plants", PathSegments.validate("../plants")); + } + + @Test + public void testValueStaysWithinOneSegment() { + HttpUrl url = HttpUrl.parse("https://api.example.com") + .newBuilder() + .addPathSegments("plants") + .addPathSegment(PathSegments.validate("../leaves?x#y")) + .addPathSegments("leaves") + .addPathSegment(PathSegments.validate("L1")) + .build(); + Assertions.assertEquals("/plants/..%2Fleaves%3Fx%23y/leaves/L1", url.encodedPath()); + } +} diff --git a/src/test/resources/wire-tests/ConstrueCodesWireTest_testExtractHpoPhenotypeExtraction_response.json b/src/test/resources/wire-tests/ConstrueCodesWireTest_testExtractHpoPhenotypeExtraction_response.json new file mode 100644 index 00000000..9ea7abcc --- /dev/null +++ b/src/test/resources/wire-tests/ConstrueCodesWireTest_testExtractHpoPhenotypeExtraction_response.json @@ -0,0 +1,96 @@ +{ + "system": { + "name": "HPO", + "version": "2025" + }, + "codes": [ + { + "code": "HP:0001250", + "description": "Seizures", + "valid": true, + "reason": "reason", + "is_ancestor": true, + "citations": [ + { + "text": "Patient has type 2 diabetes", + "begin_offset": 0, + "end_offset": 27 + } + ], + "categories": [ + { + "uri": "HP:0000707", + "label": "Abnormality of the nervous system" + } + ] + }, + { + "code": "HP:0010864", + "description": "Intellectual disability, severe", + "valid": true, + "reason": "reason", + "is_ancestor": true, + "citations": [ + { + "text": "Patient has type 2 diabetes", + "begin_offset": 0, + "end_offset": 27 + } + ], + "categories": [ + { + "uri": "HP:0000707", + "label": "Abnormality of the nervous system" + } + ] + }, + { + "code": "HP:0000252", + "description": "Microcephaly", + "valid": true, + "reason": "reason", + "is_ancestor": true, + "citations": [ + { + "text": "Patient has type 2 diabetes", + "begin_offset": 0, + "end_offset": 27 + } + ], + "categories": [ + { + "uri": "HP:0000152", + "label": "Abnormality of head or neck" + }, + { + "uri": "HP:0033127", + "label": "Abnormality of the musculoskeletal system" + }, + { + "uri": "HP:0000707", + "label": "Abnormality of the nervous system" + } + ] + }, + { + "code": "HP:0001252", + "description": "Hypotonia", + "valid": true, + "reason": "reason", + "is_ancestor": true, + "citations": [ + { + "text": "Patient has type 2 diabetes", + "begin_offset": 0, + "end_offset": 27 + } + ], + "categories": [ + { + "uri": "HP:0033127", + "label": "Abnormality of the musculoskeletal system" + } + ] + } + ] +} \ No newline at end of file diff --git a/src/test/resources/wire-tests/ConstrueCodesWireTest_testExtractIcd10CmUmls2026Aa_response.json b/src/test/resources/wire-tests/ConstrueCodesWireTest_testExtractIcd10CmUmls2026Aa_response.json new file mode 100644 index 00000000..851777fe --- /dev/null +++ b/src/test/resources/wire-tests/ConstrueCodesWireTest_testExtractIcd10CmUmls2026Aa_response.json @@ -0,0 +1,68 @@ +{ + "system": { + "name": "ICD-10-CM", + "version": "2025" + }, + "codes": [ + { + "code": "Q60.3", + "description": "Renal hypoplasia, unspecified", + "valid": true, + "reason": "reason", + "is_ancestor": true, + "citations": [ + { + "text": "Patient has type 2 diabetes", + "begin_offset": 0, + "end_offset": 27 + } + ], + "categories": [ + { + "uri": "HP:0025142", + "label": "Constitutional symptom" + } + ] + }, + { + "code": "R93.42", + "description": "Abnormal findings on diagnostic imaging of kidney", + "valid": true, + "reason": "reason", + "is_ancestor": true, + "citations": [ + { + "text": "Patient has type 2 diabetes", + "begin_offset": 0, + "end_offset": 27 + } + ], + "categories": [ + { + "uri": "HP:0025142", + "label": "Constitutional symptom" + } + ] + }, + { + "code": "N28.1", + "description": "Cyst of kidney, acquired", + "valid": true, + "reason": "reason", + "is_ancestor": true, + "citations": [ + { + "text": "Patient has type 2 diabetes", + "begin_offset": 0, + "end_offset": 27 + } + ], + "categories": [ + { + "uri": "HP:0025142", + "label": "Constitutional symptom" + } + ] + } + ] +} \ No newline at end of file diff --git a/src/test/resources/wire-tests/ConstrueCodesWireTest_testExtractMedicationValidation_response.json b/src/test/resources/wire-tests/ConstrueCodesWireTest_testExtractMedicationValidation_response.json new file mode 100644 index 00000000..5ca409d4 --- /dev/null +++ b/src/test/resources/wire-tests/ConstrueCodesWireTest_testExtractMedicationValidation_response.json @@ -0,0 +1,68 @@ +{ + "system": { + "name": "RXNORM", + "version": "11042024" + }, + "codes": [ + { + "code": "861004", + "description": "Metformin hydrochloride 500 MG Oral Tablet", + "valid": true, + "reason": "Metformin 500mg prescribed for type 2 diabetes", + "is_ancestor": true, + "citations": [ + { + "text": "Patient has type 2 diabetes", + "begin_offset": 0, + "end_offset": 27 + } + ], + "categories": [ + { + "uri": "HP:0025142", + "label": "Constitutional symptom" + } + ] + }, + { + "code": "314076", + "description": "Lisinopril 10 MG Oral Tablet", + "valid": true, + "reason": "Lisinopril 10mg continued for hypertension", + "is_ancestor": true, + "citations": [ + { + "text": "Patient has type 2 diabetes", + "begin_offset": 0, + "end_offset": 27 + } + ], + "categories": [ + { + "uri": "HP:0025142", + "label": "Constitutional symptom" + } + ] + }, + { + "code": "259255", + "description": "Atorvastatin 20 MG Oral Tablet", + "valid": true, + "reason": "Atorvastatin 20mg added at bedtime", + "is_ancestor": true, + "citations": [ + { + "text": "Patient has type 2 diabetes", + "begin_offset": 0, + "end_offset": 27 + } + ], + "categories": [ + { + "uri": "HP:0025142", + "label": "Constitutional symptom" + } + ] + } + ] +} \ No newline at end of file diff --git a/src/test/resources/wire-tests/ConstrueCodesWireTest_testExtractWithAncestorCodes_response.json b/src/test/resources/wire-tests/ConstrueCodesWireTest_testExtractWithAncestorCodes_response.json new file mode 100644 index 00000000..d3da7933 --- /dev/null +++ b/src/test/resources/wire-tests/ConstrueCodesWireTest_testExtractWithAncestorCodes_response.json @@ -0,0 +1,108 @@ +{ + "system": { + "name": "ICD-10-CM", + "version": "2025" + }, + "codes": [ + { + "code": "E11.22", + "description": "Type 2 diabetes mellitus with diabetic chronic kidney disease", + "valid": true, + "reason": "reason", + "is_ancestor": true, + "citations": [ + { + "text": "Patient has type 2 diabetes", + "begin_offset": 0, + "end_offset": 27 + } + ], + "categories": [ + { + "uri": "HP:0025142", + "label": "Constitutional symptom" + } + ] + }, + { + "code": "E11.2", + "description": "Type 2 diabetes mellitus with kidney complications", + "valid": true, + "reason": "reason", + "is_ancestor": true, + "citations": [ + { + "text": "Patient has type 2 diabetes", + "begin_offset": 0, + "end_offset": 27 + } + ], + "categories": [ + { + "uri": "HP:0025142", + "label": "Constitutional symptom" + } + ] + }, + { + "code": "E11", + "description": "Type 2 diabetes mellitus", + "valid": true, + "reason": "reason", + "is_ancestor": true, + "citations": [ + { + "text": "Patient has type 2 diabetes", + "begin_offset": 0, + "end_offset": 27 + } + ], + "categories": [ + { + "uri": "HP:0025142", + "label": "Constitutional symptom" + } + ] + }, + { + "code": "N18.3", + "description": "Chronic kidney disease, stage 3 (moderate)", + "valid": true, + "reason": "reason", + "is_ancestor": true, + "citations": [ + { + "text": "Patient has type 2 diabetes", + "begin_offset": 0, + "end_offset": 27 + } + ], + "categories": [ + { + "uri": "HP:0025142", + "label": "Constitutional symptom" + } + ] + }, + { + "code": "N18", + "description": "Chronic kidney disease (CKD)", + "valid": true, + "reason": "reason", + "is_ancestor": true, + "citations": [ + { + "text": "Patient has type 2 diabetes", + "begin_offset": 0, + "end_offset": 27 + } + ], + "categories": [ + { + "uri": "HP:0025142", + "label": "Constitutional symptom" + } + ] + } + ] +} \ No newline at end of file diff --git a/src/test/resources/wire-tests/ConstrueCodesWireTest_testExtractWithSourceCitations_response.json b/src/test/resources/wire-tests/ConstrueCodesWireTest_testExtractWithSourceCitations_response.json new file mode 100644 index 00000000..8d74f305 --- /dev/null +++ b/src/test/resources/wire-tests/ConstrueCodesWireTest_testExtractWithSourceCitations_response.json @@ -0,0 +1,68 @@ +{ + "system": { + "name": "SNOMED_CT_US_LITE", + "version": "20240901" + }, + "codes": [ + { + "code": "29857009", + "description": "Chest pain", + "valid": true, + "reason": "Patient presents with chest pain radiating to left arm", + "is_ancestor": true, + "citations": [ + { + "text": "chest pain radiating to left arm", + "begin_offset": 31, + "end_offset": 63 + } + ], + "categories": [ + { + "uri": "HP:0025142", + "label": "Constitutional symptom" + } + ] + }, + { + "code": "267036007", + "description": "Dyspnea", + "valid": true, + "reason": "Patient reports shortness of breath", + "is_ancestor": true, + "citations": [ + { + "text": "shortness of breath", + "begin_offset": 65, + "end_offset": 84 + } + ], + "categories": [ + { + "uri": "HP:0025142", + "label": "Constitutional symptom" + } + ] + }, + { + "code": "415690000", + "description": "Sweating", + "valid": true, + "reason": "Diaphoresis noted on presentation", + "is_ancestor": true, + "citations": [ + { + "text": "diaphoresis", + "begin_offset": 90, + "end_offset": 101 + } + ], + "categories": [ + { + "uri": "HP:0025142", + "label": "Constitutional symptom" + } + ] + } + ] +} \ No newline at end of file diff --git a/src/test/resources/wire-tests/Fhir2OmopWireTest_testCreateOidFormLoincCoding_response.json b/src/test/resources/wire-tests/Fhir2OmopWireTest_testCreateOidFormLoincCoding_response.json new file mode 100644 index 00000000..cc01b2d0 --- /dev/null +++ b/src/test/resources/wire-tests/Fhir2OmopWireTest_testCreateOidFormLoincCoding_response.json @@ -0,0 +1,497 @@ +{ + "success": true, + "message": "FHIR resources mapped to OMOP CDM v5.4", + "tables": { + "location": [ + {} + ], + "care_site": [ + {} + ], + "provider": [ + {} + ], + "person": [ + { + "person_id": 1, + "gender_concept_id": 8532, + "year_of_birth": 1985, + "month_of_birth": 7, + "day_of_birth": 22, + "race_concept_id": 0, + "ethnicity_concept_id": 0, + "person_source_value": "patient-1", + "gender_source_value": "female", + "gender_source_concept_id": 0, + "race_source_concept_id": 0, + "ethnicity_source_concept_id": 0 + } + ], + "death": [ + {} + ], + "observation_period": [ + { + "observation_period_id": 1, + "person_id": 1, + "observation_period_start_date": "2024-01-15", + "observation_period_end_date": "2024-01-16", + "period_type_concept_id": 32817 + } + ], + "visit_occurrence": [ + {} + ], + "condition_occurrence": [ + { + "condition_occurrence_id": 1, + "person_id": 1, + "condition_concept_id": 201826, + "condition_start_date": "2024-01-15", + "condition_type_concept_id": 32817, + "condition_source_value": "44054006", + "condition_source_concept_id": 201826 + } + ], + "drug_exposure": [ + { + "drug_exposure_id": 1, + "person_id": 1, + "drug_concept_id": 40163924, + "drug_exposure_start_date": "2024-01-16", + "drug_type_concept_id": 32838, + "drug_source_value": "860975", + "drug_source_concept_id": 40163924 + } + ], + "procedure_occurrence": [ + {} + ], + "measurement": [ + { + "unit_concept_id": null, + "unit_source_concept_id": null + } + ], + "device_exposure": [ + { + "device_exposure_id": 1000000, + "person_id": 1000000, + "device_concept_id": 1000000, + "device_exposure_start_datetime": "2026-01-01T23:30:00Z", + "device_exposure_end_datetime": "2026-01-03T08:00:00Z", + "device_type_concept_id": 1000000, + "device_source_concept_id": 1000000, + "unit_concept_id": null, + "unit_source_concept_id": null + } + ], + "specimen": [ + { + "specimen_id": 1000000, + "person_id": 1000000, + "specimen_concept_id": 1000000, + "specimen_type_concept_id": 1000000, + "unit_concept_id": null, + "anatomic_site_concept_id": null + } + ], + "observation": [ + { + "unit_concept_id": null + } + ] + }, + "quantity_transformations": [ + { + "resource_type": "MedicationRequest", + "resource_id": "tablet-order", + "source_path": "dispenseRequest.quantity", + "source_quantity": { + "value": 1000, + "system": "http://unitsofmeasure.org", + "code": "mg" + }, + "omop_table": "drug_exposure", + "omop_field": "quantity", + "omop_id": 1, + "outcome": "DERIVED", + "policy": "fixed_dose_form_count", + "reason": "unsupported_quantity", + "evidence": { + "drug_concept_id": 100, + "drug_concept_class": "Clinical Drug", + "dose_form": { + "concept_id": 200, + "code": "317541", + "vocabulary_id": "RxNorm" + }, + "strength": { + "ingredient_concept_id": 300, + "amount_value": 500, + "amount_unit": { + "concept_id": 400, + "code": "mg", + "vocabulary_id": "UCUM" + } + } + }, + "unit_conversion": { + "source_code": "source_code", + "result": 1.1, + "result_unit": { + "concept_id": 1000000, + "code": "code", + "vocabulary_id": "vocabulary_id" + } + }, + "result": 2, + "result_unit": { + "concept_id": 200, + "code": "317541", + "vocabulary_id": "RxNorm" + } + }, + { + "resource_type": "MedicationAdministration", + "resource_id": "injection-dose", + "source_path": "dosage.dose", + "source_quantity": { + "value": 80, + "system": "http://unitsofmeasure.org", + "code": "mL" + }, + "omop_table": "drug_exposure", + "omop_field": "quantity", + "omop_id": 2, + "outcome": "DERIVED", + "policy": "clinical_injection_ingredient_amount", + "reason": "unsupported_quantity", + "evidence": { + "drug_concept_id": 101, + "drug_concept_class": "Clinical Drug", + "dose_form": { + "concept_id": 201, + "code": "316949", + "vocabulary_id": "RxNorm" + }, + "strength": { + "ingredient_concept_id": 301, + "numerator_value": 2, + "numerator_unit": { + "concept_id": 400, + "code": "mg", + "vocabulary_id": "UCUM" + }, + "denominator_unit": { + "concept_id": 401, + "code": "mL", + "vocabulary_id": "UCUM" + }, + "denominator_basis": { + "kind": "PER_UNIT", + "value": 1 + } + } + }, + "unit_conversion": { + "source_code": "source_code", + "result": 1.1, + "result_unit": { + "concept_id": 1000000, + "code": "code", + "vocabulary_id": "vocabulary_id" + } + }, + "result": 160, + "result_unit": { + "concept_id": 400, + "code": "mg", + "vocabulary_id": "UCUM" + } + }, + { + "resource_type": "MedicationRequest", + "resource_id": "counted-tablets", + "source_path": "dispenseRequest.quantity", + "source_quantity": { + "value": 30, + "system": "http://terminology.hl7.org/CodeSystem/v3-orderableDrugForm", + "code": "TAB" + }, + "omop_table": "drug_exposure", + "omop_field": "quantity", + "omop_id": 4, + "outcome": "DIRECT", + "policy": "fixed_dose_form_count", + "reason": "unsupported_quantity", + "evidence": { + "drug_concept_id": 100, + "drug_concept_class": "Clinical Drug", + "dose_form": { + "concept_id": 200, + "code": "317541", + "vocabulary_id": "RxNorm" + }, + "strength": { + "ingredient_concept_id": 300, + "amount_value": 500, + "amount_unit": { + "concept_id": 400, + "code": "mg", + "vocabulary_id": "UCUM" + } + } + }, + "unit_conversion": { + "source_code": "source_code", + "result": 1.1, + "result_unit": { + "concept_id": 1000000, + "code": "code", + "vocabulary_id": "vocabulary_id" + } + }, + "result": 30, + "result_unit": { + "concept_id": 200, + "code": "317541", + "vocabulary_id": "RxNorm" + } + }, + { + "resource_type": "MedicationRequest", + "resource_id": "free-text-unit", + "source_path": "dispenseRequest.quantity", + "source_quantity": { + "value": 2, + "unit": "tablet" + }, + "omop_table": "drug_exposure", + "omop_field": "quantity", + "omop_id": 3, + "outcome": "OMITTED", + "policy": "fixed_dose_form_count", + "reason": "unsupported_unit", + "evidence": { + "drug_concept_id": 1000000 + }, + "unit_conversion": { + "source_code": "source_code", + "result": 1.1, + "result_unit": { + "concept_id": 1000000, + "code": "code", + "vocabulary_id": "vocabulary_id" + } + }, + "result": 1.1, + "result_unit": { + "concept_id": 1000000, + "code": "code", + "vocabulary_id": "vocabulary_id" + } + } + ], + "rows_for_review": [ + { + "resource_type": "resource_type", + "resource_id": "resource_id", + "source_full_url": "source_full_url", + "omop_table": "omop_table", + "omop_id": 1000000, + "reasons": [ + "condition_verification_unconfirmed" + ], + "source_statuses": { + "key": "value" + } + } + ], + "mappings": [ + { + "resource_type": "Patient", + "resource_id": "patient-1", + "omop_table": "person", + "omop_id": 1, + "omop_field": "gender_concept_id", + "source_system": "http://hl7.org/fhir/administrative-gender", + "source_code": "female", + "source_name": "female", + "source_coding_index": 1, + "target_vocabulary": "Gender", + "target_code": "F", + "target_name": "FEMALE", + "mapping_status": "MAPPED", + "selected": true, + "resolution": { + "semantic_mapping": { + "status": "UNMAPPED", + "vocab_version": "v20260829", + "reason_code": "AMBIGUOUS" + }, + "status": "RESOLVED" + }, + "note": "FHIR administrative gender; assumed sex at birth" + }, + { + "resource_type": "Condition", + "resource_id": "condition-1", + "omop_table": "condition_occurrence", + "omop_id": 1, + "omop_field": "condition_concept_id", + "source_system": "http://snomed.info/sct", + "source_code": "44054006", + "source_name": "Type 2 diabetes mellitus", + "source_coding_index": 1, + "target_vocabulary": "SNOMED", + "target_code": "44054006", + "target_name": "Type 2 diabetes mellitus", + "mapping_status": "ALREADY_STANDARD", + "selected": true, + "resolution": { + "semantic_mapping": { + "status": "UNMAPPED", + "vocab_version": "v20260829", + "reason_code": "AMBIGUOUS" + }, + "status": "RESOLVED" + }, + "note": "note" + }, + { + "resource_type": "MedicationRequest", + "resource_id": "medreq-1", + "omop_table": "drug_exposure", + "omop_id": 1, + "omop_field": "drug_concept_id", + "source_system": "http://www.nlm.nih.gov/research/umls/rxnorm", + "source_code": "860975", + "source_name": "metformin hydrochloride 500 MG", + "source_coding_index": 1, + "target_vocabulary": "RXNORM", + "target_code": "860975", + "target_name": "metformin hydrochloride 500 MG", + "mapping_status": "ALREADY_STANDARD", + "selected": true, + "resolution": { + "semantic_mapping": { + "status": "UNMAPPED", + "vocab_version": "v20260829", + "reason_code": "AMBIGUOUS" + }, + "status": "RESOLVED" + }, + "note": "note" + } + ], + "device_exposure_contexts": [ + { + "resource_type": "DeviceUseStatement", + "resource_id": "use-1", + "source_full_url": "source_full_url", + "exposures": [ + { + "device_exposure_id": 1, + "event_index": 0 + }, + { + "device_exposure_id": 2, + "event_index": 1 + } + ], + "quantity_basis": "default_absent_count", + "evidence": [ + { + "path": "timingTiming", + "value": { + "event": [ + "2026-01-01T09:00:00Z", + "2026-01-03T09:00:00Z" + ] + } + } + ] + } + ], + "specimen_source_contexts": [ + { + "specimen_id": 1000000, + "resource_id": "resource_id", + "full_url": "full_url", + "status": "available", + "identifiers": [ + { + "value": "value" + } + ], + "accession_identifier": { + "value": "value" + }, + "source_id_path": "source_id_path" + } + ], + "medication_dispense_source_contexts": [ + { + "resource_id": "resource_id", + "full_url": "full_url", + "omop_table": "drug_exposure", + "omop_id": 1000000, + "source": { + "key": "value" + } + } + ], + "provider_role_contexts": [ + { + "provider_id": 1000000, + "role_source_value": "role_source_value", + "practitioner_reference": "practitioner_reference", + "practitioner_identifier": { + "value": "value" + }, + "role_codes": [ + {} + ], + "specialties": [ + {} + ], + "care_sites": [ + { + "path": "path", + "reference": "reference" + } + ], + "active": true, + "period_start": "period_start", + "period_end": "period_end" + } + ], + "dropped": [ + { + "resource_type": "resource_type", + "resource_id": "resource_id", + "reason": "reason" + } + ], + "diagnostics": [ + { + "resource_type": "resource_type", + "resource_id": "resource_id", + "omop_table": "omop_table", + "omop_id": 1000000, + "path": "path", + "reference": "reference", + "outcome": "UNRESOLVED", + "reason": "reason" + } + ], + "vocab_version": "v20260829", + "summary": { + "codes_already_standard": 2, + "codes_normalized": 1, + "codes_unmapped": 0, + "rows_for_review": 1, + "off_vocab_rate": 0.3333333333333333 + } +} \ No newline at end of file diff --git a/src/test/resources/wire-tests/Fhir2OmopWireTest_testCreate_response.json b/src/test/resources/wire-tests/Fhir2OmopWireTest_testCreate_response.json index 0129a4a4..cc01b2d0 100644 --- a/src/test/resources/wire-tests/Fhir2OmopWireTest_testCreate_response.json +++ b/src/test/resources/wire-tests/Fhir2OmopWireTest_testCreate_response.json @@ -68,12 +68,246 @@ {} ], "measurement": [ - {} + { + "unit_concept_id": null, + "unit_source_concept_id": null + } + ], + "device_exposure": [ + { + "device_exposure_id": 1000000, + "person_id": 1000000, + "device_concept_id": 1000000, + "device_exposure_start_datetime": "2026-01-01T23:30:00Z", + "device_exposure_end_datetime": "2026-01-03T08:00:00Z", + "device_type_concept_id": 1000000, + "device_source_concept_id": 1000000, + "unit_concept_id": null, + "unit_source_concept_id": null + } + ], + "specimen": [ + { + "specimen_id": 1000000, + "person_id": 1000000, + "specimen_concept_id": 1000000, + "specimen_type_concept_id": 1000000, + "unit_concept_id": null, + "anatomic_site_concept_id": null + } ], "observation": [ - {} + { + "unit_concept_id": null + } ] }, + "quantity_transformations": [ + { + "resource_type": "MedicationRequest", + "resource_id": "tablet-order", + "source_path": "dispenseRequest.quantity", + "source_quantity": { + "value": 1000, + "system": "http://unitsofmeasure.org", + "code": "mg" + }, + "omop_table": "drug_exposure", + "omop_field": "quantity", + "omop_id": 1, + "outcome": "DERIVED", + "policy": "fixed_dose_form_count", + "reason": "unsupported_quantity", + "evidence": { + "drug_concept_id": 100, + "drug_concept_class": "Clinical Drug", + "dose_form": { + "concept_id": 200, + "code": "317541", + "vocabulary_id": "RxNorm" + }, + "strength": { + "ingredient_concept_id": 300, + "amount_value": 500, + "amount_unit": { + "concept_id": 400, + "code": "mg", + "vocabulary_id": "UCUM" + } + } + }, + "unit_conversion": { + "source_code": "source_code", + "result": 1.1, + "result_unit": { + "concept_id": 1000000, + "code": "code", + "vocabulary_id": "vocabulary_id" + } + }, + "result": 2, + "result_unit": { + "concept_id": 200, + "code": "317541", + "vocabulary_id": "RxNorm" + } + }, + { + "resource_type": "MedicationAdministration", + "resource_id": "injection-dose", + "source_path": "dosage.dose", + "source_quantity": { + "value": 80, + "system": "http://unitsofmeasure.org", + "code": "mL" + }, + "omop_table": "drug_exposure", + "omop_field": "quantity", + "omop_id": 2, + "outcome": "DERIVED", + "policy": "clinical_injection_ingredient_amount", + "reason": "unsupported_quantity", + "evidence": { + "drug_concept_id": 101, + "drug_concept_class": "Clinical Drug", + "dose_form": { + "concept_id": 201, + "code": "316949", + "vocabulary_id": "RxNorm" + }, + "strength": { + "ingredient_concept_id": 301, + "numerator_value": 2, + "numerator_unit": { + "concept_id": 400, + "code": "mg", + "vocabulary_id": "UCUM" + }, + "denominator_unit": { + "concept_id": 401, + "code": "mL", + "vocabulary_id": "UCUM" + }, + "denominator_basis": { + "kind": "PER_UNIT", + "value": 1 + } + } + }, + "unit_conversion": { + "source_code": "source_code", + "result": 1.1, + "result_unit": { + "concept_id": 1000000, + "code": "code", + "vocabulary_id": "vocabulary_id" + } + }, + "result": 160, + "result_unit": { + "concept_id": 400, + "code": "mg", + "vocabulary_id": "UCUM" + } + }, + { + "resource_type": "MedicationRequest", + "resource_id": "counted-tablets", + "source_path": "dispenseRequest.quantity", + "source_quantity": { + "value": 30, + "system": "http://terminology.hl7.org/CodeSystem/v3-orderableDrugForm", + "code": "TAB" + }, + "omop_table": "drug_exposure", + "omop_field": "quantity", + "omop_id": 4, + "outcome": "DIRECT", + "policy": "fixed_dose_form_count", + "reason": "unsupported_quantity", + "evidence": { + "drug_concept_id": 100, + "drug_concept_class": "Clinical Drug", + "dose_form": { + "concept_id": 200, + "code": "317541", + "vocabulary_id": "RxNorm" + }, + "strength": { + "ingredient_concept_id": 300, + "amount_value": 500, + "amount_unit": { + "concept_id": 400, + "code": "mg", + "vocabulary_id": "UCUM" + } + } + }, + "unit_conversion": { + "source_code": "source_code", + "result": 1.1, + "result_unit": { + "concept_id": 1000000, + "code": "code", + "vocabulary_id": "vocabulary_id" + } + }, + "result": 30, + "result_unit": { + "concept_id": 200, + "code": "317541", + "vocabulary_id": "RxNorm" + } + }, + { + "resource_type": "MedicationRequest", + "resource_id": "free-text-unit", + "source_path": "dispenseRequest.quantity", + "source_quantity": { + "value": 2, + "unit": "tablet" + }, + "omop_table": "drug_exposure", + "omop_field": "quantity", + "omop_id": 3, + "outcome": "OMITTED", + "policy": "fixed_dose_form_count", + "reason": "unsupported_unit", + "evidence": { + "drug_concept_id": 1000000 + }, + "unit_conversion": { + "source_code": "source_code", + "result": 1.1, + "result_unit": { + "concept_id": 1000000, + "code": "code", + "vocabulary_id": "vocabulary_id" + } + }, + "result": 1.1, + "result_unit": { + "concept_id": 1000000, + "code": "code", + "vocabulary_id": "vocabulary_id" + } + } + ], + "rows_for_review": [ + { + "resource_type": "resource_type", + "resource_id": "resource_id", + "source_full_url": "source_full_url", + "omop_table": "omop_table", + "omop_id": 1000000, + "reasons": [ + "condition_verification_unconfirmed" + ], + "source_statuses": { + "key": "value" + } + } + ], "mappings": [ { "resource_type": "Patient", @@ -84,11 +318,20 @@ "source_system": "http://hl7.org/fhir/administrative-gender", "source_code": "female", "source_name": "female", + "source_coding_index": 1, "target_vocabulary": "Gender", "target_code": "F", "target_name": "FEMALE", "mapping_status": "MAPPED", "selected": true, + "resolution": { + "semantic_mapping": { + "status": "UNMAPPED", + "vocab_version": "v20260829", + "reason_code": "AMBIGUOUS" + }, + "status": "RESOLVED" + }, "note": "FHIR administrative gender; assumed sex at birth" }, { @@ -100,11 +343,20 @@ "source_system": "http://snomed.info/sct", "source_code": "44054006", "source_name": "Type 2 diabetes mellitus", + "source_coding_index": 1, "target_vocabulary": "SNOMED", "target_code": "44054006", "target_name": "Type 2 diabetes mellitus", "mapping_status": "ALREADY_STANDARD", "selected": true, + "resolution": { + "semantic_mapping": { + "status": "UNMAPPED", + "vocab_version": "v20260829", + "reason_code": "AMBIGUOUS" + }, + "status": "RESOLVED" + }, "note": "note" }, { @@ -116,14 +368,80 @@ "source_system": "http://www.nlm.nih.gov/research/umls/rxnorm", "source_code": "860975", "source_name": "metformin hydrochloride 500 MG", + "source_coding_index": 1, "target_vocabulary": "RXNORM", "target_code": "860975", "target_name": "metformin hydrochloride 500 MG", "mapping_status": "ALREADY_STANDARD", "selected": true, + "resolution": { + "semantic_mapping": { + "status": "UNMAPPED", + "vocab_version": "v20260829", + "reason_code": "AMBIGUOUS" + }, + "status": "RESOLVED" + }, "note": "note" } ], + "device_exposure_contexts": [ + { + "resource_type": "DeviceUseStatement", + "resource_id": "use-1", + "source_full_url": "source_full_url", + "exposures": [ + { + "device_exposure_id": 1, + "event_index": 0 + }, + { + "device_exposure_id": 2, + "event_index": 1 + } + ], + "quantity_basis": "default_absent_count", + "evidence": [ + { + "path": "timingTiming", + "value": { + "event": [ + "2026-01-01T09:00:00Z", + "2026-01-03T09:00:00Z" + ] + } + } + ] + } + ], + "specimen_source_contexts": [ + { + "specimen_id": 1000000, + "resource_id": "resource_id", + "full_url": "full_url", + "status": "available", + "identifiers": [ + { + "value": "value" + } + ], + "accession_identifier": { + "value": "value" + }, + "source_id_path": "source_id_path" + } + ], + "medication_dispense_source_contexts": [ + { + "resource_id": "resource_id", + "full_url": "full_url", + "omop_table": "drug_exposure", + "omop_id": 1000000, + "source": { + "key": "value" + } + } + ], "provider_role_contexts": [ { "provider_id": 1000000, @@ -168,11 +486,12 @@ "reason": "reason" } ], - "vocab_version": "v20260227", + "vocab_version": "v20260829", "summary": { "codes_already_standard": 2, "codes_normalized": 1, "codes_unmapped": 0, + "rows_for_review": 1, "off_vocab_rate": 0.3333333333333333 } } \ No newline at end of file diff --git a/src/test/resources/wire-tests/WorkflowsWireTest_testExecutePreviewMode_response.json b/src/test/resources/wire-tests/WorkflowsWireTest_testExecutePreviewMode_response.json new file mode 100644 index 00000000..b19a30d8 --- /dev/null +++ b/src/test/resources/wire-tests/WorkflowsWireTest_testExecutePreviewMode_response.json @@ -0,0 +1,64 @@ +{ + "success": true, + "message": "Workflow preview completed - no resources were persisted", + "results": { + "steps": { + "step_1_id": { + "type": "search_result", + "original_query": "find patient by first name Mary and last name Johnson", + "resource_type": "Patient", + "search_params": { + "family": "Johnson", + "given": "Mary" + }, + "results": [ + { + "resourceType": "Patient", + "id": "patient-123", + "name": [ + { + "family": "Johnson", + "given": [ + "Mary" + ], + "use": "usual" + } + ], + "gender": "female", + "birthDate": "1961-01-01" + } + ] + }, + "step_2_id": { + "type": "create_result", + "resource_type": "Condition", + "original_desc": "create condition with diagnosis code M79.3 for the patient from step_1_id", + "preview": true, + "created_resource": { + "resourceType": "Condition", + "clinicalStatus": { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/condition-clinical", + "code": "active" + } + ] + }, + "code": { + "coding": [ + { + "system": "http://hl7.org/fhir/sid/icd-10", + "code": "M79.3", + "display": "Panniculitis, unspecified" + } + ] + }, + "subject": { + "reference": "Patient/step_1_id" + } + } + } + } + }, + "preview": true +} \ No newline at end of file