@obsidiane/meridiane 0.0.6 → 0.0.10
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 +19 -31
- package/cli.js +64 -44
- package/package.json +3 -7
- package/templates/_lib_template/package.json +2 -1
- package/templates/_lib_template/src/lib/bridge/rest/api-platform.adapter.ts +48 -47
- package/templates/_lib_template/src/lib/bridge/rest/query-builder.ts +29 -29
- package/templates/_lib_template/src/lib/bridge/sse/eventsource-wrapper.ts +50 -54
- package/templates/_lib_template/src/lib/bridge/sse/mercure-topic.mapper.ts +48 -0
- package/templates/_lib_template/src/lib/bridge/sse/mercure-url.builder.ts +7 -7
- package/templates/_lib_template/src/lib/bridge/sse/mercure.adapter.ts +104 -49
- package/templates/_lib_template/src/lib/bridge/sse/ref-count-topic.registry.ts +23 -21
- package/templates/_lib_template/src/lib/bridge.types.ts +33 -0
- package/templates/_lib_template/src/lib/facades/bridge.facade.ts +116 -0
- package/templates/_lib_template/src/lib/facades/facade.factory.ts +21 -19
- package/templates/_lib_template/src/lib/facades/facade.interface.ts +13 -18
- package/templates/_lib_template/src/lib/facades/resource.facade.ts +42 -56
- package/templates/_lib_template/src/lib/interceptors/bridge-debug.interceptor.ts +32 -0
- package/templates/_lib_template/src/lib/interceptors/bridge-defaults.interceptor.ts +53 -0
- package/templates/_lib_template/src/lib/interceptors/content-type.interceptor.ts +36 -37
- package/templates/_lib_template/src/lib/ports/realtime.port.ts +5 -3
- package/templates/_lib_template/src/lib/ports/resource-repository.port.ts +14 -15
- package/templates/_lib_template/src/lib/provide-bridge.ts +122 -19
- package/templates/_lib_template/src/lib/tokens.ts +14 -1
- package/templates/_lib_template/src/lib/utils/url.ts +15 -0
- package/templates/_lib_template/src/models/index.ts +0 -1
- package/templates/_lib_template/src/public-api.ts +3 -2
- package/tools/build.js +104 -0
- package/tools/core/generate.js +152 -0
- package/tools/core/json.js +71 -0
- package/tools/core/lib-name.js +15 -0
- package/tools/core/logger.js +51 -0
- package/tools/core/paths.js +17 -0
- package/tools/core/spec.js +59 -0
- package/tools/core/standalone.js +117 -0
- package/tools/dev.js +218 -0
- package/tools/generator/models/handlebars.js +8 -2
- package/tools/generator/models/naming.js +21 -15
- package/tools/generator/models/openapi-to-models.js +75 -38
- package/tools/generator/models/schema-utils.js +84 -66
- package/tools/generator/models/templates/model.hbs +3 -3
- package/tools/generator/models/utils.js +1 -2
- package/models.config.example.js +0 -24
- package/templates/_lib_template/src/lib/bridge/credentials.policy.ts +0 -9
- package/templates/_lib_template/src/lib/utils/utils.ts +0 -21
- package/templates/_lib_template/tsconfig.spec.json +0 -14
- package/tools/generate-lib.js +0 -136
- package/tools/generate-models.js +0 -121
package/tools/dev.js
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import process from 'node:process';
|
|
3
|
+
import fs from 'node:fs/promises';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { spawn } from 'node:child_process';
|
|
6
|
+
|
|
7
|
+
import { deriveLibName } from './core/lib-name.js';
|
|
8
|
+
import { normalizePreset, splitListArgs } from './core/paths.js';
|
|
9
|
+
import { readOpenApiSpec } from './core/spec.js';
|
|
10
|
+
import { createLogger } from './core/logger.js';
|
|
11
|
+
import { generateBridgeWorkspace } from './core/generate.js';
|
|
12
|
+
import { ensureStandaloneWorkspace, ensureToolchainInstalled } from './core/standalone.js';
|
|
13
|
+
|
|
14
|
+
function run(cmd, args, { cwd, env } = {}) {
|
|
15
|
+
return new Promise((resolve) => {
|
|
16
|
+
const child = spawn(cmd, args, {
|
|
17
|
+
stdio: 'inherit',
|
|
18
|
+
cwd: cwd ?? process.cwd(),
|
|
19
|
+
env: env ?? process.env,
|
|
20
|
+
});
|
|
21
|
+
child.on('exit', (code) => resolve(code ?? 0));
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function pathIsFile(p) {
|
|
26
|
+
try {
|
|
27
|
+
const st = await fs.stat(p);
|
|
28
|
+
return st.isFile();
|
|
29
|
+
} catch {
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function readPackageJsonName(dir) {
|
|
35
|
+
try {
|
|
36
|
+
const raw = await fs.readFile(path.join(dir, 'package.json'), 'utf8');
|
|
37
|
+
const parsed = JSON.parse(raw);
|
|
38
|
+
return typeof parsed?.name === 'string' ? parsed.name : undefined;
|
|
39
|
+
} catch {
|
|
40
|
+
return undefined;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function resolveDevAppRoot(startCwd) {
|
|
45
|
+
if (await pathIsFile(path.join(startCwd, 'package.json'))) return startCwd;
|
|
46
|
+
throw new Error('No package.json found. Run `meridiane dev` from your Angular app root.');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function isMeridianeSandboxApp(appRoot) {
|
|
50
|
+
const name = await readPackageJsonName(appRoot);
|
|
51
|
+
return name === '@obsidiane/meridiane-sandbox';
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function resolveSandboxAppRoot(startCwd) {
|
|
55
|
+
// Allow running from apps/sandbox directly
|
|
56
|
+
if (await isMeridianeSandboxApp(startCwd)) return startCwd;
|
|
57
|
+
|
|
58
|
+
// Allow running from this repo root (monorepo) and target apps/sandbox
|
|
59
|
+
const sandboxApp = path.join(startCwd, 'apps', 'sandbox');
|
|
60
|
+
if (await pathIsFile(path.join(sandboxApp, 'package.json')) && (await isMeridianeSandboxApp(sandboxApp))) {
|
|
61
|
+
return sandboxApp;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
throw new Error('Missing <packageName>. Run from the sandbox app (`apps/sandbox`) or provide a package name.');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function findLatestTgz(distDir) {
|
|
68
|
+
const entries = await fs.readdir(distDir, { withFileTypes: true }).catch(() => []);
|
|
69
|
+
const tgzFiles = [];
|
|
70
|
+
for (const e of entries) {
|
|
71
|
+
if (!e.isFile()) continue;
|
|
72
|
+
if (!e.name.endsWith('.tgz')) continue;
|
|
73
|
+
const full = path.join(distDir, e.name);
|
|
74
|
+
const st = await fs.stat(full);
|
|
75
|
+
tgzFiles.push({ full, mtimeMs: st.mtimeMs });
|
|
76
|
+
}
|
|
77
|
+
tgzFiles.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
78
|
+
return tgzFiles[0]?.full;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function splitPackageName(packageName) {
|
|
82
|
+
const s = String(packageName || '').trim();
|
|
83
|
+
if (!s) throw new Error('Missing packageName');
|
|
84
|
+
if (s.startsWith('@')) {
|
|
85
|
+
const [scope, name] = s.split('/');
|
|
86
|
+
if (!scope || !name) throw new Error(`Invalid packageName: ${packageName}`);
|
|
87
|
+
return { scope, name };
|
|
88
|
+
}
|
|
89
|
+
return { scope: undefined, name: s };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function resolveNodeModulesRoot(startDir) {
|
|
93
|
+
let cur = startDir;
|
|
94
|
+
for (;;) {
|
|
95
|
+
const candidate = path.join(cur, 'node_modules');
|
|
96
|
+
try {
|
|
97
|
+
const st = await fs.stat(candidate);
|
|
98
|
+
if (st.isDirectory()) return candidate;
|
|
99
|
+
} catch {
|
|
100
|
+
// ignore
|
|
101
|
+
}
|
|
102
|
+
const parent = path.dirname(cur);
|
|
103
|
+
if (parent === cur) break;
|
|
104
|
+
cur = parent;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const nm = path.join(startDir, 'node_modules');
|
|
108
|
+
await fs.mkdir(nm, { recursive: true });
|
|
109
|
+
return nm;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function installPackageFromDist({ packageName, distDir, appRoot, log }) {
|
|
113
|
+
const nmRoot = await resolveNodeModulesRoot(appRoot);
|
|
114
|
+
const { scope, name } = splitPackageName(packageName);
|
|
115
|
+
const pkgDir = scope ? path.join(nmRoot, scope, name) : path.join(nmRoot, name);
|
|
116
|
+
|
|
117
|
+
log?.step?.(`installation locale dans node_modules (${scope ? `${scope}/${name}` : name})`);
|
|
118
|
+
|
|
119
|
+
await fs.rm(pkgDir, { recursive: true, force: true });
|
|
120
|
+
await fs.mkdir(path.dirname(pkgDir), { recursive: true });
|
|
121
|
+
|
|
122
|
+
await fs.cp(distDir, pkgDir, {
|
|
123
|
+
recursive: true,
|
|
124
|
+
filter: (src) => !src.endsWith('.tgz'),
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
return pkgDir;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export async function runDev(packageName, opts) {
|
|
131
|
+
const startCwd = process.cwd();
|
|
132
|
+
const debug = !!opts.debug;
|
|
133
|
+
const log = createLogger({ debug });
|
|
134
|
+
|
|
135
|
+
const useSandboxDefaults = packageName === undefined;
|
|
136
|
+
const appRoot = useSandboxDefaults ? await resolveSandboxAppRoot(startCwd) : await resolveDevAppRoot(startCwd);
|
|
137
|
+
|
|
138
|
+
const effectivePackageName = packageName ?? '@obsidiane/bridge-sandbox';
|
|
139
|
+
const libName = deriveLibName(effectivePackageName);
|
|
140
|
+
|
|
141
|
+
const noModels = opts.models === false;
|
|
142
|
+
const preset = normalizePreset(useSandboxDefaults ? (opts.preset ?? 'native') : opts.preset);
|
|
143
|
+
const include = splitListArgs(opts.include);
|
|
144
|
+
const exclude = splitListArgs(opts.exclude);
|
|
145
|
+
|
|
146
|
+
const version = '0.0.0-dev';
|
|
147
|
+
const requiredMode = 'all-optional';
|
|
148
|
+
const specSource = useSandboxDefaults ? (opts.spec ?? 'http://localhost:8000/api/docs.json') : opts.spec;
|
|
149
|
+
|
|
150
|
+
log.title('dev — build standalone + install local');
|
|
151
|
+
log.info(`app: ${appRoot}`);
|
|
152
|
+
log.info(`package: ${effectivePackageName}@${version}`);
|
|
153
|
+
log.info(`lib: ${libName}`);
|
|
154
|
+
if (useSandboxDefaults) log.info('defaults sandbox appliqués');
|
|
155
|
+
if (noModels) log.info('models: désactivés (--no-models)');
|
|
156
|
+
else log.info(`spec: ${specSource} | preset: ${preset}`);
|
|
157
|
+
|
|
158
|
+
const { workspaceRoot, distRoot, npmCacheDir, requireFromWs } = await ensureStandaloneWorkspace({ repoRoot: appRoot, log });
|
|
159
|
+
log.info(`workspace: ${workspaceRoot}`);
|
|
160
|
+
|
|
161
|
+
let spec;
|
|
162
|
+
if (!noModels) {
|
|
163
|
+
log.step('lecture de la spec OpenAPI');
|
|
164
|
+
spec = await readOpenApiSpec(specSource);
|
|
165
|
+
log.success('spec chargée');
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
await ensureToolchainInstalled({ workspaceRoot, requireFromWs, npmCacheDir, log });
|
|
169
|
+
|
|
170
|
+
await generateBridgeWorkspace({
|
|
171
|
+
cwd: workspaceRoot,
|
|
172
|
+
libName,
|
|
173
|
+
packageName: effectivePackageName,
|
|
174
|
+
version,
|
|
175
|
+
noModels,
|
|
176
|
+
spec,
|
|
177
|
+
requiredMode,
|
|
178
|
+
preset,
|
|
179
|
+
include,
|
|
180
|
+
exclude,
|
|
181
|
+
debug,
|
|
182
|
+
log,
|
|
183
|
+
distRoot,
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
const ngPackageJsonPath = path.join(workspaceRoot, 'projects', libName, 'ng-package.json');
|
|
187
|
+
const tsconfigDevPath = path.join(workspaceRoot, 'projects', libName, 'tsconfig.lib.json');
|
|
188
|
+
|
|
189
|
+
log.step('ng-packagr');
|
|
190
|
+
const ngPackagrPath = requireFromWs.resolve('ng-packagr/src/cli/main.js');
|
|
191
|
+
const packagrCode = await run(process.execPath, [ngPackagrPath, '-p', ngPackageJsonPath, '-c', tsconfigDevPath], {
|
|
192
|
+
cwd: workspaceRoot,
|
|
193
|
+
});
|
|
194
|
+
if (packagrCode !== 0) process.exit(packagrCode);
|
|
195
|
+
|
|
196
|
+
const distDir = path.join(distRoot, libName);
|
|
197
|
+
log.step(`npm pack (${path.relative(appRoot, distDir)})`);
|
|
198
|
+
const packCode = await run('npm', ['pack'], {
|
|
199
|
+
cwd: distDir,
|
|
200
|
+
env: {
|
|
201
|
+
...process.env,
|
|
202
|
+
npm_config_cache: npmCacheDir,
|
|
203
|
+
npm_config_update_notifier: 'false',
|
|
204
|
+
npm_config_fund: 'false',
|
|
205
|
+
npm_config_audit: 'false',
|
|
206
|
+
},
|
|
207
|
+
});
|
|
208
|
+
if (packCode !== 0) process.exit(packCode);
|
|
209
|
+
|
|
210
|
+
await findLatestTgz(distDir); // keep producing the tgz in dist for inspection/use
|
|
211
|
+
await installPackageFromDist({ packageName: effectivePackageName, distDir, appRoot, log });
|
|
212
|
+
|
|
213
|
+
log.success('bridge installé (node_modules) ✅');
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
217
|
+
throw new Error('This module is not meant to be executed directly.');
|
|
218
|
+
}
|
|
@@ -2,13 +2,19 @@ import fs from 'node:fs/promises';
|
|
|
2
2
|
import * as path from 'node:path';
|
|
3
3
|
import Handlebars from 'handlebars';
|
|
4
4
|
|
|
5
|
+
const templateCache = new Map();
|
|
6
|
+
|
|
5
7
|
/**
|
|
6
8
|
* Rend un template Handlebars avec un contexte et écrit le fichier.
|
|
7
9
|
* @param {{ templatePath: string, outPath: string, ctx: any }} params
|
|
8
10
|
*/
|
|
9
11
|
export async function renderTemplateToFile({templatePath, outPath, ctx}) {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
+
let tpl = templateCache.get(templatePath);
|
|
13
|
+
if (!tpl) {
|
|
14
|
+
const tplSrc = await fs.readFile(templatePath, 'utf8');
|
|
15
|
+
tpl = Handlebars.compile(tplSrc, {noEscape: true});
|
|
16
|
+
templateCache.set(templatePath, tpl);
|
|
17
|
+
}
|
|
12
18
|
const out = tpl(ctx);
|
|
13
19
|
await fs.mkdir(path.dirname(outPath), {recursive: true});
|
|
14
20
|
await fs.writeFile(outPath, out, 'utf8');
|
|
@@ -1,20 +1,26 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
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
|
+
*
|
|
4
5
|
* @param {string} original
|
|
6
|
+
* @param {{ preset: 'all'|'native' }} cfg
|
|
5
7
|
*/
|
|
6
|
-
export function
|
|
7
|
-
const
|
|
8
|
-
if (
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
if (groupTokens.length && groupTokens[0].toLowerCase() === String(baseSimple).toLowerCase()) {
|
|
13
|
-
groupTokens.shift();
|
|
14
|
-
}
|
|
15
|
-
const group = groupTokens.join(' ');
|
|
16
|
-
return group ? `${base} ${group}` : base;
|
|
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];
|
|
17
14
|
}
|
|
18
|
-
return original.replace(/\.(jsonld|jsonapi)$/ig, '');
|
|
19
|
-
}
|
|
20
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
|
+
}
|
|
@@ -1,46 +1,52 @@
|
|
|
1
1
|
import { sanitizeTypeName, quoteKeyIfNeeded } from './utils.js';
|
|
2
|
-
import { filterSchemaNames, mergeAllOf, isObject } from './schema-utils.js';
|
|
3
|
-
import {
|
|
2
|
+
import { filterSchemaNames, applySchemaNameFilters, mergeAllOf, isObject, schemaNameFromRef } from './schema-utils.js';
|
|
3
|
+
import { schemaLabel } from './naming.js';
|
|
4
4
|
import { tsTypeOf, withNullable } from './type-resolver.js';
|
|
5
5
|
|
|
6
|
+
/**
|
|
7
|
+
* @typedef {{
|
|
8
|
+
* requiredMode?: 'all-optional'|'spec',
|
|
9
|
+
* preset?: 'all'|'native',
|
|
10
|
+
* includeSchemaNames?: Array<RegExp|string|((name: string) => boolean)>,
|
|
11
|
+
* excludeSchemaNames?: Array<RegExp|string|((name: string) => boolean)>,
|
|
12
|
+
* }} BuildOptions
|
|
13
|
+
*/
|
|
14
|
+
|
|
6
15
|
/**
|
|
7
16
|
* Construit une map originalName -> sanitizedName, en assurant l’unicité
|
|
8
17
|
* @param {string[]} schemaNames
|
|
9
18
|
*/
|
|
10
|
-
function
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
const roots = parsed.filter((p) => !p.group);
|
|
18
|
-
const grouped = parsed.filter((p) => !!p.group);
|
|
19
|
-
|
|
20
|
-
// Root sanitized names (to avoid collisions when dropping group)
|
|
21
|
-
const rootSanitized = new Set(roots.map((p) => sanitizeTypeName(p.base, new Set())));
|
|
22
|
-
|
|
23
|
-
// Count base candidates among grouped to see if unique
|
|
24
|
-
const baseCount = new Map();
|
|
25
|
-
for (const p of grouped) {
|
|
26
|
-
const cand = sanitizeTypeName(p.base, new Set());
|
|
27
|
-
baseCount.set(cand, (baseCount.get(cand) || 0) + 1);
|
|
28
|
-
}
|
|
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
|
+
}
|
|
29
26
|
|
|
30
|
-
|
|
27
|
+
function buildNameMap(schemaNames, schemas, preset) {
|
|
28
|
+
// Build final map with uniqueness (deterministic order)
|
|
31
29
|
const used = new Set();
|
|
32
30
|
const nameMap = new Map();
|
|
33
|
-
for (const
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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);
|
|
41
47
|
}
|
|
42
|
-
nameMap.set(p.name, sanitizeTypeName(label, used));
|
|
43
48
|
}
|
|
49
|
+
|
|
44
50
|
return nameMap;
|
|
45
51
|
}
|
|
46
52
|
|
|
@@ -53,8 +59,9 @@ function buildNameMap(schemaNames) {
|
|
|
53
59
|
export function buildModelsFromOpenAPI(spec, options) {
|
|
54
60
|
const opts = { requiredMode: 'all-optional', ...(options || {}) };
|
|
55
61
|
const schemas = spec?.components?.schemas || {};
|
|
56
|
-
const schemaNames = filterSchemaNames(schemas, opts);
|
|
57
|
-
const
|
|
62
|
+
const schemaNames = applySchemaNameFilters(filterSchemaNames(schemas, opts), opts);
|
|
63
|
+
const preset = opts.preset ?? 'all';
|
|
64
|
+
const nameMap = buildNameMap(schemaNames, schemas, preset);
|
|
58
65
|
const allSanitizedNames = new Set([...nameMap.values()]);
|
|
59
66
|
|
|
60
67
|
/** @type {ModelDefinition[]} */
|
|
@@ -62,19 +69,49 @@ export function buildModelsFromOpenAPI(spec, options) {
|
|
|
62
69
|
|
|
63
70
|
for (const originalName of schemaNames) {
|
|
64
71
|
const raw = schemas[originalName];
|
|
72
|
+
const sanitized = nameMap.get(originalName);
|
|
73
|
+
if (!sanitized) continue;
|
|
74
|
+
|
|
75
|
+
/** @type {string[]} */
|
|
76
|
+
const extendsTypes = [];
|
|
77
|
+
|
|
65
78
|
let effective = raw;
|
|
66
79
|
if (raw?.allOf) {
|
|
67
|
-
const merged = mergeAllOf(raw.allOf
|
|
68
|
-
if (merged)
|
|
80
|
+
const merged = mergeAllOf(raw.allOf);
|
|
81
|
+
if (merged) {
|
|
82
|
+
effective = merged;
|
|
83
|
+
} else {
|
|
84
|
+
// Non-flattenable allOf (refs, mixed forms): keep inline object props and model refs as extends.
|
|
85
|
+
const props = {};
|
|
86
|
+
const required = new Set();
|
|
87
|
+
const ext = new Set();
|
|
88
|
+
|
|
89
|
+
for (const part of raw.allOf) {
|
|
90
|
+
if (part?.$ref) {
|
|
91
|
+
const orig = schemaNameFromRef(part.$ref);
|
|
92
|
+
const refName = orig ? nameMap.get(orig) : undefined;
|
|
93
|
+
if (refName && refName !== sanitized && allSanitizedNames.has(refName)) ext.add(refName);
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
const isObj = part?.type === 'object' || isObject(part?.properties);
|
|
97
|
+
if (!isObj) continue;
|
|
98
|
+
Object.assign(props, part.properties || {});
|
|
99
|
+
for (const r of part.required || []) required.add(r);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
extendsTypes.push(...[...ext].sort((a, b) => a.localeCompare(b)));
|
|
103
|
+
effective = { type: 'object', properties: props, required: [...required] };
|
|
104
|
+
}
|
|
69
105
|
}
|
|
70
106
|
|
|
71
|
-
const isObjectLike = effective?.type === 'object' || isObject(effective?.properties);
|
|
107
|
+
const isObjectLike = effective?.type === 'object' || isObject(effective?.properties) || extendsTypes.length > 0;
|
|
72
108
|
if (!isObjectLike) continue;
|
|
73
109
|
|
|
74
|
-
const sanitized = nameMap.get(originalName);
|
|
75
110
|
const required = new Set(effective.required || []);
|
|
76
111
|
const deps = new Set();
|
|
77
112
|
|
|
113
|
+
for (const e of extendsTypes) deps.add(e);
|
|
114
|
+
|
|
78
115
|
const props = Object.entries(effective.properties || {}).map(([propName, prop]) => {
|
|
79
116
|
const baseType = tsTypeOf(prop, deps, nameMap);
|
|
80
117
|
const type = withNullable(baseType, prop);
|
|
@@ -91,7 +128,7 @@ export function buildModelsFromOpenAPI(spec, options) {
|
|
|
91
128
|
.filter((n) => allSanitizedNames.has(n))
|
|
92
129
|
.sort((a, b) => a.localeCompare(b));
|
|
93
130
|
|
|
94
|
-
models.push({name: sanitized, props, imports});
|
|
131
|
+
models.push({name: sanitized, props, imports, extendsTypes});
|
|
95
132
|
}
|
|
96
133
|
|
|
97
134
|
return {models};
|
|
@@ -1,16 +1,5 @@
|
|
|
1
|
-
/** Hydra base schemas to ignore in
|
|
2
|
-
const
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* Récupère la RegExp Hydra à partir d'une config optionnelle.
|
|
6
|
-
* @param {{ hydraBaseRegex?: RegExp|string }} [cfg]
|
|
7
|
-
*/
|
|
8
|
-
function hydraRe(cfg) {
|
|
9
|
-
const r = cfg?.hydraBaseRegex;
|
|
10
|
-
if (!r) return DEFAULT_HYDRA_BASE_RE;
|
|
11
|
-
if (r instanceof RegExp) return r;
|
|
12
|
-
try { return new RegExp(r); } catch { return DEFAULT_HYDRA_BASE_RE; }
|
|
13
|
-
}
|
|
1
|
+
/** Hydra base schemas to ignore in safe `allOf` merges */
|
|
2
|
+
const HYDRA_BASE_RE = /^Hydra(?:Item|Collection)BaseSchema$/;
|
|
14
3
|
|
|
15
4
|
/**
|
|
16
5
|
* Détermine si une valeur est un objet simple (non tableau)
|
|
@@ -37,12 +26,12 @@ export function schemaNameFromRef($ref) {
|
|
|
37
26
|
* @param {any[]} schemas
|
|
38
27
|
* @returns {any|null}
|
|
39
28
|
*/
|
|
40
|
-
export function mergeAllOf(schemas
|
|
29
|
+
export function mergeAllOf(schemas) {
|
|
41
30
|
const out = {type: 'object', properties: {}, required: []};
|
|
42
31
|
for (const s of schemas) {
|
|
43
32
|
if (s.$ref) {
|
|
44
33
|
const ref = schemaNameFromRef(s.$ref);
|
|
45
|
-
if (
|
|
34
|
+
if (HYDRA_BASE_RE.test(ref)) {
|
|
46
35
|
continue;
|
|
47
36
|
}
|
|
48
37
|
return null;
|
|
@@ -59,67 +48,96 @@ export function mergeAllOf(schemas, cfg) {
|
|
|
59
48
|
}
|
|
60
49
|
|
|
61
50
|
/**
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
* - Exclut HydraItem/Collection base schemas
|
|
65
|
-
* - Garde les noms groupés (avec `-`) en choisissant la meilleure variante
|
|
66
|
-
* - Garde les racines sans point et sans suffixe jsonld/jsonapi
|
|
51
|
+
* Liste les noms de schémas dans `components.schemas`.
|
|
52
|
+
* La sélection/filtres est gérée par `applySchemaNameFilters`.
|
|
67
53
|
* @param {Record<string, any>} schemas
|
|
68
54
|
* @returns {string[]}
|
|
69
55
|
*/
|
|
70
56
|
export function filterSchemaNames(schemas, cfg) {
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
+
}
|
|
76
78
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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';
|
|
80
97
|
|
|
81
|
-
|
|
82
|
-
|
|
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] : [];
|
|
83
102
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
+
});
|
|
87
110
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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);
|
|
103
132
|
continue;
|
|
104
133
|
}
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
const m = n.match(/^(.*?)(?:\.(?:jsonld|jsonapi))?-(.+)$/i);
|
|
111
|
-
if (!m) {
|
|
112
|
-
const curr = grouped.get(n);
|
|
113
|
-
if (!curr || rank(n) > rank(curr)) grouped.set(n, n);
|
|
114
|
-
continue;
|
|
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);
|
|
115
138
|
}
|
|
116
|
-
|
|
117
|
-
const group = m[2];
|
|
118
|
-
const key = `${base}|${group}`;
|
|
119
|
-
const curr = grouped.get(key);
|
|
120
|
-
if (!curr || rank(n) > rank(curr)) grouped.set(key, n);
|
|
139
|
+
out = [...byBase.values()].sort((a, b) => a.localeCompare(b));
|
|
121
140
|
}
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
return [...roots, ...[...grouped.values()], ...noGroupSelected].sort((a, b) => a.localeCompare(b));
|
|
141
|
+
|
|
142
|
+
return out;
|
|
125
143
|
}
|
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import { Item } from '
|
|
1
|
+
import type { Item } from '../lib/ports/resource-repository.port';
|
|
2
2
|
{{#each imports}}
|
|
3
|
-
import { {{this}} } from './{{this}}';
|
|
3
|
+
import type { {{this}} } from './{{this}}';
|
|
4
4
|
{{/each}}
|
|
5
5
|
|
|
6
|
-
export interface {{name}} extends Item {
|
|
6
|
+
export interface {{name}} extends Item{{#each extendsTypes}}, {{this}}{{/each}} {
|
|
7
7
|
{{#each props}}
|
|
8
8
|
{{{tsKey}}}{{#if optional}}?{{/if}}: {{type}};
|
|
9
9
|
{{/each}}
|
|
@@ -33,8 +33,7 @@ export function pascalCase(tokens) {
|
|
|
33
33
|
* @param {Set<string>} used
|
|
34
34
|
*/
|
|
35
35
|
export function sanitizeTypeName(original, used) {
|
|
36
|
-
const
|
|
37
|
-
const tokens = base.split(/[^A-Za-z0-9]+/);
|
|
36
|
+
const tokens = String(original).split(/[^A-Za-z0-9]+/);
|
|
38
37
|
let name = pascalCase(tokens) || 'Model';
|
|
39
38
|
if (/^[0-9]/.test(name)) name = '_' + name;
|
|
40
39
|
if (RESERVED.has(name)) name = name + 'Model';
|