@xemahq/api-client-generator 0.2.0 → 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.
Files changed (46) hide show
  1. package/dist/bin/xema-openapi.js +2 -2
  2. package/dist/bin/xema-openapi.js.map +1 -1
  3. package/dist/lib/client-package.d.ts.map +1 -1
  4. package/dist/lib/client-package.js +9 -4
  5. package/dist/lib/client-package.js.map +1 -1
  6. package/dist/lib/default-stubs.d.ts.map +1 -1
  7. package/dist/lib/default-stubs.js +35 -10
  8. package/dist/lib/default-stubs.js.map +1 -1
  9. package/dist/lib/extract-openapi.d.ts +2 -2
  10. package/dist/lib/extract-openapi.d.ts.map +1 -1
  11. package/dist/lib/extract-openapi.js +16 -7
  12. package/dist/lib/extract-openapi.js.map +1 -1
  13. package/dist/lib/generate-clients.d.ts +1 -1
  14. package/dist/lib/generate-clients.d.ts.map +1 -1
  15. package/dist/lib/generate-clients.js +1 -1
  16. package/dist/lib/generate-clients.js.map +1 -1
  17. package/dist/lib/overrides.d.ts.map +1 -1
  18. package/dist/lib/overrides.js +10 -2
  19. package/dist/lib/overrides.js.map +1 -1
  20. package/dist/lib/repo-post-process.d.ts.map +1 -1
  21. package/dist/lib/repo-post-process.js +2 -1
  22. package/dist/lib/repo-post-process.js.map +1 -1
  23. package/dist/lib/seed-openapi-env.d.ts.map +1 -1
  24. package/dist/lib/seed-openapi-env.js +0 -20
  25. package/dist/lib/seed-openapi-env.js.map +1 -1
  26. package/dist/lib/service-config.d.ts +1 -1
  27. package/dist/lib/service-config.d.ts.map +1 -1
  28. package/dist/lib/service-config.js +2 -2
  29. package/dist/lib/service-config.js.map +1 -1
  30. package/dist/lib/service-nest.d.ts.map +1 -1
  31. package/dist/lib/service-nest.js.map +1 -1
  32. package/package.json +5 -3
  33. package/src/bin/xema-client-generate.ts +11 -0
  34. package/src/bin/xema-openapi.ts +51 -0
  35. package/src/index.ts +9 -0
  36. package/src/lib/client-package.ts +215 -0
  37. package/src/lib/client-readme.ts +124 -0
  38. package/src/lib/custom-fetch-template.ts +200 -0
  39. package/src/lib/default-stubs.ts +422 -0
  40. package/src/lib/extract-openapi.ts +240 -0
  41. package/src/lib/generate-clients.ts +240 -0
  42. package/src/lib/overrides.ts +81 -0
  43. package/src/lib/repo-post-process.ts +106 -0
  44. package/src/lib/seed-openapi-env.ts +137 -0
  45. package/src/lib/service-config.ts +170 -0
  46. package/src/lib/service-nest.ts +58 -0
