@xemahq/api-client-generator 0.1.5 → 0.3.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/dist/bin/xema-openapi.js +2 -2
- package/dist/bin/xema-openapi.js.map +1 -1
- package/dist/index.d.ts +0 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +0 -1
- package/dist/index.js.map +1 -1
- package/dist/lib/client-package.d.ts.map +1 -1
- package/dist/lib/client-package.js +9 -4
- package/dist/lib/client-package.js.map +1 -1
- package/dist/lib/default-stubs.d.ts.map +1 -1
- package/dist/lib/default-stubs.js +35 -10
- package/dist/lib/default-stubs.js.map +1 -1
- package/dist/lib/extract-openapi.d.ts +2 -2
- package/dist/lib/extract-openapi.d.ts.map +1 -1
- package/dist/lib/extract-openapi.js +35 -39
- package/dist/lib/extract-openapi.js.map +1 -1
- package/dist/lib/generate-clients.d.ts +1 -1
- package/dist/lib/generate-clients.d.ts.map +1 -1
- package/dist/lib/generate-clients.js +33 -6
- package/dist/lib/generate-clients.js.map +1 -1
- package/dist/lib/overrides.d.ts.map +1 -1
- package/dist/lib/overrides.js +10 -2
- package/dist/lib/overrides.js.map +1 -1
- package/dist/lib/repo-post-process.d.ts.map +1 -1
- package/dist/lib/repo-post-process.js +2 -1
- package/dist/lib/repo-post-process.js.map +1 -1
- package/dist/lib/seed-openapi-env.d.ts +2 -1
- package/dist/lib/seed-openapi-env.d.ts.map +1 -1
- package/dist/lib/seed-openapi-env.js +14 -24
- package/dist/lib/seed-openapi-env.js.map +1 -1
- package/dist/lib/service-config.d.ts +1 -1
- package/dist/lib/service-config.d.ts.map +1 -1
- package/dist/lib/service-config.js +2 -2
- package/dist/lib/service-config.js.map +1 -1
- package/dist/lib/service-nest.d.ts.map +1 -1
- package/dist/lib/service-nest.js.map +1 -1
- package/package.json +6 -4
- package/src/bin/xema-client-generate.ts +11 -0
- package/src/bin/xema-openapi.ts +51 -0
- package/src/index.ts +9 -0
- package/src/lib/client-package.ts +215 -0
- package/src/lib/client-readme.ts +124 -0
- package/src/lib/custom-fetch-template.ts +200 -0
- package/src/lib/default-stubs.ts +422 -0
- package/src/lib/extract-openapi.ts +240 -0
- package/src/lib/generate-clients.ts +240 -0
- package/src/lib/overrides.ts +81 -0
- package/src/lib/repo-post-process.ts +106 -0
- package/src/lib/seed-openapi-env.ts +137 -0
- package/src/lib/service-config.ts +170 -0
- package/src/lib/service-nest.ts +58 -0
- package/dist/lib/prune-spec.d.ts +0 -16
- package/dist/lib/prune-spec.d.ts.map +0 -1
- package/dist/lib/prune-spec.js +0 -100
- package/dist/lib/prune-spec.js.map +0 -1
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import { existsSync, rmSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
ALL_SURFACES,
|
|
6
|
+
type ApiSurface,
|
|
7
|
+
assertNoUnclassifiedSurfaces,
|
|
8
|
+
collectOperationIds,
|
|
9
|
+
formatRouteExtensionCoverage,
|
|
10
|
+
pruneSpecForSurface,
|
|
11
|
+
scanApiSurfaces,
|
|
12
|
+
stampRouteExtensions,
|
|
13
|
+
type OpenApiDoc,
|
|
14
|
+
} from '@xemahq/xema-decorators';
|
|
15
|
+
|
|
16
|
+
import { applyDefaultOpenApiStubs } from './default-stubs';
|
|
17
|
+
import { loadServiceOverrides } from './overrides';
|
|
18
|
+
import { seedOpenApiEnv } from './seed-openapi-env';
|
|
19
|
+
import {
|
|
20
|
+
resolveServiceContext,
|
|
21
|
+
surfaceSpecPath,
|
|
22
|
+
type ResolvedServiceContext,
|
|
23
|
+
} from './service-config';
|
|
24
|
+
import { loadServiceNest, type ServiceNest } from './service-nest';
|
|
25
|
+
|
|
26
|
+
import type { INestApplication } from '@nestjs/common';
|
|
27
|
+
|
|
28
|
+
async function buildApp(
|
|
29
|
+
ctx: ResolvedServiceContext,
|
|
30
|
+
nest: ServiceNest,
|
|
31
|
+
): Promise<INestApplication> {
|
|
32
|
+
// AppModule is required lazily, AFTER env seeding, so its import-time
|
|
33
|
+
// `process.env` reads see the dummies.
|
|
34
|
+
|
|
35
|
+
const appModuleMod = require(join(ctx.serviceDir, 'src', 'app.module')) as {
|
|
36
|
+
AppModule: unknown;
|
|
37
|
+
};
|
|
38
|
+
if (!appModuleMod.AppModule) {
|
|
39
|
+
throw new Error(
|
|
40
|
+
`api-client-generator: ${join(ctx.serviceDir, 'src/app.module')} does not ` +
|
|
41
|
+
`export \`AppModule\`.`,
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
let builder = nest.Test.createTestingModule({
|
|
45
|
+
imports: [appModuleMod.AppModule as never],
|
|
46
|
+
});
|
|
47
|
+
builder = applyDefaultOpenApiStubs(builder, ctx.serviceDir);
|
|
48
|
+
const overrides = loadServiceOverrides(ctx.serviceDir);
|
|
49
|
+
if (overrides?.applyOverrides) {
|
|
50
|
+
builder = overrides.applyOverrides(builder) ?? builder;
|
|
51
|
+
}
|
|
52
|
+
const moduleRef = await builder.compile();
|
|
53
|
+
// Deliberately skip app.init(): SwaggerModule only needs compiled decorator
|
|
54
|
+
// metadata, and init() would fire onModuleInit hooks that hit real DBs.
|
|
55
|
+
return moduleRef.createNestApplication();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Boot the service, build one OpenAPI document per non-empty {@link ApiSurface},
|
|
60
|
+
* and write `openapi.<surface>.json` files at the service root. Stale spec
|
|
61
|
+
* files for surfaces that became empty are deleted so `openapi:check` stays
|
|
62
|
+
* honest.
|
|
63
|
+
*
|
|
64
|
+
* Fail-fast on any route that resolves to no surface (the `api-surface:check`
|
|
65
|
+
* gate) and on any document operation the surface scan never saw (no silent
|
|
66
|
+
* drop).
|
|
67
|
+
*
|
|
68
|
+
* @returns the surfaces that were written.
|
|
69
|
+
*/
|
|
70
|
+
export async function extractOpenApiForService(
|
|
71
|
+
serviceDir: string = process.cwd(),
|
|
72
|
+
/**
|
|
73
|
+
* Write the specs here instead of into the service directory. Used by the
|
|
74
|
+
* code→spec drift gate so it can extract on a dirty tree without clobbering
|
|
75
|
+
* uncommitted work. Omitted everywhere else, which keeps `pnpm openapi`
|
|
76
|
+
* behaviour byte-identical.
|
|
77
|
+
*/
|
|
78
|
+
outDir?: string,
|
|
79
|
+
): Promise<ApiSurface[]> {
|
|
80
|
+
// Layer 1 + (Layer 3 env) BEFORE importing AppModule. `resolveServiceContext`
|
|
81
|
+
// only reads `package.json`, so it is safe to run before the env is seeded —
|
|
82
|
+
// and the serviceId it yields is what makes the scratch DB schema unique per
|
|
83
|
+
// service under parallel extraction.
|
|
84
|
+
const ctx = resolveServiceContext(serviceDir);
|
|
85
|
+
seedOpenApiEnv(ctx.api.serviceId);
|
|
86
|
+
const overrides = loadServiceOverrides(ctx.serviceDir);
|
|
87
|
+
if (overrides?.env) {
|
|
88
|
+
for (const [k, v] of Object.entries(overrides.env)) {
|
|
89
|
+
if (process.env[k] === undefined) {
|
|
90
|
+
process.env[k] = v;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const nest = loadServiceNest(ctx.serviceDir);
|
|
96
|
+
const app = await buildApp(ctx, nest);
|
|
97
|
+
try {
|
|
98
|
+
const builder = new nest.DocumentBuilder()
|
|
99
|
+
.setTitle(ctx.api.title)
|
|
100
|
+
.setVersion(ctx.version)
|
|
101
|
+
.addBearerAuth();
|
|
102
|
+
if (ctx.api.description) {
|
|
103
|
+
builder.setDescription(ctx.api.description);
|
|
104
|
+
}
|
|
105
|
+
const fullDoc = nest.SwaggerModule.createDocument(app, builder.build(), {
|
|
106
|
+
operationIdFactory: (controllerKey: string, methodKey: string) =>
|
|
107
|
+
`${controllerKey}_${methodKey}`,
|
|
108
|
+
}) as unknown as OpenApiDoc;
|
|
109
|
+
|
|
110
|
+
// Construct DiscoveryService directly from the app's ModulesContainer
|
|
111
|
+
// (always available) rather than app.get(DiscoveryService) — the latter
|
|
112
|
+
// would trigger on-demand instantiation that can't see ModulesContainer in
|
|
113
|
+
// the root context.
|
|
114
|
+
const modulesContainer = app.get(nest.ModulesContainer, { strict: false });
|
|
115
|
+
const discovery = new nest.DiscoveryService(modulesContainer);
|
|
116
|
+
const metadataScanner = new nest.MetadataScanner();
|
|
117
|
+
const scan = scanApiSurfaces(discovery, metadataScanner);
|
|
118
|
+
|
|
119
|
+
// A route needs a surface ONLY if it actually appears in the OpenAPI
|
|
120
|
+
// document. Routes excluded from the spec (`@ApiExcludeController` /
|
|
121
|
+
// `@ApiExcludeEndpoint`, e.g. health probes and event listeners) ship in
|
|
122
|
+
// no client, so they need no surface — they are not candidates.
|
|
123
|
+
const docOps = collectOperationIds(fullDoc);
|
|
124
|
+
const unclassifiedInDoc = scan.unclassified.filter((u) =>
|
|
125
|
+
docOps.has(u.operationId),
|
|
126
|
+
);
|
|
127
|
+
if (unclassifiedInDoc.length > 0) {
|
|
128
|
+
assertNoUnclassifiedSurfaces(ctx.api.serviceId, {
|
|
129
|
+
unclassified: unclassifiedInDoc,
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
// No silent drop: a doc operation the scan never saw at all (in neither
|
|
133
|
+
// surfaces, excluded, nor unclassified) means a spec-only route the
|
|
134
|
+
// runtime scan can't reach — fail fast rather than drop it.
|
|
135
|
+
const seen = new Set<string>([
|
|
136
|
+
...scan.surfaces.keys(),
|
|
137
|
+
...scan.excluded,
|
|
138
|
+
...scan.unclassified.map((u) => u.operationId),
|
|
139
|
+
]);
|
|
140
|
+
const unaccounted = [...docOps].filter((id) => !seen.has(id));
|
|
141
|
+
if (unaccounted.length > 0) {
|
|
142
|
+
throw new Error(
|
|
143
|
+
`api-client-generator: ${ctx.api.serviceId} has ${unaccounted.length} ` +
|
|
144
|
+
`OpenAPI operation(s) the surface scan never saw ` +
|
|
145
|
+
`(${unaccounted.join(', ')}). A controller is emitted into the spec ` +
|
|
146
|
+
`but unreachable from the DiscoveryService scan.`,
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Publish the route metadata the platform already computes as `x-xema-*`
|
|
151
|
+
// extensions ON THE FULL DOCUMENT, before pruning. The pruner is
|
|
152
|
+
// deletion-only, so every per-surface spec inherits the extensions without
|
|
153
|
+
// a second pass, and each spec is a faithful subset of what a live pod
|
|
154
|
+
// serves at `/api/openapi.json`.
|
|
155
|
+
//
|
|
156
|
+
// Coverage is LOGGED, not enforced: `surface` is complete by construction
|
|
157
|
+
// (see the assert above) but `resource`/`action`/`permission` depend on
|
|
158
|
+
// `@XemaResource`/`@XemaRoute` adoption, which is deliberately absent on
|
|
159
|
+
// `@XemaPublicRoute`/`@XemaInternalRoute` handlers. Failing here would red
|
|
160
|
+
// every service's build for a gap this wave does not own; omitting the key
|
|
161
|
+
// and printing the count keeps the gap visible and measurable instead.
|
|
162
|
+
const coverage = stampRouteExtensions(fullDoc, scan.projections);
|
|
163
|
+
|
|
164
|
+
const written: ApiSurface[] = [];
|
|
165
|
+
const emitted = new Set<string>();
|
|
166
|
+
for (const surface of ALL_SURFACES) {
|
|
167
|
+
const keep = new Set<string>(
|
|
168
|
+
[...scan.surfaces.entries()]
|
|
169
|
+
.filter(([, s]) => s === surface)
|
|
170
|
+
.map(([id]) => id),
|
|
171
|
+
);
|
|
172
|
+
const specPath = surfaceSpecPath(ctx, surface, outDir);
|
|
173
|
+
const { doc, pathCount } = pruneSpecForSurface(fullDoc, keep);
|
|
174
|
+
if (pathCount === 0) {
|
|
175
|
+
// Surface has no endpoints → emit nothing; clean any stale file.
|
|
176
|
+
if (existsSync(specPath)) {
|
|
177
|
+
rmSync(specPath);
|
|
178
|
+
}
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
for (const id of collectOperationIds(doc)) {
|
|
182
|
+
emitted.add(id);
|
|
183
|
+
}
|
|
184
|
+
writeFileSync(specPath, `${JSON.stringify(doc, null, 2)}\n`, 'utf8');
|
|
185
|
+
written.push(surface);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Closing the loop on "no silent drop": the checks above prove every doc
|
|
189
|
+
// operation was CLASSIFIED; this one proves every classified operation was
|
|
190
|
+
// actually WRITTEN. Together they make the union of the per-surface specs a
|
|
191
|
+
// lossless partition of the full document, so a pruner/classifier defect
|
|
192
|
+
// can never quietly shrink a spec (and, downstream, truncate a client) —
|
|
193
|
+
// it reds the build instead.
|
|
194
|
+
const expected = [...docOps].filter(
|
|
195
|
+
(id) => scan.surfaces.has(id) && !scan.excluded.has(id),
|
|
196
|
+
);
|
|
197
|
+
const lost = expected.filter((id) => !emitted.has(id));
|
|
198
|
+
if (lost.length > 0) {
|
|
199
|
+
throw new Error(
|
|
200
|
+
`api-client-generator: ${ctx.api.serviceId} classified ` +
|
|
201
|
+
`${expected.length} OpenAPI operation(s) but only wrote ` +
|
|
202
|
+
`${emitted.size} — ${lost.length} operation(s) were lost between the ` +
|
|
203
|
+
`document and the per-surface spec(s) (${lost.join(', ')}). The ` +
|
|
204
|
+
`emitted spec would be incomplete and would truncate the generated ` +
|
|
205
|
+
`client.`,
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// eslint-disable-next-line no-console
|
|
210
|
+
console.log(
|
|
211
|
+
`[xema-openapi] ${ctx.api.serviceId}: wrote ${
|
|
212
|
+
written.length
|
|
213
|
+
} surface spec(s) → ${written.join(', ') || '(none)'} ` +
|
|
214
|
+
`[${formatRouteExtensionCoverage(coverage)}]`,
|
|
215
|
+
);
|
|
216
|
+
return written;
|
|
217
|
+
} finally {
|
|
218
|
+
await app.close().catch(() => {});
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** CLI entry: run extraction from cwd; exit non-zero on failure. */
|
|
223
|
+
export function runOpenApiCli(): void {
|
|
224
|
+
// `--out-dir <path>` redirects the specs out of the service directory. The
|
|
225
|
+
// only caller is the code→spec drift gate; without an argument the CLI is
|
|
226
|
+
// byte-for-byte what `pnpm openapi` has always done.
|
|
227
|
+
const flagIndex = process.argv.indexOf('--out-dir');
|
|
228
|
+
if (flagIndex !== -1 && process.argv[flagIndex + 1] === undefined) {
|
|
229
|
+
console.error('[xema-openapi] --out-dir requires a path');
|
|
230
|
+
process.exit(1);
|
|
231
|
+
}
|
|
232
|
+
const outDir = flagIndex === -1 ? undefined : process.argv[flagIndex + 1];
|
|
233
|
+
|
|
234
|
+
extractOpenApiForService(process.cwd(), outDir)
|
|
235
|
+
.then(() => process.exit(0))
|
|
236
|
+
.catch((err: unknown) => {
|
|
237
|
+
console.error('[xema-openapi] extraction failed:', err);
|
|
238
|
+
process.exit(1);
|
|
239
|
+
});
|
|
240
|
+
}
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { existsSync, rmSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { pathToFileURL } from 'node:url';
|
|
5
|
+
|
|
6
|
+
import { ALL_SURFACES, type ApiSurface } from '@xemahq/xema-decorators';
|
|
7
|
+
|
|
8
|
+
import { ensureClientPackage } from './client-package';
|
|
9
|
+
import { ensureClientReadme } from './client-readme';
|
|
10
|
+
import { postProcessClient } from './repo-post-process';
|
|
11
|
+
import {
|
|
12
|
+
resolveServiceContext,
|
|
13
|
+
surfaceClientDir,
|
|
14
|
+
surfaceClientPackageName,
|
|
15
|
+
surfaceSpecPath,
|
|
16
|
+
type ResolvedServiceContext,
|
|
17
|
+
} from './service-config';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Native dynamic import of a module resolved from the SERVICE's node_modules
|
|
21
|
+
* (so we use the service's orval, not the generator's). `new Function` keeps
|
|
22
|
+
* the `import()` from being downleveled to `require()` by the CJS transpile,
|
|
23
|
+
* so an ESM-only orval still loads.
|
|
24
|
+
*/
|
|
25
|
+
async function importFromService(
|
|
26
|
+
serviceDir: string,
|
|
27
|
+
request: string,
|
|
28
|
+
): Promise<Record<string, unknown>> {
|
|
29
|
+
const resolved = require.resolve(request, { paths: [serviceDir] });
|
|
30
|
+
const dynamicImport = new Function('u', 'return import(u)') as (
|
|
31
|
+
u: string,
|
|
32
|
+
) => Promise<Record<string, unknown>>;
|
|
33
|
+
return dynamicImport(pathToFileURL(resolved).href);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
type OrvalGenerate = (config: unknown) => Promise<unknown>;
|
|
37
|
+
|
|
38
|
+
async function resolveOrvalGenerate(
|
|
39
|
+
serviceDir: string,
|
|
40
|
+
): Promise<OrvalGenerate> {
|
|
41
|
+
const mod = await importFromService(serviceDir, 'orval');
|
|
42
|
+
const generate =
|
|
43
|
+
(mod.generate as OrvalGenerate | undefined) ??
|
|
44
|
+
(mod.default as { generate?: OrvalGenerate } | undefined)?.generate;
|
|
45
|
+
if (typeof generate !== 'function') {
|
|
46
|
+
throw new Error(
|
|
47
|
+
'api-client-generator: could not resolve orval `generate` from the service.',
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
return generate;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* In-memory orval config equivalent to the legacy per-service orval.config.ts.
|
|
55
|
+
* orval's programmatic `generate()` expects a SINGLE normalized project config
|
|
56
|
+
* (`{ input, output }`), not the named-project map the CLI reads from a file.
|
|
57
|
+
*/
|
|
58
|
+
function orvalConfigFor(specPath: string, clientSrc: string): unknown {
|
|
59
|
+
return {
|
|
60
|
+
input: { target: specPath },
|
|
61
|
+
output: {
|
|
62
|
+
mode: 'tags-split',
|
|
63
|
+
target: `${clientSrc}/endpoints`,
|
|
64
|
+
schemas: `${clientSrc}/models`,
|
|
65
|
+
client: 'fetch',
|
|
66
|
+
override: {
|
|
67
|
+
fetch: { includeHttpResponseReturnType: false },
|
|
68
|
+
mutator: {
|
|
69
|
+
path: `${clientSrc}/custom-fetch.ts`,
|
|
70
|
+
name: 'customFetch',
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Orphan backlog, keyed `<serviceId>:<surface>` → why it is not fixable from
|
|
79
|
+
* the generator. **Currently EMPTY, and it must stay that way.**
|
|
80
|
+
*
|
|
81
|
+
* It briefly carried five entries: services that capability auto-projection had
|
|
82
|
+
* reclassified fully-internal without retiring their PUBLIC-surface client
|
|
83
|
+
* package. All five are resolved — `audit-log-api` regained a genuine Public
|
|
84
|
+
* surface (two `platform_admin` browser reads); `biome-fetcher-api`,
|
|
85
|
+
* `policy-distributor-api`, `workspace-git-api` and
|
|
86
|
+
* `workspace-storage-pool-api` are service-to-service only and their public
|
|
87
|
+
* client packages were deleted.
|
|
88
|
+
*
|
|
89
|
+
* An entry here is a tracked backlog item, never a silent pass — the same
|
|
90
|
+
* contract as `tooling/boundaries/stack-confidentiality-baseline.json`. Adding
|
|
91
|
+
* one buys time for a condition the generator genuinely cannot resolve; it is
|
|
92
|
+
* NOT a place to park a newly-orphaned client. Resolve the orphan instead:
|
|
93
|
+
* either the service emits the surface (fix the classification at the route,
|
|
94
|
+
* and give it a real guard) or it does not (delete the package and migrate
|
|
95
|
+
* consumers to the internal client).
|
|
96
|
+
*/
|
|
97
|
+
const KNOWN_ORPHANED_SURFACE_CLIENTS: Readonly<Record<string, string>> =
|
|
98
|
+
Object.freeze({});
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Fail fast when a surface lost its spec (it has no endpoints any more) while a
|
|
102
|
+
* previously generated client package for that surface is still on disk with
|
|
103
|
+
* generated endpoints in it.
|
|
104
|
+
*
|
|
105
|
+
* Skipping such a surface silently — which is what the plain
|
|
106
|
+
* `if (!existsSync(specPath)) continue` did — leaves a package that still
|
|
107
|
+
* builds, still publishes and still exports operations the service no longer
|
|
108
|
+
* classifies into that surface. That is dead code the generator can neither
|
|
109
|
+
* refresh nor prune, so it must be an error the operator resolves (delete or
|
|
110
|
+
* deprecate the package), not a warning.
|
|
111
|
+
*
|
|
112
|
+
* Guarded by `surfacesWithSpec.length > 0`: when the service has NO spec at all
|
|
113
|
+
* the caller simply has not run `xema-openapi` yet, which is a different (and
|
|
114
|
+
* not-an-orphan) situation.
|
|
115
|
+
*
|
|
116
|
+
* {@link KNOWN_ORPHANED_SURFACE_CLIENTS} is the escape hatch for an orphan the
|
|
117
|
+
* generator genuinely cannot resolve. It is EMPTY — the backlog this guard
|
|
118
|
+
* originally uncovered has been worked off — so today every orphan is a hard
|
|
119
|
+
* error. See that constant before adding to it.
|
|
120
|
+
*/
|
|
121
|
+
function assertNoOrphanedSurfaceClients(
|
|
122
|
+
ctx: ResolvedServiceContext,
|
|
123
|
+
surfacesWithSpec: readonly ApiSurface[],
|
|
124
|
+
): void {
|
|
125
|
+
if (surfacesWithSpec.length === 0) {
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
const orphans = ALL_SURFACES.filter(
|
|
129
|
+
(surface) =>
|
|
130
|
+
!surfacesWithSpec.includes(surface) &&
|
|
131
|
+
existsSync(join(surfaceClientDir(ctx, surface), 'src', 'endpoints')),
|
|
132
|
+
);
|
|
133
|
+
if (orphans.length === 0) {
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const baselined = orphans.filter((surface) =>
|
|
138
|
+
Object.hasOwn(
|
|
139
|
+
KNOWN_ORPHANED_SURFACE_CLIENTS,
|
|
140
|
+
`${ctx.api.serviceId}:${surface}`,
|
|
141
|
+
),
|
|
142
|
+
);
|
|
143
|
+
const blocking = orphans.filter((surface) => !baselined.includes(surface));
|
|
144
|
+
|
|
145
|
+
for (const surface of baselined) {
|
|
146
|
+
const key = `${ctx.api.serviceId}:${surface}`;
|
|
147
|
+
|
|
148
|
+
console.warn(
|
|
149
|
+
`[xema-client-generate] KNOWN ORPHAN ${key} — ${surfaceClientDir(ctx, surface)} ` +
|
|
150
|
+
`is stale and will not be regenerated. ${KNOWN_ORPHANED_SURFACE_CLIENTS[key]}`,
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (blocking.length === 0) {
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
throw new Error(
|
|
158
|
+
`api-client-generator: ${ctx.api.serviceId} emits no spec for surface(s) ` +
|
|
159
|
+
`${blocking.join(', ')}, but a generated client package still exists at ` +
|
|
160
|
+
`${blocking.map((s) => surfaceClientDir(ctx, s)).join(', ')}. That client ` +
|
|
161
|
+
`is orphaned — it would keep exporting operations the surface no longer ` +
|
|
162
|
+
`has. Delete the package (and its workspace consumers) or restore the ` +
|
|
163
|
+
`surface classification.`,
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Generate (and compile) one ORVAL client package per surface that has an
|
|
169
|
+
* emitted spec. Surfaces without a spec are skipped — no empty packages. All
|
|
170
|
+
* generated clients share the service version (stamped by
|
|
171
|
+
* {@link ensureClientPackage}).
|
|
172
|
+
*
|
|
173
|
+
* @returns the surfaces that were generated.
|
|
174
|
+
*/
|
|
175
|
+
export async function generateClientsForService(
|
|
176
|
+
serviceDir: string = process.cwd(),
|
|
177
|
+
): Promise<ApiSurface[]> {
|
|
178
|
+
const ctx: ResolvedServiceContext = resolveServiceContext(serviceDir);
|
|
179
|
+
const generate = await resolveOrvalGenerate(ctx.serviceDir);
|
|
180
|
+
|
|
181
|
+
const surfacesWithSpec = ALL_SURFACES.filter((s) =>
|
|
182
|
+
existsSync(surfaceSpecPath(ctx, s)),
|
|
183
|
+
);
|
|
184
|
+
assertNoOrphanedSurfaceClients(ctx, surfacesWithSpec);
|
|
185
|
+
|
|
186
|
+
const generated: ApiSurface[] = [];
|
|
187
|
+
for (const surface of surfacesWithSpec) {
|
|
188
|
+
const specPath = surfaceSpecPath(ctx, surface);
|
|
189
|
+
const { clientDir, clientSrc } = ensureClientPackage(ctx, surface);
|
|
190
|
+
ensureClientReadme({
|
|
191
|
+
clientDir,
|
|
192
|
+
packageName: surfaceClientPackageName(ctx.api.serviceId, surface),
|
|
193
|
+
serviceTitle: ctx.api.title,
|
|
194
|
+
surface,
|
|
195
|
+
repoRoot: ctx.repoRoot,
|
|
196
|
+
});
|
|
197
|
+
// Clean prior generated output so endpoints/models removed from this
|
|
198
|
+
// surface's spec (e.g. routes reclassified to another surface) don't linger
|
|
199
|
+
// as stale files — orval does not prune removed operations.
|
|
200
|
+
rmSync(join(clientSrc, 'endpoints'), { recursive: true, force: true });
|
|
201
|
+
rmSync(join(clientSrc, 'models'), { recursive: true, force: true });
|
|
202
|
+
await generate(orvalConfigFor(specPath, clientSrc));
|
|
203
|
+
postProcessClient(ctx.repoRoot, ctx.serviceDir, clientSrc);
|
|
204
|
+
// `tsc` is additive: it emits for the sources it sees and NEVER deletes an
|
|
205
|
+
// output whose source disappeared. Without this, every operation ever
|
|
206
|
+
// generated into this surface survives in `dist/` forever — so a route
|
|
207
|
+
// reclassified to another surface keeps a stale `.js` + `.d.ts` in the old
|
|
208
|
+
// client. Consumers then type-check GREEN against a symbol the surface no
|
|
209
|
+
// longer has, the published tarball (`files: ["dist"]`) ships endpoints the
|
|
210
|
+
// spec does not contain, and the breakage only appears on a clean
|
|
211
|
+
// checkout/CI where `dist` starts empty. Pruning `dist` alongside
|
|
212
|
+
// `src/endpoints` + `src/models` keeps the compiled output a faithful image
|
|
213
|
+
// of the spec.
|
|
214
|
+
rmSync(join(clientDir, 'dist'), { recursive: true, force: true });
|
|
215
|
+
// Compile to dist using the client package's own tsconfig.
|
|
216
|
+
execFileSync('pnpm', ['exec', 'tsc', '-p', 'tsconfig.json'], {
|
|
217
|
+
cwd: clientDir,
|
|
218
|
+
stdio: 'inherit',
|
|
219
|
+
});
|
|
220
|
+
generated.push(surface);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// eslint-disable-next-line no-console
|
|
224
|
+
console.log(
|
|
225
|
+
`[xema-client-generate] ${ctx.api.serviceId}: generated ${
|
|
226
|
+
generated.length
|
|
227
|
+
} client(s) → ${generated.join(', ') || '(none)'}`,
|
|
228
|
+
);
|
|
229
|
+
return generated;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** CLI entry: generate clients from cwd; exit non-zero on failure. */
|
|
233
|
+
export function runClientGenerateCli(): void {
|
|
234
|
+
generateClientsForService()
|
|
235
|
+
.then(() => process.exit(0))
|
|
236
|
+
.catch((err: unknown) => {
|
|
237
|
+
console.error('[xema-client-generate] generation failed:', err);
|
|
238
|
+
process.exit(1);
|
|
239
|
+
});
|
|
240
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
|
|
4
|
+
import type { TestingModuleBuilder } from '@nestjs/testing';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Layer 3 of the DI-stub strategy: the optional per-service escape hatch. Most
|
|
8
|
+
* services need none of this — the universal env seed (Layer 1) + convention
|
|
9
|
+
* stubs (Layer 2) cover them. A service with a bespoke provider that boots at
|
|
10
|
+
* import/compile time ships an `openapi.overrides.ts` at its root:
|
|
11
|
+
*
|
|
12
|
+
* ```ts
|
|
13
|
+
* // <service>/openapi.overrides.ts
|
|
14
|
+
* import type { OpenApiOverrides } from '@xemahq/api-client-generator';
|
|
15
|
+
* export default {
|
|
16
|
+
* env: { SOME_CUSTOM_VAR: 'dummy' },
|
|
17
|
+
* applyOverrides: (builder) => builder.overrideProvider(WeirdSvc).useValue({ … }),
|
|
18
|
+
* } satisfies OpenApiOverrides;
|
|
19
|
+
* ```
|
|
20
|
+
*/
|
|
21
|
+
export interface OpenApiOverrides {
|
|
22
|
+
/** Extra dummy env vars seeded before `AppModule` is imported. */
|
|
23
|
+
readonly env?: Record<string, string>;
|
|
24
|
+
/**
|
|
25
|
+
* Hook applied to the `Test.createTestingModule(...)` builder AFTER the
|
|
26
|
+
* default convention stubs, so a service can override a default or stub an
|
|
27
|
+
* extra provider. Return the builder (or void — it is mutated in place).
|
|
28
|
+
*/
|
|
29
|
+
readonly applyOverrides?: (
|
|
30
|
+
builder: TestingModuleBuilder,
|
|
31
|
+
) => TestingModuleBuilder | void;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const OVERRIDES_BASENAME = 'openapi.overrides';
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Load `<serviceDir>/openapi.overrides.ts` (or `.js`) if present. Returns
|
|
38
|
+
* `undefined` when absent — the common case. Throws if the file exists but
|
|
39
|
+
* exports a malformed shape (fail-fast; no silent ignore).
|
|
40
|
+
*/
|
|
41
|
+
export function loadServiceOverrides(
|
|
42
|
+
serviceDir: string,
|
|
43
|
+
): OpenApiOverrides | undefined {
|
|
44
|
+
const candidate = [`${OVERRIDES_BASENAME}.ts`, `${OVERRIDES_BASENAME}.js`]
|
|
45
|
+
.map((f) => join(serviceDir, f))
|
|
46
|
+
.find((p) => existsSync(p));
|
|
47
|
+
if (!candidate) {
|
|
48
|
+
return undefined;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const loaded: unknown = require(candidate);
|
|
52
|
+
if (loaded === null || typeof loaded !== 'object') {
|
|
53
|
+
throw new Error(
|
|
54
|
+
`api-client-generator: ${candidate} must export an overrides object.`,
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
const selected =
|
|
58
|
+
'default' in loaded &&
|
|
59
|
+
loaded.default !== null &&
|
|
60
|
+
typeof loaded.default === 'object'
|
|
61
|
+
? loaded.default
|
|
62
|
+
: loaded;
|
|
63
|
+
const overrides = Object.fromEntries(Object.entries(selected));
|
|
64
|
+
if (
|
|
65
|
+
overrides.env !== undefined &&
|
|
66
|
+
(typeof overrides.env !== 'object' || overrides.env === null)
|
|
67
|
+
) {
|
|
68
|
+
throw new Error(
|
|
69
|
+
`api-client-generator: ${candidate} \`env\` must be a Record<string,string>.`,
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
if (
|
|
73
|
+
overrides.applyOverrides !== undefined &&
|
|
74
|
+
typeof overrides.applyOverrides !== 'function'
|
|
75
|
+
) {
|
|
76
|
+
throw new Error(
|
|
77
|
+
`api-client-generator: ${candidate} \`applyOverrides\` must be a function.`,
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
return overrides;
|
|
81
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Bridge to the repository's existing `tooling/codegen` ORVAL post-processors.
|
|
6
|
+
* The generator reuses them (rather than re-implementing) so the generated
|
|
7
|
+
* client is byte-identical to what `scripts/build-client-index.js` produced
|
|
8
|
+
* today — same barrel, same fixers, same arguments.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
interface PostProcessors {
|
|
12
|
+
regenerateModelsBarrel: (clientSrc: string) => number;
|
|
13
|
+
fixOrvalResponseTypes: (serviceRoot: string) => number;
|
|
14
|
+
fixOrvalArrayParams: (clientSrc: string) => number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function requireRepoTool(repoRoot: string, file: string): unknown {
|
|
18
|
+
const path = join(repoRoot, 'tooling', 'codegen', file);
|
|
19
|
+
if (!existsSync(path)) {
|
|
20
|
+
throw new Error(
|
|
21
|
+
`api-client-generator: required post-processor ${path} not found. The ` +
|
|
22
|
+
`repo must ship tooling/codegen/${file}.`,
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return require(path);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function loadRepoPostProcessors(repoRoot: string): PostProcessors {
|
|
30
|
+
const { regenerateModelsBarrel } = requireRepoTool(
|
|
31
|
+
repoRoot,
|
|
32
|
+
'regenerate-models-barrel.js',
|
|
33
|
+
) as { regenerateModelsBarrel: (c: string) => number };
|
|
34
|
+
const { fixOrvalResponseTypes } = requireRepoTool(
|
|
35
|
+
repoRoot,
|
|
36
|
+
'fix-orval-response-types.js',
|
|
37
|
+
) as { fixOrvalResponseTypes: (r: string) => number };
|
|
38
|
+
const { fixOrvalArrayParams } = requireRepoTool(
|
|
39
|
+
repoRoot,
|
|
40
|
+
'fix-orval-array-params.js',
|
|
41
|
+
) as { fixOrvalArrayParams: (c: string) => number };
|
|
42
|
+
return { regenerateModelsBarrel, fixOrvalResponseTypes, fixOrvalArrayParams };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Rebuild `<clientSrc>/index.ts` — the barrel re-exporting custom-fetch +
|
|
47
|
+
* models + every tags-split endpoint file. Identical to the loop in the legacy
|
|
48
|
+
* per-service `build-client-index.js`.
|
|
49
|
+
*/
|
|
50
|
+
function rebuildIndexBarrel(clientSrc: string): number {
|
|
51
|
+
const lines = [
|
|
52
|
+
'// Auto-generated by @xemahq/api-client-generator — do not edit manually.',
|
|
53
|
+
"export { configureClient, getClientConfig, ClientError, customFetch, type ClientConfig } from './custom-fetch';",
|
|
54
|
+
];
|
|
55
|
+
|
|
56
|
+
const modelsIndex = join(clientSrc, 'models', 'index.ts');
|
|
57
|
+
if (existsSync(modelsIndex)) {
|
|
58
|
+
const content = readFileSync(modelsIndex, 'utf8');
|
|
59
|
+
if (/^export /m.test(content)) {
|
|
60
|
+
lines.push("export * from './models';");
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const endpointsDir = join(clientSrc, 'endpoints');
|
|
65
|
+
if (existsSync(endpointsDir)) {
|
|
66
|
+
for (const entry of readdirSync(endpointsDir, { withFileTypes: true })) {
|
|
67
|
+
if (!entry.isDirectory()) {
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
const files = readdirSync(join(endpointsDir, entry.name));
|
|
71
|
+
const tsFile = files.find(
|
|
72
|
+
(f) => f.endsWith('.ts') && !f.endsWith('.d.ts'),
|
|
73
|
+
);
|
|
74
|
+
if (tsFile) {
|
|
75
|
+
lines.push(
|
|
76
|
+
`export * from './endpoints/${entry.name}/${tsFile.replace('.ts', '')}';`,
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
lines.push('');
|
|
83
|
+
writeFileSync(join(clientSrc, 'index.ts'), lines.join('\n'));
|
|
84
|
+
return lines.length - 2;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Run the full post-ORVAL pipeline for one generated client, mirroring the
|
|
89
|
+
* legacy `build-client-index.js` ordering exactly: models barrel → index
|
|
90
|
+
* barrel → response-type fix → array-param fix.
|
|
91
|
+
*
|
|
92
|
+
* `serviceRoot` is passed to `fixOrvalResponseTypes` unchanged from the legacy
|
|
93
|
+
* call site (it resolves its own models path internally), so the output is
|
|
94
|
+
* identical to today's pipeline.
|
|
95
|
+
*/
|
|
96
|
+
export function postProcessClient(
|
|
97
|
+
repoRoot: string,
|
|
98
|
+
serviceRoot: string,
|
|
99
|
+
clientSrc: string,
|
|
100
|
+
): void {
|
|
101
|
+
const tools = loadRepoPostProcessors(repoRoot);
|
|
102
|
+
tools.regenerateModelsBarrel(clientSrc);
|
|
103
|
+
rebuildIndexBarrel(clientSrc);
|
|
104
|
+
tools.fixOrvalResponseTypes(serviceRoot);
|
|
105
|
+
tools.fixOrvalArrayParams(clientSrc);
|
|
106
|
+
}
|