micro-contracts 0.9.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/LICENSE +22 -0
- package/README.md +351 -0
- package/dist/cli/templates.d.ts +16 -0
- package/dist/cli/templates.d.ts.map +1 -0
- package/dist/cli/templates.js +377 -0
- package/dist/cli/templates.js.map +1 -0
- package/dist/cli.d.ts +9 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +978 -0
- package/dist/cli.js.map +1 -0
- package/dist/generator/dependencyGenerator.d.ts +43 -0
- package/dist/generator/dependencyGenerator.d.ts.map +1 -0
- package/dist/generator/dependencyGenerator.js +159 -0
- package/dist/generator/dependencyGenerator.js.map +1 -0
- package/dist/generator/domainGenerator.d.ts +16 -0
- package/dist/generator/domainGenerator.d.ts.map +1 -0
- package/dist/generator/domainGenerator.js +212 -0
- package/dist/generator/domainGenerator.js.map +1 -0
- package/dist/generator/index.d.ts +37 -0
- package/dist/generator/index.d.ts.map +1 -0
- package/dist/generator/index.js +747 -0
- package/dist/generator/index.js.map +1 -0
- package/dist/generator/linter.d.ts +24 -0
- package/dist/generator/linter.d.ts.map +1 -0
- package/dist/generator/linter.js +202 -0
- package/dist/generator/linter.js.map +1 -0
- package/dist/generator/overlayProcessor.d.ts +90 -0
- package/dist/generator/overlayProcessor.d.ts.map +1 -0
- package/dist/generator/overlayProcessor.js +532 -0
- package/dist/generator/overlayProcessor.js.map +1 -0
- package/dist/generator/schemaGenerator.d.ts +10 -0
- package/dist/generator/schemaGenerator.d.ts.map +1 -0
- package/dist/generator/schemaGenerator.js +299 -0
- package/dist/generator/schemaGenerator.js.map +1 -0
- package/dist/generator/templateProcessor.d.ts +178 -0
- package/dist/generator/templateProcessor.d.ts.map +1 -0
- package/dist/generator/templateProcessor.js +607 -0
- package/dist/generator/templateProcessor.js.map +1 -0
- package/dist/generator/typeGenerator.d.ts +9 -0
- package/dist/generator/typeGenerator.d.ts.map +1 -0
- package/dist/generator/typeGenerator.js +395 -0
- package/dist/generator/typeGenerator.js.map +1 -0
- package/dist/guardrails/allowlist.d.ts +45 -0
- package/dist/guardrails/allowlist.d.ts.map +1 -0
- package/dist/guardrails/allowlist.js +261 -0
- package/dist/guardrails/allowlist.js.map +1 -0
- package/dist/guardrails/config.d.ts +40 -0
- package/dist/guardrails/config.d.ts.map +1 -0
- package/dist/guardrails/config.js +174 -0
- package/dist/guardrails/config.js.map +1 -0
- package/dist/guardrails/docs.d.ts +24 -0
- package/dist/guardrails/docs.d.ts.map +1 -0
- package/dist/guardrails/docs.js +138 -0
- package/dist/guardrails/docs.js.map +1 -0
- package/dist/guardrails/drift.d.ts +23 -0
- package/dist/guardrails/drift.d.ts.map +1 -0
- package/dist/guardrails/drift.js +127 -0
- package/dist/guardrails/drift.js.map +1 -0
- package/dist/guardrails/index.d.ts +19 -0
- package/dist/guardrails/index.d.ts.map +1 -0
- package/dist/guardrails/index.js +25 -0
- package/dist/guardrails/index.js.map +1 -0
- package/dist/guardrails/lint.d.ts +20 -0
- package/dist/guardrails/lint.d.ts.map +1 -0
- package/dist/guardrails/lint.js +274 -0
- package/dist/guardrails/lint.js.map +1 -0
- package/dist/guardrails/manifest.d.ts +43 -0
- package/dist/guardrails/manifest.d.ts.map +1 -0
- package/dist/guardrails/manifest.js +231 -0
- package/dist/guardrails/manifest.js.map +1 -0
- package/dist/guardrails/runner.d.ts +31 -0
- package/dist/guardrails/runner.d.ts.map +1 -0
- package/dist/guardrails/runner.js +268 -0
- package/dist/guardrails/runner.js.map +1 -0
- package/dist/guardrails/security.d.ts +31 -0
- package/dist/guardrails/security.d.ts.map +1 -0
- package/dist/guardrails/security.js +181 -0
- package/dist/guardrails/security.js.map +1 -0
- package/dist/guardrails/typecheck.d.ts +15 -0
- package/dist/guardrails/typecheck.d.ts.map +1 -0
- package/dist/guardrails/typecheck.js +104 -0
- package/dist/guardrails/typecheck.js.map +1 -0
- package/dist/guardrails/types.d.ts +196 -0
- package/dist/guardrails/types.d.ts.map +1 -0
- package/dist/guardrails/types.js +8 -0
- package/dist/guardrails/types.js.map +1 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +7 -0
- package/dist/index.js.map +1 -0
- package/dist/types.d.ts +489 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +297 -0
- package/dist/types.js.map +1 -0
- package/docs/architecture.svg +226 -0
- package/docs/development-guardrails.md +541 -0
- package/docs/guardrails-concept.svg +252 -0
- package/docs/overlays-deep-dive.md +298 -0
- package/package.json +66 -0
|
@@ -0,0 +1,747 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* micro-contracts Generator
|
|
3
|
+
*/
|
|
4
|
+
import fs from 'fs';
|
|
5
|
+
import path from 'path';
|
|
6
|
+
import yaml from 'js-yaml';
|
|
7
|
+
import { isMultiModuleConfig, resolveModuleConfig, } from '../types.js';
|
|
8
|
+
import { generateTypes } from './typeGenerator.js';
|
|
9
|
+
import { generateSchemas } from './schemaGenerator.js';
|
|
10
|
+
import { generateDomainInterfaces } from './domainGenerator.js';
|
|
11
|
+
import { lintSpec, formatLintResults } from './linter.js';
|
|
12
|
+
import { processOverlays, generateExtensionInterfaces, formatOverlayLog, rebaseRefs, } from './overlayProcessor.js';
|
|
13
|
+
import { buildTemplateContext, generateWithTemplate, loadTemplateWithResolution, resolveTemplatePath, } from './templateProcessor.js';
|
|
14
|
+
import { validateDependsOn, } from './dependencyGenerator.js';
|
|
15
|
+
import { extractDependencies } from '../types.js';
|
|
16
|
+
export { generateTypes } from './typeGenerator.js';
|
|
17
|
+
export { generateSchemas } from './schemaGenerator.js';
|
|
18
|
+
export { generateDomainInterfaces } from './domainGenerator.js';
|
|
19
|
+
export { lintSpec, formatLintResults } from './linter.js';
|
|
20
|
+
export { processOverlays, generateExtensionInterfaces } from './overlayProcessor.js';
|
|
21
|
+
export { buildTemplateContext, generateWithTemplate } from './templateProcessor.js';
|
|
22
|
+
/**
|
|
23
|
+
* Write file only if content has changed (ignoring timestamp in header).
|
|
24
|
+
* This prevents unnecessary git diffs when only the timestamp changes.
|
|
25
|
+
* Returns true if file was written, false if content was unchanged.
|
|
26
|
+
*/
|
|
27
|
+
function writeFileIfChanged(filePath, newContent) {
|
|
28
|
+
// Resolve to absolute path for consistency
|
|
29
|
+
const absolutePath = path.resolve(filePath);
|
|
30
|
+
if (fs.existsSync(absolutePath)) {
|
|
31
|
+
const existingContent = fs.readFileSync(absolutePath, 'utf-8');
|
|
32
|
+
if (existingContent === newContent) {
|
|
33
|
+
return false; // No change, skip writing
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
fs.writeFileSync(absolutePath, newContent);
|
|
37
|
+
return true;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Write file and log result. Uses writeFileIfChanged to avoid unnecessary updates.
|
|
41
|
+
*/
|
|
42
|
+
function writeAndLog(filePath, content, indent = ' ') {
|
|
43
|
+
const written = writeFileIfChanged(filePath, content);
|
|
44
|
+
if (written) {
|
|
45
|
+
console.log(`${indent}Written: ${filePath}`);
|
|
46
|
+
}
|
|
47
|
+
else {
|
|
48
|
+
console.log(`${indent}Unchanged: ${filePath}`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Load OpenAPI spec from file
|
|
53
|
+
*/
|
|
54
|
+
export function loadOpenAPISpec(filePath) {
|
|
55
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
56
|
+
if (filePath.endsWith('.yaml') || filePath.endsWith('.yml')) {
|
|
57
|
+
return yaml.load(content);
|
|
58
|
+
}
|
|
59
|
+
else if (filePath.endsWith('.json')) {
|
|
60
|
+
return JSON.parse(content);
|
|
61
|
+
}
|
|
62
|
+
else {
|
|
63
|
+
throw new Error(`Unsupported file format: ${filePath}`);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Load config from file (supports both legacy and multi-module formats)
|
|
68
|
+
*/
|
|
69
|
+
export function loadConfig(configPath) {
|
|
70
|
+
const content = fs.readFileSync(configPath, 'utf-8');
|
|
71
|
+
return yaml.load(content);
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Parse module filter from options
|
|
75
|
+
*/
|
|
76
|
+
function parseModuleFilter(modules) {
|
|
77
|
+
if (!modules)
|
|
78
|
+
return null;
|
|
79
|
+
if (Array.isArray(modules))
|
|
80
|
+
return modules;
|
|
81
|
+
return modules.split(',').map(m => m.trim()).filter(Boolean);
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Generate all files from config
|
|
85
|
+
*/
|
|
86
|
+
export async function generate(config, options = {}) {
|
|
87
|
+
// Handle multi-module config
|
|
88
|
+
if (isMultiModuleConfig(config)) {
|
|
89
|
+
await generateMultiModule(config, options);
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
// Legacy single-module config is no longer supported
|
|
93
|
+
throw new Error('Legacy single-module configuration format is no longer supported. ' +
|
|
94
|
+
'Please migrate to the multi-module format with a "modules:" section. ' +
|
|
95
|
+
'See README.md for configuration examples.');
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Generate for multi-module config
|
|
99
|
+
*/
|
|
100
|
+
async function generateMultiModule(config, options) {
|
|
101
|
+
const moduleFilter = parseModuleFilter(options.modules);
|
|
102
|
+
const moduleNames = Object.keys(config.modules);
|
|
103
|
+
// Filter modules if specified
|
|
104
|
+
const targetModules = moduleFilter
|
|
105
|
+
? moduleNames.filter(m => moduleFilter.includes(m))
|
|
106
|
+
: moduleNames;
|
|
107
|
+
if (targetModules.length === 0) {
|
|
108
|
+
if (moduleFilter) {
|
|
109
|
+
console.error(`No matching modules found. Available: ${moduleNames.join(', ')}`);
|
|
110
|
+
process.exit(1);
|
|
111
|
+
}
|
|
112
|
+
console.log('No modules defined in config.');
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
console.log(`Generating for modules: ${targetModules.join(', ')}`);
|
|
116
|
+
// Generate each module
|
|
117
|
+
for (const moduleName of targetModules) {
|
|
118
|
+
const moduleConfig = config.modules[moduleName];
|
|
119
|
+
const resolved = resolveModuleConfig(moduleName, moduleConfig, config.defaults);
|
|
120
|
+
console.log(`\n${'='.repeat(60)}`);
|
|
121
|
+
console.log(`Module: ${moduleName}`);
|
|
122
|
+
console.log(`${'='.repeat(60)}`);
|
|
123
|
+
await generateModule(resolved, options);
|
|
124
|
+
}
|
|
125
|
+
console.log('\nGeneration complete!');
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Generate for a single resolved module
|
|
129
|
+
*/
|
|
130
|
+
async function generateModule(config, options) {
|
|
131
|
+
// Load OpenAPI spec
|
|
132
|
+
const openapiPath = path.resolve(config.openapi);
|
|
133
|
+
console.log(`Loading OpenAPI spec from: ${openapiPath}`);
|
|
134
|
+
if (!fs.existsSync(openapiPath)) {
|
|
135
|
+
throw new Error(`OpenAPI spec not found: ${openapiPath}`);
|
|
136
|
+
}
|
|
137
|
+
let spec = loadOpenAPISpec(openapiPath);
|
|
138
|
+
console.log(` Title: ${spec.info.title}`);
|
|
139
|
+
console.log(` Version: ${spec.info.version}`);
|
|
140
|
+
// Run linting first (unless skipped)
|
|
141
|
+
if (!options.skipLint) {
|
|
142
|
+
console.log('\nLinting OpenAPI spec...');
|
|
143
|
+
const lintResult = lintSpec(spec);
|
|
144
|
+
console.log(formatLintResults(lintResult));
|
|
145
|
+
if (!lintResult.valid) {
|
|
146
|
+
throw new Error('Lint failed. Fix errors before generating.');
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
// Apply overlays if configured
|
|
150
|
+
let overlayResult = null;
|
|
151
|
+
if (config.overlays.length > 0) {
|
|
152
|
+
console.log('\nApplying overlays...');
|
|
153
|
+
overlayResult = processOverlays(spec, {
|
|
154
|
+
collision: config.overlayCollision,
|
|
155
|
+
files: config.overlays,
|
|
156
|
+
}, process.cwd(), openapiPath // Pass spec path for $ref rebasing
|
|
157
|
+
);
|
|
158
|
+
spec = overlayResult.spec;
|
|
159
|
+
console.log(formatOverlayLog(overlayResult));
|
|
160
|
+
// Note: Transformed spec is written to packages/contract/*/docs/openapi.generated.yaml
|
|
161
|
+
}
|
|
162
|
+
const generateAll = !options.contractsOnly && !options.serverOnly &&
|
|
163
|
+
!options.frontendOnly && !options.docsOnly;
|
|
164
|
+
// Validate and generate dependencies
|
|
165
|
+
const dependencies = extractDependencies(spec);
|
|
166
|
+
if (config.dependsOn) {
|
|
167
|
+
const validation = validateDependsOn(dependencies.allDeps.map(d => d.raw), config.dependsOn, config.name);
|
|
168
|
+
if (!validation.valid) {
|
|
169
|
+
for (const err of validation.errors) {
|
|
170
|
+
console.error(`ERROR: ${err}`);
|
|
171
|
+
}
|
|
172
|
+
throw new Error('Dependency validation failed');
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
// Generate contract package
|
|
176
|
+
if (generateAll || options.contractsOnly) {
|
|
177
|
+
await generateContractPackage(spec, config, false, overlayResult);
|
|
178
|
+
// Generate public contract if there are public endpoints
|
|
179
|
+
if (hasPublicEndpoints(spec)) {
|
|
180
|
+
await generateContractPackage(spec, config, true, overlayResult);
|
|
181
|
+
}
|
|
182
|
+
// Generate deps/ re-exports if module has dependencies
|
|
183
|
+
if (dependencies.allDeps.length > 0) {
|
|
184
|
+
await generateDepsReExports(config, dependencies);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
// Generate using new outputs system if configured
|
|
188
|
+
if (config.outputs.length > 0) {
|
|
189
|
+
await generateFromOutputs(spec, config, overlayResult, options);
|
|
190
|
+
}
|
|
191
|
+
else {
|
|
192
|
+
// Fallback to legacy server/frontend generation
|
|
193
|
+
// Generate server routes
|
|
194
|
+
if ((generateAll || options.serverOnly) && config.server) {
|
|
195
|
+
await generateServerRoutes(spec, config, overlayResult);
|
|
196
|
+
}
|
|
197
|
+
// Generate frontend clients
|
|
198
|
+
if ((generateAll || options.frontendOnly) && config.frontend) {
|
|
199
|
+
await generateFrontendClient(spec, config, overlayResult);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
// Generate documentation
|
|
203
|
+
if ((generateAll || options.docsOnly) && config.docs.enabled) {
|
|
204
|
+
const docsDir = path.join(config.contractOutput, 'docs');
|
|
205
|
+
const openapiFile = path.join(docsDir, 'openapi.generated.yaml');
|
|
206
|
+
await generateDocumentation(openapiFile, docsDir);
|
|
207
|
+
// Also generate for public contract if exists
|
|
208
|
+
if (hasPublicEndpoints(spec)) {
|
|
209
|
+
const publicDocsDir = path.join(config.contractPublicOutput, 'docs');
|
|
210
|
+
const publicOpenapiFile = path.join(publicDocsDir, 'openapi.generated.yaml');
|
|
211
|
+
if (fs.existsSync(publicOpenapiFile)) {
|
|
212
|
+
await generateDocumentation(publicOpenapiFile, publicDocsDir);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
/**
|
|
218
|
+
* Check if spec has any public endpoints
|
|
219
|
+
*/
|
|
220
|
+
function hasPublicEndpoints(spec) {
|
|
221
|
+
for (const pathItem of Object.values(spec.paths)) {
|
|
222
|
+
for (const method of ['get', 'post', 'put', 'patch', 'delete']) {
|
|
223
|
+
const operation = pathItem[method];
|
|
224
|
+
if (operation && operation['x-micro-contracts-published'] === true) {
|
|
225
|
+
return true;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
return false;
|
|
230
|
+
}
|
|
231
|
+
/**
|
|
232
|
+
* Generate contract package
|
|
233
|
+
*/
|
|
234
|
+
async function generateContractPackage(spec, config, publicOnly, overlayResult = null) {
|
|
235
|
+
const outputDir = publicOnly ? config.contractPublicOutput : config.contractOutput;
|
|
236
|
+
const label = publicOnly ? 'public contract' : 'contract';
|
|
237
|
+
console.log(`\nGenerating ${label} package...`);
|
|
238
|
+
// For public contract, use filtered spec
|
|
239
|
+
const targetSpec = publicOnly ? filterPublicSpec(spec) : spec;
|
|
240
|
+
// Create directories
|
|
241
|
+
const dirs = [
|
|
242
|
+
outputDir,
|
|
243
|
+
path.join(outputDir, 'domains'),
|
|
244
|
+
path.join(outputDir, 'schemas'),
|
|
245
|
+
path.join(outputDir, 'errors'),
|
|
246
|
+
path.join(outputDir, 'docs'),
|
|
247
|
+
];
|
|
248
|
+
for (const dir of dirs) {
|
|
249
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
250
|
+
}
|
|
251
|
+
// Note: We no longer delete files before generating to enable change detection.
|
|
252
|
+
// Orphaned files from removed domains/schemas should be manually cleaned up.
|
|
253
|
+
// Generate domain interfaces
|
|
254
|
+
console.log(` Generating domain interfaces...`);
|
|
255
|
+
const domainInterfaces = generateDomainInterfaces(targetSpec, { publicOnly });
|
|
256
|
+
for (const [name, content] of domainInterfaces) {
|
|
257
|
+
const fileName = name === 'index' ? 'index.ts' : `${name}Api.ts`;
|
|
258
|
+
const filePath = path.join(outputDir, 'domains', fileName);
|
|
259
|
+
writeAndLog(filePath, content);
|
|
260
|
+
}
|
|
261
|
+
// Generate types (use filtered spec for public)
|
|
262
|
+
console.log(` Generating schema types...`);
|
|
263
|
+
const typesContent = generateTypes(targetSpec);
|
|
264
|
+
const typesPath = path.join(outputDir, 'schemas', 'types.ts');
|
|
265
|
+
writeAndLog(typesPath, typesContent);
|
|
266
|
+
// Generate validators (JSON Schemas) - use filtered spec for public
|
|
267
|
+
console.log(` Generating validators...`);
|
|
268
|
+
const validatorsContent = generateSchemas(targetSpec);
|
|
269
|
+
const validatorsPath = path.join(outputDir, 'schemas', 'validators.ts');
|
|
270
|
+
writeAndLog(validatorsPath, validatorsContent);
|
|
271
|
+
// Generate schemas index
|
|
272
|
+
const schemasIndex = `/**
|
|
273
|
+
* Schema exports
|
|
274
|
+
* Auto-generated - DO NOT EDIT
|
|
275
|
+
*/
|
|
276
|
+
|
|
277
|
+
export * from './types.js';
|
|
278
|
+
export { allSchemas } from './validators.js';
|
|
279
|
+
`;
|
|
280
|
+
writeAndLog(path.join(outputDir, 'schemas', 'index.ts'), schemasIndex);
|
|
281
|
+
// Generate errors
|
|
282
|
+
const hasEndpoints = Object.keys(targetSpec.paths).length > 0;
|
|
283
|
+
console.log(` Generating error types...`);
|
|
284
|
+
const errorsContent = hasEndpoints ? generateErrors() : generateEmptyErrors();
|
|
285
|
+
writeAndLog(path.join(outputDir, 'errors', 'index.ts'), errorsContent);
|
|
286
|
+
// Generate overlay interfaces if overlays were applied
|
|
287
|
+
if (overlayResult && overlayResult.extensionInfo.size > 0 && !publicOnly) {
|
|
288
|
+
console.log(` Generating overlay interfaces...`);
|
|
289
|
+
const overlaysDir = path.join(outputDir, 'overlays');
|
|
290
|
+
fs.mkdirSync(overlaysDir, { recursive: true });
|
|
291
|
+
const overlayContent = generateExtensionInterfaces(overlayResult.extensionInfo);
|
|
292
|
+
const overlayPath = path.join(overlaysDir, 'index.ts');
|
|
293
|
+
writeAndLog(overlayPath, overlayContent);
|
|
294
|
+
}
|
|
295
|
+
// Generate package index
|
|
296
|
+
const hasOverlays = overlayResult && overlayResult.extensionInfo.size > 0 && !publicOnly;
|
|
297
|
+
const indexContent = `/**
|
|
298
|
+
* ${publicOnly ? 'Public ' : ''}Contract Package
|
|
299
|
+
* Auto-generated - DO NOT EDIT
|
|
300
|
+
*/
|
|
301
|
+
|
|
302
|
+
export * from './domains/index.js';
|
|
303
|
+
export * from './schemas/index.js';
|
|
304
|
+
export * from './errors/index.js';
|
|
305
|
+
${hasOverlays ? "export * from './overlays/index.js';" : ''}
|
|
306
|
+
`;
|
|
307
|
+
writeAndLog(path.join(outputDir, 'index.ts'), indexContent);
|
|
308
|
+
// Copy OpenAPI spec to docs with source info header
|
|
309
|
+
// Rebase $ref paths from source directory to output directory
|
|
310
|
+
const sourceDir = path.dirname(config.openapi);
|
|
311
|
+
const docsDir = path.join(outputDir, 'docs');
|
|
312
|
+
const rebasedSpec = rebaseRefs(targetSpec, sourceDir, docsDir);
|
|
313
|
+
const specHeader = `# Auto-generated OpenAPI specification
|
|
314
|
+
# DO NOT EDIT MANUALLY
|
|
315
|
+
#
|
|
316
|
+
# Source: ${config.openapi}
|
|
317
|
+
# Regenerate: micro-contracts generate
|
|
318
|
+
${publicOnly ? '# Filtered for public endpoints only\n' : ''}
|
|
319
|
+
`;
|
|
320
|
+
const yamlContent = specHeader + yaml.dump(rebasedSpec, { lineWidth: -1 });
|
|
321
|
+
writeAndLog(path.join(docsDir, 'openapi.generated.yaml'), yamlContent);
|
|
322
|
+
}
|
|
323
|
+
/**
|
|
324
|
+
* Generate using flexible outputs configuration
|
|
325
|
+
*/
|
|
326
|
+
async function generateFromOutputs(spec, config, overlayResult, options) {
|
|
327
|
+
const generateAll = !options.contractsOnly && !options.serverOnly &&
|
|
328
|
+
!options.frontendOnly && !options.docsOnly;
|
|
329
|
+
console.log(`\nGenerating from outputs configuration...`);
|
|
330
|
+
const hasPublic = hasPublicEndpoints(spec);
|
|
331
|
+
for (const output of config.outputs) {
|
|
332
|
+
// Skip disabled outputs
|
|
333
|
+
if (!output.enabled)
|
|
334
|
+
continue;
|
|
335
|
+
// Check conditions
|
|
336
|
+
if (output.condition === 'hasPublicEndpoints' && !hasPublic) {
|
|
337
|
+
console.log(` Skipping ${output.id} (no public endpoints)`);
|
|
338
|
+
continue;
|
|
339
|
+
}
|
|
340
|
+
const hasOverlays = overlayResult && overlayResult.extensionInfo.size > 0;
|
|
341
|
+
if (output.condition === 'hasOverlays' && !hasOverlays) {
|
|
342
|
+
console.log(` Skipping ${output.id} (no overlays)`);
|
|
343
|
+
continue;
|
|
344
|
+
}
|
|
345
|
+
// Filter by generation type
|
|
346
|
+
const isServerOutput = output.id.includes('server');
|
|
347
|
+
const isFrontendOutput = output.id.includes('frontend') || output.id.includes('client');
|
|
348
|
+
if (options.serverOnly && !isServerOutput)
|
|
349
|
+
continue;
|
|
350
|
+
if (options.frontendOnly && !isFrontendOutput)
|
|
351
|
+
continue;
|
|
352
|
+
if (!generateAll && !options.serverOnly && !options.frontendOnly)
|
|
353
|
+
continue;
|
|
354
|
+
// Check if file exists and overwrite is disabled
|
|
355
|
+
if (!output.overwrite && fs.existsSync(output.output)) {
|
|
356
|
+
console.log(` Skipping ${output.id} (file exists, overwrite=false)`);
|
|
357
|
+
continue;
|
|
358
|
+
}
|
|
359
|
+
console.log(` Generating ${output.id}...`);
|
|
360
|
+
try {
|
|
361
|
+
// Build template context with output-specific config
|
|
362
|
+
// Expand {module} placeholders in config values
|
|
363
|
+
const expandPlaceholder = (val, fallback) => (val?.replace(/{module}/g, config.name) ?? fallback);
|
|
364
|
+
const templateContext = buildTemplateContext(spec, config.name, {
|
|
365
|
+
domainsPath: expandPlaceholder(output.config?.domainsPath, `fastify.domains.${config.name}`),
|
|
366
|
+
contractPackage: expandPlaceholder(output.config?.contractPackage, `@project/contract/${config.name}`),
|
|
367
|
+
extensionInfo: overlayResult?.extensionInfo,
|
|
368
|
+
appliedOverlays: overlayResult?.appliedOverlays,
|
|
369
|
+
});
|
|
370
|
+
// Add output-specific config to context
|
|
371
|
+
const extendedContext = {
|
|
372
|
+
...templateContext,
|
|
373
|
+
outputConfig: output.config || {},
|
|
374
|
+
};
|
|
375
|
+
// Resolve and load template
|
|
376
|
+
const specDir = path.dirname(config.openapi).replace(/\/openapi$/, '').replace(`/${config.name}`, '');
|
|
377
|
+
const templatePath = resolveTemplatePath({
|
|
378
|
+
specDir,
|
|
379
|
+
moduleName: config.name,
|
|
380
|
+
templateName: path.basename(output.template),
|
|
381
|
+
}) || output.template;
|
|
382
|
+
if (!fs.existsSync(templatePath)) {
|
|
383
|
+
console.warn(` Warning: Template not found: ${output.template}`);
|
|
384
|
+
continue;
|
|
385
|
+
}
|
|
386
|
+
const template = loadTemplateWithResolution({
|
|
387
|
+
specDir,
|
|
388
|
+
moduleName: config.name,
|
|
389
|
+
templateName: path.basename(output.template),
|
|
390
|
+
});
|
|
391
|
+
const content = template(extendedContext);
|
|
392
|
+
// Ensure output directory exists
|
|
393
|
+
const outputDir = path.dirname(output.output);
|
|
394
|
+
if (!fs.existsSync(outputDir)) {
|
|
395
|
+
fs.mkdirSync(outputDir, { recursive: true });
|
|
396
|
+
}
|
|
397
|
+
// Write output file (only if content changed)
|
|
398
|
+
writeAndLog(output.output, content);
|
|
399
|
+
}
|
|
400
|
+
catch (error) {
|
|
401
|
+
console.error(` Error generating ${output.id}:`, error instanceof Error ? error.message : error);
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
/**
|
|
406
|
+
* Generate deps/ re-exports for cross-module dependencies
|
|
407
|
+
*/
|
|
408
|
+
async function generateDepsReExports(config, dependencies) {
|
|
409
|
+
if (dependencies.allDeps.length === 0) {
|
|
410
|
+
console.log(`\n No dependencies declared, skipping deps/ generation`);
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
413
|
+
console.log(`\nGenerating deps/ re-exports...`);
|
|
414
|
+
// Build contract-published paths map
|
|
415
|
+
const contractPublicPaths = new Map();
|
|
416
|
+
// Group deps by module
|
|
417
|
+
const moduleNames = new Set();
|
|
418
|
+
for (const dep of dependencies.allDeps) {
|
|
419
|
+
moduleNames.add(dep.module);
|
|
420
|
+
}
|
|
421
|
+
for (const moduleName of moduleNames) {
|
|
422
|
+
// Assume contract-published follows same pattern as config
|
|
423
|
+
contractPublicPaths.set(moduleName, `@project/contract-published/${moduleName}`);
|
|
424
|
+
}
|
|
425
|
+
// Generate deps files directly from already-extracted dependencies
|
|
426
|
+
const generatedFiles = generateSimpleDepsFiles(config.name, dependencies, contractPublicPaths);
|
|
427
|
+
for (const file of generatedFiles) {
|
|
428
|
+
const dir = path.dirname(file.path);
|
|
429
|
+
if (!fs.existsSync(dir)) {
|
|
430
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
431
|
+
}
|
|
432
|
+
writeAndLog(file.path, file.content, ' ');
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
/**
|
|
436
|
+
* Generate simple deps files without complex type resolution
|
|
437
|
+
*/
|
|
438
|
+
function generateSimpleDepsFiles(moduleName, dependencies, _contractPublicPaths) {
|
|
439
|
+
const files = [];
|
|
440
|
+
// Group deps by target module
|
|
441
|
+
const depsByModule = new Map();
|
|
442
|
+
for (const dep of dependencies.allDeps) {
|
|
443
|
+
if (!depsByModule.has(dep.module)) {
|
|
444
|
+
depsByModule.set(dep.module, []);
|
|
445
|
+
}
|
|
446
|
+
depsByModule.get(dep.module).push({
|
|
447
|
+
domain: dep.domain,
|
|
448
|
+
method: dep.method,
|
|
449
|
+
raw: dep.raw,
|
|
450
|
+
});
|
|
451
|
+
}
|
|
452
|
+
// Generate file for each target module
|
|
453
|
+
for (const [targetModule, deps] of depsByModule) {
|
|
454
|
+
// Use relative path from packages/contract/{module}/deps/ to packages/contract-published/{target}/
|
|
455
|
+
// From packages/contract/{module}/deps/ to packages/contract-published/{target}/
|
|
456
|
+
// ../../../contract-published/{target}/
|
|
457
|
+
const relativePathPrefix = `../../../contract-published/${targetModule}`;
|
|
458
|
+
const content = `/**
|
|
459
|
+
* Auto-generated from x-micro-contracts-depend-on - DO NOT EDIT
|
|
460
|
+
* Source module: ${moduleName}
|
|
461
|
+
* Target module: ${targetModule}
|
|
462
|
+
* Dependencies: ${deps.map(d => d.raw).join(', ')}
|
|
463
|
+
*/
|
|
464
|
+
|
|
465
|
+
// Re-exported types from ${targetModule} (contract-published)
|
|
466
|
+
export type * from '${relativePathPrefix}/schemas/types.js';
|
|
467
|
+
export type * from '${relativePathPrefix}/domains/index.js';
|
|
468
|
+
`;
|
|
469
|
+
files.push({
|
|
470
|
+
path: `packages/contract/${moduleName}/deps/${targetModule}.ts`,
|
|
471
|
+
content,
|
|
472
|
+
});
|
|
473
|
+
}
|
|
474
|
+
// Generate index file
|
|
475
|
+
if (files.length > 0) {
|
|
476
|
+
const indexContent = `/**
|
|
477
|
+
* Auto-generated deps index - DO NOT EDIT
|
|
478
|
+
*/
|
|
479
|
+
|
|
480
|
+
${Array.from(depsByModule.keys()).map(m => `export * from './${m}.js';`).join('\n')}
|
|
481
|
+
`;
|
|
482
|
+
files.push({
|
|
483
|
+
path: `packages/contract/${moduleName}/deps/index.ts`,
|
|
484
|
+
content: indexContent,
|
|
485
|
+
});
|
|
486
|
+
}
|
|
487
|
+
return files;
|
|
488
|
+
}
|
|
489
|
+
/**
|
|
490
|
+
* Generate server routes
|
|
491
|
+
*/
|
|
492
|
+
async function generateServerRoutes(spec, config, overlayResult = null) {
|
|
493
|
+
if (!config.server)
|
|
494
|
+
return;
|
|
495
|
+
const outputDir = path.resolve(config.server.output);
|
|
496
|
+
const routesFile = config.server.routes;
|
|
497
|
+
console.log(`\nGenerating server routes...`);
|
|
498
|
+
fs.mkdirSync(outputDir, { recursive: true });
|
|
499
|
+
// Template is required for server routes generation
|
|
500
|
+
if (!config.server.template) {
|
|
501
|
+
throw new Error('Server template is required. Please specify server.template in your config.');
|
|
502
|
+
}
|
|
503
|
+
const templateContext = buildTemplateContext(spec, config.name, {
|
|
504
|
+
domainsPath: config.server.domainsPath,
|
|
505
|
+
contractPackage: `@project/contract/${config.name}`,
|
|
506
|
+
extensionInfo: overlayResult?.extensionInfo,
|
|
507
|
+
appliedOverlays: overlayResult?.appliedOverlays,
|
|
508
|
+
});
|
|
509
|
+
const routesContent = generateWithTemplate(config.server.template, 'server', templateContext);
|
|
510
|
+
const routesPath = path.join(outputDir, routesFile);
|
|
511
|
+
writeAndLog(routesPath, routesContent, ' ');
|
|
512
|
+
}
|
|
513
|
+
/**
|
|
514
|
+
* Generate frontend client
|
|
515
|
+
*/
|
|
516
|
+
async function generateFrontendClient(spec, config, overlayResult = null) {
|
|
517
|
+
if (!config.frontend)
|
|
518
|
+
return;
|
|
519
|
+
const outputDir = path.resolve(config.frontend.output);
|
|
520
|
+
const clientFile = config.frontend.client;
|
|
521
|
+
console.log(`\nGenerating frontend client...`);
|
|
522
|
+
fs.mkdirSync(outputDir, { recursive: true });
|
|
523
|
+
// Template is required for frontend client generation
|
|
524
|
+
if (!config.frontend.template) {
|
|
525
|
+
throw new Error('Frontend template is required. Please specify frontend.template in your config.');
|
|
526
|
+
}
|
|
527
|
+
const templateContext = buildTemplateContext(spec, config.name, {
|
|
528
|
+
contractPackage: `@project/contract/${config.name}`,
|
|
529
|
+
extensionInfo: overlayResult?.extensionInfo,
|
|
530
|
+
appliedOverlays: overlayResult?.appliedOverlays,
|
|
531
|
+
});
|
|
532
|
+
const clientContent = generateWithTemplate(config.frontend.template, 'frontend', templateContext);
|
|
533
|
+
const clientPath = path.join(outputDir, clientFile);
|
|
534
|
+
writeAndLog(clientPath, clientContent, ' ');
|
|
535
|
+
// Generate domain re-exports
|
|
536
|
+
const domainContent = generateDomainReExports(config.name);
|
|
537
|
+
const domainPath = path.join(outputDir, config.frontend.domain);
|
|
538
|
+
writeAndLog(domainPath, domainContent, ' ');
|
|
539
|
+
}
|
|
540
|
+
/**
|
|
541
|
+
* Generate domain re-exports file
|
|
542
|
+
*/
|
|
543
|
+
function generateDomainReExports(moduleName) {
|
|
544
|
+
const lines = [];
|
|
545
|
+
lines.push('/**');
|
|
546
|
+
lines.push(' * Domain re-exports');
|
|
547
|
+
lines.push(' * Auto-generated - DO NOT EDIT');
|
|
548
|
+
lines.push(' */');
|
|
549
|
+
lines.push('');
|
|
550
|
+
// Re-export API clients from api.generated
|
|
551
|
+
lines.push('// API clients');
|
|
552
|
+
lines.push("export * from './api.generated';");
|
|
553
|
+
lines.push('');
|
|
554
|
+
// Re-export types from contract package
|
|
555
|
+
lines.push('// Contract types');
|
|
556
|
+
lines.push(`export * from '@project/contract/${moduleName}/schemas';`);
|
|
557
|
+
lines.push(`export * from '@project/contract/${moduleName}/domains';`);
|
|
558
|
+
lines.push(`export * from '@project/contract/${moduleName}/errors';`);
|
|
559
|
+
lines.push('');
|
|
560
|
+
return lines.join('\n');
|
|
561
|
+
}
|
|
562
|
+
/**
|
|
563
|
+
* Generate documentation (Redoc HTML)
|
|
564
|
+
*/
|
|
565
|
+
async function generateDocumentation(openapiPath, outputDir) {
|
|
566
|
+
const { execSync } = await import('child_process');
|
|
567
|
+
const htmlPath = path.join(outputDir, 'api-reference.html');
|
|
568
|
+
try {
|
|
569
|
+
console.log(` Generating Redoc HTML...`);
|
|
570
|
+
execSync(`npx @redocly/cli build-docs "${openapiPath}" -o "${htmlPath}"`, {
|
|
571
|
+
stdio: 'pipe',
|
|
572
|
+
});
|
|
573
|
+
console.log(` Written: ${htmlPath}`);
|
|
574
|
+
}
|
|
575
|
+
catch (error) {
|
|
576
|
+
console.log(` Warning: Redoc HTML generation failed. Install @redocly/cli if needed.`);
|
|
577
|
+
console.log(` Run: npx @redocly/cli build-docs "${openapiPath}" -o "${htmlPath}"`);
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
/**
|
|
581
|
+
* Generate error types
|
|
582
|
+
*/
|
|
583
|
+
function generateErrors() {
|
|
584
|
+
return `/**
|
|
585
|
+
* Error types
|
|
586
|
+
* Auto-generated - DO NOT EDIT
|
|
587
|
+
*/
|
|
588
|
+
|
|
589
|
+
// Re-export ProblemDetails from schemas (RFC 9457)
|
|
590
|
+
export type { ProblemDetails, ValidationError } from '../schemas/types.js';
|
|
591
|
+
import type { ProblemDetails } from '../schemas/types.js';
|
|
592
|
+
|
|
593
|
+
/**
|
|
594
|
+
* API Error wrapper
|
|
595
|
+
*/
|
|
596
|
+
export class ApiError extends Error {
|
|
597
|
+
constructor(
|
|
598
|
+
public readonly status: number,
|
|
599
|
+
public readonly problem: ProblemDetails,
|
|
600
|
+
public readonly requestId?: string,
|
|
601
|
+
) {
|
|
602
|
+
super(problem.title);
|
|
603
|
+
this.name = 'ApiError';
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
get isValidationError(): boolean {
|
|
607
|
+
return this.status === 400;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
get isNotFound(): boolean {
|
|
611
|
+
return this.status === 404;
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
get isServerError(): boolean {
|
|
615
|
+
return this.status >= 500;
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
`;
|
|
619
|
+
}
|
|
620
|
+
/**
|
|
621
|
+
* Generate empty error types (when no endpoints exist)
|
|
622
|
+
*/
|
|
623
|
+
function generateEmptyErrors() {
|
|
624
|
+
return `/**
|
|
625
|
+
* Error types
|
|
626
|
+
* Auto-generated - DO NOT EDIT
|
|
627
|
+
*
|
|
628
|
+
* No endpoints defined - error types not needed.
|
|
629
|
+
*/
|
|
630
|
+
`;
|
|
631
|
+
}
|
|
632
|
+
/**
|
|
633
|
+
* Filter OpenAPI spec for public endpoints only
|
|
634
|
+
*/
|
|
635
|
+
function filterPublicSpec(spec) {
|
|
636
|
+
const filtered = {
|
|
637
|
+
...spec,
|
|
638
|
+
paths: {},
|
|
639
|
+
tags: [],
|
|
640
|
+
components: {
|
|
641
|
+
schemas: {},
|
|
642
|
+
responses: spec.components?.responses,
|
|
643
|
+
parameters: spec.components?.parameters,
|
|
644
|
+
requestBodies: spec.components?.requestBodies,
|
|
645
|
+
},
|
|
646
|
+
};
|
|
647
|
+
// Collect all referenced schemas and tags from public endpoints
|
|
648
|
+
const usedSchemas = new Set();
|
|
649
|
+
const usedTags = new Set();
|
|
650
|
+
// Filter paths to only include x-micro-contracts-published: true
|
|
651
|
+
for (const [pathKey, pathItem] of Object.entries(spec.paths)) {
|
|
652
|
+
const filteredPathItem = {};
|
|
653
|
+
let hasPublicOperation = false;
|
|
654
|
+
for (const method of ['get', 'post', 'put', 'patch', 'delete']) {
|
|
655
|
+
const operation = pathItem[method];
|
|
656
|
+
if (operation && operation['x-micro-contracts-published'] === true) {
|
|
657
|
+
filteredPathItem[method] = operation;
|
|
658
|
+
hasPublicOperation = true;
|
|
659
|
+
// Collect schema references from this operation
|
|
660
|
+
collectSchemaRefs(operation, usedSchemas);
|
|
661
|
+
// Collect tags from this operation
|
|
662
|
+
if (operation.tags) {
|
|
663
|
+
for (const tag of operation.tags) {
|
|
664
|
+
usedTags.add(tag);
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
if (hasPublicOperation) {
|
|
670
|
+
filtered.paths[pathKey] = filteredPathItem;
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
// Recursively resolve schema references
|
|
674
|
+
const allUsedSchemas = resolveSchemaRefsRecursively(spec, usedSchemas);
|
|
675
|
+
// Filter schemas to only include used ones
|
|
676
|
+
if (spec.components?.schemas) {
|
|
677
|
+
for (const [schemaName, schema] of Object.entries(spec.components.schemas)) {
|
|
678
|
+
if (allUsedSchemas.has(schemaName)) {
|
|
679
|
+
filtered.components.schemas[schemaName] = schema;
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
// Filter tags to only include used ones
|
|
684
|
+
if (spec.tags) {
|
|
685
|
+
filtered.tags = spec.tags.filter(tag => usedTags.has(tag.name));
|
|
686
|
+
}
|
|
687
|
+
// Clean up empty components
|
|
688
|
+
if (Object.keys(filtered.components?.schemas || {}).length === 0) {
|
|
689
|
+
delete filtered.components?.schemas;
|
|
690
|
+
}
|
|
691
|
+
// Clean up empty tags
|
|
692
|
+
if (filtered.tags?.length === 0) {
|
|
693
|
+
delete filtered.tags;
|
|
694
|
+
}
|
|
695
|
+
return filtered;
|
|
696
|
+
}
|
|
697
|
+
/**
|
|
698
|
+
* Collect $ref references from an operation
|
|
699
|
+
*/
|
|
700
|
+
function collectSchemaRefs(obj, refs) {
|
|
701
|
+
if (!obj || typeof obj !== 'object')
|
|
702
|
+
return;
|
|
703
|
+
if (Array.isArray(obj)) {
|
|
704
|
+
for (const item of obj) {
|
|
705
|
+
collectSchemaRefs(item, refs);
|
|
706
|
+
}
|
|
707
|
+
return;
|
|
708
|
+
}
|
|
709
|
+
const record = obj;
|
|
710
|
+
// Check for $ref
|
|
711
|
+
if (typeof record['$ref'] === 'string') {
|
|
712
|
+
const ref = record['$ref'];
|
|
713
|
+
const match = ref.match(/#\/components\/schemas\/(.+)/);
|
|
714
|
+
if (match) {
|
|
715
|
+
refs.add(match[1]);
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
// Recurse into nested objects
|
|
719
|
+
for (const value of Object.values(record)) {
|
|
720
|
+
collectSchemaRefs(value, refs);
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
/**
|
|
724
|
+
* Recursively resolve schema references (schemas can reference other schemas)
|
|
725
|
+
*/
|
|
726
|
+
function resolveSchemaRefsRecursively(spec, initialRefs) {
|
|
727
|
+
const allRefs = new Set(initialRefs);
|
|
728
|
+
const toProcess = [...initialRefs];
|
|
729
|
+
while (toProcess.length > 0) {
|
|
730
|
+
const schemaName = toProcess.pop();
|
|
731
|
+
const schema = spec.components?.schemas?.[schemaName];
|
|
732
|
+
if (!schema)
|
|
733
|
+
continue;
|
|
734
|
+
// Collect refs from this schema
|
|
735
|
+
const nestedRefs = new Set();
|
|
736
|
+
collectSchemaRefs(schema, nestedRefs);
|
|
737
|
+
// Add new refs to process
|
|
738
|
+
for (const ref of nestedRefs) {
|
|
739
|
+
if (!allRefs.has(ref)) {
|
|
740
|
+
allRefs.add(ref);
|
|
741
|
+
toProcess.push(ref);
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
return allRefs;
|
|
746
|
+
}
|
|
747
|
+
//# sourceMappingURL=index.js.map
|