Skip to content

Commit 01dffb0

Browse files
authored
Merge pull request #84 from Ardenexal/feat/73-validation-outcome
feat(validation): implement FHIRValidationReportMapper for OperationOutcome conversion
2 parents 08d8e8e + a3f9b3e commit 01dffb0

7 files changed

Lines changed: 497 additions & 9 deletions

.ai/mcp/mcp.json

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
{
2+
"mcpServers": {
3+
"symfony-ai-mate": {
4+
"command": "./vendor/bin/mate",
5+
"args": [
6+
"serve",
7+
"--force-keep-alive"
8+
]
9+
}
10+
}
11+
}

src/Component/Validation/README.md

Lines changed: 26 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -173,7 +173,7 @@ categories. Coverage as of the current release:
173173
| **Obligations** (populate) | ⚠️ | ⚠️ | ⚠️ | `SHALL`/`SHOULD:populate` enforced; filter evaluation deferred |
174174
| **Obligations** (behaviour-only) | ℹ️ N/A | ℹ️ N/A | ℹ️ N/A | display/persist/handle cannot be checked from a resource instance |
175175
| **Questionnaire validation** |||| Separate `FHIRQuestionnaireValidator` (planned) |
176-
| **`$validate` operation output** | | | | `OperationOutcome` adapter planned |
176+
| **`$validate` operation output** | | | | `FHIRValidationService::validateForOperation()` |
177177
| **Narrative / XHTML** |||| Not implemented |
178178
| **Business rules** (auth, duplicates) | ℹ️ N/A | ℹ️ N/A | ℹ️ N/A | Outside library scope; requires server context |
179179

@@ -244,9 +244,10 @@ values themselves.
244244
**Questionnaire validation** is implemented by a separate `FHIRQuestionnaireValidator`
245245
service, not by `FHIRValidationService`. See the Questionnaire Validation section below.
246246

247-
**`$validate` operation**`FHIRValidationService` returns a `FHIRValidationReport`,
248-
not an `OperationOutcome`. An `FHIRValidationReportMapper` producing
249-
standards-compliant `OperationOutcomeResource` objects is planned.
247+
**`$validate` operation** — use `FHIRValidationService::validateForOperation()` to
248+
validate a resource and receive a standards-compliant `OperationOutcomeResource` directly.
249+
`FHIRValidationService::validate()` remains available when a `FHIRValidationReport` is
250+
preferred over an `OperationOutcome`.
250251

251252
---
252253

@@ -568,15 +569,30 @@ the reference validator's).
568569

569570
## OperationOutcome Mapping
570571

