fhir-openapi-translator 0.1.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +51 -0
- package/README.md +10 -6
- package/dist/{chunk-RR46JDST.js → chunk-RRAIF3A2.js} +181 -11
- package/dist/chunk-RRAIF3A2.js.map +1 -0
- package/dist/cli.js +31 -7
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +36 -0
- package/dist/index.js +1 -1
- package/docs/REFERENCE.md +159 -32
- package/package.json +3 -2
- package/dist/chunk-RR46JDST.js.map +0 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,57 @@ All notable changes to this project are documented here. The format follows
|
|
|
4
4
|
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project
|
|
5
5
|
adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
6
|
|
|
7
|
+
## [0.2.0] - 2026-09-17
|
|
8
|
+
|
|
9
|
+
Search parameters gain derived metadata and a way to emit fewer of them.
|
|
10
|
+
|
|
11
|
+
No API or CLI breakage — every 0.1.1 invocation behaves as before. Generated
|
|
12
|
+
output does change, though, so **a committed spec will show drift**: on an R4
|
|
13
|
+
`Observation`, schemas and paths are byte-identical, 6 of 38 search parameters
|
|
14
|
+
gain `x-fhir-search-values`/`x-fhir-search-prefixes` and a fuller description,
|
|
15
|
+
and 4 more have trailing whitespace trimmed from HL7's description text.
|
|
16
|
+
Regenerate committed specs, or `fhir-oas check` will fail in CI.
|
|
17
|
+
|
|
18
|
+
### Added
|
|
19
|
+
|
|
20
|
+
- `--search-params` limits which resource-specific search parameters are
|
|
21
|
+
emitted: a preset (`all`, `minimal`, `none`), an explicit code list
|
|
22
|
+
(`code,date,subject`), or per-resource (`Patient:name,birthdate`). Unknown
|
|
23
|
+
codes are an error rather than being silently dropped. Combines with
|
|
24
|
+
`--capability` by intersection. The common result parameters (`_id`,
|
|
25
|
+
`_count`, ...) are always emitted. `minimal` is tiered — common parameter
|
|
26
|
+
names first, then parameters addressing a top-level element directly, then
|
|
27
|
+
everything — so no resource that defines a search parameter is left with
|
|
28
|
+
none, while a median resource keeps 4. Presets can be tuned with `+code` and
|
|
29
|
+
`-code` (`minimal,+based-on`, `all,-note`), because no fixed rule can know
|
|
30
|
+
that `Observation.based-on`, `CarePlan.goal` or
|
|
31
|
+
`MedicationStatement.adherence` matter for their resource.
|
|
32
|
+
- `fhir-oas --version` (and `-V`) prints the installed version. It is read from
|
|
33
|
+
`package.json` at runtime rather than inlined at build time, so the CLI
|
|
34
|
+
cannot report a version the package does not have.
|
|
35
|
+
- `./package.json` is now a subpath export. Reaching for it previously raised
|
|
36
|
+
`ERR_PACKAGE_PATH_NOT_EXPORTED`, which some bundlers and
|
|
37
|
+
version-introspection scripts trip over.
|
|
38
|
+
- `x-fhir-search-values` on token search parameters bound to a required
|
|
39
|
+
ValueSet, and `x-fhir-search-prefixes` on `number`/`date`/`quantity`
|
|
40
|
+
parameters, listing the comparison prefixes (`eq`, `ne`, `gt`, `lt`, `ge`,
|
|
41
|
+
`le`, `sa`, `eb`, `ap`) their values may carry. Both are metadata rather than
|
|
42
|
+
schema constraints: an `enum` would reject the comma-OR, `system|code` and
|
|
43
|
+
`:modifier` forms that FHIR search permits.
|
|
44
|
+
|
|
45
|
+
### Changed
|
|
46
|
+
|
|
47
|
+
- Search parameter descriptions are now derived from the definitions rather
|
|
48
|
+
than passed through from HL7's prose, which is not uniform — `Encounter-status`
|
|
49
|
+
spelled its codes out while `Observation-status` did not, though both are
|
|
50
|
+
token parameters over a required binding. Every parameter of a given kind now
|
|
51
|
+
reads the same way, and reports the codes its own FHIR version defines.
|
|
52
|
+
- Search parameters shared across resources carry a union expression
|
|
53
|
+
(`Patient.gender | Person.gender | ...`); the branch matching the resource
|
|
54
|
+
being generated is now resolved, so `MedicationRequest.status` and
|
|
55
|
+
`MedicationDispense.status` each report their own code list. Previously such
|
|
56
|
+
parameters were left unenumerated entirely.
|
|
57
|
+
|
|
7
58
|
## [0.1.1] - 2026-09-13
|
|
8
59
|
|
|
9
60
|
Three correctness fixes. Specs generated with 0.1.0 should be regenerated:
|
package/README.md
CHANGED
|
@@ -50,6 +50,9 @@ fhir-oas generate Patient -f r4 --operations
|
|
|
50
50
|
# Apply an Implementation Guide profile (e.g. US Core)
|
|
51
51
|
fhir-oas generate Patient -f r4 --ig hl7.fhir.us.core@5.0.1 --profile us-core-patient
|
|
52
52
|
|
|
53
|
+
# Emit only the search parameters your deployment indexes (38 -> 9 here)
|
|
54
|
+
fhir-oas generate Observation -f r4 --search-params minimal,+based-on
|
|
55
|
+
|
|
53
56
|
# Match one server's declared surface (reads its /metadata)
|
|
54
57
|
fhir-oas generate -f r4 --capability https://server.example.org/fhir
|
|
55
58
|
|
|
@@ -76,16 +79,17 @@ const doc = generateOpenApi({ resources: ["Patient"], fhirVersion: "r4" });
|
|
|
76
79
|
- **Custom operations** from the official OperationDefinitions.
|
|
77
80
|
- **Profiles / IGs** — apply US Core-style constraints from any IG package.
|
|
78
81
|
- **CapabilityStatement-driven** — generate exactly what a server supports.
|
|
82
|
+
- **Search parameters, documented and tunable** — accepted codes and comparison prefixes per FHIR version; emit only the ones your deployment indexes.
|
|
79
83
|
- **Merge mode & drift guard** — coexist with hand-written specs, catch drift in CI.
|
|
80
84
|
|
|
81
85
|
## Where it fits
|
|
82
86
|
|
|
83
|
-
|
|
|
84
|
-
|
|
85
|
-
| Output
|
|
86
|
-
| Runtime needed
|
|
87
|
-
| US Core / IG profiles
|
|
88
|
-
| Per-resource, codegen-tuned specs |
|
|
87
|
+
| | fhir-openapi-translator | HAPI / Firely |
|
|
88
|
+
| --------------------------------- | :----------------------: | :--------------------: |
|
|
89
|
+
| Output | OpenAPI (→ any language) | Java / .NET models |
|
|
90
|
+
| Runtime needed | none (offline CLI) | a running server / SDK |
|
|
91
|
+
| US Core / IG profiles | ✅ | ✅ |
|
|
92
|
+
| Per-resource, codegen-tuned specs | ✅ | — |
|
|
89
93
|
|
|
90
94
|
**Complements a FHIR SDK, doesn't replace it.** Keep HAPI or Firely for server-side models and conformance — this produces the OpenAPI contract around them: for consumers in any language, and for the tooling you already run (gateways, mock servers, contract tests).
|
|
91
95
|
|
|
@@ -496,6 +496,148 @@ function applyProfile(context, requested, registry) {
|
|
|
496
496
|
return { schemaName, resourceType: profile.type, url: profile.url };
|
|
497
497
|
}
|
|
498
498
|
|
|
499
|
+
// src/searchParams.ts
|
|
500
|
+
var SEARCH_PREFIXES = ["eq", "ne", "gt", "lt", "ge", "le", "sa", "eb", "ap"];
|
|
501
|
+
var PREFIXABLE_TYPES = /* @__PURE__ */ new Set(["number", "date", "quantity"]);
|
|
502
|
+
function prefixesFor(searchParamType) {
|
|
503
|
+
return PREFIXABLE_TYPES.has(searchParamType) ? SEARCH_PREFIXES : void 0;
|
|
504
|
+
}
|
|
505
|
+
function requiredBindingCodes(fhirVersion) {
|
|
506
|
+
const index = /* @__PURE__ */ new Map();
|
|
507
|
+
for (const sd of loadStructureDefinitions(fhirVersion)) {
|
|
508
|
+
for (const el of sd.elements) {
|
|
509
|
+
if (el.binding?.strength === "required" && el.binding.codes?.length) {
|
|
510
|
+
index.set(el.path, el.binding.codes);
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
return index;
|
|
515
|
+
}
|
|
516
|
+
var bindingCache = /* @__PURE__ */ new Map();
|
|
517
|
+
function searchParamCodes(fhirVersion, resource, type, expression) {
|
|
518
|
+
if (type !== "token" || !expression) return void 0;
|
|
519
|
+
const branch = expression.split("|").map((part) => part.trim()).find((part) => part.startsWith(`${resource}.`) || !expression.includes("|"));
|
|
520
|
+
if (!branch || !/^[A-Za-z]+(?:\.[A-Za-z]+)+$/.test(branch)) return void 0;
|
|
521
|
+
if (branch.split(".")[0] !== resource) return void 0;
|
|
522
|
+
let index = bindingCache.get(fhirVersion);
|
|
523
|
+
if (!index) {
|
|
524
|
+
index = requiredBindingCodes(fhirVersion);
|
|
525
|
+
bindingCache.set(fhirVersion, index);
|
|
526
|
+
}
|
|
527
|
+
return index.get(branch);
|
|
528
|
+
}
|
|
529
|
+
var COMMON_CODES = /* @__PURE__ */ new Set([
|
|
530
|
+
"identifier",
|
|
531
|
+
"status",
|
|
532
|
+
"patient",
|
|
533
|
+
"subject",
|
|
534
|
+
"encounter",
|
|
535
|
+
"code",
|
|
536
|
+
"category",
|
|
537
|
+
"date",
|
|
538
|
+
"type",
|
|
539
|
+
"url",
|
|
540
|
+
"name"
|
|
541
|
+
]);
|
|
542
|
+
function directElementBranch(resource, expression) {
|
|
543
|
+
if (!expression) return void 0;
|
|
544
|
+
const branch = expression.split("|").map((part) => part.trim()).find((part) => part.startsWith(`${resource}.`));
|
|
545
|
+
return branch && /^[A-Za-z]+\.[A-Za-z]+$/.test(branch) ? branch : void 0;
|
|
546
|
+
}
|
|
547
|
+
function minimalCodes(resource, available) {
|
|
548
|
+
const common = available.filter((sp) => COMMON_CODES.has(sp.code));
|
|
549
|
+
if (common.length > 0) return new Set(common.map((sp) => sp.code));
|
|
550
|
+
const direct = available.filter((sp) => directElementBranch(resource, sp.expression));
|
|
551
|
+
if (direct.length > 0) return new Set(direct.map((sp) => sp.code));
|
|
552
|
+
return new Set(available.map((sp) => sp.code));
|
|
553
|
+
}
|
|
554
|
+
var PRESETS = /* @__PURE__ */ new Set(["all", "minimal", "none"]);
|
|
555
|
+
function parseOne(value) {
|
|
556
|
+
const parts = value.split(",").map((part) => part.trim()).filter(Boolean);
|
|
557
|
+
if (parts.length === 0) throw new Error(`--search-params: empty selection in "${value}"`);
|
|
558
|
+
const add = /* @__PURE__ */ new Set();
|
|
559
|
+
const remove = /* @__PURE__ */ new Set();
|
|
560
|
+
const literal = /* @__PURE__ */ new Set();
|
|
561
|
+
let preset;
|
|
562
|
+
for (const [index, part] of parts.entries()) {
|
|
563
|
+
if (part.startsWith("+") || part.startsWith("-")) {
|
|
564
|
+
const code = part.slice(1);
|
|
565
|
+
if (!code) throw new Error(`--search-params: "${part}" names no parameter`);
|
|
566
|
+
(part.startsWith("+") ? add : remove).add(code);
|
|
567
|
+
continue;
|
|
568
|
+
}
|
|
569
|
+
const lower = part.toLowerCase();
|
|
570
|
+
if (PRESETS.has(lower)) {
|
|
571
|
+
if (index !== 0) {
|
|
572
|
+
throw new Error(
|
|
573
|
+
`--search-params: preset "${part}" must come first in "${value}" (e.g. "minimal,+${parts[0]}").`
|
|
574
|
+
);
|
|
575
|
+
}
|
|
576
|
+
preset = lower;
|
|
577
|
+
continue;
|
|
578
|
+
}
|
|
579
|
+
literal.add(part);
|
|
580
|
+
}
|
|
581
|
+
if (preset && literal.size > 0) {
|
|
582
|
+
throw new Error(
|
|
583
|
+
`--search-params: "${value}" mixes the preset "${preset}" with bare codes ${[...literal].map((c) => `"${c}"`).join(", ")}. Prefix them with + to add to the preset, or drop the preset to list codes exactly.`
|
|
584
|
+
);
|
|
585
|
+
}
|
|
586
|
+
const rule = { base: preset ?? literal };
|
|
587
|
+
if (add.size > 0) rule.add = add;
|
|
588
|
+
if (remove.size > 0) rule.remove = remove;
|
|
589
|
+
return rule;
|
|
590
|
+
}
|
|
591
|
+
function parseSearchParamSpec(specs) {
|
|
592
|
+
const selection = {};
|
|
593
|
+
const byResource = /* @__PURE__ */ new Map();
|
|
594
|
+
for (const spec of specs) {
|
|
595
|
+
const colon = spec.indexOf(":");
|
|
596
|
+
if (colon > 0 && /^[A-Z][A-Za-z]*$/.test(spec.slice(0, colon))) {
|
|
597
|
+
const resource = spec.slice(0, colon);
|
|
598
|
+
if (byResource.has(resource)) {
|
|
599
|
+
throw new Error(`--search-params: ${resource} given more than once`);
|
|
600
|
+
}
|
|
601
|
+
byResource.set(resource, parseOne(spec.slice(colon + 1)));
|
|
602
|
+
continue;
|
|
603
|
+
}
|
|
604
|
+
if (selection.default !== void 0) {
|
|
605
|
+
throw new Error(
|
|
606
|
+
`--search-params: more than one unscoped selection ("${spec}"). Scope them per resource (Patient:name,birthdate) or pass a single list.`
|
|
607
|
+
);
|
|
608
|
+
}
|
|
609
|
+
selection.default = parseOne(spec);
|
|
610
|
+
}
|
|
611
|
+
if (byResource.size > 0) selection.byResource = byResource;
|
|
612
|
+
return selection;
|
|
613
|
+
}
|
|
614
|
+
function resolveSearchParamCodes(selection, fhirVersion, resource) {
|
|
615
|
+
const rule = selection?.byResource?.get(resource) ?? selection?.default;
|
|
616
|
+
if (rule === void 0) return void 0;
|
|
617
|
+
if (rule.base === "all" && !rule.add && !rule.remove) return void 0;
|
|
618
|
+
const available = loadSearchParameters(fhirVersion).filter((sp) => sp.base.includes(resource));
|
|
619
|
+
const codes = new Set(available.map((sp) => sp.code));
|
|
620
|
+
const named = [
|
|
621
|
+
...typeof rule.base === "string" ? [] : rule.base,
|
|
622
|
+
...rule.add ?? [],
|
|
623
|
+
...rule.remove ?? []
|
|
624
|
+
];
|
|
625
|
+
const unknown = named.filter((code) => !codes.has(code));
|
|
626
|
+
if (unknown.length > 0) {
|
|
627
|
+
throw new Error(
|
|
628
|
+
`--search-params: ${resource} has no search parameter ${unknown.map((c) => `"${c}"`).join(", ")} in ${fhirVersion.toUpperCase()}. Run "fhir-oas generate ${resource} -f ${fhirVersion}" to see the available codes.`
|
|
629
|
+
);
|
|
630
|
+
}
|
|
631
|
+
let selected;
|
|
632
|
+
if (rule.base === "all") selected = new Set(codes);
|
|
633
|
+
else if (rule.base === "none") selected = /* @__PURE__ */ new Set();
|
|
634
|
+
else if (rule.base === "minimal") selected = minimalCodes(resource, available);
|
|
635
|
+
else selected = new Set(rule.base);
|
|
636
|
+
for (const code of rule.add ?? []) selected.add(code);
|
|
637
|
+
for (const code of rule.remove ?? []) selected.delete(code);
|
|
638
|
+
return selected;
|
|
639
|
+
}
|
|
640
|
+
|
|
499
641
|
// src/backends/schemaJson.ts
|
|
500
642
|
function buildRegistryFromSchemaJson(fhirVersion) {
|
|
501
643
|
const schema = loadFhirSchema(fhirVersion);
|
|
@@ -920,16 +1062,41 @@ function commonSearchParameterComponents() {
|
|
|
920
1062
|
}
|
|
921
1063
|
return out;
|
|
922
1064
|
}
|
|
1065
|
+
function isCodeListDescription(description) {
|
|
1066
|
+
return description.includes("|") && /^[A-Za-z0-9\-.\s|+]+$/.test(description);
|
|
1067
|
+
}
|
|
1068
|
+
function describeSearchParameter(description, codes, prefixes) {
|
|
1069
|
+
let base = description.trim();
|
|
1070
|
+
if (codes?.length && isCodeListDescription(base)) base = "";
|
|
1071
|
+
const notes = [];
|
|
1072
|
+
if (codes?.length) notes.push(`Accepted values: ${codes.join(" | ")}.`);
|
|
1073
|
+
if (prefixes?.length) {
|
|
1074
|
+
notes.push(`Values may carry a comparison prefix (${prefixes.join(", ")}), e.g. ge2021-01-01.`);
|
|
1075
|
+
}
|
|
1076
|
+
if (notes.length === 0) return base;
|
|
1077
|
+
if (base.length === 0) return notes.join(" ");
|
|
1078
|
+
const separator = base.includes("\n") ? "\n\n" : /[.?!]$/.test(base) ? " " : ". ";
|
|
1079
|
+
return `${base}${separator}${notes.join(" ")}`;
|
|
1080
|
+
}
|
|
923
1081
|
function resourceSearchParameters(fhirVersion, resource, only) {
|
|
924
|
-
return loadSearchParameters(fhirVersion).filter((sp) => sp.base.includes(resource) && (!only || only.has(sp.code))).sort((a, b) => a.code.localeCompare(b.code)).map((sp) =>
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
1082
|
+
return loadSearchParameters(fhirVersion).filter((sp) => sp.base.includes(resource) && (!only || only.has(sp.code))).sort((a, b) => a.code.localeCompare(b.code)).map((sp) => {
|
|
1083
|
+
const codes = searchParamCodes(fhirVersion, resource, sp.type, sp.expression);
|
|
1084
|
+
const prefixes = prefixesFor(sp.type);
|
|
1085
|
+
return {
|
|
1086
|
+
name: sp.code,
|
|
1087
|
+
in: "query",
|
|
1088
|
+
required: false,
|
|
1089
|
+
description: describeSearchParameter(sp.description ?? "", codes, prefixes),
|
|
1090
|
+
// FHIR search values carry prefixes and modifiers, so all are strings.
|
|
1091
|
+
// The accepted values are advertised via x-fhir-search-values rather
|
|
1092
|
+
// than `enum`, which would reject the comma-OR (`status=final,amended`),
|
|
1093
|
+
// system|code and `:modifier` forms that FHIR search permits.
|
|
1094
|
+
schema: { type: "string" },
|
|
1095
|
+
"x-fhir-search-type": sp.type,
|
|
1096
|
+
...codes?.length ? { "x-fhir-search-values": codes } : {},
|
|
1097
|
+
...prefixes?.length ? { "x-fhir-search-prefixes": prefixes } : {}
|
|
1098
|
+
};
|
|
1099
|
+
});
|
|
933
1100
|
}
|
|
934
1101
|
var historyParameters = () => [
|
|
935
1102
|
{ $ref: `${PARAMETERS}_count` },
|
|
@@ -1215,11 +1382,13 @@ function generateOpenApi(options) {
|
|
|
1215
1382
|
const paths = {};
|
|
1216
1383
|
for (const resource of resources) {
|
|
1217
1384
|
const cap = capabilityByResource.get(resource);
|
|
1385
|
+
const selected = resolveSearchParamCodes(options.searchParams, options.fhirVersion, resource);
|
|
1386
|
+
const searchParamCodes2 = cap?.searchParamCodes && selected ? new Set([...selected].filter((code) => cap.searchParamCodes.has(code))) : selected ?? cap?.searchParamCodes;
|
|
1218
1387
|
Object.assign(
|
|
1219
1388
|
paths,
|
|
1220
1389
|
buildResourcePaths(options.fhirVersion, resource, schemaFor(resource), {
|
|
1221
1390
|
interactions: cap?.interactions,
|
|
1222
|
-
searchParamCodes:
|
|
1391
|
+
searchParamCodes: searchParamCodes2
|
|
1223
1392
|
})
|
|
1224
1393
|
);
|
|
1225
1394
|
}
|
|
@@ -1467,6 +1636,7 @@ export {
|
|
|
1467
1636
|
loadIg,
|
|
1468
1637
|
loadIgSync,
|
|
1469
1638
|
buildCoreValueSetFallback,
|
|
1639
|
+
parseSearchParamSpec,
|
|
1470
1640
|
listResources,
|
|
1471
1641
|
generateOpenApi,
|
|
1472
1642
|
loadCapabilityStatement,
|
|
@@ -1476,4 +1646,4 @@ export {
|
|
|
1476
1646
|
diffAgainstYaml,
|
|
1477
1647
|
stringifyDocument
|
|
1478
1648
|
};
|
|
1479
|
-
//# sourceMappingURL=chunk-
|
|
1649
|
+
//# sourceMappingURL=chunk-RRAIF3A2.js.map
|