Skip to content

Commit 974a8c2

Browse files
authored
Merge pull request #19 from entur/feat/corrected-xmlshape
Replace serializeValue with schema-aware toXmlShape
2 parents 7c24217 + 1be36a3 commit 974a8c2

5 files changed

Lines changed: 281 additions & 25 deletions

File tree

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
# Wire in `type_helpers.ts` for XML serialization
2+
3+
## Context
4+
5+
`serialize.ts` currently uses a convention-only `serializeValue()` that walks objects
6+
recursively, renames `$`-prefixed keys to `@_` attributes, and stringifies booleans.
7+
It has **no schema awareness** — it misses:
8+
9+
- simpleContent `value``#text` (e.g. `TextType { value, $lang }` stays as `{ value, @_lang }` instead of `{ '#text', @_lang }`)
10+
- Ref-structure attrs (e.g. `DeckPlanRef { value, $ref, $version }` — nested `$ref` doesn't get `@_` rename because `serializeValue` doesn't recurse into string-valued refs)
11+
12+
`generated/type_helpers.ts` has a schema-aware `toXmlShape()` that handles all of
13+
this correctly. It was generated from the NeTEx JSON Schema by
14+
`netex-typescript-model/typescript/scripts/generate-hathor-helpers.ts` using
15+
`makeInlinedToXmlShape()`.
16+
17+
## What to do
18+
19+
### 1. Replace `serializeValue` with `toXmlShape` in `serialize.ts`
20+
21+
```ts
22+
import { XMLBuilder } from 'fast-xml-parser';
23+
import { toXmlShape } from './generated/type_helpers.js';
24+
import type { VehicleType } from './generated/types.js';
25+
26+
const builder = new XMLBuilder({
27+
format: true,
28+
indentBy: ' ',
29+
ignoreAttributes: false,
30+
});
31+
32+
export function serialize(obj: Partial<VehicleType>): string {
33+
const xmlObj = toXmlShape(obj as Record<string, any>);
34+
return builder.build({ VehicleType: xmlObj }) as string;
35+
}
36+
```
37+
38+
Delete `serializeValue()` — it is fully replaced.
39+
40+
### 2. Update serialize tests
41+
42+
The existing tests in `__tests__/serialize.test.ts` pass partial VehicleType
43+
objects directly. Most will keep working since `toXmlShape` uses a base loop
44+
that copies unknown properties as-is. But check:
45+
46+
- **Ref tests** (`DeckPlanRef`, `IncludedIn`, `ClassifiedAsRef`, `BrandingRef`) —
47+
if these are currently passed as bare strings (`{ DeckPlanRef: 'test' }`),
48+
`toXmlShape` will copy them through unchanged (same behavior). But if the
49+
editor starts passing ref _objects_ (`{ value: 'x', $ref: 'x' }`), the
50+
dispatch `default` branch won't transform nested attrs. See TODOs in
51+
`type_helpers.ts` for adding ref-type functions.
52+
53+
- **Name / ShortName / Description** — tests pass `{ Value: 'a' }` (capital V).
54+
The stem shape from `normalize` uses `value` (lowercase). Verify which casing
55+
the editor actually produces and align the tests.
56+
57+
- **Roundtrip test** — the `normalize → serialize → parse → normalize` roundtrip
58+
should still pass since `toXmlShape` handles the same primitives. Run it to
59+
confirm.
60+
61+
### 3. Re-export (optional)
62+
63+
If consumers outside the editor package need `toXmlShape` directly, add to
64+
`index.ts`:
65+
66+
```ts
67+
export { toXmlShape } from './generated/type_helpers.js';
68+
```
69+
70+
## Known gaps (see TODOs in `type_helpers.ts`)
71+
72+
| Gap | Impact | Fix |
73+
|-----|--------|-----|
74+
| Ref types (`BrandingRef`, `DeckPlanRef`, `ValidBetween`, `VehicleTypeRefStructure`, `VehicleModelRefStructure`) hit dispatch `default` pass-through | Nested `$ref`/`$version` attrs won't get `@_` rename | Add these to `TYPES[]` in the generator and add dispatch cases |
75+
| `VehicleManoeuvringRequirements` is hand-written | Works, but won't track schema changes | Replace when/if type lands in NeTEx XSD |
76+
| Only VehicleType tree is covered | Vehicle / DeckPlan exports won't use schema-aware transform | Add their types when Hathor needs them |
77+
78+
## How to regenerate
79+
80+
From `netex-typescript-model/`:
81+
82+
```bash
83+
cd typescript
84+
npx tsx scripts/generate-hathor-helpers.ts
85+
```
86+
87+
Requires `generated-src/base/base.schema.json` (run `make all` first if missing).

