@obsidiane/meridiane 1.0.1 → 2.0.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/README.md CHANGED
@@ -21,18 +21,21 @@ npm i -g @obsidiane/meridiane
21
21
  ```bash
22
22
  # Dev: génère le bridge (+ models par défaut)
23
23
  # (build standalone + install local dans node_modules)
24
- npx meridiane dev <packageName> --spec <url|file> [--preset[=native|all]] [--include <substr>]... [--exclude <substr>]... [--no-models] [--debug]
24
+ npx meridiane dev <packageName> --spec <url|file> [--formats <mimeTypes>]... [--include <substr>]... [--exclude <substr>]... [--no-models] [--debug]
25
25
 
26
26
  # CI/CD: génère + build Angular + npm pack (artefact prêt à publier)
27
- npx meridiane build <packageName> --version <semver> --spec <url|file> [--preset[=native|all]] [--include <substr>]... [--exclude <substr>]... [--no-models] [--debug]
27
+ npx meridiane build <packageName> --version <semver> --spec <url|file> [--formats <mimeTypes>]... [--include <substr>]... [--exclude <substr>]... [--no-models] [--debug]
28
28
  ```
29
29
 
30
30
  Options :
31
31
  - `--debug` : active des logs supplémentaires (CLI).
32
+ - `--formats` : peut être répété ou fourni en liste séparée par virgules :
33
+ - `--formats application/ld+json`
34
+ - `--formats application/ld+json,application/json`
32
35
 
33
36
  Bonnes pratiques :
34
- - utiliser `--preset=native` pour un mode “contract-driven” (LD+JSON) :
35
- - modèles générés uniquement s’ils sont utilisés par les endpoints
37
+ - utiliser `--formats application/ld+json` (ou plusieurs formats) pour un mode “contract-driven” :
38
+ - modèles générés uniquement s’ils sont utilisés par les endpoints (pour ces formats)
36
39
  - groups conservés
37
40
  - pas de modèles `*.jsonMergePatch` (PATCH = `Partial<...>`)
38
41
  - noms normalisés (pas de suffixe `.jsonld`)
@@ -45,7 +48,7 @@ Note (repo Meridiane) :
45
48
  ## Exemple CI (générer + build + publish)
46
49
 
47
50
  ```bash
48
- npx -y @obsidiane/meridiane@0.1.0 build @acme/backend-bridge --version 0.1.0 --spec https://staging.example/api/docs.json --preset=native
51
+ npx -y @obsidiane/meridiane@0.1.0 build @acme/backend-bridge --version 0.1.0 --spec https://staging.example/api/docs.json --formats application/ld+json
49
52
  npm publish dist/backend-bridge
50
53
  ```
51
54
 
package/cli.js CHANGED
@@ -8,16 +8,7 @@ import { createLogger } from './tools/core/logger.js';
8
8
 
9
9
  const program = new Command();
10
10
 
11
- program
12
- .name('meridiane')
13
- .description('Generate an Angular bridge + models from an API Platform OpenAPI spec.')
14
- .showHelpAfterError()
15
- .showSuggestionAfterError();
16
-
17
- function presetParser(value, previous) {
18
- if (value === undefined && previous === undefined) return true; // `--preset` (no value) => native
19
- return value ?? previous;
20
- }
11
+ program.name('meridiane').description('Generate an Angular bridge + models from an API Platform OpenAPI spec.').showHelpAfterError().showSuggestionAfterError();
21
12
 
