Skip to content

Commit 614832d

Browse files
committed
feat(sdc): implement SDC playground with Questionnaire form handling and OAuth client integration
1 parent 766404a commit 614832d

53 files changed

Lines changed: 6081 additions & 17 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,3 +41,5 @@ phpstan.neon
4141
###< phpunit/phpunit ###
4242

4343
node_modules/
44+
/.playwright-mcp/
45+

demo/CLAUDE.md

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,12 @@ This file documents conventions for the demo Symfony 7.4 application.
1515

1616
Located in `src/Controller/`. Use `#[Route]` attribute for routing. Extend `AbstractController`.
1717

18+
- `SdcController` (`/sdc`) — the SDC Populate/Extract playground: pick or paste a Questionnaire, fill in
19+
its form (repeats, `enableWhen`, quantity, `answerValueSet` code-checking all supported), Populate it
20+
from launch-context JSON, and Extract into a transaction Bundle. A curated example gallery and a
21+
"View QuestionnaireResponse JSON" toggle are on the page itself. See "External FHIR/Terminology
22+
Servers" below for its optional live-server env vars.
23+
1824
## FHIR Services (via FHIRBundle)
1925

2026
Inject by type hint — all autowired:
@@ -30,6 +36,78 @@ Inject by type hint — all autowired:
3036

3137
`config/packages/fhir.yaml` — configures default version, output/cache directories, validation, and FHIRPath cache.
3238

