@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,240 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { existsSync, rmSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { pathToFileURL } from 'node:url';
|
|
5
|
+
|
|
6
|
+
import { ALL_SURFACES, type ApiSurface } from '@xemahq/xema-decorators';
|
|
7
|
+
|
|
8
|
+
import { ensureClientPackage } from './client-package';
|
|
9
|
+
import { ensureClientReadme } from './client-readme';
|
|
10
|
+
import { postProcessClient } from './repo-post-process';
|
|
11
|
+
import {
|
|
12
|
+
resolveServiceContext,
|
|
13
|
+
surfaceClientDir,
|
|
14
|
+
surfaceClientPackageName,
|
|
15
|
+
surfaceSpecPath,
|
|
16
|
+
type ResolvedServiceContext,
|
|
17
|
+
} from './service-config';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Native dynamic import of a module resolved from the SERVICE's node_modules
|
|
21
|
+
* (so we use the service's orval, not the generator's). `new Function` keeps
|
|
22
|
+
* the `import()` from being downleveled to `require()` by the CJS transpile,
|
|
23
|
+
* so an ESM-only orval still loads.
|
|
24
|
+
*/
|
|
25
|
+
async function importFromService(
|
|
26
|
+
serviceDir: string,
|
|
27
|
+
request: string,
|
|
28
|
+
): Promise<Record<string, unknown>> {
|
|
29
|
+
const resolved = require.resolve(request, { paths: [serviceDir] });
|
|
30
|
+
const dynamicImport = new Function('u', 'return import(u)') as (
|
|
31
|
+
u: string,
|
|
32
|
+
) => Promise<Record<string, unknown>>;
|
|
33
|
+
return dynamicImport(pathToFileURL(resolved).href);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
type OrvalGenerate = (config: unknown) => Promise<unknown>;
|
|
37
|
+
|
|
38
|
+
async function resolveOrvalGenerate(
|
|
39
|
+
serviceDir: string,
|
|
40
|
+
): Promise<OrvalGenerate> {
|
|
41
|
+
const mod = await importFromService(serviceDir, 'orval');
|
|
42
|
+
const generate =
|
|
43
|
+
(mod.generate as OrvalGenerate | undefined) ??
|
|
44
|
+
(mod.default as { generate?: OrvalGenerate } | undefined)?.generate;
|
|
45
|
+
if (typeof generate !== 'function') {
|
|
46
|
+
throw new Error(
|
|
47
|
+
'api-client-generator: could not resolve orval `generate` from the service.',
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
return generate;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* In-memory orval config equivalent to the legacy per-service orval.config.ts.
|
|
55
|
+
* orval's programmatic `generate()` expects a SINGLE normalized project config
|
|
56
|
+
* (`{ input, output }`), not the named-project map the CLI reads from a file.
|
|
57
|
+
*/
|
|
58
|
+
function orvalConfigFor(specPath: string, clientSrc: string): unknown {
|
|
59
|
+
return {
|
|
60
|
+
input: { target: specPath },
|
|
61
|
+
output: {
|
|
62
|
+
mode: 'tags-split',
|
|
63
|
+
target: `${clientSrc}/endpoints`,
|
|
64
|
+
schemas: `${clientSrc}/models`,
|
|
65
|
+
client: 'fetch',
|
|
66
|
+
override: {
|
|
67
|
+
fetch: { includeHttpResponseReturnType: false },
|
|
68
|
+
mutator: {
|
|
69
|
+
path: `${clientSrc}/custom-fetch.ts`,
|
|
70
|
+
name: 'customFetch',
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Orphan backlog, keyed `<serviceId>:<surface>` → why it is not fixable from
|
|
79
|
+
* the generator. **Currently EMPTY, and it must stay that way.**
|
|
80
|
+
*
|
|
81
|
+
* It briefly carried five entries: services that capability auto-projection had
|
|
82
|
+
* reclassified fully-internal without retiring their PUBLIC-surface client
|
|
83
|
+
* package. All five are resolved — `audit-log-api` regained a genuine Public
|
|
84
|
+
* surface (two `platform_admin` browser reads); `biome-fetcher-api`,
|
|
85
|
+
* `policy-distributor-api`, `workspace-git-api` and
|
|
86
|
+
* `workspace-storage-pool-api` are service-to-service only and their public
|
|
87
|
+
* client packages were deleted.
|
|
88
|
+
*
|
|
89
|
+
* An entry here is a tracked backlog item, never a silent pass — the same
|
|
90
|
+
* contract as `tooling/boundaries/stack-confidentiality-baseline.json`. Adding
|
|
91
|
+
* one buys time for a condition the generator genuinely cannot resolve; it is
|
|
92
|
+
* NOT a place to park a newly-orphaned client. Resolve the orphan instead:
|
|
93
|
+
* either the service emits the surface (fix the classification at the route,
|
|
94
|
+
* and give it a real guard) or it does not (delete the package and migrate
|
|
95
|
+
* consumers to the internal client).
|
|
96
|
+
*/
|
|
97
|
+
const KNOWN_ORPHANED_SURFACE_CLIENTS: Readonly<Record<string, string>> =
|
|
98
|
+
Object.freeze({});
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Fail fast when a surface lost its spec (it has no endpoints any more) while a
|
|
102
|
+
* previously generated client package for that surface is still on disk with
|
|
103
|
+
* generated endpoints in it.
|
|
104
|
+
*
|
|
105
|
+
* Skipping such a surface silently — which is what the plain
|
|
106
|
+
* `if (!existsSync(specPath)) continue` did — leaves a package that still
|
|
107
|
+
* builds, still publishes and still exports operations the service no longer
|
|
108
|
+
* classifies into that surface. That is dead code the generator can neither
|
|
109
|
+
* refresh nor prune, so it must be an error the operator resolves (delete or
|
|
110
|
+
* deprecate the package), not a warning.
|
|
111
|
+
*
|
|
112
|
+
* Guarded by `surfacesWithSpec.length > 0`: when the service has NO spec at all
|
|
113
|
+
* the caller simply has not run `xema-openapi` yet, which is a different (and
|
|
114
|
+
* not-an-orphan) situation.
|
|
115
|
+
*
|
|
116
|
+
* {@link KNOWN_ORPHANED_SURFACE_CLIENTS} is the escape hatch for an orphan the
|
|
117
|
+
* generator genuinely cannot resolve. It is EMPTY — the backlog this guard
|
|
118
|
+
* originally uncovered has been worked off — so today every orphan is a hard
|
|
119
|
+
* error. See that constant before adding to it.
|
|
120
|
+
*/
|
|
121
|
+
function assertNoOrphanedSurfaceClients(
|
|
122
|
+
ctx: ResolvedServiceContext,
|
|
123
|
+
surfacesWithSpec: readonly ApiSurface[],
|
|
124
|
+
): void {
|
|
125
|
+
if (surfacesWithSpec.length === 0) {
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
const orphans = ALL_SURFACES.filter(
|
|
129
|
+
(surface) =>
|
|
130
|
+
!surfacesWithSpec.includes(surface) &&
|
|
131
|
+
existsSync(join(surfaceClientDir(ctx, surface), 'src', 'endpoints')),
|
|
132
|
+
);
|
|
133
|
+
if (orphans.length === 0) {
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const baselined = orphans.filter((surface) =>
|
|
138
|
+
Object.hasOwn(
|
|
139
|
+
KNOWN_ORPHANED_SURFACE_CLIENTS,
|
|
140
|
+
`${ctx.api.serviceId}:${surface}`,
|
|
141
|
+
),
|
|
142
|
+
);
|
|
143
|
+
const blocking = orphans.filter((surface) => !baselined.includes(surface));
|
|
144
|
+
|
|
145
|
+
for (const surface of baselined) {
|
|
146
|
+
const key = `${ctx.api.serviceId}:${surface}`;
|
|
147
|
+
|
|
148
|
+
console.warn(
|
|
149
|
+
`[xema-client-generate] KNOWN ORPHAN ${key} — ${surfaceClientDir(ctx, surface)} ` +
|
|
150
|
+
`is stale and will not be regenerated. ${KNOWN_ORPHANED_SURFACE_CLIENTS[key]}`,
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (blocking.length === 0) {
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
throw new Error(
|
|
158
|
+
`api-client-generator: ${ctx.api.serviceId} emits no spec for surface(s) ` +
|
|
159
|
+
`${blocking.join(', ')}, but a generated client package still exists at ` +
|
|
160
|
+
`${blocking.map((s) => surfaceClientDir(ctx, s)).join(', ')}. That client ` +
|
|
161
|
+
`is orphaned — it would keep exporting operations the surface no longer ` +
|
|
162
|
+
`has. Delete the package (and its workspace consumers) or restore the ` +
|
|
163
|
+
`surface classification.`,
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Generate (and compile) one ORVAL client package per surface that has an
|
|
169
|
+
* emitted spec. Surfaces without a spec are skipped — no empty packages. All
|
|
170
|
+
* generated clients share the service version (stamped by
|
|
171
|
+
* {@link ensureClientPackage}).
|
|
172
|
+
*
|
|
173
|
+
* @returns the surfaces that were generated.
|
|
174
|
+
*/
|
|
175
|
+
export async function generateClientsForService(
|
|
176
|
+
serviceDir: string = process.cwd(),
|
|
177
|
+
): Promise<ApiSurface[]> {
|
|
178
|
+
const ctx: ResolvedServiceContext = resolveServiceContext(serviceDir);
|
|
179
|
+
const generate = await resolveOrvalGenerate(ctx.serviceDir);
|
|
180
|
+
|
|
181
|
+
const surfacesWithSpec = ALL_SURFACES.filter((s) =>
|
|
182
|
+
existsSync(surfaceSpecPath(ctx, s)),
|
|
183
|
+
);
|
|
184
|
+
assertNoOrphanedSurfaceClients(ctx, surfacesWithSpec);
|
|
185
|
+
|
|
186
|
+
const generated: ApiSurface[] = [];
|
|
187
|
+
for (const surface of surfacesWithSpec) {
|
|
188
|
+
const specPath = surfaceSpecPath(ctx, surface);
|
|
189
|
+
const { clientDir, clientSrc } = ensureClientPackage(ctx, surface);
|
|
190
|
+
ensureClientReadme({
|
|
191
|
+
clientDir,
|
|
192
|
+
packageName: surfaceClientPackageName(ctx.api.serviceId, surface),
|
|
193
|
+
serviceTitle: ctx.api.title,
|
|
194
|
+
surface,
|
|
195
|
+
repoRoot: ctx.repoRoot,
|
|
196
|
+
});
|
|
197
|
+
// Clean prior generated output so endpoints/models removed from this
|
|
198
|
+
// surface's spec (e.g. routes reclassified to another surface) don't linger
|
|
199
|
+
// as stale files — orval does not prune removed operations.
|
|
200
|
+
rmSync(join(clientSrc, 'endpoints'), { recursive: true, force: true });
|
|
201
|
+
rmSync(join(clientSrc, 'models'), { recursive: true, force: true });
|
|
202
|
+
await generate(orvalConfigFor(specPath, clientSrc));
|
|
203
|
+
postProcessClient(ctx.repoRoot, ctx.serviceDir, clientSrc);
|
|
204
|
+
// `tsc` is additive: it emits for the sources it sees and NEVER deletes an
|
|
205
|
+
// output whose source disappeared. Without this, every operation ever
|
|
206
|
+
// generated into this surface survives in `dist/` forever — so a route
|
|
207
|
+
// reclassified to another surface keeps a stale `.js` + `.d.ts` in the old
|
|
208
|
+
// client. Consumers then type-check GREEN against a symbol the surface no
|
|
209
|
+
// longer has, the published tarball (`files: ["dist"]`) ships endpoints the
|
|
210
|
+
// spec does not contain, and the breakage only appears on a clean
|
|
211
|
+
// checkout/CI where `dist` starts empty. Pruning `dist` alongside
|
|
212
|
+
// `src/endpoints` + `src/models` keeps the compiled output a faithful image
|
|
213
|
+
// of the spec.
|
|
214
|
+
rmSync(join(clientDir, 'dist'), { recursive: true, force: true });
|
|
215
|
+
// Compile to dist using the client package's own tsconfig.
|
|
216
|
+
execFileSync('pnpm', ['exec', 'tsc', '-p', 'tsconfig.json'], {
|
|
217
|
+
cwd: clientDir,
|
|
218
|
+
stdio: 'inherit',
|
|
219
|
+
});
|
|
220
|
+
generated.push(surface);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// eslint-disable-next-line no-console
|
|
224
|
+
console.log(
|
|
225
|
+
`[xema-client-generate] ${ctx.api.serviceId}: generated ${
|
|
226
|
+
generated.length
|
|
227
|
+
} client(s) → ${generated.join(', ') || '(none)'}`,
|
|
228
|
+
);
|
|
229
|
+
return generated;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** CLI entry: generate clients from cwd; exit non-zero on failure. */
|
|
233
|
+
export function runClientGenerateCli(): void {
|
|
234
|
+
generateClientsForService()
|
|
235
|
+
.then(() => process.exit(0))
|
|
236
|
+
.catch((err: unknown) => {
|
|
237
|
+
console.error('[xema-client-generate] generation failed:', err);
|
|
238
|
+
process.exit(1);
|
|
239
|
+
});
|
|
240
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
|
|
4
|
+
import type { TestingModuleBuilder } from '@nestjs/testing';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Layer 3 of the DI-stub strategy: the optional per-service escape hatch. Most
|
|
8
|
+
* services need none of this — the universal env seed (Layer 1) + convention
|
|
9
|
+
* stubs (Layer 2) cover them. A service with a bespoke provider that boots at
|
|
10
|
+
* import/compile time ships an `openapi.overrides.ts` at its root:
|
|
11
|
+
*
|
|
12
|
+
* ```ts
|
|
13
|
+
* // <service>/openapi.overrides.ts
|
|
14
|
+
* import type { OpenApiOverrides } from '@xemahq/api-client-generator';
|
|
15
|
+
* export default {
|
|
16
|
+
* env: { SOME_CUSTOM_VAR: 'dummy' },
|
|
17
|
+
* applyOverrides: (builder) => builder.overrideProvider(WeirdSvc).useValue({ … }),
|
|
18
|
+
* } satisfies OpenApiOverrides;
|
|
19
|
+
* ```
|
|
20
|
+
*/
|
|
21
|
+
export interface OpenApiOverrides {
|
|
22
|
+
/** Extra dummy env vars seeded before `AppModule` is imported. */
|
|
23
|
+
readonly env?: Record<string, string>;
|
|
24
|
+
/**
|
|
25
|
+
* Hook applied to the `Test.createTestingModule(...)` builder AFTER the
|
|
26
|
+
* default convention stubs, so a service can override a default or stub an
|
|
27
|
+
* extra provider. Return the builder (or void — it is mutated in place).
|
|
28
|
+
*/
|
|
29
|
+
readonly applyOverrides?: (
|
|
30
|
+
builder: TestingModuleBuilder,
|
|
31
|
+
) => TestingModuleBuilder | void;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const OVERRIDES_BASENAME = 'openapi.overrides';
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Load `<serviceDir>/openapi.overrides.ts` (or `.js`) if present. Returns
|
|
38
|
+
* `undefined` when absent — the common case. Throws if the file exists but
|
|
39
|
+
* exports a malformed shape (fail-fast; no silent ignore).
|
|
40
|
+
*/
|
|
41
|
+
export function loadServiceOverrides(
|
|
42
|
+
serviceDir: string,
|
|
43
|
+
): OpenApiOverrides | undefined {
|
|
44
|
+
const candidate = [`${OVERRIDES_BASENAME}.ts`, `${OVERRIDES_BASENAME}.js`]
|
|
45
|
+
.map((f) => join(serviceDir, f))
|
|
46
|
+
.find((p) => existsSync(p));
|
|
47
|
+
if (!candidate) {
|
|
48
|
+
return undefined;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const loaded: unknown = require(candidate);
|
|
52
|
+
if (loaded === null || typeof loaded !== 'object') {
|
|
53
|
+
throw new Error(
|
|
54
|
+
`api-client-generator: ${candidate} must export an overrides object.`,
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
const selected =
|
|
58
|
+
'default' in loaded &&
|
|
59
|
+
loaded.default !== null &&
|
|
60
|
+
typeof loaded.default === 'object'
|
|
61
|
+
? loaded.default
|
|
62
|
+
: loaded;
|
|
63
|
+
const overrides = Object.fromEntries(Object.entries(selected));
|
|
64
|
+
if (
|
|
65
|
+
overrides.env !== undefined &&
|
|
66
|
+
(typeof overrides.env !== 'object' || overrides.env === null)
|
|
67
|
+
) {
|
|
68
|
+
throw new Error(
|
|
69
|
+
`api-client-generator: ${candidate} \`env\` must be a Record<string,string>.`,
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
if (
|
|
73
|
+
overrides.applyOverrides !== undefined &&
|
|
74
|
+
typeof overrides.applyOverrides !== 'function'
|
|
75
|
+
) {
|
|
76
|
+
throw new Error(
|
|
77
|
+
`api-client-generator: ${candidate} \`applyOverrides\` must be a function.`,
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
return overrides;
|
|
81
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Bridge to the repository's existing `tooling/codegen` ORVAL post-processors.
|
|
6
|
+
* The generator reuses them (rather than re-implementing) so the generated
|
|
7
|
+
* client is byte-identical to what `scripts/build-client-index.js` produced
|
|
8
|
+
* today — same barrel, same fixers, same arguments.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
interface PostProcessors {
|
|
12
|
+
regenerateModelsBarrel: (clientSrc: string) => number;
|
|
13
|
+
fixOrvalResponseTypes: (serviceRoot: string) => number;
|
|
14
|
+
fixOrvalArrayParams: (clientSrc: string) => number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function requireRepoTool(repoRoot: string, file: string): unknown {
|
|
18
|
+
const path = join(repoRoot, 'tooling', 'codegen', file);
|
|
19
|
+
if (!existsSync(path)) {
|
|
20
|
+
throw new Error(
|
|
21
|
+
`api-client-generator: required post-processor ${path} not found. The ` +
|
|
22
|
+
`repo must ship tooling/codegen/${file}.`,
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return require(path);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function loadRepoPostProcessors(repoRoot: string): PostProcessors {
|
|
30
|
+
const { regenerateModelsBarrel } = requireRepoTool(
|
|
31
|
+
repoRoot,
|
|
32
|
+
'regenerate-models-barrel.js',
|
|
33
|
+
) as { regenerateModelsBarrel: (c: string) => number };
|
|
34
|
+
const { fixOrvalResponseTypes } = requireRepoTool(
|
|
35
|
+
repoRoot,
|
|
36
|
+
'fix-orval-response-types.js',
|
|
37
|
+
) as { fixOrvalResponseTypes: (r: string) => number };
|
|
38
|
+
const { fixOrvalArrayParams } = requireRepoTool(
|
|
39
|
+
repoRoot,
|
|
40
|
+
'fix-orval-array-params.js',
|
|
41
|
+
) as { fixOrvalArrayParams: (c: string) => number };
|
|
42
|
+
return { regenerateModelsBarrel, fixOrvalResponseTypes, fixOrvalArrayParams };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Rebuild `<clientSrc>/index.ts` — the barrel re-exporting custom-fetch +
|
|
47
|
+
* models + every tags-split endpoint file. Identical to the loop in the legacy
|
|
48
|
+
* per-service `build-client-index.js`.
|
|
49
|
+
*/
|
|
50
|
+
function rebuildIndexBarrel(clientSrc: string): number {
|
|
51
|
+
const lines = [
|
|
52
|
+
'// Auto-generated by @xemahq/api-client-generator — do not edit manually.',
|
|
53
|
+
"export { configureClient, getClientConfig, ClientError, customFetch, type ClientConfig } from './custom-fetch';",
|
|
54
|
+
];
|
|
55
|
+
|
|
56
|
+
const modelsIndex = join(clientSrc, 'models', 'index.ts');
|
|
57
|
+
if (existsSync(modelsIndex)) {
|
|
58
|
+
const content = readFileSync(modelsIndex, 'utf8');
|
|
59
|
+
if (/^export /m.test(content)) {
|
|
60
|
+
lines.push("export * from './models';");
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const endpointsDir = join(clientSrc, 'endpoints');
|
|
65
|
+
if (existsSync(endpointsDir)) {
|
|
66
|
+
for (const entry of readdirSync(endpointsDir, { withFileTypes: true })) {
|
|
67
|
+
if (!entry.isDirectory()) {
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
const files = readdirSync(join(endpointsDir, entry.name));
|
|
71
|
+
const tsFile = files.find(
|
|
72
|
+
(f) => f.endsWith('.ts') && !f.endsWith('.d.ts'),
|
|
73
|
+
);
|
|
74
|
+
if (tsFile) {
|
|
75
|
+
lines.push(
|
|
76
|
+
`export * from './endpoints/${entry.name}/${tsFile.replace('.ts', '')}';`,
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
lines.push('');
|
|
83
|
+
writeFileSync(join(clientSrc, 'index.ts'), lines.join('\n'));
|
|
84
|
+
return lines.length - 2;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Run the full post-ORVAL pipeline for one generated client, mirroring the
|
|
89
|
+
* legacy `build-client-index.js` ordering exactly: models barrel → index
|
|
90
|
+
* barrel → response-type fix → array-param fix.
|
|
91
|
+
*
|
|
92
|
+
* `serviceRoot` is passed to `fixOrvalResponseTypes` unchanged from the legacy
|
|
93
|
+
* call site (it resolves its own models path internally), so the output is
|
|
94
|
+
* identical to today's pipeline.
|
|
95
|
+
*/
|
|
96
|
+
export function postProcessClient(
|
|
97
|
+
repoRoot: string,
|
|
98
|
+
serviceRoot: string,
|
|
99
|
+
clientSrc: string,
|
|
100
|
+
): void {
|
|
101
|
+
const tools = loadRepoPostProcessors(repoRoot);
|
|
102
|
+
tools.regenerateModelsBarrel(clientSrc);
|
|
103
|
+
rebuildIndexBarrel(clientSrc);
|
|
104
|
+
tools.fixOrvalResponseTypes(serviceRoot);
|
|
105
|
+
tools.fixOrvalArrayParams(clientSrc);
|
|
106
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Layer 1 of the DI-stub strategy: seed deterministic dummy env vars BEFORE
|
|
3
|
+
* the service's `AppModule` is imported. NestJS modules read `process.env` at
|
|
4
|
+
* import time, so this MUST run first.
|
|
5
|
+
*
|
|
6
|
+
* Every value is a throwaway placeholder. `app.init()` is deliberately skipped
|
|
7
|
+
* during extraction, so nothing here is ever dialed — the values only need to
|
|
8
|
+
* exist so import-time `getOrThrow`/`new URL(...)` calls don't crash.
|
|
9
|
+
*
|
|
10
|
+
* This is the union of the seeds the 41 hand-written `extract-openapi.ts`
|
|
11
|
+
* scripts set. A service that reads an env var not covered here adds it via the
|
|
12
|
+
* optional per-service `openapi.overrides.ts` `env` field (Layer 3) — the
|
|
13
|
+
* generator never silently invents a value it wasn't told about beyond this
|
|
14
|
+
* shared baseline.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const DUMMY_ENCRYPTION_KEY_HEX =
|
|
18
|
+
'0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef';
|
|
19
|
+
const DUMMY_BASE64_KEY = 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=';
|
|
20
|
+
|
|
21
|
+
/** Set a default only if the var is unset (`??=` semantics). */
|
|
22
|
+
function seed(vars: Record<string, string>): void {
|
|
23
|
+
for (const [key, value] of Object.entries(vars)) {
|
|
24
|
+
if (process.env[key] === undefined) {
|
|
25
|
+
process.env[key] = value;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The throwaway database every extraction points at. Never dialed. */
|
|
31
|
+
const SCRATCH_DB_NAME = 'dummy_openapi';
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Per-service scratch Postgres schema.
|
|
35
|
+
*
|
|
36
|
+
* Extraction is run in PARALLEL by turbo across ~40 services. If they all point
|
|
37
|
+
* at one database AND one `public` schema they share a single
|
|
38
|
+
* `_prisma_migrations` table, so any code path that migrates at DI-construction
|
|
39
|
+
* time interleaves foreign migration histories and wedges its neighbours. Giving
|
|
40
|
+
* each service its own schema makes the scratch surfaces disjoint by
|
|
41
|
+
* construction. Postgres identifiers are lower-cased and capped at 63 bytes.
|
|
42
|
+
*/
|
|
43
|
+
export function openApiScratchSchema(serviceId: string): string {
|
|
44
|
+
return `extract_${serviceId.replace(/[^a-zA-Z0-9]+/g, '_')}`
|
|
45
|
+
.toLowerCase()
|
|
46
|
+
.slice(0, 63);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* @param serviceId the `xema.api.serviceId` of the service being extracted —
|
|
51
|
+
* used to derive a scratch schema unique to this extraction process.
|
|
52
|
+
*/
|
|
53
|
+
export function seedOpenApiEnv(serviceId: string): void {
|
|
54
|
+
const schema = openApiScratchSchema(serviceId);
|
|
55
|
+
seed({
|
|
56
|
+
NODE_ENV: 'development',
|
|
57
|
+
|
|
58
|
+
// Extraction-mode flag. `XemaServiceModule` eagerly resolves peer URLs from
|
|
59
|
+
// the KernelState/etcd registry at DI-construction time; with no live cluster
|
|
60
|
+
// that throws and the module can't even be constructed for scanning. Setting
|
|
61
|
+
// this makes the SDK's construction-time resolvers (`resolveHttpUrlEagerly`)
|
|
62
|
+
// short-circuit to a non-routable `.invalid` stub. The var NAME is the
|
|
63
|
+
// service-registry-nest `OPENAPI_EXTRACTION_ENV_VAR` contract; the SDK is a
|
|
64
|
+
// higher layer, so we set the env by name rather than import it here.
|
|
65
|
+
XEMA_OPENAPI_EXTRACTION: '1',
|
|
66
|
+
|
|
67
|
+
// Database (Prisma + raw pools). The scratch schema is per-service — see
|
|
68
|
+
// `openApiScratchSchema`. `DB_SCHEMA` is NOT redundant with the URL's
|
|
69
|
+
// `?schema=`: `createBiomePrismaAdapterFromEnv` passes it as the adapter's
|
|
70
|
+
// second-arg `{ schema }`, while `?schema=` is what a Prisma CLI/migrate
|
|
71
|
+
// invocation reads.
|
|
72
|
+
DATABASE_URL: `postgresql://dummy:dummy@localhost:5432/${SCRATCH_DB_NAME}?schema=${schema}`,
|
|
73
|
+
DB_HOST: 'localhost',
|
|
74
|
+
DB_PORT: '5432',
|
|
75
|
+
DB_NAME: SCRATCH_DB_NAME,
|
|
76
|
+
DB_SCHEMA: schema,
|
|
77
|
+
DB_USER: 'dummy',
|
|
78
|
+
DB_PASSWORD: 'dummy',
|
|
79
|
+
REDIS_URL: 'redis://localhost:6379',
|
|
80
|
+
OPENSEARCH_URL: 'http://localhost:9200',
|
|
81
|
+
|
|
82
|
+
// Identity / auth.
|
|
83
|
+
IDENTITY_API_URL: 'http://localhost:3053',
|
|
84
|
+
IDENTITY_API_INTERNAL_TOKEN: 'dummy-token',
|
|
85
|
+
JWT_PUBLIC_KEY_URL:
|
|
86
|
+
'http://localhost:8180/realms/dummy/protocol/openid-connect/certs',
|
|
87
|
+
JWT_ISSUER: 'http://localhost:8180/realms/dummy',
|
|
88
|
+
GATEWAY_JWT_SECRET: 'dummy-gateway-jwt-secret-for-openapi-extraction',
|
|
89
|
+
SHARE_TOKEN_SECRET: 'dummy-share-token-secret',
|
|
90
|
+
OPENCODE_WORKER_PASSWORD: 'dummy-worker-password',
|
|
91
|
+
|
|
92
|
+
// Service registry (etcd-only autodiscovery). `createKernelState` fail-fasts
|
|
93
|
+
// without this; the etcd3 client is lazy (no dial until first call) and
|
|
94
|
+
// extraction skips `app.init()`, so the dummy endpoint is never contacted.
|
|
95
|
+
XEMA_ETCD_ENDPOINTS: 'localhost:2379',
|
|
96
|
+
|
|
97
|
+
// Event hub. `WEBHOOK_URL` is the delivery callback every service that
|
|
98
|
+
// SUBSCRIBES declares to `EventHubModule.forRootAsync` — it is boot-REQUIRED
|
|
99
|
+
// (`getOrThrow`) in ~20 services, so it belongs in the shared baseline
|
|
100
|
+
// rather than being re-declared in ~20 `openapi.overrides.ts` files.
|
|
101
|
+
EVENT_HUB_URL: 'http://localhost:3128',
|
|
102
|
+
WEBHOOK_URL: 'http://localhost:3000/webhooks/events',
|
|
103
|
+
|
|
104
|
+
// Cross-service HTTP URLs (resolved via the registry in prod; dummy here).
|
|
105
|
+
BACKLOG_API_URL: 'http://localhost:3100',
|
|
106
|
+
MEMORY_API_URL: 'http://localhost:3060',
|
|
107
|
+
LLM_REGISTRY_API_URL: 'http://localhost:3112',
|
|
108
|
+
LLM_GATEWAY_API_URL: 'http://localhost:3110',
|
|
109
|
+
ARTIFACT_STORE_API_URL: 'http://localhost:3130',
|
|
110
|
+
KNOWLEDGE_BASE_API_URL: 'http://localhost:3200',
|
|
111
|
+
PROJECT_REGISTRY_API_URL: 'http://localhost:3300',
|
|
112
|
+
CONNECTOR_GATEWAY_API_URL: 'http://localhost:3900',
|
|
113
|
+
DESIGN_SYSTEM_BUILDER_API_URL: 'http://localhost:3210',
|
|
114
|
+
SEARCH_API_URL: 'http://localhost:3220',
|
|
115
|
+
BILLING_API_URL: 'http://localhost:3140',
|
|
116
|
+
CAPABILITY_REGISTRY_API_URL: 'http://localhost:3700',
|
|
117
|
+
|
|
118
|
+
// SCM / git.
|
|
119
|
+
SCM_WEBHOOK_SECRET: 'dummy-webhook-secret',
|
|
120
|
+
SCM_WEBHOOK_BASE_URL: 'http://localhost:3500/webhooks',
|
|
121
|
+
GITEA_URL: 'http://localhost:3000',
|
|
122
|
+
GITEA_ADMIN_USER: 'gitea_admin',
|
|
123
|
+
GITEA_ADMIN_PASS: 'gitea_admin_pass',
|
|
124
|
+
|
|
125
|
+
// Encryption keys (hex 32-byte + base64 variants used across services).
|
|
126
|
+
MASTER_ENCRYPTION_KEY: DUMMY_ENCRYPTION_KEY_HEX,
|
|
127
|
+
ENCRYPTION_MASTER_KEY: DUMMY_BASE64_KEY,
|
|
128
|
+
LLM_REGISTRY_ENCRYPTION_KEY:
|
|
129
|
+
'abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789',
|
|
130
|
+
VARIABLE_ENCRYPTION_KEY:
|
|
131
|
+
'fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210',
|
|
132
|
+
|
|
133
|
+
// Platform observability endpoints.
|
|
134
|
+
PLATFORM_METRICS_ENDPOINT: 'http://localhost:9090/metrics',
|
|
135
|
+
PLATFORM_AUDIT_ENDPOINT: 'http://localhost:3601/audit',
|
|
136
|
+
});
|
|
137
|
+
}
|