@obsidiane/meridiane 0.0.6
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 +60 -0
- package/cli.js +50 -0
- package/models.config.example.js +24 -0
- package/package.json +37 -0
- package/templates/_lib_template/ng-package.json +7 -0
- package/templates/_lib_template/package.json +12 -0
- package/templates/_lib_template/src/lib/bridge/credentials.policy.ts +9 -0
- package/templates/_lib_template/src/lib/bridge/rest/api-platform.adapter.ts +101 -0
- package/templates/_lib_template/src/lib/bridge/rest/query-builder.ts +43 -0
- package/templates/_lib_template/src/lib/bridge/sse/eventsource-wrapper.ts +74 -0
- package/templates/_lib_template/src/lib/bridge/sse/mercure-url.builder.ts +17 -0
- package/templates/_lib_template/src/lib/bridge/sse/mercure.adapter.ts +206 -0
- package/templates/_lib_template/src/lib/bridge/sse/ref-count-topic.registry.ts +43 -0
- package/templates/_lib_template/src/lib/facades/facade.factory.ts +36 -0
- package/templates/_lib_template/src/lib/facades/facade.interface.ts +35 -0
- package/templates/_lib_template/src/lib/facades/resource.facade.ts +115 -0
- package/templates/_lib_template/src/lib/interceptors/content-type.interceptor.ts +50 -0
- package/templates/_lib_template/src/lib/ports/realtime.port.ts +34 -0
- package/templates/_lib_template/src/lib/ports/resource-repository.port.ts +79 -0
- package/templates/_lib_template/src/lib/provide-bridge.ts +35 -0
- package/templates/_lib_template/src/lib/tokens.ts +7 -0
- package/templates/_lib_template/src/lib/utils/utils.ts +21 -0
- package/templates/_lib_template/src/models/index.ts +2 -0
- package/templates/_lib_template/src/public-api.ts +8 -0
- package/templates/_lib_template/tsconfig.lib.json +18 -0
- package/templates/_lib_template/tsconfig.lib.prod.json +11 -0
- package/templates/_lib_template/tsconfig.spec.json +14 -0
- package/tools/generate-lib.js +136 -0
- package/tools/generate-models.js +121 -0
- package/tools/generator/models/handlebars.js +15 -0
- package/tools/generator/models/naming.js +20 -0
- package/tools/generator/models/openapi-to-models.js +98 -0
- package/tools/generator/models/schema-utils.js +125 -0
- package/tools/generator/models/templates/index.hbs +3 -0
- package/tools/generator/models/templates/model.hbs +10 -0
- package/tools/generator/models/type-resolver.js +96 -0
- package/tools/generator/models/utils.js +63 -0
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import fs from 'node:fs/promises';
|
|
4
|
+
import process from 'node:process';
|
|
5
|
+
import {fileURLToPath, pathToFileURL} from 'node:url';
|
|
6
|
+
|
|
7
|
+
import {renderTemplateToFile} from './generator/models/handlebars.js';
|
|
8
|
+
import {buildModelsFromOpenAPI} from './generator/models/openapi-to-models.js';
|
|
9
|
+
import {ensureCleanDir} from './generator/models/utils.js';
|
|
10
|
+
|
|
11
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Récupère un argument CLI de la forme --name=value
|
|
15
|
+
* @param {string} name
|
|
16
|
+
* @param {string|undefined} def
|
|
17
|
+
* @returns {string|undefined}
|
|
18
|
+
*/
|
|
19
|
+
function getArg(name, def) {
|
|
20
|
+
const a = process.argv.find((x) => x.startsWith(`--${name}=`));
|
|
21
|
+
return a ? a.split('=')[1] : def;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Lit la specification OpenAPI depuis une URL http(s) ou un chemin fichier JSON.
|
|
26
|
+
* @param {string} specPathOrUrl
|
|
27
|
+
*/
|
|
28
|
+
async function readSpec(specPathOrUrl) {
|
|
29
|
+
if (/^https?:\/\//.test(specPathOrUrl)) {
|
|
30
|
+
const res = await fetch(specPathOrUrl);
|
|
31
|
+
if (!res.ok) {
|
|
32
|
+
throw new Error(`Impossible de télécharger la spec : ${res.status} ${res.statusText}`);
|
|
33
|
+
}
|
|
34
|
+
return await res.json();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
try {
|
|
38
|
+
const src = await fs.readFile(specPathOrUrl, 'utf8');
|
|
39
|
+
return JSON.parse(src);
|
|
40
|
+
} catch (err) {
|
|
41
|
+
if (err.code === 'ENOENT') {
|
|
42
|
+
throw new Error(`Fichier non trouvé : ${specPathOrUrl}`);
|
|
43
|
+
}
|
|
44
|
+
if (err.name === 'SyntaxError') {
|
|
45
|
+
throw new Error(`JSON invalide dans ${specPathOrUrl} : ${err.message}`);
|
|
46
|
+
}
|
|
47
|
+
throw err;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Charge un fichier de config local `models.config.js` (CWD) s'il existe.
|
|
52
|
+
* Le module peut exporter par défaut ou via export nommé.
|
|
53
|
+
*/
|
|
54
|
+
async function loadUserConfig() {
|
|
55
|
+
const cfgPath = path.resolve(process.cwd(), 'models.config.js');
|
|
56
|
+
try {
|
|
57
|
+
const st = await fs.stat(cfgPath);
|
|
58
|
+
if (!st.isFile()) return {};
|
|
59
|
+
const mod = await import(pathToFileURL(cfgPath).href);
|
|
60
|
+
return (mod?.default && typeof mod.default === 'object') ? mod.default : (mod || {});
|
|
61
|
+
} catch {
|
|
62
|
+
return {};
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
async function main() {
|
|
66
|
+
const specPathOrUrl = process.argv[2];
|
|
67
|
+
if (!specPathOrUrl) {
|
|
68
|
+
console.error('Usage: generate-models.js <OpenAPI spec (url|fichier)> [--item-import=../lib/ports/resource-repository.port] [--out=models] [--required-mode=all-optional|spec] [--no-index]');
|
|
69
|
+
process.exit(1);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const workDir = process.cwd();
|
|
73
|
+
const userCfg = await loadUserConfig();
|
|
74
|
+
const outDir = path.resolve(workDir, getArg('out', userCfg.outDir || 'models'));
|
|
75
|
+
const writeIndex = !('' + process.argv.join(' ')).includes('--no-index');
|
|
76
|
+
const itemImportPath = getArg('item-import', userCfg.itemImportPath || '../lib/ports/resource-repository.port');
|
|
77
|
+
const requiredMode = (() => {
|
|
78
|
+
const v = getArg('required-mode', userCfg.requiredMode || 'all-optional');
|
|
79
|
+
return (v === 'spec' || v === 'all-optional') ? v : 'all-optional';
|
|
80
|
+
})();
|
|
81
|
+
const preferFlavor = userCfg.preferFlavor; // 'jsonld' | 'jsonapi' | 'none'
|
|
82
|
+
const hydraBaseRegex = userCfg.hydraBaseRegex; // RegExp|string
|
|
83
|
+
|
|
84
|
+
console.log(`📥 Spec OpenAPI: ${specPathOrUrl}`);
|
|
85
|
+
const spec = await readSpec(specPathOrUrl);
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
console.log('🔧 Construction des interfaces…');
|
|
89
|
+
const {models} = buildModelsFromOpenAPI(spec, { requiredMode, preferFlavor, hydraBaseRegex });
|
|
90
|
+
|
|
91
|
+
const templatesDir = path.join(__dirname, 'generator', 'models', 'templates');
|
|
92
|
+
await ensureCleanDir(outDir);
|
|
93
|
+
|
|
94
|
+
for (const m of models) {
|
|
95
|
+
await renderTemplateToFile({
|
|
96
|
+
templatePath: path.join(templatesDir, 'model.hbs'),
|
|
97
|
+
outPath: path.join(outDir, `${m.name}.ts`),
|
|
98
|
+
ctx: {
|
|
99
|
+
itemImportPath,
|
|
100
|
+
name: m.name,
|
|
101
|
+
props: m.props,
|
|
102
|
+
imports: m.imports,
|
|
103
|
+
},
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (writeIndex) {
|
|
108
|
+
await renderTemplateToFile({
|
|
109
|
+
templatePath: path.join(templatesDir, 'index.hbs'),
|
|
110
|
+
outPath: path.join(outDir, 'index.ts'),
|
|
111
|
+
ctx: {models: models.map((m) => ({name: m.name}))},
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
console.log(`✅ ${models.length} modèle(s) écrit(s) dans ${path.relative(workDir, outDir)}`);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
main().catch((err) => {
|
|
119
|
+
console.error(err);
|
|
120
|
+
process.exit(1);
|
|
121
|
+
});
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import * as path from 'node:path';
|
|
3
|
+
import Handlebars from 'handlebars';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Rend un template Handlebars avec un contexte et écrit le fichier.
|
|
7
|
+
* @param {{ templatePath: string, outPath: string, ctx: any }} params
|
|
8
|
+
*/
|
|
9
|
+
export async function renderTemplateToFile({templatePath, outPath, ctx}) {
|
|
10
|
+
const tplSrc = await fs.readFile(templatePath, 'utf8');
|
|
11
|
+
const tpl = Handlebars.compile(tplSrc, {noEscape: true});
|
|
12
|
+
const out = tpl(ctx);
|
|
13
|
+
await fs.mkdir(path.dirname(outPath), {recursive: true});
|
|
14
|
+
await fs.writeFile(outPath, out, 'utf8');
|
|
15
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Convertit un nom de schéma OpenAPI en un nom lisible (sans artefacts jsonld/jsonapi)
|
|
3
|
+
* et intégrant les groupes (ex: Identity.jsonld-user.read -> Identity user read)
|
|
4
|
+
* @param {string} original
|
|
5
|
+
*/
|
|
6
|
+
export function friendlyName(original) {
|
|
7
|
+
const m = original.match(/^(.*?)(?:\.(?:jsonld|jsonapi))?-(.+)$/i);
|
|
8
|
+
if (m) {
|
|
9
|
+
const base = m[1];
|
|
10
|
+
const baseSimple = base.split('.').pop();
|
|
11
|
+
const groupTokens = m[2].split('.');
|
|
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;
|
|
17
|
+
}
|
|
18
|
+
return original.replace(/\.(jsonld|jsonapi)$/ig, '');
|
|
19
|
+
}
|
|
20
|
+
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { sanitizeTypeName, quoteKeyIfNeeded } from './utils.js';
|
|
2
|
+
import { filterSchemaNames, mergeAllOf, isObject } from './schema-utils.js';
|
|
3
|
+
import { friendlyName } from './naming.js';
|
|
4
|
+
import { tsTypeOf, withNullable } from './type-resolver.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Construit une map originalName -> sanitizedName, en assurant l’unicité
|
|
8
|
+
* @param {string[]} schemaNames
|
|
9
|
+
*/
|
|
10
|
+
function buildNameMap(schemaNames) {
|
|
11
|
+
const parse = (original) => {
|
|
12
|
+
const m = original.match(/^(.*?)(?:\.(?:jsonld|jsonapi))?-(.+)$/i);
|
|
13
|
+
return m ? { base: m[1], group: m[2] } : { base: original, group: null };
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const parsed = schemaNames.map((n) => ({ name: n, ...parse(n) }));
|
|
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
|
+
}
|
|
29
|
+
|
|
30
|
+
// Build final map with uniqueness
|
|
31
|
+
const used = new Set();
|
|
32
|
+
const nameMap = new Map();
|
|
33
|
+
for (const p of parsed) {
|
|
34
|
+
let label;
|
|
35
|
+
if (p.group) {
|
|
36
|
+
const cand = sanitizeTypeName(p.base, new Set());
|
|
37
|
+
const isUniqueBase = (baseCount.get(cand) === 1) && !rootSanitized.has(cand);
|
|
38
|
+
label = isUniqueBase ? p.base : friendlyName(p.name);
|
|
39
|
+
} else {
|
|
40
|
+
label = friendlyName(p.name);
|
|
41
|
+
}
|
|
42
|
+
nameMap.set(p.name, sanitizeTypeName(label, used));
|
|
43
|
+
}
|
|
44
|
+
return nameMap;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Construit les définitions de modèles à partir d’une spec OpenAPI (components.schemas)
|
|
49
|
+
* @param {any} spec
|
|
50
|
+
* @param {BuildOptions} [options]
|
|
51
|
+
* @returns {{ models: ModelDefinition[] }}
|
|
52
|
+
*/
|
|
53
|
+
export function buildModelsFromOpenAPI(spec, options) {
|
|
54
|
+
const opts = { requiredMode: 'all-optional', ...(options || {}) };
|
|
55
|
+
const schemas = spec?.components?.schemas || {};
|
|
56
|
+
const schemaNames = filterSchemaNames(schemas, opts);
|
|
57
|
+
const nameMap = buildNameMap(schemaNames);
|
|
58
|
+
const allSanitizedNames = new Set([...nameMap.values()]);
|
|
59
|
+
|
|
60
|
+
/** @type {ModelDefinition[]} */
|
|
61
|
+
const models = [];
|
|
62
|
+
|
|
63
|
+
for (const originalName of schemaNames) {
|
|
64
|
+
const raw = schemas[originalName];
|
|
65
|
+
let effective = raw;
|
|
66
|
+
if (raw?.allOf) {
|
|
67
|
+
const merged = mergeAllOf(raw.allOf, opts);
|
|
68
|
+
if (merged) effective = merged;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const isObjectLike = effective?.type === 'object' || isObject(effective?.properties);
|
|
72
|
+
if (!isObjectLike) continue;
|
|
73
|
+
|
|
74
|
+
const sanitized = nameMap.get(originalName);
|
|
75
|
+
const required = new Set(effective.required || []);
|
|
76
|
+
const deps = new Set();
|
|
77
|
+
|
|
78
|
+
const props = Object.entries(effective.properties || {}).map(([propName, prop]) => {
|
|
79
|
+
const baseType = tsTypeOf(prop, deps, nameMap);
|
|
80
|
+
const type = withNullable(baseType, prop);
|
|
81
|
+
return {
|
|
82
|
+
name: propName,
|
|
83
|
+
tsKey: quoteKeyIfNeeded(propName),
|
|
84
|
+
type,
|
|
85
|
+
optional: opts.requiredMode === 'spec' ? !required.has(propName) : true,
|
|
86
|
+
};
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
const imports = [...deps]
|
|
90
|
+
.filter((n) => n !== sanitized)
|
|
91
|
+
.filter((n) => allSanitizedNames.has(n))
|
|
92
|
+
.sort((a, b) => a.localeCompare(b));
|
|
93
|
+
|
|
94
|
+
models.push({name: sanitized, props, imports});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return {models};
|
|
98
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/** Hydra base schemas to ignore in merges and filtering */
|
|
2
|
+
const DEFAULT_HYDRA_BASE_RE = /^Hydra(?:Item|Collection)BaseSchema$/;
|
|
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
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Détermine si une valeur est un objet simple (non tableau)
|
|
17
|
+
* @param {unknown} v
|
|
18
|
+
* @returns {v is Record<string, unknown>}
|
|
19
|
+
*/
|
|
20
|
+
export function isObject(v) {
|
|
21
|
+
return !!v && typeof v === 'object' && !Array.isArray(v);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Extrait le nom du schéma depuis une référence $ref
|
|
26
|
+
* @param {string} $ref
|
|
27
|
+
*/
|
|
28
|
+
export function schemaNameFromRef($ref) {
|
|
29
|
+
const m = $ref.match(/#\/components\/schemas\/(.+)$/);
|
|
30
|
+
return m ? m[1] : $ref;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Essaie d'aplatir un allOf en un objet unique.
|
|
35
|
+
* Ignore HydraItemBaseSchema/HydraCollectionBaseSchema quand rencontré.
|
|
36
|
+
* Retourne null si l'aplatissement n'est pas possible de manière sûre.
|
|
37
|
+
* @param {any[]} schemas
|
|
38
|
+
* @returns {any|null}
|
|
39
|
+
*/
|
|
40
|
+
export function mergeAllOf(schemas, cfg) {
|
|
41
|
+
const out = {type: 'object', properties: {}, required: []};
|
|
42
|
+
for (const s of schemas) {
|
|
43
|
+
if (s.$ref) {
|
|
44
|
+
const ref = schemaNameFromRef(s.$ref);
|
|
45
|
+
if (hydraRe(cfg).test(ref)) {
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
if (s.type === 'object') {
|
|
51
|
+
Object.assign(out.properties, s.properties || {});
|
|
52
|
+
if (Array.isArray(s.required)) out.required.push(...s.required);
|
|
53
|
+
} else {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
out.required = [...new Set(out.required)];
|
|
58
|
+
return out;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Filtre et déduplique les noms de schémas à générer.
|
|
63
|
+
* Préférence entre variantes: jsonld > none > jsonapi.
|
|
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
|
|
67
|
+
* @param {Record<string, any>} schemas
|
|
68
|
+
* @returns {string[]}
|
|
69
|
+
*/
|
|
70
|
+
export function filterSchemaNames(schemas, cfg) {
|
|
71
|
+
const prefer = (cfg?.preferFlavor === 'jsonapi') ? ['jsonapi','none','jsonld']
|
|
72
|
+
: (cfg?.preferFlavor === 'none') ? ['none','jsonld','jsonapi']
|
|
73
|
+
: ['jsonld','none','jsonapi']; // default jsonld
|
|
74
|
+
const flavorOf = (name) => /\.jsonld\b/i.test(name) ? 'jsonld' : /\.jsonapi\b/i.test(name) ? 'jsonapi' : 'none';
|
|
75
|
+
const rank = (name) => 3 - prefer.indexOf(flavorOf(name));
|
|
76
|
+
|
|
77
|
+
const roots = [];
|
|
78
|
+
const grouped = new Map();
|
|
79
|
+
const variantsNoGroup = new Map(); // base -> best variant (no '-')
|
|
80
|
+
|
|
81
|
+
for (const n of Object.keys(schemas)) {
|
|
82
|
+
if (hydraRe(cfg).test(n)) continue;
|
|
83
|
+
|
|
84
|
+
const hasGroup = n.includes('-');
|
|
85
|
+
const hasDot = n.includes('.');
|
|
86
|
+
const isJsonFlavor = /\.jsonld\b|\.jsonapi\b/i.test(n);
|
|
87
|
+
|
|
88
|
+
if (!hasGroup) {
|
|
89
|
+
// Cas 1: racine simple (pas de point, pas de variante jsonld/jsonapi)
|
|
90
|
+
if (!hasDot && !isJsonFlavor) {
|
|
91
|
+
roots.push(n);
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
// Cas 2: variante jsonld/jsonapi sans groupe (ex: Workflow.WorkflowInput.jsonld ou Foo.jsonld)
|
|
95
|
+
if (isJsonFlavor) {
|
|
96
|
+
const base = n.replace(/\.(jsonld|jsonapi)\b/i, '');
|
|
97
|
+
// Si une base "pure" existe dans la spec, on préfère l’ignorer pour éviter les doublons
|
|
98
|
+
if (Object.prototype.hasOwnProperty.call(schemas, base)) {
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
const curr = variantsNoGroup.get(base);
|
|
102
|
+
if (!curr || rank(n) > rank(curr)) variantsNoGroup.set(base, n);
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
// Cas 3: nom avec point mais sans suffixe jsonld/jsonapi et sans group — on le considère tel quel
|
|
106
|
+
variantsNoGroup.set(n, n);
|
|
107
|
+
continue;
|
|
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;
|
|
115
|
+
}
|
|
116
|
+
const base = m[1];
|
|
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);
|
|
121
|
+
}
|
|
122
|
+
// Ajoute les variantes sans groupe sélectionnées
|
|
123
|
+
const noGroupSelected = [...variantsNoGroup.values()];
|
|
124
|
+
return [...roots, ...[...grouped.values()], ...noGroupSelected].sort((a, b) => a.localeCompare(b));
|
|
125
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { schemaNameFromRef } from './schema-utils.js';
|
|
2
|
+
import { quoteKeyIfNeeded } from './utils.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Ajoute | null à un type TS si le schéma est nullable (compat OAS 3.0),
|
|
6
|
+
* sauf si 'null' est déjà présent dans l'union.
|
|
7
|
+
* @param {string} ts
|
|
8
|
+
* @param {any} schema
|
|
9
|
+
*/
|
|
10
|
+
export function withNullable(ts, schema) {
|
|
11
|
+
return schema?.nullable && !/\|\s*null\b/.test(ts) ? `${ts} | null` : ts;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Résout un schéma OpenAPI vers un type TypeScript, en collectant les dépendances ($ref)
|
|
16
|
+
* @param {any} schema
|
|
17
|
+
* @param {Set<string>} deps
|
|
18
|
+
* @param {Map<string,string>} nameMap
|
|
19
|
+
* @returns {string}
|
|
20
|
+
*/
|
|
21
|
+
export function tsTypeOf(schema, deps, nameMap) {
|
|
22
|
+
if (!schema) return 'any';
|
|
23
|
+
|
|
24
|
+
if (schema.$ref) {
|
|
25
|
+
const orig = schemaNameFromRef(schema.$ref);
|
|
26
|
+
const name = nameMap.get(orig) || orig;
|
|
27
|
+
deps.add(name);
|
|
28
|
+
return name;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Enum prioritaires: union littérale (supporte null)
|
|
32
|
+
if (schema.enum) {
|
|
33
|
+
const lit = schema.enum.map((v) => {
|
|
34
|
+
if (v === null) return 'null';
|
|
35
|
+
if (typeof v === 'string') return `'${v.replace(/'/g, "\\'")}'`;
|
|
36
|
+
if (typeof v === 'number' || typeof v === 'bigint') return String(v);
|
|
37
|
+
if (typeof v === 'boolean') return v ? 'true' : 'false';
|
|
38
|
+
return 'any';
|
|
39
|
+
});
|
|
40
|
+
return [...new Set(lit)].join(' | ');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// OpenAPI 3.1: null explicite et tableau de types (union)
|
|
44
|
+
if (schema.type === 'null') return 'null';
|
|
45
|
+
if (Array.isArray(schema.type)) {
|
|
46
|
+
const parts = new Set();
|
|
47
|
+
for (const t of schema.type) {
|
|
48
|
+
if (t === 'null') parts.add('null');
|
|
49
|
+
else if (t === 'string') parts.add('string');
|
|
50
|
+
else if (t === 'integer' || t === 'number') parts.add('number');
|
|
51
|
+
else if (t === 'boolean') parts.add('boolean');
|
|
52
|
+
else if (t === 'array') parts.add(tsTypeOf({ type: 'array', items: schema.items }, deps, nameMap));
|
|
53
|
+
else if (t === 'object') {
|
|
54
|
+
const objTs = tsTypeOf({ type: 'object', properties: schema.properties, required: schema.required, additionalProperties: schema.additionalProperties }, deps, nameMap);
|
|
55
|
+
parts.add(objTs);
|
|
56
|
+
} else parts.add('any');
|
|
57
|
+
}
|
|
58
|
+
return [...parts].join(' | ');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (schema.allOf) {
|
|
62
|
+
// Aplatir au niveau buildModelsFromOpenAPI; ici on combine si nécessaire
|
|
63
|
+
return schema.allOf.map((s) => tsTypeOf(s, deps, nameMap)).join(' & ');
|
|
64
|
+
}
|
|
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(' | ');
|
|
67
|
+
|
|
68
|
+
if (schema.type === 'array') {
|
|
69
|
+
const it = tsTypeOf(schema.items || {}, deps, nameMap);
|
|
70
|
+
return `${it}[]`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (schema.type === 'object' || (schema.properties || schema.additionalProperties)) {
|
|
74
|
+
const props = schema.properties || {};
|
|
75
|
+
const entries = Object.entries(props);
|
|
76
|
+
const inline = entries.map(([k, v]) => {
|
|
77
|
+
const t = tsTypeOf(v, deps, nameMap);
|
|
78
|
+
const opt = (schema.required || []).includes(k) ? '' : '?';
|
|
79
|
+
const nullable = v?.nullable && !/\|\s*null\b/.test(t) ? ' | null' : '';
|
|
80
|
+
const key = quoteKeyIfNeeded(k);
|
|
81
|
+
return `${key}${opt}: ${t}${nullable};`;
|
|
82
|
+
});
|
|
83
|
+
if (schema.additionalProperties) {
|
|
84
|
+
const at = tsTypeOf(schema.additionalProperties, deps, nameMap);
|
|
85
|
+
inline.push(`[key: string]: ${at};`);
|
|
86
|
+
}
|
|
87
|
+
return `{ ${inline.join(' ')} }`;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (schema.type === 'string') return 'string';
|
|
91
|
+
if (schema.type === 'integer' || schema.type === 'number') return 'number';
|
|
92
|
+
if (schema.type === 'boolean') return 'boolean';
|
|
93
|
+
|
|
94
|
+
return 'any';
|
|
95
|
+
}
|
|
96
|
+
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Supprime et recrée un dossier (idempotent)
|
|
5
|
+
* @param {string} dir
|
|
6
|
+
*/
|
|
7
|
+
export async function ensureCleanDir(dir) {
|
|
8
|
+
await fs.rm(dir, { recursive: true, force: true });
|
|
9
|
+
await fs.mkdir(dir, { recursive: true });
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const RESERVED = new Set([
|
|
13
|
+
'break', 'case', 'catch', 'class', 'const', 'continue', 'debugger', 'default', 'delete', 'do', 'else', 'enum', 'export', 'extends', 'false', 'finally', 'for', 'function', 'if', 'import', 'in', 'instanceof', 'new', 'null', 'return', 'super', 'switch', 'this', 'throw', 'true', 'try', 'typeof', 'var', 'void', 'while', 'with', 'yield', 'let', 'implements', 'interface', 'package', 'private', 'protected', 'public', 'static', 'await', 'abstract', 'boolean', 'byte', 'char', 'double', 'final', 'float', 'goto', 'int', 'long', 'native', 'short', 'synchronized', 'throws', 'transient', 'volatile'
|
|
14
|
+
]);
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Construit un PascalCase à partir d’une liste de tokens
|
|
18
|
+
* @param {string[]} tokens
|
|
19
|
+
*/
|
|
20
|
+
export function pascalCase(tokens) {
|
|
21
|
+
return tokens
|
|
22
|
+
.filter(Boolean)
|
|
23
|
+
.map(t => t.replace(/^[^A-Za-z0-9]+|[^A-Za-z0-9]+$/g, ''))
|
|
24
|
+
.filter(Boolean)
|
|
25
|
+
.map(t => t.charAt(0).toUpperCase() + t.slice(1))
|
|
26
|
+
.join('');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Nettoie et désambiguïse un nom de type TypeScript, en évitant les mots réservés
|
|
31
|
+
* et collisions via un suffixe numérique.
|
|
32
|
+
* @param {string} original
|
|
33
|
+
* @param {Set<string>} used
|
|
34
|
+
*/
|
|
35
|
+
export function sanitizeTypeName(original, used) {
|
|
36
|
+
const base = original.includes('.') ? original.split('.').pop() : original;
|
|
37
|
+
const tokens = base.split(/[^A-Za-z0-9]+/);
|
|
38
|
+
let name = pascalCase(tokens) || 'Model';
|
|
39
|
+
if (/^[0-9]/.test(name)) name = '_' + name;
|
|
40
|
+
if (RESERVED.has(name)) name = name + 'Model';
|
|
41
|
+
let unique = name, i = 2;
|
|
42
|
+
while (used.has(unique)) {
|
|
43
|
+
unique = name + i++;
|
|
44
|
+
}
|
|
45
|
+
used.add(unique);
|
|
46
|
+
return unique;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Vérifie qu’une clé est un identifiant TS valide ET non réservé
|
|
51
|
+
* @param {string} k
|
|
52
|
+
*/
|
|
53
|
+
export function isValidTsIdentifier(k) {
|
|
54
|
+
return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(k) && !RESERVED.has(k);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Ajoute des quotes sur une clé si nécessaire (clé non valide en TS)
|
|
59
|
+
* @param {string} k
|
|
60
|
+
*/
|
|
61
|
+
export function quoteKeyIfNeeded(k) {
|
|
62
|
+
return isValidTsIdentifier(k) ? k : `'${k.replace(/'/g, "\\'")}'`;
|
|
63
|
+
}
|