@@ -0,0 +1,422 @@
1
+ import { dirname, join } from 'node:path';
2
+
3
+ import type { TestingModuleBuilder } from '@nestjs/testing';
4
+
5
+ /**
6
+ * Layer 2 of the DI-stub strategy: convention stubs applied to every service.
7
+ *
8
+ * Each token is resolved DYNAMICALLY from the service's own `node_modules`
9
+ * (or, for `PrismaService`, the conventional `src/prisma/prisma.service`
10
+ * path) so the generator stays decoupled — it declares no dependency on
11
+ * identity-client / event-hub-client / platform-common / the service's prisma
12
+ * module. A token that does not resolve is simply skipped (the service does
13
+ * not use it). A resolved token that is not actually in the DI graph yields a
14
+ * harmless unused override.
15
+ *
16
+ * This is NOT a heuristic: the conventional prisma path either resolves to a
17
+ * real module or it does not. When a service's wiring needs something beyond
18
+ * this baseline, it supplies an `openapi.overrides.ts` (Layer 3).
19
+ */
20
+
21
+ /** Resolve a module export from the service's resolution root; undefined if absent. */
22
+ function tryResolveExport(
23
+ fromDir: string,
24
+ request: string,
25
+ exportName: string,
26
+ ): unknown {
27
+ try {
28
+ const resolved = require.resolve(request, { paths: [fromDir] });
29
+
30
+ const mod = require(resolved) as Record<string, unknown>;
31
+ return mod[exportName];
32
+ } catch {
33
+ return undefined;
34
+ }
35
+ }
36
+
37
+ /** The service's package.json `name` (used only for dummy identity registration
38
+ * fields that extraction never dials); falls back to the directory basename. */
39
+ function readServiceName(serviceDir: string): string {
40
+ try {
41
+ const pkg = require(join(serviceDir, 'package.json')) as { name?: string };
42
+ if (typeof pkg.name === 'string' && pkg.name.length > 0) {
43
+ return pkg.name;
44
+ }
45
+ } catch {
46
+ // no readable package.json — fall through to the basename.
47
+ }
48
+ return (
49
+ dirname(join(serviceDir, 'x')).split(/[\\/]/).pop() ?? 'openapi-service'
50
+ );
51
+ }
52
+
53
+ /**
54
+ * The two accessors `@xemahq/biome-database-nest` declares as returning a
55
+ * CLIENT rather than a query result (`prisma-service-factory.ts`:
56
+ * `asSystem(): TClient`, `forOrg(orgId): TClient`).
57
+ *
58
+ * They must return the stub itself. The proxy's `apply` trap answers every
59
+ * call with `Promise.resolve([])`, which is right for a query but wrong for
60
+ * an accessor — `prisma.asSystem()` yielded a Promise, so every consumer that
61
+ * reads a property off the returned client saw `undefined`. That is not a
62
+ * cosmetic mismatch: `OrgErasureModule`'s boot gate reads
63
+ * `client._runtimeDataModel.models` off it and refuses to start, which broke
64
+ * OpenAPI extraction for EVERY service using org-erasure.
65
+ *
66
+ * A closed set from a typed interface, not a growing list of guessed names.
67
+ */
68
+ const CLIENT_ACCESSORS: ReadonlySet<string> = new Set(['asSystem', 'forOrg']);
69
+
70
+ /**
71
+ * The real model list, read from the service's GENERATED Prisma client
72
+ * without instantiating it or touching a database.
73
+ *
74
+ * `OrgErasureModule` asserts at boot that its `orgField` matches at least one
75
+ * model — a participant matching none would answer every erasure
76
+ * `{totalDeleted: 0}` and have it recorded as success. Handing the extraction
77
+ * an empty or fabricated model list would either defeat that gate or trip it
78
+ * for the wrong reason, so the stub carries the schema the service actually
79
+ * generated.
80
+ */
81
+ function readGeneratedModels(
82
+ serviceDir: string,
83
+ ): Record<string, { readonly fields: readonly { readonly name: string }[] }> {
84
+ const client = tryResolveExport(serviceDir, '@prisma/client', 'Prisma') as
85
+ | { dmmf?: { datamodel?: { models?: readonly PrismaDmmfModel[] } } }
86
+ | undefined;
87
+ const models = client?.dmmf?.datamodel?.models;
88
+ if (models === undefined) {
89
+ throw new Error(
90
+ `[openapi-stubs] ${serviceDir} has a PrismaService but its generated ` +
91
+ `Prisma client exposes no dmmf datamodel. Run \`pnpm prisma:generate\` ` +
92
+ `in that service before extracting — the extraction cannot fabricate a ` +
93
+ `schema, and a service using OrgErasureModule asserts against it at boot.`,
94
+ );
95
+ }
96
+ return Object.fromEntries(models.map((model) => [model.name, model]));
97
+ }
98
+
99
+ interface PrismaDmmfModel {
100
+ readonly name: string;
101
+ readonly fields: readonly { readonly name: string }[];
102
+ }
103
+
104
+ /** The defensive Prisma stub the hand-written scripts used: every access is a no-op thenable-free proxy. */
105
+ function createPrismaStub(serviceDir: string): unknown {
106
+ const runtimeDataModel = { models: readGeneratedModels(serviceDir) };
107
+ const handler: ProxyHandler<(...args: unknown[]) => unknown> = {
108
+ get: (_t, prop) => {
109
+ if (prop === 'then') {
110
+ return undefined;
111
+ }
112
+ if (prop === '_runtimeDataModel') {
113
+ return runtimeDataModel;
114
+ }
115
+ if (prop === '$connect' || prop === '$disconnect' || prop === '$on') {
116
+ return () => Promise.resolve();
117
+ }
118
+ if (typeof prop === 'string' && CLIENT_ACCESSORS.has(prop)) {
119
+ return () => stub;
120
+ }
121
+ return new Proxy(() => Promise.resolve([]), handler);
122
+ },
123
+ apply: () => Promise.resolve([]),
124
+ };
125
+ const stub: unknown = new Proxy({} as never, handler);
126
+ return stub;
127
+ }
128
+
129
+ const IDENTITY_STUB = {
130
+ waitForReady: () => Promise.resolve(true),
131
+ getAccessToken: () => Promise.resolve('dummy-token'),
132
+ getKeycloakBaseUrl: () => 'http://localhost:8180',
133
+ getAllowedRealms: () => ['master'],
134
+ getClientId: () => 'openapi-extraction',
135
+ };
136
+
137
+ const EVENT_HUB_BOOTSTRAP_STUB = {
138
+ onModuleInit: async () => {},
139
+ onModuleDestroy: async () => {},
140
+ isEventHubRegistered: () => true,
141
+ waitForRegistration: () => Promise.resolve(true),
142
+ };
143
+
144
+ const EVENT_HUB_CLIENT_STUB = {
145
+ getAuthInfo: () => ({
146
+ jwksUrl: 'http://localhost:8180/realms/dummy/protocol/openid-connect/certs',
147
+ issuer: 'http://localhost:8180/realms/dummy',
148
+ }),
149
+ };
150
+
151
+ /**
152
+ * `@nestjs/core` provider CLASSES that a foreign-core dependency injects and that
153
+ * are only STORED (or used at a bootstrap phase extraction SKIPS), never during
154
+ * the metadata-only DI compile — so an inert cross-store stand-in is safe:
155
+ *
156
+ * - `ModuleRef` — `@xemahq/xema-service-nest` injects it into the
157
+ * `ServiceRegistryModule` options factory to LAZILY resolve
158
+ * `IdentityBootstrapService` for request-time service-token minting (a genuine
159
+ * module-init value cycle forbids injecting the service directly). The closure
160
+ * is never invoked during extraction.
161
+ * - `Reflector` — `@nestjs/schedule`'s `SchedulerMetadataAccessor` injects it;
162
+ * it is read only by the scheduler's `onApplicationBootstrap` scan, which
163
+ * extraction never runs. The api-surface scan reads decorator metadata through
164
+ * the global `reflect-metadata` polyfill, NOT a Nest `Reflector`, so stubbing
165
+ * this token does not affect classification.
166
+ */
167
+ const BRIDGED_CORE_PROVIDERS = ['ModuleRef', 'Reflector'] as const;
168
+
169
+ /**
170
+ * Dependencies that inject an `@nestjs/core` provider resolved from their OWN
171
+ * peer copy of `@nestjs/core`. In a multi-`@nestjs/core` checkout each resolves a
172
+ * copy DISTINCT from the service's, so the provider CLASS they inject mismatches
173
+ * the one the app's module registered under.
174
+ */
175
+ const FOREIGN_CORE_INJECTORS = [
176
+ '@xemahq/xema-service-nest',
177
+ '@xemahq/service-registry-nest',
178
+ '@nestjs/schedule',
179
+ ];
180
+
181
+ /**
182
+ * Apply the convention stubs to a `Test.createTestingModule(...)` builder.
183
+ * Returns the same builder for chaining.
184
+ */
185
+ export function applyDefaultOpenApiStubs(
186
+ builder: TestingModuleBuilder,
187
+ serviceDir: string,
188
+ ): TestingModuleBuilder {
189
+ // PrismaService — conventional service-local path.
190
+ const PrismaService = tryResolveExport(
191
+ serviceDir,
192
+ join(serviceDir, 'src', 'prisma', 'prisma.service'),
193
+ 'PrismaService',
194
+ );
195
+ if (PrismaService) {
196
+ builder
197
+ .overrideProvider(PrismaService as never)
198
+ .useValue(createPrismaStub(serviceDir));
199
+ }
200
+
201
+ // Identity bootstrap.
202
+ const IdentityBootstrapService = tryResolveExport(
203
+ serviceDir,
204
+ '@xemahq/identity-client',
205
+ 'IdentityBootstrapService',
206
+ );
207
+ if (IdentityBootstrapService) {
208
+ builder
209
+ .overrideProvider(IdentityBootstrapService as never)
210
+ .useValue(IDENTITY_STUB);
211
+ }
212
+
213
+ // Identity bootstrap CONFIG. `IdentityBootstrapModule.forRootAsync`'s options
214
+ // factory eagerly resolves `identity-api` from the service registry
215
+ // (`resolveHttpUrlEagerly`) at DI-compile time — which cannot succeed in an
216
+ // extraction sandbox (no live registry). Overriding the resolved config token
217
+ // skips that factory entirely; the `IdentityBootstrapService` that consumes it
218
+ // is already stubbed above, so these values are never read.
219
+ const IDENTITY_BOOTSTRAP_CONFIG = tryResolveExport(
220
+ serviceDir,
221
+ '@xemahq/identity-client',
222
+ 'IDENTITY_BOOTSTRAP_CONFIG',
223
+ );
224
+ if (IDENTITY_BOOTSTRAP_CONFIG) {
225
+ builder.overrideProvider(IDENTITY_BOOTSTRAP_CONFIG as never).useValue({
226
+ identityApiUrl: 'http://localhost:3053',
227
+ identityApiInternalToken: 'dummy-token',
228
+ serviceName: readServiceName(serviceDir),
229
+ serviceDisplayName: readServiceName(serviceDir),
230
+ });
231
+ }
232
+
233
+ // Event hub.
234
+ const EventHubBootstrapService = tryResolveExport(
235
+ serviceDir,
236
+ '@xemahq/event-hub-client',
237
+ 'EventHubBootstrapService',
238
+ );
239
+ if (EventHubBootstrapService) {
240
+ builder
241
+ .overrideProvider(EventHubBootstrapService as never)
242
+ .useValue(EVENT_HUB_BOOTSTRAP_STUB);
243
+ }
244
+ const EventHubClient = tryResolveExport(
245
+ serviceDir,
246
+ '@xemahq/event-hub-client',
247
+ 'EventHubClient',
248
+ );
249
+ if (EventHubClient) {
250
+ builder
251
+ .overrideProvider(EventHubClient as never)
252
+ .useValue(EVENT_HUB_CLIENT_STUB);
253
+ }
254
+
255
+ // Service-token guard (let every route through during extraction).
256
+ const ServiceTokenGuard = tryResolveExport(
257
+ serviceDir,
258
+ '@xemahq/platform-common',
259
+ 'ServiceTokenGuard',
260
+ );
261
+ if (ServiceTokenGuard) {
262
+ builder
263
+ .overrideGuard(ServiceTokenGuard as never)
264
+ .useValue({ canActivate: () => true });
265
+ }
266
+
267
+ // XemaRuntimeScanner — stub so it never runs during extraction (manifest
268
+ // emission / capability auto-register are irrelevant to spec extraction).
269
+ const XemaRuntimeScanner = tryResolveExport(
270
+ serviceDir,
271
+ '@xemahq/xema-decorators',
272
+ 'XemaRuntimeScanner',
273
+ );
274
+ if (XemaRuntimeScanner) {
275
+ builder.overrideProvider(XemaRuntimeScanner as never).useValue({
276
+ onApplicationBootstrap: async () => {},
277
+ });
278
+ }
279
+
280
+ // DiscoveryModule instantiates `DiscoveryService` EAGERLY (it is a provider,
281
+ // not lazy). Under local cross-repo dev the symlinked `@xemahq/xema-decorators`
282
+ // resolves a DIFFERENT `@nestjs/core` store than the service, so the
283
+ // DiscoveryService token built into the app may carry a `ModulesContainer`
284
+ // the service-store testing container can't resolve. Override it (from both
285
+ // the service's and the decorators' core stores) with an inert stub: the
286
+ // token actually in the graph is replaced; the other is a harmless no-op
287
+ // (CI's single-core per-repo install has only one). Spec extraction never
288
+ // needs it — the surface scan builds a fresh DiscoveryService from the app's
289
+ // real ModulesContainer afterwards.
290
+ //
291
+ // NOTE (local-dev only): other infra modules that eagerly inject core
292
+ // internals under a FOREIGN `@nestjs/core` hit the same multi-store mismatch
293
+ // locally — e.g. `@nestjs/schedule`'s `DiscoveryModule` provides its own
294
+ // `DiscoveryService` (→ `ModulesContainer`). We therefore override
295
+ // `DiscoveryService` in EVERY reachable core store (the service's, the
296
+ // decorators', and each foreign-core injector's). The exported core PROVIDER
297
+ // tokens those foreign modules inject (`Reflector`, `ModuleRef`) are bridged
298
+ // separately by {@link applyCrossStoreCoreProviderBridge}. The definitive fix
299
+ // remains a single `@nestjs/core` (which CI's standalone per-repo install
300
+ // gives); this is the aggregator/local-dev compensation.
301
+ const discoveryStub = { getProviders: () => [], getControllers: () => [] };
302
+ const coreRoots = new Set<string>([serviceDir]);
303
+ for (const pkg of ['@xemahq/xema-decorators', ...FOREIGN_CORE_INJECTORS]) {
304
+ try {
305
+ coreRoots.add(dirname(require.resolve(pkg, { paths: [serviceDir] })));
306
+ } catch {
307
+ // package not resolvable from this service — nothing to add for it.
308
+ }
309
+ }
310
+ const seenDiscovery = new Set<unknown>();
311
+ for (const root of coreRoots) {
312
+ const DiscoveryService = tryResolveExport(
313
+ root,
314
+ '@nestjs/core',
315
+ 'DiscoveryService',
316
+ );
317
+ if (DiscoveryService && !seenDiscovery.has(DiscoveryService)) {
318
+ seenDiscovery.add(DiscoveryService);
319
+ builder
320
+ .overrideProvider(DiscoveryService as never)
321
+ .useValue(discoveryStub);
322
+ }
323
+ }
324
+
325
+ // Service registry — the permissive build-time stub.
326
+ const SERVICE_REGISTRY_TOKEN = tryResolveExport(
327
+ serviceDir,
328
+ '@xemahq/service-registry-nest',
329
+ 'SERVICE_REGISTRY_TOKEN',
330
+ );
331
+ const createOpenApiStubRegistry = tryResolveExport(
332
+ serviceDir,
333
+ '@xemahq/service-registry-nest',
334
+ 'createOpenApiStubRegistry',
335
+ ) as (() => unknown) | undefined;
336
+ if (SERVICE_REGISTRY_TOKEN && createOpenApiStubRegistry) {
337
+ builder
338
+ .overrideProvider(SERVICE_REGISTRY_TOKEN as never)
339
+ .useValue(createOpenApiStubRegistry());
340
+ }
341
+
342
+ applyCrossStoreCoreProviderBridge(builder, serviceDir);
343
+
344
+ return builder;
345
+ }
346
+
347
+ /**
348
+ * Bridge cross-store `@nestjs/core` provider tokens across a dual-`@nestjs/core`
349
+ * checkout.
350
+ *
351
+ * A core-provider inject (`ModuleRef`, `Reflector`, …) is a lookup keyed by the
352
+ * provider CLASS OBJECT. NestJS registers one instance per module under the class
353
+ * from the `@nestjs/core` that INSTANTIATED the module — the SERVICE's copy, since
354
+ * `Test`/`NestFactory` are resolved from `serviceDir`. In a cross-repo / workspace
355
+ * checkout a symlinked dependency (e.g. `@xemahq/xema-service-nest`,
356
+ * `@nestjs/schedule`) resolves its OWN `@nestjs/core` via realpath, so the class it
357
+ * bakes into an `inject` array is a DIFFERENT object than the app's →
358
+ * `providers.has(Token)` is false → construction fails under BOTH `Test.compile()`
359
+ * AND `NestFactory` (this is a token-identity fault, not a boot-method one).
360
+ * Production and standalone-CI are single-`@nestjs/core`, so the class objects
361
+ * coincide and this never bites there — hence the bridge is a no-op when the
362
+ * foreign copy IS the service's copy.
363
+ *
364
+ * The cross-store token is resolved to an inert stand-in that FAIL-FASTS if any of
365
+ * its methods are actually called (they never are during a metadata-only compile).
366
+ * Every OTHER unresolved token returns `undefined`, so the `TestingInjector`
367
+ * rethrows as normal — no broad silent mocking, no swallowed dependency error.
368
+ * This extends `service-nest.ts`'s dual-store strategy to the core providers that
369
+ * cannot be redirected by resolving Nest from the service dir (their tokens are
370
+ * baked into module metadata at `AppModule` import time, before the generator gets
371
+ * control).
372
+ */
373
+ function applyCrossStoreCoreProviderBridge(
374
+ builder: TestingModuleBuilder,
375
+ serviceDir: string,
376
+ ): void {
377
+ const serviceProviders = BRIDGED_CORE_PROVIDERS.map((name) =>
378
+ tryResolveExport(serviceDir, '@nestjs/core', name),
379
+ );
380
+ const crossStore = new Set<unknown>();
381
+ for (const pkg of FOREIGN_CORE_INJECTORS) {
382
+ let pkgDir: string;
383
+ try {
384
+ pkgDir = dirname(require.resolve(pkg, { paths: [serviceDir] }));
385
+ } catch {
386
+ continue; // service does not depend on this package.
387
+ }
388
+ BRIDGED_CORE_PROVIDERS.forEach((name, i) => {
389
+ const foreign = tryResolveExport(pkgDir, '@nestjs/core', name);
390
+ // Only a copy DISTINCT from the service's own is a hazard; a single-core
391
+ // install resolves to the same class object → nothing to bridge.
392
+ if (foreign && foreign !== serviceProviders[i]) {
393
+ crossStore.add(foreign);
394
+ }
395
+ });
396
+ }
397
+ if (crossStore.size === 0) {
398
+ return; // single `@nestjs/core` end-to-end — every inject resolves natively.
399
+ }
400
+ // An inert stand-in. Property READS return a fail-fast function (so NestJS's
401
+ // benign resolution-time introspection — e.g. the `.then` thenable probe, which
402
+ // returns `undefined` — does not throw), and only CALLING a method fail-fasts.
403
+ const failFast = (): never => {
404
+ throw new Error(
405
+ 'api-client-generator: a cross-store @nestjs/core provider stub was invoked ' +
406
+ 'during OpenAPI extraction. It exists only so a provider can be CONSTRUCTED; ' +
407
+ 'it must never be called (extraction skips app.init() and serves no request).',
408
+ );
409
+ };
410
+ const inert = new Proxy(
411
+ {},
412
+ {
413
+ get(_target, prop): unknown {
414
+ if (prop === 'then' || typeof prop === 'symbol') {
415
+ return undefined; // thenable / well-known-symbol probes stay benign.
416
+ }
417
+ return failFast;
418
+ },
419
+ },
420
+ );
421
+ builder.useMocker((token) => (crossStore.has(token) ? inert : undefined));
422
+ }
@@ -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
+ }