571-
An `FHIRValidationReportMapper` producing standards-compliant
572-
`OperationOutcomeResource` objects for FHIR `$validate` operation responses is
573-
planned (see GitHub #73). When available it will map violations as follows:
572+
`FHIRValidationService::validateForOperation()` returns a standards-compliant
573+
`OperationOutcomeResource` for FHIR `$validate` operation responses. Pass the target
574+
FHIR version (`'R4'`, `'R4B'`, or `'R5'`) to receive a version-typed resource:
575+
576+
```php
577+
$outcome = $service->validateForOperation($patient, fhirVersion: 'R4');
578+
// $outcome is an R4\Resource\OperationOutcomeResource
579+
580+
$outcome = $service->validateForOperation($patient, mode: 'create', fhirVersion: 'R5');
581+
// $outcome is an R5\Resource\OperationOutcomeResource
582+
```
583+
584+
Violations map as follows:
574585

575586
| `FHIRValidationViolation::$severity` | `OperationOutcomeIssue::$severity` | `OperationOutcomeIssue::$code` |
576587
|---|---|---|
577588
| `error` | `error` | `invariant` (FHIRPath), `value` (binding), `invalid` (default) |
578589
| `warning` | `warning` | same mapping |
579-
| `info` | `information` | `processing` (eval-error), `informational` (must-support) |
590+
| `info` (`fhir:eval-error`) | `information` | `not-supported` |
591+
| `info` (`fhir:unchecked-binding`) | `information` | `not-supported` |
592+
| `info` (`fhir:info`, general) | `information` | `informational` |
593+
594+
When no violations are found, the outcome contains a single `information`-severity issue:
595+
`"No issues found — resource is valid."`
580596

581597
The `$validate` operation endpoint format per spec:
582598
```
@@ -588,7 +604,8 @@ of findings. Structural errors (unparseable JSON/XML) may produce 4xx responses
588604
this library is reached.
589605

590606
`mode=delete` is not supported by this library — referential integrity checks require
591-
a FHIR server context.
607+
a FHIR server context. A call with `mode='delete'` returns an information-severity
608+
outcome explaining this limitation.
592609

593610
---
594611

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Ardenexal\FHIRTools\Component\Validation;
6+
7+
use Ardenexal\FHIRTools\Component\Metadata\Attribute\Validation\FHIRPathInvariant;
8+
use Ardenexal\FHIRTools\Component\Metadata\Attribute\Validation\FHIRValueSetBinding;
9+
use Ardenexal\FHIRTools\Component\Models\R4\DataType\IssueSeverityType as R4IssueSeverityType;
10+
use Ardenexal\FHIRTools\Component\Models\R4\DataType\IssueTypeType as R4IssueTypeType;
11+
use Ardenexal\FHIRTools\Component\Models\R4\Resource\OperationOutcome\OperationOutcomeIssue as R4Issue;
12+
use Ardenexal\FHIRTools\Component\Models\R4\Resource\OperationOutcomeResource as R4Outcome;
13+
use Ardenexal\FHIRTools\Component\Models\R4B\DataType\IssueSeverityType as R4BIssueSeverityType;
14+
use Ardenexal\FHIRTools\Component\Models\R4B\DataType\IssueTypeType as R4BIssueTypeType;
15+
use Ardenexal\FHIRTools\Component\Models\R4B\Resource\OperationOutcome\OperationOutcomeIssue as R4BIssue;
16+
use Ardenexal\FHIRTools\Component\Models\R4B\Resource\OperationOutcomeResource as R4BOutcome;
17+
use Ardenexal\FHIRTools\Component\Models\R5\DataType\IssueSeverityType as R5IssueSeverityType;
18+
use Ardenexal\FHIRTools\Component\Models\R5\DataType\IssueTypeType as R5IssueTypeType;
19+
use Ardenexal\FHIRTools\Component\Models\R5\Resource\OperationOutcome\OperationOutcomeIssue as R5Issue;
20+
use Ardenexal\FHIRTools\Component\Models\R5\Resource\OperationOutcomeResource as R5Outcome;
21+
22+
/**
23+
* Converts a FHIRValidationReport into a version-appropriate OperationOutcomeResource.
24+
*
25+
* The generated IssueType enum only contains top-level group codes. Sub-codes such as
26+
* 'invariant' and 'value' are not enum cases but are valid codes in the FHIR issue-type
27+
* value set; they are passed as raw strings via CodePrimitive which accepts any string.
28+
*/
29+
final class FHIRValidationReportMapper
30+
{
31+
/**
32+
* @param string $fhirVersion 'R4' | 'R4B' | 'R5'
33+
*
34+
* @return object OperationOutcomeResource for the requested FHIR version
35+
*/
36+
public function toOperationOutcome(
37+
FHIRValidationReport $report,
38+
string $fhirVersion = 'R4',
39+
): object {
40+
return match ($fhirVersion) {
41+
'R4' => $this->buildR4($report->violations),
42+
'R4B' => $this->buildR4B($report->violations),
43+
'R5' => $this->buildR5($report->violations),
44+
default => throw new \InvalidArgumentException(sprintf('Unsupported FHIR version "%s". Supported values: R4, R4B, R5.', $fhirVersion)),
45+
};
46+
}
47+
48+
/** @param list<FHIRValidationViolation> $violations */
49+
private function buildR4(array $violations): R4Outcome
50+
{
51+
return new R4Outcome(
52+
issue: $violations !== []
53+
? array_map(fn (FHIRValidationViolation $v): R4Issue => new R4Issue(
54+
severity: new R4IssueSeverityType($this->mapSeverity($v->severity)),
55+
code: new R4IssueTypeType($this->mapIssueType($v)),
56+
diagnostics: $v->message,
57+
expression: $v->path !== '' ? [$v->path] : [],
58+
), $violations)
59+
: [new R4Issue(
60+
severity: new R4IssueSeverityType('information'),
61+
code: new R4IssueTypeType('informational'),
62+
diagnostics: 'No issues found — resource is valid.',
63+
)],
64+
);
65+
}
66+
67+
/** @param list<FHIRValidationViolation> $violations */
68+
private function buildR4B(array $violations): R4BOutcome
69+
{
70+
return new R4BOutcome(
71+
issue: $violations !== []
72+
? array_map(fn (FHIRValidationViolation $v): R4BIssue => new R4BIssue(
73+
severity: new R4BIssueSeverityType($this->mapSeverity($v->severity)),
74+
code: new R4BIssueTypeType($this->mapIssueType($v)),
75+
diagnostics: $v->message,
76+
expression: $v->path !== '' ? [$v->path] : [],
77+
), $violations)
78+
: [new R4BIssue(
79+
severity: new R4BIssueSeverityType('information'),
80+
code: new R4BIssueTypeType('informational'),
81+
diagnostics: 'No issues found — resource is valid.',
82+
)],
83+
);
84+
}
85+
86+
/** @param list<FHIRValidationViolation> $violations */
87+
private function buildR5(array $violations): R5Outcome
88+
{
89+
return new R5Outcome(
90+
issue: $violations !== []
91+
? array_map(fn (FHIRValidationViolation $v): R5Issue => new R5Issue(
92+
severity: new R5IssueSeverityType($this->mapSeverity($v->severity)),
93+
code: new R5IssueTypeType($this->mapIssueType($v)),
94+
diagnostics: $v->message,
95+
expression: $v->path !== '' ? [$v->path] : [],
96+
), $violations)
97+
: [new R5Issue(
98+
severity: new R5IssueSeverityType('information'),
99+
code: new R5IssueTypeType('informational'),
100+
diagnostics: 'No issues found — resource is valid.',
101+
)],
102+
);
103+
}
104+
105+
private function mapSeverity(string $severity): string
106+
{
107+
return match ($severity) {
108+
'error' => 'error',
109+
'warning' => 'warning',
110+
default => 'information',
111+
};
112+
}
113+
114+
private function mapIssueType(FHIRValidationViolation $violation): string
115+
{
116+
if ($violation->code === FHIRViolationCode::EVAL_ERROR
117+
|| $violation->code === FHIRViolationCode::UNCHECKED_BINDING
118+
|| $violation->constraintClass === FHIRValidationService::class) {
119+
return 'not-supported';
120+
}
121+
122+
if ($violation->code === FHIRViolationCode::INFO) {
123+
return 'informational';
124+
}
125+
126+
return match ($violation->constraintClass) {
127+
FHIRPathInvariant::class => 'invariant',
128+
FHIRValueSetBinding::class => 'value',
129+
default => 'invalid',
130+
};
131+
}
132+
}

src/Component/Validation/src/FHIRValidationService.php

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ public function __construct(
3939
private readonly FHIRPathService $pathService,
4040
private readonly ?FHIRIGTypeRegistry $registry = null,
4141
private readonly FHIRTypeHierarchyResolverInterface $typeResolver = new FhirPropertyTypeHierarchyResolver(),
42+
private readonly FHIRValidationReportMapper $reportMapper = new FHIRValidationReportMapper(),
4243
) {
4344
}
4445

@@ -95,6 +96,37 @@ public function validate(
9596
return new FHIRValidationReport($violations);
9697
}
9798

99+
public function validateForOperation(
100+
object $resource,
101+
string $mode = '',
102+
array $profileUrls = [],
103+
string $fhirVersion = 'R4',
104+
): object {
105+
if (!in_array($mode, ['', 'create', 'update', 'profile', 'delete'], true)) {
106+
throw new \InvalidArgumentException(sprintf('Unsupported mode "%s". Supported values: \'\', create, update, profile, delete.', $mode));
107+
}
108+
109+
if ($mode === 'delete') {
110+
$report = new FHIRValidationReport([
111+
new FHIRValidationViolation(
112+
severity: 'info',
113+
path: '',
114+
message: 'delete mode: referential integrity check requires a server context — library cannot perform this validation.',
115+
constraintClass: self::class,
116+
profileGroup: null,
117+
invariantKey: null,
118+
code: FHIRViolationCode::INFO,
119+
),
120+
]);
121+
122+
return $this->reportMapper->toOperationOutcome($report, $fhirVersion);
123+
}
124+
125+
$report = $this->validate($resource, $profileUrls);
126+
127+
return $this->reportMapper->toOperationOutcome($report, $fhirVersion);
128+
}
129+
98130
/**
99131
* Converts a Symfony ConstraintViolation into a structured FHIRValidationViolation.
100132
*

src/Component/Validation/src/FHIRValidationServiceInterface.php

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,4 +22,22 @@ public function validate(
2222
bool $includeMustSupportInfo = false,
2323
?FHIRObligationContext $obligationContext = null,
2424
): FHIRValidationReport;
25+
26+
/**
27+
* Validate a FHIR resource and return a standards-compliant OperationOutcome.
28+
*
29+
* @param string $mode '' | 'create' | 'update' | 'profile' | 'delete'
30+
* 'delete' returns an information-severity outcome explaining
31+
* that referential-integrity checks require a server context
32+
* @param list<string> $profileUrls profile canonical URLs (used when mode='profile')
33+
* @param string $fhirVersion 'R4' | 'R4B' | 'R5'
34+
*
35+
* @return object OperationOutcomeResource for the requested FHIR version
36+
*/
37+
public function validateForOperation(
38+
object $resource,
39+
string $mode = '',
40+
array $profileUrls = [],
41+
string $fhirVersion = 'R4',
42+
): object;
2543
}

0 commit comments

Comments
 (0)