packages/my_vehicletype-editor/src/SimpleEditor.tsx

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,14 +32,28 @@ export interface SimpleEditorProps {
3232

3333
const EURO_CLASSES = ['', 'Euro5', 'Euro6'] as const;
3434

35-
// TextType[] flattening helpers
35+
// Inline helpers that convert between display strings and the VehicleType
36+
// structure on every keystroke. There is no separate "simple" data shape —
37+
// the state is always Partial<VehicleType>. These just bridge the gap
38+
// between a single text field and the underlying NeTEx type:
39+
// textVal / textSet — TextType[] ↔ single string (first item, lang=nb)
40+
// numVal / parseNum — number ↔ input string
3641
const textVal = (arr?: TextType[]) => arr?.[0]?.Value ?? '';
3742
const textSet = (text: string): TextType[] | undefined =>
3843
text ? [{ Value: text, $lang: 'nb' }] : undefined;
3944

4045
const numVal = (n: number | undefined) => (n != null ? String(n) : '');
4146
const parseNum = (s: string) => (s === '' ? undefined : Number(s));
4247

48+
/**
49+
* Friendly form editor over {@link VehicleType}.
50+
*
51+
* Reads from and writes directly to `Partial<VehicleType>` — there is no
52+
* intermediate "simplified" shape. Each onChange handler converts its input
53+
* value into the full NeTEx structure inline (e.g. a Name text field calls
54+
* `textSet` which rebuilds `TextType[]`). The complex type flows out on
55+
* every keystroke, so no `simplifiedToStem` transform is needed downstream.
56+
*/
4357
export function SimpleEditor({ value, onChange }: SimpleEditorProps): React.JSX.Element {
4458
const { containerRef, topFraction, isResizing, onMouseDown } = useResizablePane(0.65);
4559
const [bottomTab, setBottomTab] = useState(0);
Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
/**
2+
* Stem-object → XMLBuilder-shape converters for VehicleType and its children.
3+
*
4+
* **Generated by** `netex-typescript-model/typescript/scripts/generate-hathor-helpers.ts`
5+
* from `makeInlinedToXmlShape()` in `to-xml-shape.ts`, driven by the base JSON Schema
6+
* (`generated-src/base/base.schema.json`). Re-run the generator to pick up schema changes.
7+
*
8+
* **Architecture:** `base()` / `baseSimple()` handle the generic transform (attr rename
9+
* `$`→`@_`, bool stringify, simpleContent `value`→`#text`). Per-entity functions add
10+
* override lines only for complex children that need recursive `toXmlShape` delegation.
11+
* `toXmlShapeDispatch` routes child names to the correct function.
12+
*
13+
* **Entry point:** `toXmlShape(obj)` — takes a VehicleType stem object, returns the
14+
* shape expected by fast-xml-parser `XMLBuilder`.
15+
*/
16+
// Auto-generated by generate-hathor-helpers.ts — do not edit
17+
/* eslint-disable @typescript-eslint/no-explicit-any */
18+
19+
type XmlShape = Record<string, any>;
20+
type ToXmlShapeFn = (name: string, obj: any) => any;
21+
22+
function base(obj: Record<string, any>): XmlShape {
23+
const out: XmlShape = {};
24+
for (const k of Object.keys(obj)) {
25+
const v = obj[k];
26+
if (v === undefined) continue;
27+
if (k[0] === '$') out['@_' + k.slice(1)] = typeof v === 'boolean' ? String(v) : v;
28+
else out[k] = typeof v === 'boolean' ? String(v) : v;
29+
}
30+
return out;
31+
}
32+
33+
function baseSimple(obj: Record<string, any>): XmlShape {
34+
const out: XmlShape = {};
35+
for (const k of Object.keys(obj)) {
36+
const v = obj[k];
37+
if (v === undefined) continue;
38+
if (k[0] === '$') out['@_' + k.slice(1)] = typeof v === 'boolean' ? String(v) : v;
39+
else if (k === 'value') out['#text'] = v;
40+
else out[k] = typeof v === 'boolean' ? String(v) : v;
41+
}
42+
return out;
43+
}
44+
45+
export function textTypeToXmlShape(obj: Record<string, any>): XmlShape {
46+
const out = baseSimple(obj);
47+
return out;
48+
}
49+
50+
export function privateCodeStructureToXmlShape(obj: Record<string, any>): XmlShape {
51+
const out = baseSimple(obj);
52+
return out;
53+
}
54+
55+
export function keyValueStructureToXmlShape(obj: Record<string, any>): XmlShape {
56+
const out = baseSimple(obj);
57+
return out;
58+
}
59+
60+
export function passengerCapacityStructureToXmlShape(
61+
obj: Record<string, any>,
62+
toXmlShape: ToXmlShapeFn
63+
): XmlShape {
64+
const out = base(obj);
65+
if (obj['ValidBetween'] !== undefined)
66+
out['ValidBetween'] = obj['ValidBetween'].map(function (item: any) {
67+
return toXmlShape('ValidBetween', item);
68+
});
69+
if (obj['keyList'] !== undefined) out['keyList'] = toXmlShape('keyList', obj['keyList']);
70+
if (obj['privateCodes'] !== undefined)
71+
out['privateCodes'] = toXmlShape('privateCodes', obj['privateCodes']);
72+
if (obj['BrandingRef'] !== undefined)
73+
out['BrandingRef'] = toXmlShape('BrandingRef', obj['BrandingRef']);
74+
return out;
75+
}
76+
77+
export function keyListToXmlShape(obj: Record<string, any>, toXmlShape: ToXmlShapeFn): XmlShape {
78+
const out = base(obj);
79+
if (obj['KeyValue'] !== undefined)
80+
out['KeyValue'] = obj['KeyValue'].map(function (item: any) {
81+
return toXmlShape('KeyValueStructure', item);
82+
});
83+
return out;
84+
}
85+
86+
export function privateCodesToXmlShape(
87+
obj: Record<string, any>,
88+
toXmlShape: ToXmlShapeFn
89+
): XmlShape {
90+
const out = base(obj);
91+
if (obj['PrivateCode'] !== undefined)
92+
out['PrivateCode'] = obj['PrivateCode'].map(function (item: any) {
93+
return toXmlShape('PrivateCode', item);
94+
});
95+
return out;
96+
}
97+
98+
export function vehicleTypeToXmlShape(
99+
obj: Record<string, any>,
100+
toXmlShape: ToXmlShapeFn
101+
): XmlShape {
102+
const out = base(obj);
103+
if (obj['ValidBetween'] !== undefined)
104+
out['ValidBetween'] = obj['ValidBetween'].map(function (item: any) {
105+
return toXmlShape('ValidBetween', item);
106+
});
107+
if (obj['keyList'] !== undefined) out['keyList'] = toXmlShape('keyList', obj['keyList']);
108+
if (obj['privateCodes'] !== undefined)
109+
out['privateCodes'] = toXmlShape('privateCodes', obj['privateCodes']);
110+
if (obj['BrandingRef'] !== undefined)
111+
out['BrandingRef'] = toXmlShape('BrandingRef', obj['BrandingRef']);
112+
if (obj['Name'] !== undefined)
113+
out['Name'] = obj['Name'].map(function (item: any) {
114+
return toXmlShape('TextType', item);
115+
});
116+
if (obj['ShortName'] !== undefined)
117+
out['ShortName'] = obj['ShortName'].map(function (item: any) {
118+
return toXmlShape('TextType', item);
119+
});
120+
if (obj['Description'] !== undefined)
121+
out['Description'] = obj['Description'].map(function (item: any) {
122+
return toXmlShape('TextType', item);
123+
});
124+
if (obj['PrivateCode'] !== undefined)
125+
out['PrivateCode'] = toXmlShape('PrivateCode', obj['PrivateCode']);
126+
if (obj['PassengerCapacity'] !== undefined)
127+
out['PassengerCapacity'] = toXmlShape('PassengerCapacityStructure', obj['PassengerCapacity']);
128+
if (obj['DeckPlanRef'] !== undefined)
129+
out['DeckPlanRef'] = toXmlShape('DeckPlanRef', obj['DeckPlanRef']);
130+
if (obj['IncludedIn'] !== undefined)
131+
out['IncludedIn'] = toXmlShape('VehicleTypeRefStructure', obj['IncludedIn']);
132+
if (obj['ClassifiedAsRef'] !== undefined)
133+
out['ClassifiedAsRef'] = toXmlShape('VehicleModelRefStructure', obj['ClassifiedAsRef']);
134+
return out;
135+
}
136+
137+
// --- hand-written (not in schema) ---
138+
// TODO: VehicleManoeuvringRequirements is not in the NeTEx XSD. If it gets added
139+
// upstream, replace this stub with a generated function in TYPES[].
140+
export function vehicleManoeuvringRequirementsToXmlShape(obj: Record<string, any>): XmlShape {
141+
return base(obj);
142+
}
143+
144+
// TODO: Dispatch only covers types reachable from VehicleType. To support
145+
// Vehicle / DeckPlan XML export, add their leaf types to TYPES[] in the
146+
// generator and add cases here.
147+
// TODO: BrandingRef, DeckPlanRef, ValidBetween, VehicleTypeRefStructure, and
148+
// VehicleModelRefStructure hit the `default` pass-through — their nested
149+
// attrs ($ref, $version) won't get the @_ rename. Generate functions for
150+
// these ref types once Hathor needs valid XML for them.
151+
function toXmlShapeDispatch(name: string, obj: any): any {
152+
if (obj === undefined || obj === null) return obj;
153+
if (typeof obj !== 'object') return obj;
154+
const rec = obj as Record<string, any>;
155+
switch (name) {
156+
case 'TextType':
157+
return textTypeToXmlShape(rec);
158+
case 'PrivateCode':
159+
return privateCodeStructureToXmlShape(rec);
160+
case 'KeyValueStructure':
161+
return keyValueStructureToXmlShape(rec);
162+
case 'PassengerCapacityStructure':
163+
return passengerCapacityStructureToXmlShape(rec, toXmlShapeDispatch);
164+
case 'keyList':
165+
return keyListToXmlShape(rec, toXmlShapeDispatch);
166+
case 'privateCodes':
167+
return privateCodesToXmlShape(rec, toXmlShapeDispatch);
168+
default:
169+
return rec;
170+
}
171+
}
172+
173+
export function toXmlShape(obj: Record<string, any>): XmlShape {
174+
return vehicleTypeToXmlShape(obj, toXmlShapeDispatch);
175+
}

packages/my_vehicletype-editor/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,3 +32,4 @@ export { validate } from './generated/validate.js';
3232
export type { ValidationResult } from './generated/validate.js';
3333
export { normalize } from './normalize.js';
3434
export { serialize } from './serialize.js';
35+
export { toXmlShape } from './generated/type_helpers.js';
Lines changed: 3 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { XMLBuilder } from 'fast-xml-parser';
2+
import { toXmlShape } from './generated/type_helpers.js';
23
import type { VehicleType } from './generated/types.js';
34

45
const builder = new XMLBuilder({
@@ -7,30 +8,8 @@ const builder = new XMLBuilder({
78
ignoreAttributes: false,
89
});
910

10-
function serializeValue(obj: Record<string, unknown>): Record<string, unknown> {
11-
const out: Record<string, unknown> = {};
12-
for (const [key, val] of Object.entries(obj)) {
13-
if (val === undefined) continue;
14-
if (key.startsWith('$')) {
15-
out[`@_${key.slice(1)}`] = typeof val === 'boolean' ? String(val) : val;
16-
} else if (Array.isArray(val)) {
17-
out[key] = val.map(item =>
18-
typeof item === 'object' && item !== null
19-
? serializeValue(item as Record<string, unknown>)
20-
: item
21-
);
22-
} else if (typeof val === 'object' && val !== null) {
23-
out[key] = serializeValue(val as Record<string, unknown>);
24-
} else if (typeof val === 'boolean') {
25-
out[key] = String(val);
26-
} else {
27-
out[key] = val;
28-
}
29-
}
30-
return out;
31-
}
32-
3311
export function serialize(obj: Partial<VehicleType>): string {
34-
const xmlObj = serializeValue(obj as Record<string, unknown>);
12+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
13+
const xmlObj = toXmlShape(obj as Record<string, any>);
3514
return builder.build({ VehicleType: xmlObj }) as string;
3615
}

0 commit comments

Comments
 (0)