@xemahq/api-client-generator 0.1.5 → 0.3.0

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