@xemahq/api-client-generator 0.4.3 → 0.5.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/lib/generate-clients.d.ts.map +1 -1
- package/dist/lib/generate-clients.js +7 -0
- package/dist/lib/generate-clients.js.map +1 -1
- package/package.json +3 -4
- package/src/bin/xema-client-generate.ts +0 -11
- package/src/bin/xema-openapi.ts +0 -51
- package/src/index.ts +0 -9
- package/src/lib/client-package.ts +0 -220
- package/src/lib/client-readme.ts +0 -124
- package/src/lib/custom-fetch-template.ts +0 -198
- package/src/lib/default-stubs.ts +0 -422
- package/src/lib/extract-openapi.ts +0 -240
- package/src/lib/generate-clients.ts +0 -240
- package/src/lib/overrides.ts +0 -81
- package/src/lib/repo-post-process.ts +0 -106
- package/src/lib/seed-openapi-env.ts +0 -136
- package/src/lib/service-config.ts +0 -170
- package/src/lib/service-nest.ts +0 -58
|
@@ -1,198 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Canonical `custom-fetch.ts` body, identical to the hand-maintained mutator
|
|
3
|
-
* shipped in every existing client package. Used ONLY as the fallback when a
|
|
4
|
-
* freshly scaffolded surface client has no public-surface sibling to copy from
|
|
5
|
-
* (a pure-internal/operator service). Kept byte-identical to the committed
|
|
6
|
-
* version so all clients share one fetch implementation.
|
|
7
|
-
*/
|
|
8
|
-
export const CANONICAL_CUSTOM_FETCH = `/**
|
|
9
|
-
* Configurable fetch wrapper for Orval-generated clients.
|
|
10
|
-
*
|
|
11
|
-
* Consumers must call \`configureClient()\` before using any endpoint function.
|
|
12
|
-
* The baseUrl is prepended to the relative paths generated by Orval.
|
|
13
|
-
*/
|
|
14
|
-
|
|
15
|
-
export interface ClientConfig {
|
|
16
|
-
/** Static base URL (e.g. 'http://localhost:3140') — no trailing slash. */
|
|
17
|
-
baseUrl?: string;
|
|
18
|
-
/** Per-request base-URL resolver (service-registry-safe). */
|
|
19
|
-
baseUrlResolver?: () => string | Promise<string>;
|
|
20
|
-
/** Optional async callback to get an auth token. */
|
|
21
|
-
getAuthToken?: () => Promise<string>;
|
|
22
|
-
/** Optional callback returning headers to inject on every request. */
|
|
23
|
-
getHeaders?: () => Record<string, string> | Promise<Record<string, string>>;
|
|
24
|
-
/** Optional callback invoked on 401 before a single retry. */
|
|
25
|
-
onUnauthorized?: () => Promise<void>;
|
|
26
|
-
/** Maximum retry attempts for transient failures (default: 3). */
|
|
27
|
-
maxRetries?: number;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
export class ClientError extends Error {
|
|
31
|
-
constructor(
|
|
32
|
-
public readonly status: number,
|
|
33
|
-
public readonly url: string,
|
|
34
|
-
public readonly body: unknown,
|
|
35
|
-
) {
|
|
36
|
-
super(\`HTTP \${status} from \${url}\`);
|
|
37
|
-
this.name = 'ClientError';
|
|
38
|
-
}
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
let clientConfig: ClientConfig | null = null;
|
|
42
|
-
|
|
43
|
-
export function configureClient(config: ClientConfig): void {
|
|
44
|
-
clientConfig = config;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
export function getClientConfig(): ClientConfig {
|
|
48
|
-
if (!clientConfig) {
|
|
49
|
-
throw new Error(
|
|
50
|
-
'Client not configured. Call configureClient({ baseUrl }) before using endpoint functions.',
|
|
51
|
-
);
|
|
52
|
-
}
|
|
53
|
-
return clientConfig;
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
const RETRYABLE_STATUSES = [429, 502, 503, 504];
|
|
57
|
-
|
|
58
|
-
async function buildHeaders(
|
|
59
|
-
config: ClientConfig,
|
|
60
|
-
callerHeaders: RequestInit['headers'],
|
|
61
|
-
): Promise<Headers> {
|
|
62
|
-
const headers = new Headers(callerHeaders);
|
|
63
|
-
if (config.getHeaders) {
|
|
64
|
-
const globalHeaders = await Promise.resolve(config.getHeaders());
|
|
65
|
-
for (const [key, value] of Object.entries(globalHeaders)) {
|
|
66
|
-
if (!headers.has(key)) {
|
|
67
|
-
headers.set(key, value);
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
}
|
|
71
|
-
if (config.getAuthToken && !headers.has('Authorization')) {
|
|
72
|
-
const token = await config.getAuthToken();
|
|
73
|
-
headers.set('Authorization', \`Bearer \${token}\`);
|
|
74
|
-
}
|
|
75
|
-
return headers;
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
export const customFetch = async <T>(
|
|
79
|
-
url: string,
|
|
80
|
-
options: RequestInit,
|
|
81
|
-
): Promise<T> => {
|
|
82
|
-
const config = getClientConfig();
|
|
83
|
-
const base = config.baseUrlResolver
|
|
84
|
-
? await config.baseUrlResolver()
|
|
85
|
-
: config.baseUrl;
|
|
86
|
-
if (base === undefined) {
|
|
87
|
-
throw new Error(
|
|
88
|
-
'Client not configured: set baseUrl or baseUrlResolver via configureClient().',
|
|
89
|
-
);
|
|
90
|
-
}
|
|
91
|
-
const fullUrl = \`\${base}\${url}\`;
|
|
92
|
-
const maxRetries = config.maxRetries ?? 3;
|
|
93
|
-
|
|
94
|
-
const headers = await buildHeaders(config, options.headers);
|
|
95
|
-
const requestInit: RequestInit = { ...options, headers };
|
|
96
|
-
|
|
97
|
-
let delay = 1000;
|
|
98
|
-
let lastError: Error | undefined;
|
|
99
|
-
|
|
100
|
-
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
101
|
-
try {
|
|
102
|
-
const response = await fetch(fullUrl, requestInit);
|
|
103
|
-
|
|
104
|
-
if (response.status === 401 && config.onUnauthorized && attempt === 0) {
|
|
105
|
-
await config.onUnauthorized();
|
|
106
|
-
const refreshedHeaders = await buildHeaders(config, options.headers);
|
|
107
|
-
const retryResponse = await fetch(fullUrl, { ...options, headers: refreshedHeaders });
|
|
108
|
-
const retryBody = await parseBody(retryResponse);
|
|
109
|
-
if (retryResponse.status >= 400) {
|
|
110
|
-
throw new ClientError(retryResponse.status, fullUrl, retryBody);
|
|
111
|
-
}
|
|
112
|
-
return retryBody as T;
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
if (!RETRYABLE_STATUSES.includes(response.status) || attempt >= maxRetries) {
|
|
116
|
-
const body = await parseBody(response);
|
|
117
|
-
if (response.status >= 400) {
|
|
118
|
-
throw new ClientError(response.status, fullUrl, body);
|
|
119
|
-
}
|
|
120
|
-
return body as T;
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
const retryAfter = parseRetryAfter(response.headers.get('Retry-After'));
|
|
124
|
-
const waitMs = retryAfter ?? addJitter(delay);
|
|
125
|
-
await sleep(waitMs);
|
|
126
|
-
delay = Math.min(delay * 2, 30_000);
|
|
127
|
-
} catch (error) {
|
|
128
|
-
if (error instanceof ClientError) throw error;
|
|
129
|
-
lastError = error instanceof Error ? error : new Error(String(error));
|
|
130
|
-
if (attempt >= maxRetries) throw lastError;
|
|
131
|
-
const waitMs = addJitter(delay);
|
|
132
|
-
await sleep(waitMs);
|
|
133
|
-
delay = Math.min(delay * 2, 30_000);
|
|
134
|
-
}
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
throw lastError ?? new Error(\`All retries exhausted for \${fullUrl}\`);
|
|
138
|
-
};
|
|
139
|
-
|
|
140
|
-
/*
|
|
141
|
-
* THE TRANSPORT RETURNS THE BODY UNCHANGED. Do not reintroduce an unwrap here.
|
|
142
|
-
*
|
|
143
|
-
* Xema services wrap every 2xx payload in a { data: T } envelope via the global
|
|
144
|
-
* ResponseEnvelopeInterceptor (platform-common), and the generator now emits
|
|
145
|
-
* types that describe THAT ENVELOPE — every endpoint returns
|
|
146
|
-
* Promise<XDataEnvelope> or Promise<XPaginatedEnvelope>, never a bare inner
|
|
147
|
-
* T. Consumers read the data property themselves.
|
|
148
|
-
*
|
|
149
|
-
* This template used to peel data, justified by a comment claiming generated
|
|
150
|
-
* types describe the inner T. That stopped being true when the generator moved
|
|
151
|
-
* to envelope-typed returns, and the comment outlived the fact — so the peel
|
|
152
|
-
* then contradicted every type in every package it seeded. Four clients shipped
|
|
153
|
-
* that way (resource-governance x3, workload-runtime-api): declared
|
|
154
|
-
* *DataEnvelope, returned the already-peeled inner object, so .data read
|
|
155
|
-
* undefined at runtime on every non-paginated endpoint. Paginated calls hid
|
|
156
|
-
* it, because the old peel deliberately preserved an envelope carrying
|
|
157
|
-
* pagination.
|
|
158
|
-
*
|
|
159
|
-
* ensureCustomFetch seeds this template into any client that lacks a
|
|
160
|
-
* transport, so a peel here is not one bug — it is re-imported into every new
|
|
161
|
-
* client generated from now on, which is exactly how those four were born after
|
|
162
|
-
* the hand-fix of their siblings.
|
|
163
|
-
*
|
|
164
|
-
* Enforced fleet-wide by check-client-transport-envelope in xema-base, which
|
|
165
|
-
* reads the RETURN SITES rather than banning a name — a rename, an inline
|
|
166
|
-
* .data, or ?? body.data all walk past a string ban.
|
|
167
|
-
*/
|
|
168
|
-
|
|
169
|
-
async function parseBody(response: Response): Promise<unknown> {
|
|
170
|
-
const contentType = response.headers.get('content-type');
|
|
171
|
-
if (contentType?.includes('application/json')) {
|
|
172
|
-
return response.json();
|
|
173
|
-
}
|
|
174
|
-
if (response.status === 204) {
|
|
175
|
-
return undefined;
|
|
176
|
-
}
|
|
177
|
-
return response.text();
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
function parseRetryAfter(value: string | null): number | undefined {
|
|
181
|
-
if (!value) return undefined;
|
|
182
|
-
const seconds = Number(value);
|
|
183
|
-
if (!isNaN(seconds) && seconds >= 0) return seconds * 1000;
|
|
184
|
-
const date = new Date(value);
|
|
185
|
-
if (!isNaN(date.getTime())) return Math.max(0, date.getTime() - Date.now());
|
|
186
|
-
return undefined;
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
function addJitter(delay: number): number {
|
|
190
|
-
return delay + (Math.random() * 2 - 1) * delay * 0.25;
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
function sleep(ms: number): Promise<void> {
|
|
194
|
-
return new Promise(resolve => setTimeout(resolve, ms));
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
export default customFetch;
|
|
198
|
-
`;
|
package/src/lib/default-stubs.ts
DELETED
|
@@ -1,422 +0,0 @@
|
|
|
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
|
-
}
|