22
13
  function collect(value, previous = []) {
23
14
  return [...previous, value];
@@ -26,7 +17,12 @@ function collect(value, previous = []) {
26
17
  function commonOptions(cmd) {
27
18
  return cmd
28
19
  .option('--spec <urlOrFile>', 'OpenAPI spec source (URL or local JSON file)')
29
- .option('--preset [mode]', 'Preset: native|all (default: all; `--preset` alone => native)', presetParser)
20
+ .option(
21
+ '--formats <mimeTypes>',
22
+ 'Generate only selected media types (repeatable, supports commas). Enables contract-driven mode from paths.',
23
+ collect,
24
+ []
25
+ )
30
26
  .option('--include <substr>', 'Include schema names containing substring (repeatable, supports commas)', collect, [])
31
27
  .option('--exclude <substr>', 'Exclude schema names containing substring (repeatable, supports commas)', collect, [])
32
28
  .option('--no-models', 'Skip models generation (then --spec is not required)')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@obsidiane/meridiane",
3
- "version": "1.0.1",
3
+ "version": "2.0.0",
4
4
  "description": "Générateur de librairie Angular et modèles TypeScript (OpenAPI) pour un bridge Symfony/API Platform + Mercure.",
5
5
  "repository": {
6
6
  "type": "git",
package/tools/build.js CHANGED
@@ -4,7 +4,7 @@ import { spawn } from 'node:child_process';
4
4
  import path from 'node:path';
5
5
 
6
6
  import { deriveLibName } from './core/lib-name.js';
7
- import { normalizePreset, splitListArgs } from './core/paths.js';
7
+ import { splitListArgs } from './core/paths.js';
8
8
  import { readOpenApiSpec } from './core/spec.js';
9
9
  import { createLogger } from './core/logger.js';
10
10
  import { generateBridgeWorkspace } from './core/generate.js';
@@ -30,20 +30,21 @@ export async function runBuild(packageName, opts) {
30
30
  if (!version) throw new Error('Missing --version');
31
31
 
32
32
  const noModels = opts.models === false;
33
- const preset = normalizePreset(opts.preset);
33
+ const formats = splitListArgs(opts.formats);
34
34
  const include = splitListArgs(opts.include);
35
35
  const exclude = splitListArgs(opts.exclude);
36
36
 
37
37
  const libName = deriveLibName(packageName);
38
38
  const requiredMode = 'spec';
39
+ const effectiveFormats = formats.length > 0 ? formats : ['application/ld+json'];
39
40
 
40
- // Standalone mode (native): works from any repo (backend pipeline or otherwise).
41
+ // Standalone mode: works from any repo (backend pipeline or otherwise).
41
42
  log.title('build — standalone');
42
43
  log.info(`repo: ${cwd}`);
43
44
  log.info(`package: ${packageName}@${version}`);
44
45
  log.info(`lib: ${libName}`);
45
46
  if (noModels) log.info('models: désactivés (--no-models)');
46
- else log.info(`spec: ${opts.spec} | preset: ${preset}`);
47
+ else log.info(`spec: ${opts.spec} | formats: ${effectiveFormats.join(', ')} | nullableMode: ${requiredMode}`);
47
48
 
48
49
  const { workspaceRoot, distRoot, npmCacheDir, requireFromWs } = await ensureStandaloneWorkspace({ repoRoot: cwd, log });
49
50
  log.info(`workspace: ${workspaceRoot}`);
@@ -65,7 +66,7 @@ export async function runBuild(packageName, opts) {
65
66
  noModels,
66
67
  spec,
67
68
  requiredMode,
68
- preset,
69
+ formats: effectiveFormats,
69
70
  include,
70
71
  exclude,
71
72
  debug,
@@ -52,18 +52,25 @@ async function generateLibrary({ cwd, libName, packageName, version, debug }) {
52
52
  // angular.json / workspace patching is intentionally not done here (Meridiane is standalone).
53
53
  }
54
54
 
55
- async function generateModels({ cwd, libName, spec, requiredMode, preset, include, exclude, debug }) {
55
+ async function generateModels({ cwd, libName, spec, requiredMode, formats, include, exclude, debug }) {
56
56
  const outDir = path.resolve(cwd, 'projects', libName, 'src', 'models');
57
57
  const templatesDir = path.resolve(pkgRoot, 'tools', 'generator', 'models', 'templates');
58
58
 
59
- if (debug) console.log('[meridiane] generate models', { outDir: path.relative(cwd, outDir), requiredMode, preset, includeCount: include.length, excludeCount: exclude.length });
59
+ if (debug)
60
+ console.log('[meridiane] generate models', {
61
+ outDir: path.relative(cwd, outDir),
62
+ requiredMode,
63
+ formats: Array.isArray(formats) ? formats : [],
64
+ includeCount: include.length,
65
+ excludeCount: exclude.length,
66
+ });
60
67
 
61
68
  await ensureCleanDir(outDir);
62
69
 
63
70
  // Write models + index.ts.
64
71
  const { models } = buildModelsFromOpenAPI(spec, {
65
72
  requiredMode,
66
- preset,
73
+ formats: Array.isArray(formats) ? formats : [],
67
74
  includeSchemaNames: include,
68
75
  excludeSchemaNames: exclude,
69
76
  });
@@ -77,6 +84,7 @@ async function generateModels({ cwd, libName, spec, requiredMode, preset, includ
77
84
  props: m.props,
78
85
  imports: m.imports,
79
86
  extendsTypes: m.extendsTypes || [],
87
+ extendsItem: m.extendsItem === true,
80
88
  },
81
89
  });
82
90
  }
@@ -98,8 +106,8 @@ async function generateModels({ cwd, libName, spec, requiredMode, preset, includ
98
106
  * version: string,
99
107
  * noModels: boolean,
100
108
  * spec?: any,
101
- * requiredMode: 'all-optional'|'spec',
102
- * preset: 'all'|'native',
109
+ * requiredMode: 'all'|'spec',
110
+ * formats?: string[],
103
111
  * include: string[],
104
112
  * exclude: string[],
105
113
  * debug: boolean,
@@ -117,7 +125,7 @@ export async function generateBridgeWorkspace(params) {
117
125
  noModels,
118
126
  spec,
119
127
  requiredMode,
120
- preset,
128
+ formats,
121
129
  include,
122
130
  exclude,
123
131
  debug,
@@ -147,6 +155,6 @@ export async function generateBridgeWorkspace(params) {
147
155
 
148
156
  if (!spec) throw new Error('Internal error: spec is required when noModels=false');
149
157
  log?.step?.('génération des models (OpenAPI)');
150
- await generateModels({ cwd, libName, spec, requiredMode, preset, include, exclude, debug });
158
+ await generateModels({ cwd, libName, spec, requiredMode, formats, include, exclude, debug });
151
159
  log?.success?.('génération terminée');
152
160
  }
@@ -1,10 +1,3 @@
1
- export function normalizePreset(presetOption) {
2
- if (presetOption === undefined) return 'all';
3
- if (presetOption === true) return 'native';
4
- if (presetOption === 'native' || presetOption === 'all') return presetOption;
5
- throw new Error(`Invalid --preset value: ${presetOption} (expected: native|all)`);
6
- }
7
-
8
1
  export function splitListArgs(values) {
9
2
  const out = [];
10
3
  for (const v of Array.isArray(values) ? values : []) {
package/tools/dev.js CHANGED
@@ -5,7 +5,7 @@ import path from 'node:path';
5
5
  import { spawn } from 'node:child_process';
6
6
 
7
7
  import { deriveLibName } from './core/lib-name.js';
8
- import { normalizePreset, splitListArgs } from './core/paths.js';
8
+ import { splitListArgs } from './core/paths.js';
9
9
  import { readOpenApiSpec } from './core/spec.js';
10
10
  import { createLogger } from './core/logger.js';
11
11
  import { generateBridgeWorkspace } from './core/generate.js';
@@ -139,12 +139,13 @@ export async function runDev(packageName, opts) {
139
139
  const libName = deriveLibName(effectivePackageName);
140
140
 
141
141
  const noModels = opts.models === false;
142
- const preset = normalizePreset(useSandboxDefaults ? (opts.preset ?? 'native') : opts.preset);
142
+ const formats = splitListArgs(opts.formats);
143
143
  const include = splitListArgs(opts.include);
144
144
  const exclude = splitListArgs(opts.exclude);
145
145
 
146
146
  const version = '0.0.0-dev';
147
- const requiredMode = 'all-optional';
147
+ const requiredMode = 'all';
148
+ const effectiveFormats = formats.length > 0 ? formats : ['application/ld+json'];
148
149
  const specSource = useSandboxDefaults ? (opts.spec ?? 'http://localhost:8000/api/docs.json') : opts.spec;
149
150
 
150
151
  log.title('dev — build standalone + install local');
@@ -153,7 +154,7 @@ export async function runDev(packageName, opts) {
153
154
  log.info(`lib: ${libName}`);
154
155
  if (useSandboxDefaults) log.info('defaults sandbox appliqués');
155
156
  if (noModels) log.info('models: désactivés (--no-models)');
156
- else log.info(`spec: ${specSource} | preset: ${preset}`);
157
+ else log.info(`spec: ${specSource} | formats: ${effectiveFormats.join(', ')} | nullableMode: ${requiredMode}`);
157
158
 
158
159
  const { workspaceRoot, distRoot, npmCacheDir, requireFromWs } = await ensureStandaloneWorkspace({ repoRoot: appRoot, log });
159
160
  log.info(`workspace: ${workspaceRoot}`);
@@ -175,7 +176,7 @@ export async function runDev(packageName, opts) {
175
176
  noModels,
176
177
  spec,
177
178
  requiredMode,
178
- preset,
179
+ formats: effectiveFormats,
179
180
  include,
180
181
  exclude,
181
182
  debug,
@@ -0,0 +1,159 @@
1
+ import { schemaNameFromRef } from './schema-utils.js';
2
+
3
+ function resolveRefPointer(spec, ref) {
4
+ const s = String(ref || '');
5
+ if (!s.startsWith('#/')) return undefined;
6
+ const parts = s.slice(2).split('/').filter(Boolean);
7
+ let cur = spec;
8
+ for (const p of parts) {
9
+ if (!cur || typeof cur !== 'object') return undefined;
10
+ cur = cur[p];
11
+ }
12
+ return cur;
13
+ }
14
+
15
+ function deref(spec, maybeRef) {
16
+ if (!maybeRef || typeof maybeRef !== 'object') return maybeRef;
17
+ if (typeof maybeRef.$ref === 'string') return resolveRefPointer(spec, maybeRef.$ref) || maybeRef;
18
+ return maybeRef;
19
+ }
20
+
21
+ export function canonicalizeMediaType(mediaType) {
22
+ return String(mediaType || '').split(';')[0].trim().toLowerCase();
23
+ }
24
+
25
+ function getContentSchema(content, wantedLower) {
26
+ if (!content || typeof content !== 'object') return undefined;
27
+ for (const [k, v] of Object.entries(content)) {
28
+ const canon = canonicalizeMediaType(k);
29
+ if (canon === wantedLower) return v?.schema;
30
+ }
31
+ return undefined;
32
+ }
33
+
34
+ function is2xxStatus(code) {
35
+ const s = String(code || '');
36
+ if (/^[0-9]{3}$/.test(s)) return s.startsWith('2');
37
+ return /^2xx$/i.test(s);
38
+ }
39
+
40
+ /**
41
+ * Collects only component schema refs (`#/components/schemas/*`) found in a JSON schema.
42
+ * @param {any} schema
43
+ * @param {Set<string>} out
44
+ */
45
+ function collectComponentSchemaRefs(schema, out) {
46
+ const seen = new Set();
47
+
48
+ function walk(node) {
49
+ if (!node || typeof node !== 'object') return;
50
+ if (seen.has(node)) return;
51
+ seen.add(node);
52
+
53
+ if (node.$ref && typeof node.$ref === 'string') {
54
+ const name = schemaNameFromRef(node.$ref);
55
+ if (name) out.add(name);
56
+ return;
57
+ }
58
+
59
+ if (Array.isArray(node)) {
60
+ for (const it of node) walk(it);
61
+ return;
62
+ }
63
+
64
+ if (node.allOf) walk(node.allOf);
65
+ if (node.oneOf) walk(node.oneOf);
66
+ if (node.anyOf) walk(node.anyOf);
67
+ if (node.items) walk(node.items);
68
+ if (node.properties) walk(node.properties);
69
+ if (node.additionalProperties) walk(node.additionalProperties);
70
+
71
+ for (const v of Object.values(node)) walk(v);
72
+ }
73
+
74
+ walk(schema);
75
+ }
76
+
77
+ /**
78
+ * Extracts root schemas from endpoints for one media type.
79
+ * - requestBody is ignored for PATCH (merge-patch typed as Partial<> elsewhere)
80
+ * - responses keep only 2xx and default
81
+ */
82
+ function listRootsFromPaths(spec, { mediaType } = {}) {
83
+ const wantedLower = canonicalizeMediaType(mediaType);
84
+ const roots = [];
85
+
86
+ const paths = spec?.paths;
87
+ if (!paths || typeof paths !== 'object') return roots;
88
+
89
+ for (const pathItem of Object.values(paths)) {
90
+ if (!pathItem || typeof pathItem !== 'object') continue;
91
+ for (const [method, op0] of Object.entries(pathItem)) {
92
+ const op = deref(spec, op0);
93
+ if (!op || typeof op !== 'object') continue;
94
+
95
+ const isPatch = String(method || '').toLowerCase() === 'patch';
96
+
97
+ if (!isPatch) {
98
+ const rb = deref(spec, op.requestBody);
99
+ const rbSchema = getContentSchema(rb?.content, wantedLower);
100
+ if (rbSchema) roots.push(rbSchema);
101
+ }
102
+
103
+ const responses = op.responses;
104
+ if (!responses || typeof responses !== 'object') continue;
105
+ for (const [code, resp] of Object.entries(responses)) {
106
+ if (!is2xxStatus(code) && String(code).toLowerCase() !== 'default') continue;
107
+ const resolved = deref(spec, resp);
108
+ const schema = getContentSchema(resolved?.content, wantedLower);
109
+ if (schema) roots.push(schema);
110
+ }
111
+ }
112
+ }
113
+
114
+ return roots;
115
+ }
116
+
117
+ /**
118
+ * Returns the transitive closure of schema dependencies (following component-schema $ref).
119
+ * @param {Iterable<string>} start
120
+ * @param {Record<string, any>} schemas
121
+ */
122
+ export function expandWithSchemaDependencies(start, schemas) {
123
+ const out = new Set();
124
+ const queue = [];
125
+
126
+ for (const n of start) {
127
+ if (!n || out.has(n)) continue;
128
+ out.add(n);
129
+ queue.push(n);
130
+ }
131
+
132
+ while (queue.length) {
133
+ const name = queue.pop();
134
+ const schema = schemas[name];
135
+ if (!schema) continue;
136
+ const refs = new Set();
137
+ collectComponentSchemaRefs(schema, refs);
138
+ for (const ref of refs) {
139
+ if (!ref || out.has(ref)) continue;
140
+ out.add(ref);
141
+ queue.push(ref);
142
+ }
143
+ }
144
+
145
+ return out;
146
+ }
147
+
148
+ /**
149
+ * Collects all component schemas used by endpoints for one media type.
150
+ * @param {any} spec
151
+ * @param {{ mediaType: string }} cfg
152
+ */
153
+ export function collectUsedSchemasForMediaType(spec, cfg) {
154
+ const schemas = spec?.components?.schemas || {};
155
+ const roots = listRootsFromPaths(spec, cfg);
156
+ const initialRefs = new Set();
157
+ for (const r of roots) collectComponentSchemaRefs(r, initialRefs);
158
+ return expandWithSchemaDependencies(initialRefs, schemas);
159
+ }
@@ -0,0 +1,71 @@
1
+ import { canonicalizeMediaType } from './oas-contract.js';
2
+
3
+ function pascalizeTokens(tokens) {
4
+ return tokens
5
+ .filter(Boolean)
6
+ .map((t) => String(t).replace(/^[^A-Za-z0-9]+|[^A-Za-z0-9]+$/g, ''))
7
+ .filter(Boolean)
8
+ .map((t) => t.charAt(0).toUpperCase() + t.slice(1))
9
+ .join('');
10
+ }
11
+
12
+ export function parseOpenApiSchemaName(originalName) {
13
+ const raw = String(originalName || '');
14
+ const i = raw.indexOf('-');
15
+ let baseRaw = i >= 0 ? raw.slice(0, i) : raw;
16
+ let groupRaw = i >= 0 ? raw.slice(i + 1) : '';
17
+
18
+ let baseVariant = 'none';
19
+ if (/\.jsonld$/i.test(baseRaw)) {
20
+ baseVariant = 'jsonld';
21
+ baseRaw = baseRaw.replace(/\.jsonld$/i, '');
22
+ } else if (/\.multipart$/i.test(baseRaw)) {
23
+ baseVariant = 'multipart';
24
+ baseRaw = baseRaw.replace(/\.multipart$/i, '');
25
+ }
26
+
27
+ let isMergePatch = false;
28
+ if (/\.jsonmergepatch$/i.test(baseRaw)) {
29
+ isMergePatch = true;
30
+ baseRaw = baseRaw.replace(/\.jsonmergepatch$/i, '');
31
+ }
32
+ if (/\.jsonmergepatch$/i.test(groupRaw)) {
33
+ isMergePatch = true;
34
+ groupRaw = groupRaw.replace(/\.jsonmergepatch$/i, '');
35
+ }
36
+
37
+ const basePath = baseRaw;
38
+ const groupPath = groupRaw;
39
+
40
+ return {
41
+ originalName: raw,
42
+ basePath,
43
+ groupPath,
44
+ baseVariant,
45
+ isMergePatch,
46
+ key: `${basePath.toLowerCase()}|${groupPath.toLowerCase()}`,
47
+ };
48
+ }
49
+
50
+ export function baseTsNameFromBasePath(basePath) {
51
+ const parts = String(basePath || '').split('.').filter(Boolean);
52
+ if (parts.length === 0) return 'Model';
53
+ const a = pascalizeTokens([parts[0]]);
54
+ if (parts.length === 1) return a;
55
+ const b = pascalizeTokens([parts[1]]);
56
+ if (parts.length === 2 && b.toLowerCase().startsWith(a.toLowerCase())) return b;
57
+ return parts.map((p) => pascalizeTokens([p])).join('');
58
+ }
59
+
60
+ export function groupTsNameFromGroupPath(groupPath) {
61
+ const tokens = String(groupPath || '').split(/[._:]+/g).filter(Boolean);
62
+ return pascalizeTokens(tokens);
63
+ }
64
+
65
+ export function formatSuffixToken(mediaType) {
66
+ const canon = canonicalizeMediaType(mediaType);
67
+ const tokens = canon.split(/[\/+.-]+/g).filter(Boolean);
68
+ const trimmed = tokens[0] === 'application' ? tokens.slice(1) : tokens;
69
+ return pascalizeTokens(trimmed.length ? trimmed : tokens) || 'Fmt';
70
+ }
71
+
@@ -1,193 +1,47 @@
1
1
  import { sanitizeTypeName, quoteKeyIfNeeded } from './utils.js';
2
- import { filterSchemaNames, applySchemaNameFilters, mergeAllOf, isObject, schemaNameFromRef } from './schema-utils.js';
3
- import { schemaLabel } from './naming.js';
2
+ import { mergeAllOf, isObject, schemaNameFromRef } from './schema-utils.js';
4
3
  import { tsTypeOf, withNullable } from './type-resolver.js';
4
+ import { canonicalizeMediaType, collectUsedSchemasForMediaType, expandWithSchemaDependencies } from './oas-contract.js';
5
+ import { baseTsNameFromBasePath, formatSuffixToken, groupTsNameFromGroupPath, parseOpenApiSchemaName } from './oas-schema-name.js';
5
6
 
6
7
  /**
7
8
  * @typedef {{
8
- * requiredMode?: 'all-optional'|'spec',
9
- * preset?: 'all'|'native',
9
+ * requiredMode?: 'all'|'spec',
10
+ * formats?: string[],
10
11
  * includeSchemaNames?: Array<RegExp|string|((name: string) => boolean)>,
11
12
  * excludeSchemaNames?: Array<RegExp|string|((name: string) => boolean)>,
12
13
  * }} BuildOptions
13
14
  */
14
15
 
15
16
  /**
16
- * Construit une map originalName -> sanitizedName, en assurant l’unicité
17
- * @param {string[]} schemaNames
17
+ * @typedef {{
18
+ * name: string,
19
+ * tsKey: string,
20
+ * type: string,
21
+ * optional: boolean,
22
+ * }} ModelProp
18
23
  */
19
- function normalizeNativeBase(original) {
20
- return String(original)
21
- .replace(/\.jsonld\b/ig, '')
22
- .replace(/\.jsonapi\b/ig, '')
23
- .replace(/\.jsonmergepatch\b/ig, '')
24
- .split('-')[0];
25
- }
26
-
27
- function buildNameMap(schemaNames, schemas, preset) {
28
- // Build final map with uniqueness (deterministic order)
29
- const used = new Set();
30
- const nameMap = new Map();
31
- for (const original of schemaNames) {
32
- const label = schemaLabel(original, {preset});
33
- nameMap.set(original, sanitizeTypeName(label, used));
34
- }
35
-
36
- // In native mode, alias excluded variants to the selected base type
37
- // so `$ref` doesn't leak "User-user.write" etc into generated types.
38
- if (preset === 'native') {
39
- const baseToSanitized = new Map();
40
- for (const original of schemaNames) {
41
- baseToSanitized.set(normalizeNativeBase(original), nameMap.get(original));
42
- }
43
- for (const original of Object.keys(schemas)) {
44
- const base = normalizeNativeBase(original);
45
- const sanitized = baseToSanitized.get(base);
46
- if (sanitized) nameMap.set(original, sanitized);
47
- }
48
- }
49
-
50
- return nameMap;
51
- }
52
-
53
- function getContentSchema(content, wantedLower) {
54
- if (!content || typeof content !== 'object') return undefined;
55
- for (const [k, v] of Object.entries(content)) {
56
- if (String(k).toLowerCase() === wantedLower) return v?.schema;
57
- }
58
- return undefined;
59
- }
60
-
61
- function is2xxStatus(code) {
62
- const s = String(code || '');
63
- return /^[0-9]{3}$/.test(s) && s.startsWith('2');
64
- }
65
-
66
- function collectRefsFromSchema(schema, out) {
67
- const seen = new Set();
68
-
69
- function walk(node) {
70
- if (!node || typeof node !== 'object') return;
71
- if (seen.has(node)) return;
72
- seen.add(node);
73
-
74
- if (node.$ref && typeof node.$ref === 'string') {
75
- out.add(schemaNameFromRef(node.$ref));
76
- return;
77
- }
78
-
79
- if (Array.isArray(node)) {
80
- for (const it of node) walk(it);
81
- return;
82
- }
83
24
 
84
- // Common JSON schema constructs (plus a generic fallback)
85
- if (node.allOf) walk(node.allOf);
86
- if (node.oneOf) walk(node.oneOf);
87
- if (node.anyOf) walk(node.anyOf);
88
- if (node.items) walk(node.items);
89
- if (node.properties) walk(node.properties);
90
- if (node.additionalProperties) walk(node.additionalProperties);
91
-
92
- for (const v of Object.values(node)) walk(v);
93
- }
94
-
95
- walk(schema);
96
- }
97
-
98
- function listNativeRootsFromPaths(spec, { mediaType = 'application/ld+json' } = {}) {
99
- const wantedLower = String(mediaType).toLowerCase();
100
- const roots = [];
101
-
102
- const paths = spec?.paths;
103
- if (!paths || typeof paths !== 'object') return roots;
104
-
105
- for (const pathItem of Object.values(paths)) {
106
- if (!pathItem || typeof pathItem !== 'object') continue;
107
- for (const op of Object.values(pathItem)) {
108
- if (!op || typeof op !== 'object') continue;
109
-
110
- // requestBody (LD+JSON only)
111
- const rbSchema = getContentSchema(op.requestBody?.content, wantedLower);
112
- if (rbSchema) roots.push(rbSchema);
113
-
114
- // responses 2xx (LD+JSON only)
115
- const responses = op.responses;
116
- if (!responses || typeof responses !== 'object') continue;
117
- for (const [code, resp] of Object.entries(responses)) {
118
- if (!is2xxStatus(code)) continue;
119
- const schema = getContentSchema(resp?.content, wantedLower);
120
- if (schema) roots.push(schema);
121
- }
122
- }
123
- }
124
-
125
- return roots;
126
- }
127
-
128
- function parseOpenApiSchemaName(originalName) {
129
- const raw = String(originalName || '');
130
- const i = raw.indexOf('-');
131
- let baseRaw = i >= 0 ? raw.slice(0, i) : raw;
132
- let groupRaw = i >= 0 ? raw.slice(i + 1) : '';
133
-
134
- let baseVariant = 'none';
135
- if (/\.jsonld$/i.test(baseRaw)) {
136
- baseVariant = 'jsonld';
137
- baseRaw = baseRaw.replace(/\.jsonld$/i, '');
138
- } else if (/\.multipart$/i.test(baseRaw)) {
139
- baseVariant = 'multipart';
140
- baseRaw = baseRaw.replace(/\.multipart$/i, '');
141
- }
142
-
143
- let isMergePatch = false;
144
- if (/\.jsonmergepatch$/i.test(baseRaw)) {
145
- isMergePatch = true;
146
- baseRaw = baseRaw.replace(/\.jsonmergepatch$/i, '');
147
- }
148
- if (/\.jsonmergepatch$/i.test(groupRaw)) {
149
- isMergePatch = true;
150
- groupRaw = groupRaw.replace(/\.jsonmergepatch$/i, '');
151
- }
152
-
153
- return {
154
- originalName: raw,
155
- basePath: baseRaw,
156
- groupPath: groupRaw,
157
- baseVariant,
158
- isMergePatch,
159
- key: `${baseRaw.toLowerCase()}|${groupRaw.toLowerCase()}`,
160
- };
161
- }
162
-
163
- function pascalizeTokens(tokens) {
164
- return tokens
165
- .filter(Boolean)
166
- .map((t) => String(t).replace(/^[^A-Za-z0-9]+|[^A-Za-z0-9]+$/g, ''))
167
- .filter(Boolean)
168
- .map((t) => t.charAt(0).toUpperCase() + t.slice(1))
169
- .join('');
170
- }
171
-
172
- function baseTsNameFromBasePath(basePath) {
173
- const parts = String(basePath || '').split('.').filter(Boolean);
174
- if (parts.length === 0) return 'Model';
175
- const a = pascalizeTokens([parts[0]]);
176
- if (parts.length === 1) return a;
177
- const b = pascalizeTokens([parts[1]]);
178
- if (parts.length === 2 && b.toLowerCase().startsWith(a.toLowerCase())) return b;
179
- return parts.map((p) => pascalizeTokens([p])).join('');
180
- }
25
+ /**
26
+ * @typedef {{
27
+ * name: string,
28
+ * props: ModelProp[],
29
+ * imports: string[],
30
+ * extendsTypes: string[],
31
+ * extendsItem?: boolean,
32
+ * }} ModelDefinition
33
+ */
181
34
 
182
- function groupTsNameFromGroupPath(groupPath) {
183
- const tokens = String(groupPath || '').split(/[._:]+/g).filter(Boolean);
184
- return pascalizeTokens(tokens);
35
+ function ensureNullable(ts) {
36
+ return /\|\s*null\b/.test(ts) ? ts : `${ts} | null`;
185
37
  }
186
38
 
187
- function stableUnion(types) {
188
- const uniq = [...new Set(types.filter(Boolean))];
189
- uniq.sort((a, b) => a.localeCompare(b));
190
- return uniq.join(' | ') || 'any';
39
+ function normalizeRequiredMode(value) {
40
+ const v = String(value ?? '').trim().toLowerCase();
41
+ if (!v) return 'all';
42
+ if (v === 'spec') return 'spec';
43
+ if (v === 'all') return 'all';
44
+ return 'all';
191
45
  }
192
46
 
193
47
  /**
@@ -197,250 +51,189 @@ function stableUnion(types) {
197
51
  * @returns {{ models: ModelDefinition[] }}
198
52
  */
199
53
  export function buildModelsFromOpenAPI(spec, options) {
200
- const opts = { requiredMode: 'all-optional', ...(options || {}) };
54
+ const opts = { requiredMode: 'all', ...(options || {}) };
201
55
  const schemas = spec?.components?.schemas || {};
202
- const preset = opts.preset ?? 'all';
56
+ const formatsOpt = Array.isArray(opts.formats) ? opts.formats : [];
57
+ const formats = formatsOpt.length > 0 ? formatsOpt : ['application/ld+json'];
58
+ const requiredMode = normalizeRequiredMode(opts.requiredMode);
203
59
 
204
60
  /** @type {ModelDefinition[]} */
205
61
  const models = [];
206
62
 
207
- if (preset !== 'native') {
208
- const schemaNames = applySchemaNameFilters(filterSchemaNames(schemas, opts), opts);
209
- const nameMap = buildNameMap(schemaNames, schemas, preset);
210
- const allSanitizedNames = new Set([...nameMap.values()]);
211
-
212
- for (const originalName of schemaNames) {
213
- const raw = schemas[originalName];
214
- const sanitized = nameMap.get(originalName);
215
- if (!sanitized) continue;
216
-
217
- /** @type {string[]} */
218
- const extendsTypes = [];
219
-
220
- let effective = raw;
221
- if (raw?.allOf) {
222
- const merged = mergeAllOf(raw.allOf);
223
- if (merged) {
224
- effective = merged;
225
- } else {
226
- // Non-flattenable allOf (refs, mixed forms): keep inline object props and model refs as extends.
227
- const props = {};
228
- const required = new Set();
229
- const ext = new Set();
230
-
231
- for (const part of raw.allOf) {
232
- if (part?.$ref) {
233
- const orig = schemaNameFromRef(part.$ref);
234
- const refName = orig ? nameMap.get(orig) : undefined;
235
- if (refName && refName !== sanitized && allSanitizedNames.has(refName)) ext.add(refName);
236
- continue;
237
- }
238
- const isObj = part?.type === 'object' || isObject(part?.properties);
239
- if (!isObj) continue;
240
- Object.assign(props, part.properties || {});
241
- for (const r of part.required || []) required.add(r);
242
- }
243
-
244
- extendsTypes.push(...[...ext].sort((a, b) => a.localeCompare(b)));
245
- effective = { type: 'object', properties: props, required: [...required] };
246
- }
247
- }
248
-
249
- const isObjectLike = effective?.type === 'object' || isObject(effective?.properties) || extendsTypes.length > 0;
250
- if (!isObjectLike) continue;
251
-
252
- const required = new Set(effective.required || []);
253
- const deps = new Set();
254
-
255
- for (const e of extendsTypes) deps.add(e);
256
-
257
- const props = Object.entries(effective.properties || {}).map(([propName, prop]) => {
258
- const baseType = tsTypeOf(prop, deps, nameMap, { requiredMode: opts.requiredMode ?? 'all-optional', forceNullable: false });
259
- const type = withNullable(baseType, prop);
260
- return {
261
- name: propName,
262
- tsKey: quoteKeyIfNeeded(propName),
263
- type,
264
- optional: opts.requiredMode === 'spec' ? !required.has(propName) : true,
265
- };
266
- });
267
-
268
- const imports = [...deps]
269
- .filter((n) => n !== sanitized)
270
- .filter((n) => allSanitizedNames.has(n))
271
- .sort((a, b) => a.localeCompare(b));
63
+ // Contract-driven (formats): keep only schemas used by selected media types in real endpoints.
64
+ const normalizedFormats = [];
65
+ for (const f of formats) {
66
+ const canon = canonicalizeMediaType(f);
67
+ if (!canon) continue;
68
+ if (!normalizedFormats.includes(canon)) normalizedFormats.push(canon);
69
+ }
70
+ if (normalizedFormats.length === 0) normalizedFormats.push('application/ld+json');
272
71
 
273
- models.push({name: sanitized, props, imports, extendsTypes});
274
- }
72
+ /** @type {Map<string, Set<string>>} */
73
+ const usedByFormat = new Map(); // formatLower -> Set<schemaName>
275
74
 
276
- return { models };
75
+ for (const fmt of normalizedFormats) {
76
+ usedByFormat.set(fmt, collectUsedSchemasForMediaType(spec, { mediaType: fmt }));
277
77
  }
278
78
 
279
- // preset=native (v2): contract-driven (paths), LD+JSON, groups preserved, jsonld/non-jsonld merged.
280
- const roots = listNativeRootsFromPaths(spec, { mediaType: 'application/ld+json' });
281
- const initialRefs = new Set();
282
- for (const r of roots) collectRefsFromSchema(r, initialRefs);
283
-
284
- const usedSchemaNames = new Set();
285
- const queue = [...initialRefs];
286
- while (queue.length) {
287
- const name = queue.pop();
288
- if (!name || usedSchemaNames.has(name)) continue;
289
- usedSchemaNames.add(name);
290
- const schema = schemas[name];
291
- if (!schema) continue;
292
- const refs = new Set();
293
- collectRefsFromSchema(schema, refs);
294
- for (const ref of refs) if (ref && !usedSchemaNames.has(ref)) queue.push(ref);
79
+ /** @type {Map<string, Set<string>>} */
80
+ const schemaToFormats = new Map();
81
+ for (const [fmt, names] of usedByFormat.entries()) {
82
+ for (const name of names) {
83
+ if (!name) continue;
84
+ const set = schemaToFormats.get(name) || new Set();
85
+ set.add(fmt);
86
+ schemaToFormats.set(name, set);
87
+ }
295
88
  }
296
89
 
297
- // Group and merge by (basePath, groupPath), ignoring variants.
298
- const buckets = new Map();
299
- for (const originalName of [...usedSchemaNames].sort((a, b) => a.localeCompare(b))) {
300
- if (!originalName) continue;
301
- if (/^Hydra/i.test(originalName)) continue; // technical
302
-
303
- const parsed = parseOpenApiSchemaName(originalName);
304
- if (!parsed.basePath) continue;
305
-
306
- // User include/exclude filters (apply to canonical name and to raw variants)
307
- const canonical = parsed.groupPath ? `${parsed.basePath}-${parsed.groupPath}` : parsed.basePath;
308
- const fulls = [originalName, canonical];
309
- const include = Array.isArray(opts.includeSchemaNames) ? opts.includeSchemaNames : [];
310
- const exclude = Array.isArray(opts.excludeSchemaNames) ? opts.excludeSchemaNames : [];
311
-
312
- const matches = (val, rule) => {
313
- if (rule instanceof RegExp) return rule.test(val);
314
- if (typeof rule === 'function') return !!rule(val);
315
- if (typeof rule === 'string' && rule.length) return val.includes(rule);
316
- return false;
317
- };
318
-
319
- const hasInclude = include.length > 0;
320
- const included = !hasInclude || include.some((r) => fulls.some((v) => matches(v, r)));
321
- const excluded = exclude.some((r) => fulls.some((v) => matches(v, r)));
322
- if (!included || excluded) continue;
323
-
324
- const bucket = buckets.get(parsed.key) || { basePath: parsed.basePath, groupPath: parsed.groupPath, entries: [] };
325
- bucket.entries.push(parsed);
326
- buckets.set(parsed.key, bucket);
327
- }
90
+ const include = Array.isArray(opts.includeSchemaNames) ? opts.includeSchemaNames : [];
91
+ const exclude = Array.isArray(opts.excludeSchemaNames) ? opts.excludeSchemaNames : [];
92
+ const matches = (val, rule) => {
93
+ if (rule instanceof RegExp) return rule.test(val);
94
+ if (typeof rule === 'function') return !!rule(val);
95
+ if (typeof rule === 'string' && rule.length) return val.includes(rule);
96
+ return false;
97
+ };
328
98
 
329
- // Stable bucket ordering
330
- const bucketList = [...buckets.values()].sort((a, b) => {
331
- const ka = `${a.basePath}-${a.groupPath}`.toLowerCase();
332
- const kb = `${b.basePath}-${b.groupPath}`.toLowerCase();
333
- return ka.localeCompare(kb);
99
+ const allUsed = [...schemaToFormats.keys()]
100
+ .filter(Boolean)
101
+ .filter((n) => !/^Hydra/i.test(n))
102
+ .sort((a, b) => a.localeCompare(b))
103
+ .filter((originalName) => {
104
+ const parsed = parseOpenApiSchemaName(originalName);
105
+ const canonical = parsed.groupPath ? `${parsed.basePath}-${parsed.groupPath}` : parsed.basePath;
106
+ const fulls = [originalName, canonical];
107
+
108
+ const hasInclude = include.length > 0;
109
+ const included = !hasInclude || include.some((r) => fulls.some((v) => matches(v, r)));
110
+ const excluded = exclude.some((r) => fulls.some((v) => matches(v, r)));
111
+ return included && !excluded;
112
+ });
113
+
114
+ // Ensure filters never break compilation: always keep transitive deps of selected schemas.
115
+ const candidatesSet = include.length > 0 || exclude.length > 0 ? expandWithSchemaDependencies(allUsed, schemas) : new Set(allUsed);
116
+
117
+ const candidates = [...candidatesSet]
118
+ .filter((n) => !!n)
119
+ .filter((n) => !/^Hydra/i.test(n))
120
+ .sort((a, b) => a.localeCompare(b));
121
+
122
+ // Assign a preferred format to each schema (used for naming + JSON-LD behavior).
123
+ const formatIndex = new Map(normalizedFormats.map((f, i) => [f, i]));
124
+ const schemaEntries = candidates.map((schemaName) => {
125
+ const fmts = schemaToFormats.get(schemaName) || new Set();
126
+ const best = [...fmts].sort((a, b) => (formatIndex.get(a) ?? 999) - (formatIndex.get(b) ?? 999))[0] || normalizedFormats[0];
127
+ return { schemaName, preferredFormat: best, preferredFormatIndex: formatIndex.get(best) ?? 0 };
334
128
  });
129
+ const preferredFormatBySchema = new Map(schemaEntries.map((e) => [e.schemaName, e.preferredFormat]));
130
+
131
+ // Compute desired TS names (format-agnostic), then disambiguate collisions by preferred format.
132
+ const byDesired = new Map(); // desired -> entries[]
133
+ for (const e of schemaEntries) {
134
+ const parsed = parseOpenApiSchemaName(e.schemaName);
135
+ if (parsed.isMergePatch) continue; // never generate patch models
136
+ const baseTs = baseTsNameFromBasePath(parsed.basePath);
137
+ const groupTs = groupTsNameFromGroupPath(parsed.groupPath);
138
+ const desired = `${baseTs}${groupTs}` || 'Model';
139
+ const list = byDesired.get(desired) || [];
140
+ list.push({ ...e, desired });
141
+ byDesired.set(desired, list);
142
+ }
335
143
 
336
- // Compute TS names (unique) and build originalName -> tsName map (all variants in a bucket).
144
+ // Assign unique TS names and build schemaName -> tsName map.
337
145
  const usedTsNames = new Set();
338
146
  const nameMap = new Map();
339
- for (const b of bucketList) {
340
- const baseTs = baseTsNameFromBasePath(b.basePath);
341
- const groupTs = groupTsNameFromGroupPath(b.groupPath);
342
- const desired = `${baseTs}${groupTs}` || 'Model';
343
- const tsName = sanitizeTypeName(desired, usedTsNames);
344
- b.tsName = tsName;
345
- for (const e of b.entries) {
346
- nameMap.set(e.originalName, tsName);
147
+ for (const [desired, list] of [...byDesired.entries()].sort((a, b) => a[0].localeCompare(b[0]))) {
148
+ list.sort((a, b) => {
149
+ const da = a.preferredFormatIndex - b.preferredFormatIndex;
150
+ if (da !== 0) return da;
151
+ return a.schemaName.localeCompare(b.schemaName);
152
+ });
153
+
154
+ for (let i = 0; i < list.length; i++) {
155
+ const entry = list[i];
156
+ const withFmt = i === 0 ? desired : `${desired}${formatSuffixToken(entry.preferredFormat)}`;
157
+ const tsName = sanitizeTypeName(withFmt, usedTsNames);
158
+ nameMap.set(entry.schemaName, tsName);
347
159
  }
348
160
  }
349
- const allSanitizedNames = new Set(bucketList.map((b) => b.tsName));
161
+ const allSanitizedNames = new Set([...nameMap.values()]);
350
162
 
351
- // Generate merged models per bucket.
352
- for (const b of bucketList) {
353
- const sanitized = b.tsName;
163
+ for (const schemaName of candidates) {
164
+ const sanitized = nameMap.get(schemaName);
354
165
  if (!sanitized) continue;
355
166
 
167
+ const raw = schemas[schemaName];
168
+ if (!raw) continue;
169
+
170
+ const preferredFormat = preferredFormatBySchema.get(schemaName) ?? normalizedFormats[0];
171
+ const isJsonLd = canonicalizeMediaType(preferredFormat) === 'application/ld+json';
172
+ const forceNullable = requiredMode !== 'spec';
173
+
174
+ /** @type {string[]} */
175
+ const extendsTypes = [];
356
176
  const deps = new Set();
357
- const extendsSet = new Set();
358
- const propTypes = new Map(); // propName -> Set<tsType>
359
-
360
- // Choose candidates: jsonld + base (no multipart, no merge-patch)
361
- const candidates = b.entries
362
- .filter((e) => !e.isMergePatch)
363
- .filter((e) => e.baseVariant !== 'multipart')
364
- .sort((a, b2) => {
365
- const score = (e) => (e.baseVariant === 'jsonld' ? 2 : 1);
366
- const sa = score(a), sb = score(b2);
367
- if (sb !== sa) return sb - sa;
368
- return a.originalName.localeCompare(b2.originalName);
369
- });
370
177
 
371
- for (const e of candidates) {
372
- const raw = schemas[e.originalName];
373
- if (!raw) continue;
374
-
375
- let effective = raw;
376
- /** @type {string[]} */
377
- const extendsTypes = [];
378
-
379
- if (raw?.allOf) {
380
- const merged = mergeAllOf(raw.allOf);
381
- if (merged) {
382
- effective = merged;
383
- } else {
384
- const props = {};
385
- const ext = new Set();
386
- for (const part of raw.allOf) {
387
- if (part?.$ref) {
388
- const orig = schemaNameFromRef(part.$ref);
389
- const refName = orig ? nameMap.get(orig) || orig : undefined;
390
- if (refName && refName !== sanitized && allSanitizedNames.has(refName)) ext.add(refName);
391
- continue;
392
- }
393
- const isObj = part?.type === 'object' || isObject(part?.properties);
394
- if (!isObj) continue;
395
- Object.assign(props, part.properties || {});
178
+ let effective = raw;
179
+ if (raw?.allOf) {
180
+ const merged = mergeAllOf(raw.allOf);
181
+ if (merged) {
182
+ effective = merged;
183
+ } else {
184
+ // Non-flattenable allOf: keep inline object props + other model refs as extends.
185
+ const props = {};
186
+ const required = new Set();
187
+ const ext = new Set();
188
+
189
+ for (const part of raw.allOf) {
190
+ if (part?.$ref) {
191
+ const orig = schemaNameFromRef(part.$ref);
192
+ if (!orig) continue;
193
+ if (/^Hydra/i.test(orig)) continue;
194
+ const refName = orig ? nameMap.get(orig) || orig : undefined;
195
+ if (refName && refName !== sanitized && allSanitizedNames.has(refName)) ext.add(refName);
196
+ continue;
396
197
  }
397
- extendsTypes.push(...[...ext].sort((a, b3) => a.localeCompare(b3)));
398
- effective = { type: 'object', properties: props };
198
+ const isObj = part?.type === 'object' || isObject(part?.properties);
199
+ if (!isObj) continue;
200
+ Object.assign(props, part.properties || {});
201
+ for (const r of part.required || []) required.add(r);
399
202
  }
400
- }
401
203
 
402
- const isObjectLike = effective?.type === 'object' || isObject(effective?.properties) || extendsTypes.length > 0;
403
- if (!isObjectLike) continue;
404
-
405
- for (const ex of extendsTypes) extendsSet.add(ex);
406
-
407
- for (const [propName, prop] of Object.entries(effective.properties || {})) {
408
- const baseType = tsTypeOf(prop, deps, nameMap, { requiredMode: 'all-optional', forceNullable: true });
409
- const existing = propTypes.get(propName) || new Set();
410
- existing.add(baseType);
411
- propTypes.set(propName, existing);
204
+ extendsTypes.push(...[...ext].sort((a, b) => a.localeCompare(b)));
205
+ effective = { type: 'object', properties: props, required: [...required] };
412
206
  }
413
207
  }
414
208
 
415
- const extendsTypes = [...extendsSet]
416
- .filter((n) => n !== sanitized)
417
- .filter((n) => allSanitizedNames.has(n))
418
- .sort((a, b2) => a.localeCompare(b2));
209
+ const isObjectLike = effective?.type === 'object' || isObject(effective?.properties) || extendsTypes.length > 0;
210
+ if (!isObjectLike) continue;
419
211
 
420
- // Imports are any referenced model types (+ extends), excluding self.
421
212
  for (const e of extendsTypes) deps.add(e);
422
- const imports = [...deps]
423
- .filter((n) => n !== sanitized)
424
- .filter((n) => allSanitizedNames.has(n))
425
- .sort((a, b2) => a.localeCompare(b2));
426
213
 
427
- const props = [...propTypes.keys()]
428
- .sort((a, b2) => a.localeCompare(b2))
429
- .map((propName) => {
430
- const union = stableUnion([...propTypes.get(propName)]);
431
- const type = /\|\s*null\b/.test(union) ? union : `${union} | null`;
214
+ const required = new Set(effective.required || []);
215
+ const props = Object.entries(effective.properties || {})
216
+ .filter(([propName]) => {
217
+ if (!isJsonLd) return true;
218
+ return propName !== '@id' && propName !== '@type' && propName !== '@context';
219
+ })
220
+ .map(([propName, prop]) => {
221
+ const baseType = tsTypeOf(prop, deps, nameMap, { requiredMode });
222
+ const type = forceNullable ? ensureNullable(baseType) : withNullable(baseType, prop);
432
223
  return {
433
224
  name: propName,
434
225
  tsKey: quoteKeyIfNeeded(propName),
435
226
  type,
436
- optional: true,
227
+ optional: requiredMode === 'spec' ? !required.has(propName) : true,
437
228
  };
438
229
  });
439
230
 
440
- // Skip empty models (no props + no extends): avoid polluting exports.
441
- if (props.length === 0 && extendsTypes.length === 0) continue;
231
+ const imports = [...deps]
232
+ .filter((n) => n !== sanitized)
233
+ .filter((n) => allSanitizedNames.has(n))
234
+ .sort((a, b) => a.localeCompare(b));
442
235
 
443
- models.push({ name: sanitized, props, imports, extendsTypes });
236
+ models.push({ name: sanitized, props, imports, extendsTypes, extendsItem: isJsonLd });
444
237
  }
445
238
 
446
239
  return { models };
@@ -13,10 +13,11 @@ export function isObject(v) {
13
13
  /**
14
14
  * Extrait le nom du schéma depuis une référence $ref
15
15
  * @param {string} $ref
16
+ * @returns {string|undefined}
16
17
  */
17
18
  export function schemaNameFromRef($ref) {
18
19
  const m = $ref.match(/#\/components\/schemas\/(.+)$/);
19
- return m ? m[1] : $ref;
20
+ return m ? m[1] : undefined;
20
21
  }
21
22
 
22
23
  /**
@@ -31,7 +32,7 @@ export function mergeAllOf(schemas) {
31
32
  for (const s of schemas) {
32
33
  if (s.$ref) {
33
34
  const ref = schemaNameFromRef(s.$ref);
34
- if (HYDRA_BASE_RE.test(ref)) {
35
+ if (ref && HYDRA_BASE_RE.test(ref)) {
35
36
  continue;
36
37
  }
37
38
  return null;
@@ -46,101 +47,3 @@ export function mergeAllOf(schemas) {
46
47
  out.required = [...new Set(out.required)];
47
48
  return out;
48
49
  }
49
-
50
- /**
51
- * Liste les noms de schémas dans `components.schemas`.
52
- * La sélection/filtres est gérée par `applySchemaNameFilters`.
53
- * @param {Record<string, any>} schemas
54
- * @returns {string[]}
55
- */
56
- export function filterSchemaNames(schemas, cfg) {
57
- return Object.keys(schemas).sort((a, b) => a.localeCompare(b));
58
- }
59
-
60
- /**
61
- * @param {unknown} rule
62
- * @returns {rule is ((name: string) => boolean)}
63
- */
64
- function isRuleFn(rule) {
65
- return typeof rule === 'function';
66
- }
67
-
68
- /**
69
- * @param {string} name
70
- * @param {RegExp|string|((name: string) => boolean)} rule
71
- */
72
- function matchesRule(name, rule) {
73
- if (rule instanceof RegExp) return rule.test(name);
74
- if (isRuleFn(rule)) return !!rule(name);
75
- if (typeof rule === 'string' && rule.length) return name.includes(rule);
76
- return false;
77
- }
78
-
79
- /**
80
- * Applique un preset de génération et/ou des règles include/exclude.
81
- *
82
- * - `preset: "all"`: ne filtre rien (comportement par défaut)
83
- * - `preset: "native"`: retire les schémas "techniques" (Hydra*, jsonMergePatch…)
84
- *
85
- * Les règles `includeSchemaNames` / `excludeSchemaNames` s'appliquent sur les noms
86
- * de schémas OpenAPI (ex: "User-user.read", "Conversation.jsonMergePatch", …).
87
- *
88
- * @param {string[]} schemaNames
89
- * @param {{
90
- * preset?: 'all'|'native',
91
- * includeSchemaNames?: Array<RegExp|string|((name: string) => boolean)>,
92
- * excludeSchemaNames?: Array<RegExp|string|((name: string) => boolean)>,
93
- * }} [cfg]
94
- */
95
- export function applySchemaNameFilters(schemaNames, cfg) {
96
- const preset = cfg?.preset ?? 'all';
97
-
98
- /** @type {Array<RegExp|string|((name: string) => boolean)>} */
99
- const include = Array.isArray(cfg?.includeSchemaNames) ? cfg.includeSchemaNames : [];
100
- /** @type {Array<RegExp|string|((name: string) => boolean)>} */
101
- const exclude = Array.isArray(cfg?.excludeSchemaNames) ? [...cfg.excludeSchemaNames] : [];
102
-
103
- // 1) include/exclude user filters (always applied first)
104
- const hasInclude = include.length > 0;
105
- let out = schemaNames.filter((name) => {
106
- if (hasInclude && !include.some((r) => matchesRule(name, r))) return false;
107
- if (exclude.some((r) => matchesRule(name, r))) return false;
108
- return true;
109
- });
110
-
111
- // 2) preset filters
112
- if (preset === 'native') {
113
- out = out
114
- .filter((n) => !/^Hydra/i.test(n))
115
- // API Platform adds a technical "Multipart" schema for multipart/form-data request bodies (file uploads).
116
- // It's not a resource model and tends to pollute the generated "native" model list.
117
- .filter((n) => !/^Multipart(?:$|[.-])/i.test(n))
118
- .filter((n) => !/jsonMergePatch/i.test(n))
119
- .filter((n) => !/\.jsonld\b/i.test(n))
120
- .filter((n) => !/\.jsonapi\b/i.test(n));
121
-
122
- // 3) collapse group variants: keep one schema per base (before `-`)
123
- const score = (name) => {
124
- if (!name.includes('-')) return 100;
125
- const group = name.split('-').slice(1).join('-');
126
- if (/(^|[.\-])read($|[.\-])/i.test(group)) return 50;
127
- return 0;
128
- };
129
- const byBase = new Map();
130
- for (const name of out) {
131
- const base = name.split('-')[0];
132
- const prev = byBase.get(base);
133
- if (!prev) {
134
- byBase.set(base, name);
135
- continue;
136
- }
137
- const a = prev, b = name;
138
- const sa = score(a), sb = score(b);
139
- if (sb > sa) byBase.set(base, b);
140
- else if (sb === sa && b.localeCompare(a) < 0) byBase.set(base, b);
141
- }
142
- out = [...byBase.values()].sort((a, b) => a.localeCompare(b));
143
- }
144
-
145
- return out;
146
- }
@@ -1,9 +1,11 @@
1
+ {{#if extendsItem}}
1
2
  import type { Item } from '../lib/ports/resource-repository.port';
3
+ {{/if}}
2
4
  {{#each imports}}
3
5
  import type { {{this}} } from './{{this}}';
4
6
  {{/each}}
5
7
 
6
- export interface {{name}} extends Item{{#each extendsTypes}}, {{this}}{{/each}} {
8
+ export interface {{name}}{{#if extendsItem}} extends Item{{#each extendsTypes}}, {{this}}{{/each}}{{else}}{{#each extendsTypes}}{{#if @first}} extends {{this}}{{else}}, {{this}}{{/if}}{{/each}}{{/if}} {
7
9
  {{#each props}}
8
10
  {{{tsKey}}}{{#if optional}}?{{/if}}: {{type}};
9
11
  {{/each}}
@@ -16,16 +16,19 @@ export function withNullable(ts, schema) {
16
16
  * @param {any} schema
17
17
  * @param {Set<string>} deps
18
18
  * @param {Map<string,string>} nameMap
19
- * @param {{ requiredMode?: 'all-optional'|'spec', forceNullable?: boolean }} [cfg]
19
+ * @param {{ requiredMode?: 'all'|'spec' }} [cfg]
20
20
  * @returns {string}
21
21
  */
22
22
  export function tsTypeOf(schema, deps, nameMap, cfg) {
23
23
  if (!schema) return 'any';
24
- const requiredMode = cfg?.requiredMode ?? 'all-optional';
25
- const forceNullable = !!cfg?.forceNullable;
24
+ const requiredMode = cfg?.requiredMode ?? 'all';
25
+ const forceNullable = requiredMode !== 'spec';
26
26
 
27
27
  if (schema.$ref) {
28
28
  const orig = schemaNameFromRef(schema.$ref);
29
+ if (!orig) return 'any';
30
+ if (/jsonMergePatch/i.test(orig)) return 'any';
31
+ if (/^Hydra/i.test(orig)) return 'any';
29
32
  const name = nameMap.get(orig) || orig;
30
33
  deps.add(name);
31
34
  return name;
@@ -1,26 +0,0 @@
1
- /**
2
- * Construit un label "tokenisé" (sans `.` ni `-`) à partir d'un nom de schéma OpenAPI.
3
- * Le label est ensuite passé à `sanitizeTypeName()` pour produire un identifiant TS stable.
4
- *
5
- * @param {string} original
6
- * @param {{ preset: 'all'|'native' }} cfg
7
- */
8
- export function schemaLabel(original, cfg) {
9
- const preset = cfg?.preset ?? 'all';
10
- if (preset === 'native') {
11
- // En native, on vise un nom "entity-like" : on ignore le groupe (après `-`) si présent.
12
- // Les variantes .jsonld/.jsonapi/jsonMergePatch sont censées être filtrées par le preset.
13
- return String(original).split('-')[0];
14
- }
15
-
16
- // En all, on encode tout : `.` et `-` deviennent des séparateurs de tokens.
17
- const rawTokens = String(original).split(/[.-]/g).filter(Boolean);
18
- const tokens = rawTokens.map((t) => {
19
- const lower = t.toLowerCase();
20
- if (lower === 'jsonld') return 'Jsonld';
21
- if (lower === 'jsonapi') return 'Jsonapi';
22
- if (lower === 'jsonmergepatch' || t === 'jsonMergePatch') return 'JsonMergePatch';
23
- return t;
24
- });
25
- return tokens.join(' ');
26
- }