Skip to content

Commit 6471d46

Browse files
authored
Merge pull request #60 from Ardenexal/feat/59-serialization-changes
refactor: split out serialization xml/json into seperate folders
2 parents f0d65b0 + a9ec538 commit 6471d46

23 files changed

Lines changed: 4142 additions & 3884 deletions

composer.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@
6060
"phpunit/phpunit": "^11.0|^12.5.14",
6161
"roave/security-advisories": "dev-latest",
6262
"symfony/ai-mate": "^0.7.0",
63-
"symfony/ai-symfony-mate-extension": "^0.7.0",
63+
"symfony/ai-symfony-mate-extension": "^v0.7.0",
6464
"symfony/browser-kit": "^6.4|^7.4.4",
6565
"symfony/css-selector": "^6.4|^7.4",
6666
"symfony/dom-crawler": "^6.4|^7.4.4",

docs/normalizer-refactor-plan.md

Lines changed: 244 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,244 @@
1+
# Normalizer Refactor Plan
2+
3+
GitHub Issue: https://github.com/Ardenexal/php-fhir-tools/issues/59
4+
5+
## Goal
6+
7+
Split `src/Component/Serialization/src/Normalizer/` into `Common/`, `Json/`, and `Xml/` subfolders so that adding a new serialization format only requires a new folder with 4 classes and compiler pass wiring — without touching any existing files.
8+
9+
---
10+
11+
## Target Structure
12+
13+
```
14+
Normalizer/
15+
├── Common/
16+
│ ├── AbstractFHIRNormalizer.php ← new namespace + added protected helpers
17+
│ └── FHIRNormalizerInterface.php ← new namespace only
18+
├── Json/
19+
│ ├── FHIRResourceJsonNormalizer.php
20+
│ ├── FHIRComplexTypeJsonNormalizer.php
21+
│ ├── FHIRBackboneElementJsonNormalizer.php
22+
│ └── FHIRPrimitiveTypeJsonNormalizer.php
23+
└── Xml/
24+
├── FHIRResourceXmlNormalizer.php
25+
├── FHIRComplexTypeXmlNormalizer.php
26+
├── FHIRBackboneElementXmlNormalizer.php
27+
└── FHIRPrimitiveTypeXmlNormalizer.php
28+
```
29+
30+
Format routing via `supportsNormalization()` / `supportsDenormalization()`:
31+
- **Json normalizers:** return `false` when `$format === 'xml'`
32+
- **Xml normalizers:** return `false` when `$format !== 'xml'`
33+
34+
---
35+
36+
## Duplication Fixes
37+
38+
| Issue | Fix |
39+
|---|---|
40+
| `extractResourceElementName()` duplicated in `FHIRResourceNormalizer` + `FHIRBackboneElementNormalizer` | Move to `AbstractFHIRNormalizer` as `protected` |
41+
| `normalizeExtensions()` private in Backbone, other normalizers do it inline | Move to `AbstractFHIRNormalizer` as `protected` |
42+
| Polymorphic XML resource wrap block copy-pasted in Resource + Backbone | Extract `normalizePolymorphicResourcesXml()` to Abstract |
43+
| `BackboneElementNormalizer::resolveResourceType()` hardcodes `['R4','R4B','R5']` loop | Inject `FHIRTypeResolverInterface` into `FHIRBackboneElementXmlNormalizer` instead |
44+
| Primitive validation methods (`validateDecimal`, `createPrimitiveInstance`, etc.) needed by both Json + Xml primitive normalizers | Move to `AbstractFHIRNormalizer` as `protected` |
45+
| `handleUnknownProperty()` private in `FHIRResourceNormalizer`, used in both JSON + XML denormalize paths | Move to `AbstractFHIRNormalizer` as `protected` |
46+
| `['@value' => is_bool ... (string)]` expression repeated throughout XML normalizers | Extract `wrapScalarForXml()` helper to Abstract |
47+
48+
---
49+
50+
## Phase 1 — `Common/AbstractFHIRNormalizer.php`
51+
52+
**Namespace:** `Ardenexal\FHIRTools\Component\Serialization\Normalizer\Common`
53+
54+
Keep all existing methods. Add the following new `protected` methods:
55+
56+
### Moved from concrete normalizers
57+
58+
```php
59+
// From FHIRResourceNormalizer::extractResourceElementName() and FHIRBackboneElementNormalizer::extractResourceElementName()
60+
protected function extractResourceElementName(mixed $value): ?string
61+
62+
// From FHIRBackboneElementNormalizer::normalizeExtensions()
63+
protected function normalizeExtensions(mixed $extensions, ?string $format, array $context): ?array
64+
65+
// From FHIRResourceNormalizer::handleUnknownProperty()
66+
protected function handleUnknownProperty(string $propertyName, mixed $value, string $policy, object $object, ?string $elementPath = null): void
67+
```
68+
69+
### New helpers
70+
71+
```php
72+
// Replaces the polymorphic XML wrap block copy-pasted in Resource + Backbone normalizeForXML()
73+
// Array: wraps each item as [$resourceType => $normalized]
74+
// Single object: wraps as [$resourceType => $normalized]
75+
// Returns null if nothing to normalize
76+
protected function normalizePolymorphicResourcesXml(mixed $value, PropertyMetadata $meta, array $context): mixed
77+
78+
// Replaces repeated inline: ['@value' => is_bool($v) ? 'true'/'false' : (string)$v]
79+
protected function wrapScalarForXml(mixed $value): array
80+
```
81+
82+
### Primitive validation methods (moved from FHIRPrimitiveTypeNormalizer)
83+
84+
```php
85+
protected function findFHIRPrimitiveAttribute(string $type): ?FHIRPrimitive
86+
protected function hasFHIRPrimitiveAttribute(string $type): bool
87+
protected function createPrimitiveInstance(string $type, mixed $value, mixed $extensions, ?string $format = null, array $context = []): mixed
88+
protected function validateAndConvertValue(mixed $value, string $type): mixed
89+
protected function validateString(mixed $value): ?string
90+
protected function validateInteger(mixed $value): ?int
91+
protected function validateDecimal(mixed $value): ?string // returns numeric-string|null
92+
protected function validateBoolean(mixed $value): ?bool
93+
protected function parseTemporalValue(mixed $value, string $class): ?FHIRTemporalValue
94+
```
95+
96+
**New imports required:**
97+
```php
98+
use Ardenexal\FHIRTools\Component\Models\Primitive\FHIRDate;
99+
use Ardenexal\FHIRTools\Component\Models\Primitive\FHIRDateTime;
100+
use Ardenexal\FHIRTools\Component\Models\Primitive\FHIRInstant;
101+
use Ardenexal\FHIRTools\Component\Models\Primitive\FHIRTime;
102+
use Ardenexal\FHIRTools\Component\Serialization\Exception\FHIRSerializationException;
103+
use Symfony\Component\Serializer\Exception\NotNormalizableValueException;
104+
```
105+
106+
---
107+
108+
## Phase 2 — `Common/FHIRNormalizerInterface.php`
109+
110+
Only change: namespace → `Ardenexal\FHIRTools\Component\Serialization\Normalizer\Common`
111+
112+
---
113+
114+
## Phase 3 — `Json/` normalizers
115+
116+
All Json normalizers extend `Common\AbstractFHIRNormalizer` and implement `Common\FHIRNormalizerInterface`.
117+
118+
### `FHIRResourceJsonNormalizer`
119+
- **Constructor:** `metadataExtractor` + `typeResolver` + optional `normalizer`, `denormalizer`, `fhirVersion`, `igTypeRegistry`
120+
- **`supportsNormalization()`:** `if ($format === 'xml') return false;` → then `isResource()` check
121+
- **`supportsDenormalization()`:** `if ($format === 'xml') return false;` → then walk `FhirResource` attribute hierarchy
122+
- **`normalize()`:** inline the existing `normalizeForJSON()` logic
123+
- **`denormalize()`:** inline the existing `denormalizeFromJSON()` logic
124+
- **Private:** `normalizeArrayWithExtensions()`
125+
126+
### `FHIRComplexTypeJsonNormalizer`
127+
- **Constructor:** `metadataExtractor` + `typeResolver` + optional args
128+
- **`supportsNormalization()`:** `if ($format === 'xml') return false;` → existing complex type attribute walk
129+
- **`supportsDenormalization()`:** `if ($format === 'xml') return false;` → existing `FHIRComplexType` / `FHIRExtensionDefinition` check
130+
- **`normalize()`:** inline existing `normalizeForJSON()` logic
131+
- **`denormalize()`:** JSON-specific only — skip `_` prefixed keys, no `@`-attr handling, no `unwrapXmlValue`, call `applyPrimitiveExtensions()` at end
132+
- **Private:** `isChoiceElement()`, `normalizeChoiceElement()`
133+
134+
### `FHIRBackboneElementJsonNormalizer`
135+
- **Constructor:** `metadataExtractor` + optional args — **no `FHIRTypeResolverInterface`** (not needed for JSON)
136+
- **`supportsNormalization()`:** `if ($format === 'xml') return false;``FHIRBackboneElement` attribute check
137+
- **`supportsDenormalization()`:** same guard
138+
- **`normalize()`:** inline existing `normalizeForJSON()` logic — uses `normalizeExtensions()` from Abstract
139+
- **`denormalize()`:** JSON backbone — no XML unwrapping, no `extractResourceElementName`, calls `applyPrimitiveExtensions()`
140+
141+
### `FHIRPrimitiveTypeJsonNormalizer`
142+
- **Constructor:** `metadataExtractor` + optional args
143+
- **`supportsNormalization()`:** `if ($format === 'xml') return false;``hasFHIRPrimitiveAttribute()`
144+
- **`supportsDenormalization()`:** same guard
145+
- **`normalize()`:** inline existing `normalizeForJSON()` logic
146+
- **`denormalize()`:** JSON path from `denormalizeFromArray()` only (no XML branch)
147+
- Uses primitive validation methods from `AbstractFHIRNormalizer`
148+
149+
---
150+
151+
## Phase 4 — `Xml/` normalizers
152+
153+
All Xml normalizers extend `Common\AbstractFHIRNormalizer` and implement `Common\FHIRNormalizerInterface`.
154+
155+
### `FHIRResourceXmlNormalizer`
156+
- **Constructor:** same as `FHIRResourceJsonNormalizer`
157+
- **`supportsNormalization()`:** `if ($format !== 'xml') return false;``isResource()` check
158+
- **`supportsDenormalization()`:** `if ($format !== 'xml') return false;``FhirResource` attribute walk
159+
- **`normalize()`:** inline existing `normalizeForXML()` — use `normalizePolymorphicResourcesXml()` + `wrapScalarForXml()`
160+
- **`denormalize()`:** inline existing `denormalizeFromXML()` — use `extractResourceElementName()` from Abstract
161+
- **Private:** `normalizeArrayForXML()`
162+
163+
### `FHIRComplexTypeXmlNormalizer`
164+
- **Constructor:** `metadataExtractor` + `typeResolver` + optional args (typeResolver needed for resource property resolution)
165+
- **`supportsNormalization()`:** `if ($format !== 'xml') return false;` → existing complex type check
166+
- **`supportsDenormalization()`:** `if ($format !== 'xml') return false;` → existing check
167+
- **`normalize()`:** inline existing `normalizeForXML()` — use `normalizePolymorphicResourcesXml()` + `wrapScalarForXml()`
168+
- **`denormalize()`:** XML-specific — `@`-attr handling, xhtml decode, `unwrapXmlValue`, `array_is_list` checks, resource element resolution, **no** `applyPrimitiveExtensions`
169+
- **Private:** `encodeXhtmlToString()`, `buildDomFromArray()`, `decodeXhtmlToArray()`, `transformXhtmlArrayForReencoding()`, `isChoiceElement()`, `normalizeChoiceElement()`
170+
171+
### `FHIRBackboneElementXmlNormalizer`
172+
- **Constructor:** `metadataExtractor` + **`FHIRTypeResolverInterface $typeResolver`** + optional args (replaces hardcoded `resolveResourceType()`)
173+
- **`supportsNormalization()`:** `if ($format !== 'xml') return false;` → backbone attribute check
174+
- **`supportsDenormalization()`:** same guard
175+
- **`normalize()`:** inline existing `normalizeForXML()` — use `normalizePolymorphicResourcesXml()` + `normalizeExtensions()` from Abstract
176+
- **`denormalize()`:** XML backbone — uses `extractResourceElementName()` + `$this->typeResolver->resolveResourceType()`, `unwrapXmlValue`, `array_is_list` checks
177+
178+
### `FHIRPrimitiveTypeXmlNormalizer`
179+
- **Constructor:** same as Json variant
180+
- **`supportsNormalization()`:** `if ($format !== 'xml') return false;``hasFHIRPrimitiveAttribute()`
181+
- **`supportsDenormalization()`:** same guard
182+
- **`normalize()`:** inline existing `normalizeForXML()` logic
183+
- **`denormalize()`:** XML path from `denormalizeFromArray()` only (no JSON branch)
184+
- Uses primitive validation methods from `AbstractFHIRNormalizer`
185+
186+
---
187+
188+
## Phase 5 — Wiring updates
189+
190+
### `FHIRVersionedSerializerPass.php`
191+
192+
Register 8 normalizers per version instead of 4. New service IDs:
193+
194+
```
195+
fhir.normalizer.resource.json.{v} → FHIRResourceJsonNormalizer
196+
fhir.normalizer.resource.xml.{v} → FHIRResourceXmlNormalizer
197+
fhir.normalizer.complex_type.json.{v} → FHIRComplexTypeJsonNormalizer
198+
fhir.normalizer.complex_type.xml.{v} → FHIRComplexTypeXmlNormalizer
199+
fhir.normalizer.primitive.json.{v} → FHIRPrimitiveTypeJsonNormalizer
200+
fhir.normalizer.primitive.xml.{v} → FHIRPrimitiveTypeXmlNormalizer
201+
fhir.normalizer.backbone.json.{v} → FHIRBackboneElementJsonNormalizer
202+
fhir.normalizer.backbone.xml.{v} → FHIRBackboneElementXmlNormalizer
203+
```
204+
205+
`FHIRBackboneElementXmlNormalizer` gets an extra `FHIRTypeResolverInterface` reference as second argument.
206+
207+
Serializer receives all 8 normalizer references. Suggested order (more specific first):
208+
`resource-json, resource-xml, complex-json, complex-xml, primitive-json, primitive-xml, backbone-json, backbone-xml`
209+
210+
### `FHIRSerializationService.php``createWithIG()`
211+
212+
Instantiate 8 normalizers. `FHIRBackboneElementXmlNormalizer` gets `$typeResolver` as second constructor arg.
213+
214+
---
215+
216+
## Phase 6 — Delete old files
217+
218+
```
219+
src/Component/Serialization/src/Normalizer/AbstractFHIRNormalizer.php
220+
src/Component/Serialization/src/Normalizer/FHIRNormalizerInterface.php
221+
src/Component/Serialization/src/Normalizer/FHIRResourceNormalizer.php
222+
src/Component/Serialization/src/Normalizer/FHIRComplexTypeNormalizer.php
223+
src/Component/Serialization/src/Normalizer/FHIRBackboneElementNormalizer.php
224+
src/Component/Serialization/src/Normalizer/FHIRPrimitiveTypeNormalizer.php
225+
```
226+
227+
---
228+
229+
## Phase 7 — Quality checks
230+
231+
```bash
232+
composer phpstan-ai:serialization
233+
composer test-ai:serialization
234+
```
235+
236+
All 82/82 serialization spec tests must pass. PHPStan level 8 clean.
237+
238+
---
239+
240+
## Constraints
241+
242+
- Internal refactor — breaking namespace changes are acceptable
243+
- No behaviour changes — round-trip serialization must produce identical output
244+
- Tests only need to pass at the end (things will be broken mid-refactor)

0 commit comments

Comments
 (0)