@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.
- package/dist/bin/xema-openapi.js +2 -2
- package/dist/bin/xema-openapi.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 +16 -7
- 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 +1 -1
- 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.map +1 -1
- package/dist/lib/seed-openapi-env.js +0 -20
- 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 +5 -3
- 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
|
@@ -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
|
+
}
|