@obsidiane/meridiane 1.0.0 → 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 +11 -4
- package/cli.js +7 -11
- package/package.json +1 -1
- package/tools/build.js +6 -5
- package/tools/core/generate.js +15 -7
- package/tools/core/paths.js +0 -7
- package/tools/dev.js +6 -5
- package/tools/generator/models/oas-contract.js +159 -0
- package/tools/generator/models/oas-schema-name.js +71 -0
- package/tools/generator/models/openapi-to-models.js +165 -60
- package/tools/generator/models/schema-utils.js +3 -97
- package/tools/generator/models/templates/model.hbs +3 -1
- package/tools/generator/models/type-resolver.js +24 -13
- package/tools/generator/models/naming.js +0 -26
package/README.md
CHANGED
|
@@ -21,17 +21,24 @@ 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> [--
|
|
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> [--
|
|
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 `--
|
|
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)
|
|
39
|
+
- groups conservés
|
|
40
|
+
- pas de modèles `*.jsonMergePatch` (PATCH = `Partial<...>`)
|
|
41
|
+
- noms normalisés (pas de suffixe `.jsonld`)
|
|
35
42
|
- utiliser `--no-models` si vous voulez uniquement le runtime (pas besoin de `--spec`)
|
|
36
43
|
- laisser le registry à la CI (`.npmrc`, variables d’environnement, `npm publish --registry …`)
|
|
37
44
|
|
|
@@ -41,7 +48,7 @@ Note (repo Meridiane) :
|
|
|
41
48
|
## Exemple CI (générer + build + publish)
|
|
42
49
|
|
|
43
50
|
```bash
|
|
44
|
-
npx -y @obsidiane/meridiane@0.1.0 build @acme/backend-bridge --version 0.1.0 --spec https://staging.example/api/docs.json --
|
|
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
|
|
45
52
|
npm publish dist/backend-bridge
|
|
46
53
|
```
|
|
47
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(
|
|
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
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 {
|
|
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
|
|
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
|
|
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} |
|
|
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
|
-
|
|
69
|
+
formats: effectiveFormats,
|
|
69
70
|
include,
|
|
70
71
|
exclude,
|
|
71
72
|
debug,
|
package/tools/core/generate.js
CHANGED
|
@@ -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,
|
|
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)
|
|
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
|
-
|
|
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
|
|
102
|
-
*
|
|
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
|
-
|
|
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,
|
|
158
|
+
await generateModels({ cwd, libName, spec, requiredMode, formats, include, exclude, debug });
|
|
151
159
|
log?.success?.('génération terminée');
|
|
152
160
|
}
|
package/tools/core/paths.js
CHANGED
|
@@ -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 {
|
|
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
|
|
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
|
|
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} |
|
|
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
|
-
|
|
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,53 +1,47 @@
|
|
|
1
1
|
import { sanitizeTypeName, quoteKeyIfNeeded } from './utils.js';
|
|
2
|
-
import {
|
|
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
|
|
9
|
-
*
|
|
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
|
-
*
|
|
17
|
-
*
|
|
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
24
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
25
|
+
/**
|
|
26
|
+
* @typedef {{
|
|
27
|
+
* name: string,
|
|
28
|
+
* props: ModelProp[],
|
|
29
|
+
* imports: string[],
|
|
30
|
+
* extendsTypes: string[],
|
|
31
|
+
* extendsItem?: boolean,
|
|
32
|
+
* }} ModelDefinition
|
|
33
|
+
*/
|
|
35
34
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
}
|
|
35
|
+
function ensureNullable(ts) {
|
|
36
|
+
return /\|\s*null\b/.test(ts) ? ts : `${ts} | null`;
|
|
37
|
+
}
|
|
49
38
|
|
|
50
|
-
|
|
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';
|
|
51
45
|
}
|
|
52
46
|
|
|
53
47
|
/**
|
|
@@ -57,23 +51,129 @@ function buildNameMap(schemaNames, schemas, preset) {
|
|
|
57
51
|
* @returns {{ models: ModelDefinition[] }}
|
|
58
52
|
*/
|
|
59
53
|
export function buildModelsFromOpenAPI(spec, options) {
|
|
60
|
-
const opts = { requiredMode: 'all
|
|
54
|
+
const opts = { requiredMode: 'all', ...(options || {}) };
|
|
61
55
|
const schemas = spec?.components?.schemas || {};
|
|
62
|
-
const
|
|
63
|
-
const
|
|
64
|
-
const
|
|
65
|
-
const allSanitizedNames = new Set([...nameMap.values()]);
|
|
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);
|
|
66
59
|
|
|
67
60
|
/** @type {ModelDefinition[]} */
|
|
68
61
|
const models = [];
|
|
69
62
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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');
|
|
71
|
+
|
|
72
|
+
/** @type {Map<string, Set<string>>} */
|
|
73
|
+
const usedByFormat = new Map(); // formatLower -> Set<schemaName>
|
|
74
|
+
|
|
75
|
+
for (const fmt of normalizedFormats) {
|
|
76
|
+
usedByFormat.set(fmt, collectUsedSchemasForMediaType(spec, { mediaType: fmt }));
|
|
77
|
+
}
|
|
78
|
+
|
|
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
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
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
|
+
};
|
|
98
|
+
|
|
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 };
|
|
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
|
+
}
|
|
143
|
+
|
|
144
|
+
// Assign unique TS names and build schemaName -> tsName map.
|
|
145
|
+
const usedTsNames = new Set();
|
|
146
|
+
const nameMap = new Map();
|
|
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);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
const allSanitizedNames = new Set([...nameMap.values()]);
|
|
162
|
+
|
|
163
|
+
for (const schemaName of candidates) {
|
|
164
|
+
const sanitized = nameMap.get(schemaName);
|
|
73
165
|
if (!sanitized) continue;
|
|
74
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
|
+
|
|
75
174
|
/** @type {string[]} */
|
|
76
175
|
const extendsTypes = [];
|
|
176
|
+
const deps = new Set();
|
|
77
177
|
|
|
78
178
|
let effective = raw;
|
|
79
179
|
if (raw?.allOf) {
|
|
@@ -81,7 +181,7 @@ export function buildModelsFromOpenAPI(spec, options) {
|
|
|
81
181
|
if (merged) {
|
|
82
182
|
effective = merged;
|
|
83
183
|
} else {
|
|
84
|
-
// Non-flattenable allOf
|
|
184
|
+
// Non-flattenable allOf: keep inline object props + other model refs as extends.
|
|
85
185
|
const props = {};
|
|
86
186
|
const required = new Set();
|
|
87
187
|
const ext = new Set();
|
|
@@ -89,7 +189,9 @@ export function buildModelsFromOpenAPI(spec, options) {
|
|
|
89
189
|
for (const part of raw.allOf) {
|
|
90
190
|
if (part?.$ref) {
|
|
91
191
|
const orig = schemaNameFromRef(part.$ref);
|
|
92
|
-
|
|
192
|
+
if (!orig) continue;
|
|
193
|
+
if (/^Hydra/i.test(orig)) continue;
|
|
194
|
+
const refName = orig ? nameMap.get(orig) || orig : undefined;
|
|
93
195
|
if (refName && refName !== sanitized && allSanitizedNames.has(refName)) ext.add(refName);
|
|
94
196
|
continue;
|
|
95
197
|
}
|
|
@@ -107,29 +209,32 @@ export function buildModelsFromOpenAPI(spec, options) {
|
|
|
107
209
|
const isObjectLike = effective?.type === 'object' || isObject(effective?.properties) || extendsTypes.length > 0;
|
|
108
210
|
if (!isObjectLike) continue;
|
|
109
211
|
|
|
110
|
-
const required = new Set(effective.required || []);
|
|
111
|
-
const deps = new Set();
|
|
112
|
-
|
|
113
212
|
for (const e of extendsTypes) deps.add(e);
|
|
114
213
|
|
|
115
|
-
const
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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);
|
|
223
|
+
return {
|
|
224
|
+
name: propName,
|
|
225
|
+
tsKey: quoteKeyIfNeeded(propName),
|
|
226
|
+
type,
|
|
227
|
+
optional: requiredMode === 'spec' ? !required.has(propName) : true,
|
|
228
|
+
};
|
|
229
|
+
});
|
|
125
230
|
|
|
126
231
|
const imports = [...deps]
|
|
127
232
|
.filter((n) => n !== sanitized)
|
|
128
233
|
.filter((n) => allSanitizedNames.has(n))
|
|
129
234
|
.sort((a, b) => a.localeCompare(b));
|
|
130
235
|
|
|
131
|
-
models.push({name: sanitized, props, imports, extendsTypes});
|
|
236
|
+
models.push({ name: sanitized, props, imports, extendsTypes, extendsItem: isJsonLd });
|
|
132
237
|
}
|
|
133
238
|
|
|
134
|
-
return {models};
|
|
239
|
+
return { models };
|
|
135
240
|
}
|
|
@@ -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] :
|
|
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,98 +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
|
-
.filter((n) => !/jsonMergePatch/i.test(n))
|
|
116
|
-
.filter((n) => !/\.jsonld\b/i.test(n))
|
|
117
|
-
.filter((n) => !/\.jsonapi\b/i.test(n));
|
|
118
|
-
|
|
119
|
-
// 3) collapse group variants: keep one schema per base (before `-`)
|
|
120
|
-
const score = (name) => {
|
|
121
|
-
if (!name.includes('-')) return 100;
|
|
122
|
-
const group = name.split('-').slice(1).join('-');
|
|
123
|
-
if (/(^|[.\-])read($|[.\-])/i.test(group)) return 50;
|
|
124
|
-
return 0;
|
|
125
|
-
};
|
|
126
|
-
const byBase = new Map();
|
|
127
|
-
for (const name of out) {
|
|
128
|
-
const base = name.split('-')[0];
|
|
129
|
-
const prev = byBase.get(base);
|
|
130
|
-
if (!prev) {
|
|
131
|
-
byBase.set(base, name);
|
|
132
|
-
continue;
|
|
133
|
-
}
|
|
134
|
-
const a = prev, b = name;
|
|
135
|
-
const sa = score(a), sb = score(b);
|
|
136
|
-
if (sb > sa) byBase.set(base, b);
|
|
137
|
-
else if (sb === sa && b.localeCompare(a) < 0) byBase.set(base, b);
|
|
138
|
-
}
|
|
139
|
-
out = [...byBase.values()].sort((a, b) => a.localeCompare(b));
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
return out;
|
|
143
|
-
}
|
|
@@ -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,13 +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'|'spec' }} [cfg]
|
|
19
20
|
* @returns {string}
|
|
20
21
|
*/
|
|
21
|
-
export function tsTypeOf(schema, deps, nameMap) {
|
|
22
|
+
export function tsTypeOf(schema, deps, nameMap, cfg) {
|
|
22
23
|
if (!schema) return 'any';
|
|
24
|
+
const requiredMode = cfg?.requiredMode ?? 'all';
|
|
25
|
+
const forceNullable = requiredMode !== 'spec';
|
|
23
26
|
|
|
24
27
|
if (schema.$ref) {
|
|
25
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';
|
|
26
32
|
const name = nameMap.get(orig) || orig;
|
|
27
33
|
deps.add(name);
|
|
28
34
|
return name;
|
|
@@ -49,9 +55,14 @@ export function tsTypeOf(schema, deps, nameMap) {
|
|
|
49
55
|
else if (t === 'string') parts.add('string');
|
|
50
56
|
else if (t === 'integer' || t === 'number') parts.add('number');
|
|
51
57
|
else if (t === 'boolean') parts.add('boolean');
|
|
52
|
-
else if (t === 'array') parts.add(tsTypeOf({ type: 'array', items: schema.items }, deps, nameMap));
|
|
58
|
+
else if (t === 'array') parts.add(tsTypeOf({ type: 'array', items: schema.items }, deps, nameMap, cfg));
|
|
53
59
|
else if (t === 'object') {
|
|
54
|
-
const objTs = tsTypeOf(
|
|
60
|
+
const objTs = tsTypeOf(
|
|
61
|
+
{ type: 'object', properties: schema.properties, required: schema.required, additionalProperties: schema.additionalProperties },
|
|
62
|
+
deps,
|
|
63
|
+
nameMap,
|
|
64
|
+
cfg
|
|
65
|
+
);
|
|
55
66
|
parts.add(objTs);
|
|
56
67
|
} else parts.add('any');
|
|
57
68
|
}
|
|
@@ -60,13 +71,13 @@ export function tsTypeOf(schema, deps, nameMap) {
|
|
|
60
71
|
|
|
61
72
|
if (schema.allOf) {
|
|
62
73
|
// Aplatir au niveau buildModelsFromOpenAPI; ici on combine si nécessaire
|
|
63
|
-
return schema.allOf.map((s) => tsTypeOf(s, deps, nameMap)).join(' & ');
|
|
74
|
+
return schema.allOf.map((s) => tsTypeOf(s, deps, nameMap, cfg)).join(' & ');
|
|
64
75
|
}
|
|
65
|
-
if (schema.oneOf) return schema.oneOf.map((s) => tsTypeOf(s, deps, nameMap)).join(' | ');
|
|
66
|
-
if (schema.anyOf) return schema.anyOf.map((s) => tsTypeOf(s, deps, nameMap)).join(' | ');
|
|
76
|
+
if (schema.oneOf) return schema.oneOf.map((s) => tsTypeOf(s, deps, nameMap, cfg)).join(' | ');
|
|
77
|
+
if (schema.anyOf) return schema.anyOf.map((s) => tsTypeOf(s, deps, nameMap, cfg)).join(' | ');
|
|
67
78
|
|
|
68
79
|
if (schema.type === 'array') {
|
|
69
|
-
const it = tsTypeOf(schema.items || {}, deps, nameMap);
|
|
80
|
+
const it = tsTypeOf(schema.items || {}, deps, nameMap, cfg);
|
|
70
81
|
return `${it}[]`;
|
|
71
82
|
}
|
|
72
83
|
|
|
@@ -74,14 +85,15 @@ export function tsTypeOf(schema, deps, nameMap) {
|
|
|
74
85
|
const props = schema.properties || {};
|
|
75
86
|
const entries = Object.entries(props);
|
|
76
87
|
const inline = entries.map(([k, v]) => {
|
|
77
|
-
const
|
|
78
|
-
const opt = (schema.required || []).includes(k) ? '' : '?';
|
|
79
|
-
const
|
|
88
|
+
const t0 = tsTypeOf(v, deps, nameMap, cfg);
|
|
89
|
+
const opt = requiredMode === 'spec' ? ((schema.required || []).includes(k) ? '' : '?') : '?';
|
|
90
|
+
const wantsNull = forceNullable || v?.nullable;
|
|
91
|
+
const nullable = wantsNull && !/\|\s*null\b/.test(t0) ? ' | null' : '';
|
|
80
92
|
const key = quoteKeyIfNeeded(k);
|
|
81
|
-
return `${key}${opt}: ${
|
|
93
|
+
return `${key}${opt}: ${t0}${nullable};`;
|
|
82
94
|
});
|
|
83
95
|
if (schema.additionalProperties) {
|
|
84
|
-
const at = tsTypeOf(schema.additionalProperties, deps, nameMap);
|
|
96
|
+
const at = tsTypeOf(schema.additionalProperties, deps, nameMap, cfg);
|
|
85
97
|
inline.push(`[key: string]: ${at};`);
|
|
86
98
|
}
|
|
87
99
|
return `{ ${inline.join(' ')} }`;
|
|
@@ -93,4 +105,3 @@ export function tsTypeOf(schema, deps, nameMap) {
|
|
|
93
105
|
|
|
94
106
|
return 'any';
|
|
95
107
|
}
|
|
96
|
-
|
|
@@ -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
|
-
}
|