39+
## External FHIR/Terminology Servers (SDC Playground)
40+
41+
The `/sdc` page is offline-first by default. Two env vars opt it into live server connectivity:
42+
43+
- `FHIR_SERVER_URL` — when set, `application/x-fhir-query` `itemPopulationContext`/`variable` directives
44+
in `$populate` resolve against this server instead of being skipped with an informational issue.
45+
- `FHIR_TERMINOLOGY_SERVER_URL` — when set, an `answerValueSet`-bound choice item's "Check code" action
46+
validates against this server instead of showing a "no terminology server configured" note.
47+
48+
Both default to empty string (offline). Set them via a real environment variable or `.env.local`
49+
(gitignored) — **not** `demo/.env`, which is off-limits to agent edits in this repo; the empty defaults
50+
are declared instead as committed `parameters:` in `config/services.yaml` using Symfony's
51+
`env(NAME): 'default'` convention, so no `.env` edit is ever required to keep the demo offline by default.
52+
53+
Server base URLs are **operator-configured only** — there is deliberately no "enter a FHIR server URL"
54+
field anywhere in the UI. A public URL-entry field would turn the demo host into an open SSRF proxy
55+
against whatever network it runs on. See `src/Sdc/ExternalClientFactory.php` and the
56+
`FHIRHttpClientInterface`/`FHIRTerminologyClientInterface` factory wiring in `config/services.yaml`.
57+
58+
Overriding these interfaces is app-wide, not scoped to `/sdc`: setting `FHIR_SERVER_URL` also makes
59+
`/fhirpath`'s `resolve()`/`memberOf()` go live, and `FHIR_TERMINOLOGY_SERVER_URL` also makes `/validate`'s
60+
terminology binding checks go live — matching `FHIRBundle`'s own documented override pattern.
61+
62+
### FHIR server authentication (M06)
63+
64+
Two independent, composable authentication mechanisms for the `FHIR_SERVER_URL` connection. The
65+
*destination* is always operator-configured only (env var/`.env.local`, never a request-time input); the
66+
*credential values* can additionally come from a visitor's own session — see "Session-scoped credential
67+
entry (M07)" below.
68+
69+
- **OAuth 2.0 client credentials grant**`FHIR_SERVER_OAUTH_TOKEN_URL`, `FHIR_SERVER_OAUTH_CLIENT_ID`,
70+
`FHIR_SERVER_OAUTH_CLIENT_SECRET`. The token URL is required to enable OAuth at all; the client id and
71+
secret must either both be present (from env vars, a visitor's session, or one of each) or both absent
72+
— a mismatched pair fails loudly at container-resolution time. Token-URL-alone (no id/secret anywhere
73+
yet) is a valid state: OAuth is enabled but not yet authenticated for this request. The library fetches
74+
and caches the bearer token itself (`Ardenexal\FHIRTools\Component\HttpClient\OAuth\
75+
OAuthClientCredentialsTokenProvider` / `OAuthHttpClient`).
76+
- **A manual header**`FHIR_SERVER_AUTH_HEADER_NAME` / `FHIR_SERVER_AUTH_HEADER_VALUE`, attached
77+
verbatim to every request (`StaticHeaderHttpClient`). Covers a hand-obtained `Authorization: Bearer
78+
<token>` or an `X-Api-Key`-style header. Same shape as OAuth: the name enables the mechanism, the value
79+
can arrive later (env or session).
80+
81+
Both mechanisms can be configured together as long as they don't both target the `Authorization` header
82+
(that combination fails loudly too — see `ExternalClientFactory::assertAuthConfigurationIsConsistent()`).
83+
The client secret and header value are never logged, never displayed, and never appear in any error
84+
panel — verified end-to-end in `demo/tests/Controller/SdcOAuthSecretLeakageTest.php`. The `/sdc` status
85+
badge shows *which* mechanism is active (e.g. "configured (OAuth)") but never the credential itself.
86+
87+
### Session-scoped credential entry (M07)
88+
89+
`/sdc` has a small form (shown only when the operator has enabled OAuth or a manual header — i.e. when
90+
`FHIR_SERVER_OAUTH_TOKEN_URL` or `FHIR_SERVER_AUTH_HEADER_NAME` is set) letting a visitor enter their own
91+
OAuth client id/secret, or their own header value, for **their session only** — never persisted to disk,
92+
never logged, never echoed back into the form. This lets someone test the demo as different users
93+
without editing env vars and restarting the server. `ExternalClientFactory` checks the current visitor's
94+
session first (`sdc_oauth_client_id`/`sdc_oauth_client_secret`/`sdc_auth_header_value`) and falls back to
95+
the env-var value when no session override is present — see `App\Controller\SdcController::
96+
setCredentials()`/`clearCredentials()` and `ExternalClientFactory`'s class docblock.
97+
98+
**Accepted risk, deliberately chosen — not a hardening recommendation.** The destination FHIR server and
99+
OAuth token URL/IdP stay operator-configured; only the credential *values* are visitor-enterable. This
100+
still means **any visitor who can reach `/sdc` can authenticate as whoever they claim, against whatever
101+
server the operator has configured** — there is no verification that a submitted client id/secret
102+
"belongs" to the person submitting it. This tradeoff was raised explicitly (against a safer
103+
"operator-pre-configured named profiles" alternative) and the free-text session-entry approach was
104+
chosen anyway, on the basis that this is suited to a local/trusted-access demo. **Do not expose `/sdc`
105+
on a network you don't trust without adding real access control first** (see `backlog.md`).
106+
107+
Session isolation between visitors, and that a submitted secret never leaks into any rendered page or
108+
error, are both covered by `demo/tests/Controller/SdcSessionCredentialIsolationTest.php` and
109+
`SdcSessionCredentialLeakageTest.php`.
110+
33111
## Useful Commands
34112

35113
```bash
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import { Controller } from '@hotwired/stimulus';
2+
3+
/**
4+
* Client-side `enableWhen` evaluation, no server round-trip — per M03's Kill Criteria, restricted to
5+
* the SDC "fixed grammar" (`linkId` + `operator` + one `answer[x]`), with exactly one condition per
6+
* dependent item (`enableBehavior: all`/`any` across multiple conditions is out of scope; the render
7+
* model only ever carries the item's FIRST `enableWhen` entry — see `QuestionnaireFormRenderer`).
8+
*
9+
* Hiding a dependent item uses the `hidden` attribute, never `disabled` — a `disabled` field is dropped
10+
* from form submission entirely, which would silently contradict the M03 decision that Extract keeps
11+
* answers for disabled items (matching `$populate`'s own "enableWhen is a display-time concern, not
12+
* applied" behavior).
13+
*
14+
* Trigger lookup is scoped to this controller's element (the Extract `<form>`) and assumes the trigger
15+
* itself is non-repeating (a repeating trigger has no defined "the" value to compare against — out of
16+
* scope here).
17+
*/
18+
export default class extends Controller {
19+
static targets = ['dependent'];
20+
21+
connect() {
22+
this.dependentTargets.forEach((dependentEl) => this.wireDependent(dependentEl));
23+
}
24+
25+
wireDependent(dependentEl) {
26+
let condition;
27+
try {
28+
condition = JSON.parse(dependentEl.dataset.sdcEnableWhenConditionValue);
29+
} catch {
30+
return;
31+
}
32+
33+
const trigger = this.element.querySelector(
34+
`[data-repeat-field-linkid="${condition.question}"] select, ` +
35+
`[data-repeat-field-linkid="${condition.question}"] input, ` +
36+
`[data-repeat-field-linkid="${condition.question}"] textarea`,
37+
);
38+
if (!trigger) {
39+
return;
40+
}
41+
42+
const evaluate = () => {
43+
dependentEl.hidden = !this.evaluateCondition(condition, trigger.value);
44+
};
45+
46+
trigger.addEventListener('change', evaluate);
47+
trigger.addEventListener('input', evaluate);
48+
evaluate();
49+
}
50+
51+
evaluateCondition(condition, actual) {
52+
if (condition.operator === 'exists') {
53+
const hasAnswer = actual !== '';
54+
55+
return condition.answerBoolean === undefined ? hasAnswer : hasAnswer === condition.answerBoolean;
56+
}
57+
58+
const expected = this.expectedValue(condition);
59+
const numericActual = Number(actual);
60+
const numericExpected = Number(expected);
61+
const bothNumeric = actual !== '' && expected !== '' && !Number.isNaN(numericActual) && !Number.isNaN(numericExpected);
62+
63+
switch (condition.operator) {
64+
case '=':
65+
return actual === expected;
66+
case '!=':
67+
return actual !== expected;
68+
case '>':
69+
return bothNumeric && numericActual > numericExpected;
70+
case '<':
71+
return bothNumeric && numericActual < numericExpected;
72+
case '>=':
73+
return bothNumeric && numericActual >= numericExpected;
74+
case '<=':
75+
return bothNumeric && numericActual <= numericExpected;
76+
default:
77+
return true;
78+
}
79+
}
80+
81+
/** Reads whichever `answer[x]` key is present and returns it as this codec's form-value string. */
82+
expectedValue(condition) {
83+
if ('answerBoolean' in condition) {
84+
return condition.answerBoolean ? 'true' : 'false';
85+
}
86+
if ('answerString' in condition) {
87+
return condition.answerString;
88+
}
89+
if ('answerInteger' in condition) {
90+
return String(condition.answerInteger);
91+
}
92+
if ('answerDecimal' in condition) {
93+
return String(condition.answerDecimal);
94+
}
95+
if ('answerDate' in condition) {
96+
return condition.answerDate;
97+
}
98+
if ('answerCoding' in condition && condition.answerCoding) {
99+
return condition.answerCoding.code ?? '';
100+
}
101+
102+
return '';
103+
}
104+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { Controller } from '@hotwired/stimulus';
2+
3+
/**
4+
* Toggles the "paste your own Questionnaire JSON" textarea on the SDC sample picker. Previously an
5+
* inline onchange= attribute; converted to Stimulus because inline handlers don't survive a Turbo Frame
6+
* swap re-rendering this form from scratch, whereas data-controller attributes reconnect automatically.
7+
*/
8+
export default class extends Controller {
9+
static targets = ['customJson'];
10+
11+
toggleCustom(event) {
12+
this.customJsonTarget.classList.toggle('hidden', event.target.value !== 'custom');
13+
}
14+
}
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import { Controller } from '@hotwired/stimulus';
2+
3+
/**
4+
* "Add another" / "Remove" for repeating Questionnaire items and groups. No hidden `<template>` element
5+
* is used — the last rendered `[data-repeat-instance]` row for the target linkId is cloned directly
6+
* (its structure is already correct, including any nested repeat/enableWhen wiring), its form values are
7+
* cleared, and its index-bearing `name` attributes are bumped to the next free index.
8+
*
9+
* Indices are read from the DOM at add-time, not tracked in JS state, and are never re-numbered after a
10+
* remove — {@see \App\Sdc\QuestionnaireResponseBuilder} is deliberately sparse-index-tolerant (removing
11+
* row 1 of [0,1,2] leaves [0,2] on submit, and the builder iterates values rather than assuming
12+
* contiguity), so there is no correctness reason to renumber, and renumbering would risk clobbering a
13+
* row a user is mid-edit on.
14+
*/
15+
export default class extends Controller {
16+
add(event) {
17+
const linkId = event.params.linkid;
18+
const wrapper = this.element.querySelector(
19+
`[data-repeat-field-linkid="${linkId}"], [data-repeat-group-linkid="${linkId}"]`,
20+
);
21+
if (!wrapper) {
22+
return;
23+
}
24+
25+
const rows = Array.from(wrapper.querySelectorAll(':scope > [data-repeat-instance]'));
26+
const lastRow = rows[rows.length - 1];
27+
if (!lastRow) {
28+
return;
29+
}
30+
31+
const nextIndex = this.nextIndex(rows, linkId);
32+
const clone = lastRow.cloneNode(true);
33+
this.reindex(clone, linkId, nextIndex);
34+
this.clearValues(clone);
35+
36+
lastRow.after(clone);
37+
}
38+
39+
remove(event) {
40+
event.target.closest('[data-repeat-instance]')?.remove();
41+
}
42+
43+
/** One past the highest `[<linkId>][<n>]` index currently present, so sparse removes never collide. */
44+
nextIndex(rows, linkId) {
45+
let max = -1;
46+
const pattern = new RegExp(`\\[${this.escapeForRegex(linkId)}\\]\\[(\\d+)\\]`);
47+
48+
rows.forEach((row) => {
49+
row.querySelectorAll('[name]').forEach((field) => {
50+
const match = field.getAttribute('name').match(pattern);
51+
if (match) {
52+
max = Math.max(max, Number(match[1]));
53+
}
54+
});
55+
});
56+
57+
return max + 1;
58+
}
59+
60+
/** Bumps every `[<linkId>][<oldIndex>]` occurrence in the clone's field names to `[<linkId>][<newIndex>]`. */
61+
reindex(clone, linkId, newIndex) {
62+
const pattern = new RegExp(`(\\[${this.escapeForRegex(linkId)}\\])\\[\\d+\\]`);
63+
64+
clone.querySelectorAll('[name]').forEach((field) => {
65+
field.setAttribute('name', field.getAttribute('name').replace(pattern, `$1[${newIndex}]`));
66+
});
67+
}
68+
69+
clearValues(clone) {
70+
clone.querySelectorAll('input, textarea').forEach((field) => {
71+
field.value = '';
72+
});
73+
clone.querySelectorAll('select').forEach((field) => {
74+
field.selectedIndex = 0;
75+
});
76+
}
77+
78+
escapeForRegex(value) {
79+
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
80+
}
81+
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
{
2+
"resourceType": "Questionnaire",
3+
"id": "extract-spike",
4+
"url": "http://example.org/Questionnaire/extract-spike",
5+
"status": "active",
6+
"item": [
7+
{
8+
"extension": [
9+
{
10+
"url": "http://hl7.org/fhir/uv/sdc/StructureDefinition/sdc-questionnaire-definitionExtract",
11+
"extension": [
12+
{ "url": "definition", "valueCanonical": "http://hl7.org/fhir/StructureDefinition/Patient" }
13+
]
14+
}
15+
],
16+
"linkId": "patient",
17+
"text": "Patient",
18+
"type": "group",
19+
"item": [
20+
{
21+
"definition": "http://hl7.org/fhir/StructureDefinition/Patient#Patient.name.given",
22+
"linkId": "given",
23+
"text": "Given name",
24+
"type": "string"
25+
},
26+
{
27+
"definition": "http://hl7.org/fhir/StructureDefinition/Patient#Patient.name.family",
28+
"linkId": "family",
29+
"text": "Family name",
30+
"type": "string"
31+
},
32+
{
33+
"definition": "http://hl7.org/fhir/StructureDefinition/Patient#Patient.birthDate",
34+
"linkId": "dob",
35+
"text": "Date of birth",
36+
"type": "date"
37+
}
38+
]
39+
}
40+
]
41+
}
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
{
2+
"resourceType": "Questionnaire",
3+
"status": "active",
4+
"url": "http://example.org/Questionnaire/populate-spike",
5+
"name": "PopulateSpike",
6+
"extension": [
7+
{
8+
"url": "http://hl7.org/fhir/uv/sdc/StructureDefinition/sdc-questionnaire-launchContext",
9+
"extension": [
10+
{
11+
"url": "name",
12+
"valueCoding": {
13+
"system": "http://hl7.org/fhir/uv/sdc/CodeSystem/launchContext",
14+
"code": "patient"
15+
}
16+
},
17+
{ "url": "type", "valueCode": "Patient" }
18+
]
19+
}
20+
],
21+
"item": [
22+
{
23+
"linkId": "patient-given",
24+
"type": "string",
25+
"text": "Given name",
26+
"extension": [
27+
{
28+
"url": "http://hl7.org/fhir/uv/sdc/StructureDefinition/sdc-questionnaire-initialExpression",
29+
"valueExpression": {
30+
"language": "text/fhirpath",
31+
"expression": "%patient.name.first().given.first()"
32+
}
33+
}
34+
]
35+
},
36+
{
37+
"linkId": "patient-family",
38+
"type": "string",
39+
"text": "Family name",
40+
"extension": [
41+
{
42+
"url": "http://hl7.org/fhir/uv/sdc/StructureDefinition/sdc-questionnaire-initialExpression",
43+
"valueExpression": {
44+
"language": "text/fhirpath",
45+
"expression": "%patient.name.first().family"
46+
}
47+
}
48+
]
49+
}
50+
]
51+
}

0 commit comments

Comments
 (0)