zopia 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/CHANGELOG.md +354 -0
- package/LICENSE +21 -0
- package/README.md +167 -0
- package/bin/zopia.js +20 -0
- package/docs/01-overview.md +94 -0
- package/docs/02-targets.md +55 -0
- package/docs/03-roadmap.md +205 -0
- package/docs/04-architecture.md +345 -0
- package/docs/05-concepts.md +239 -0
- package/docs/06-conversions.md +493 -0
- package/docs/07-api-docs.md +337 -0
- package/docs/08-components.md +223 -0
- package/docs/09-configuration.md +167 -0
- package/docs/10-usage.md +208 -0
- package/docs/11-testing.md +267 -0
- package/docs/12-standards.md +242 -0
- package/docs/README.md +42 -0
- package/docs/publish-workflow.yml.example +48 -0
- package/package.json +77 -0
- package/src/api-docs-navigation.ts +353 -0
- package/src/cli-command.ts +537 -0
- package/src/cli.ts +4 -0
- package/src/config.ts +190 -0
- package/src/conversions/api-docs-facade.ts +42 -0
- package/src/conversions/api-docs-generate.ts +567 -0
- package/src/conversions/api-docs-layout.ts +39 -0
- package/src/conversions/api-docs-plan.ts +130 -0
- package/src/conversions/api-docs-presets.ts +246 -0
- package/src/conversions/json-schema-to-zod.ts +931 -0
- package/src/conversions/manifest-staleness.ts +211 -0
- package/src/conversions/manifest-to-openapi.ts +1861 -0
- package/src/conversions/manifest-writer.ts +778 -0
- package/src/conversions/openapi-contracts.ts +333 -0
- package/src/conversions/openapi-external-ref.ts +233 -0
- package/src/conversions/openapi-ir.ts +74 -0
- package/src/conversions/openapi-ref.ts +38 -0
- package/src/conversions/openapi-to-api-docs-public.ts +466 -0
- package/src/conversions/openapi-to-api-docs.ts +203 -0
- package/src/conversions/openapi.ts +80 -0
- package/src/conversions/reverse-security.ts +68 -0
- package/src/conversions/yaml.ts +876 -0
- package/src/conversions/zod-to-json-schema.ts +536 -0
- package/src/diff.ts +353 -0
- package/src/errors.ts +114 -0
- package/src/index.ts +80 -0
- package/src/validation.ts +299 -0
- package/src/warnings.ts +164 -0
package/src/config.ts
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
/* Project-level configuration (v0.2.x, D-19). The CLI discovers `zopia.config.ts` (or
|
|
2
|
+
* `zopia.config.mts`) next to the current working directory — or an explicit path given with
|
|
3
|
+
* `--config` — and applies it as defaults: explicit CLI flags always win, the config file
|
|
4
|
+
* fills the gaps, and built-in defaults remain last. The file is executed JavaScript like
|
|
5
|
+
* any generated module, so only trusted projects should be configured this way (the CLI
|
|
6
|
+
* help and configuration docs repeat that warning). */
|
|
7
|
+
|
|
8
|
+
import { createHash } from 'node:crypto';
|
|
9
|
+
import { readFile, realpath, stat } from 'node:fs/promises';
|
|
10
|
+
import { isAbsolute, resolve } from 'node:path';
|
|
11
|
+
import { pathToFileURL } from 'node:url';
|
|
12
|
+
import { ZopiaError } from './errors';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Defaults for `zopia generate` resolved from a project config file.
|
|
16
|
+
*/
|
|
17
|
+
export interface ZopiaProjectGenerateConfig {
|
|
18
|
+
/** Endpoint layout used when `--mode` is absent. */
|
|
19
|
+
mode?: 'directory' | 'flat';
|
|
20
|
+
/** Write component modules when `--insert-components` is absent. */
|
|
21
|
+
insertComponents?: boolean;
|
|
22
|
+
/** Import emitted components when `--use-component-as-reference` is absent; requires `insertComponents: true`. */
|
|
23
|
+
useComponentAsReference?: boolean;
|
|
24
|
+
/** Write `.zopia-manifest.json`; `--no-manifest` on the CLI always overrides. */
|
|
25
|
+
manifest?: boolean;
|
|
26
|
+
/** Write merge-safe `custom` companion modules when `--custom` is absent. */
|
|
27
|
+
custom?: boolean;
|
|
28
|
+
/** Split generation: per-primary-tag or per-effective-server sub-trees when `--preset` is absent. */
|
|
29
|
+
preset?: 'multi-tag' | 'multi-server';
|
|
30
|
+
/** Output directory used when the positional `<output-dir>` is omitted. */
|
|
31
|
+
outDir?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Defaults for `zopia reverse` resolved from a project config file.
|
|
36
|
+
*/
|
|
37
|
+
export interface ZopiaProjectReverseConfig {
|
|
38
|
+
/** OpenAPI output version used when `--version` is absent. */
|
|
39
|
+
version?: '2.0' | '3.0' | '3.1';
|
|
40
|
+
/** JSON destination used when `--out` is absent; without any value the CLI keeps writing to stdout. */
|
|
41
|
+
out?: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Complete `zopia.config.ts` shape. Only the purposeful defaults above are loadable;
|
|
46
|
+
* unknown keys or incorrectly typed values are rejected with `ZOPIA_CONFIG_INVALID`.
|
|
47
|
+
*/
|
|
48
|
+
export interface ZopiaProjectConfig {
|
|
49
|
+
/** Defaults for `zopia generate`. */
|
|
50
|
+
generate?: ZopiaProjectGenerateConfig;
|
|
51
|
+
/** Defaults for `zopia reverse`. */
|
|
52
|
+
reverse?: ZopiaProjectReverseConfig;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Options accepted by {@link loadZopiaConfig}. */
|
|
56
|
+
export interface ZopiaConfigLoadOptions {
|
|
57
|
+
/** Directory searched for `zopia.config.ts` / `zopia.config.mts`. @default process.cwd() */
|
|
58
|
+
cwd?: string;
|
|
59
|
+
/** Explicit config path (CLI `--config`), resolved from `cwd`; errors when missing instead of silently skipping discovery. @default undefined (directory discovery) */
|
|
60
|
+
file?: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const CONFIG_FILE_NAMES = ['zopia.config.ts', 'zopia.config.mts'] as const;
|
|
64
|
+
|
|
65
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
66
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function invalidConfig(message: string, at: string, hint: string, cause?: unknown): never {
|
|
70
|
+
throw new ZopiaError('ZOPIA_CONFIG_INVALID', message, { at, hint, ...(cause === undefined ? {} : { cause }) });
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const isFile = async (path: string): Promise<boolean> => {
|
|
74
|
+
try { return (await stat(path)).isFile(); }
|
|
75
|
+
catch { return false; }
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
function validateStringOption(value: unknown, at: string): string | undefined {
|
|
79
|
+
if (value === undefined) return undefined;
|
|
80
|
+
if (typeof value !== 'string' || !value) invalidConfig(`${at} must be a non-empty string`, at, 'set a valid string value');
|
|
81
|
+
return value;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function validateBooleanOption(value: unknown, at: string): boolean | undefined {
|
|
85
|
+
if (value === undefined) return undefined;
|
|
86
|
+
if (typeof value !== 'boolean') invalidConfig(`${at} must be a boolean`, at, 'set true or false');
|
|
87
|
+
return value;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function validateUnknownKeys(record: Record<string, unknown>, allowed: readonly string[], context: string): void {
|
|
91
|
+
const unknown = Object.keys(record).find((key) => !allowed.includes(key));
|
|
92
|
+
if (unknown !== undefined) invalidConfig(`unknown ${context} config option: ${unknown}`, context === 'config' ? unknown : `${context}.${unknown}`, 'remove the unsupported option');
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Type a project config file without changing its value.
|
|
97
|
+
*
|
|
98
|
+
* @param config Configuration object to type-check statically.
|
|
99
|
+
* @returns The same configuration object.
|
|
100
|
+
*/
|
|
101
|
+
export function defineConfig<T extends ZopiaProjectConfig>(config: T): T {
|
|
102
|
+
return config;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function validateProjectConfig(candidate: unknown, file: string): ZopiaProjectConfig {
|
|
106
|
+
if (!isRecord(candidate)) invalidConfig('zopia config must export an object', file, 'export default defineConfig({ generate: { … }, reverse: { … } })');
|
|
107
|
+
validateUnknownKeys(candidate, ['generate', 'reverse'], 'config');
|
|
108
|
+
const config: ZopiaProjectConfig = {};
|
|
109
|
+
if (candidate.generate !== undefined) {
|
|
110
|
+
if (!isRecord(candidate.generate)) invalidConfig('generate config must be an object', 'generate', 'provide generate conversion defaults');
|
|
111
|
+
validateUnknownKeys(candidate.generate, ['mode', 'insertComponents', 'useComponentAsReference', 'manifest', 'custom', 'preset', 'outDir'], 'generate');
|
|
112
|
+
const mode = candidate.generate.mode;
|
|
113
|
+
if (mode !== undefined && mode !== 'directory' && mode !== 'flat') invalidConfig("generate.mode must be 'directory' or 'flat'", 'generate.mode', "use '--mode directory' or '--mode flat'");
|
|
114
|
+
const booleans: Partial<Pick<ZopiaProjectGenerateConfig, 'insertComponents' | 'useComponentAsReference' | 'manifest' | 'custom'>> = {};
|
|
115
|
+
for (const key of ['insertComponents', 'useComponentAsReference', 'manifest', 'custom'] as const) {
|
|
116
|
+
const value = validateBooleanOption(candidate.generate[key], `generate.${key}`);
|
|
117
|
+
if (value !== undefined) booleans[key] = value;
|
|
118
|
+
}
|
|
119
|
+
const preset = candidate.generate.preset;
|
|
120
|
+
if (preset !== undefined && preset !== 'multi-tag' && preset !== 'multi-server') invalidConfig("generate.preset must be 'multi-tag' or 'multi-server'", 'generate.preset', "use '--preset multi-tag' or '--preset multi-server'");
|
|
121
|
+
config.generate = {
|
|
122
|
+
...(mode === undefined ? {} : { mode }),
|
|
123
|
+
...booleans,
|
|
124
|
+
...(preset === undefined ? {} : { preset }),
|
|
125
|
+
...(candidate.generate.outDir === undefined ? {} : { outDir: validateStringOption(candidate.generate.outDir, 'generate.outDir') }),
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
if (candidate.reverse !== undefined) {
|
|
129
|
+
if (!isRecord(candidate.reverse)) invalidConfig('reverse config must be an object', 'reverse', 'provide reverse conversion defaults');
|
|
130
|
+
validateUnknownKeys(candidate.reverse, ['version', 'out'], 'reverse');
|
|
131
|
+
const version = candidate.reverse.version;
|
|
132
|
+
if (version !== undefined && version !== '2.0' && version !== '3.0' && version !== '3.1') invalidConfig("reverse.version must be '2.0', '3.0', or '3.1'", 'reverse.version', "use '--version 2.0', '--version 3.0', or '--version 3.1'");
|
|
133
|
+
config.reverse = {
|
|
134
|
+
...(version === undefined ? {} : { version }),
|
|
135
|
+
...(candidate.reverse.out === undefined ? {} : { out: validateStringOption(candidate.reverse.out, 'reverse.out') }),
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
return config;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Load the project config file for the current working directory (or an explicit `--config` path).
|
|
143
|
+
*
|
|
144
|
+
* Discovery checks `zopia.config.ts` then `zopia.config.mts` in `cwd`; an explicit `file`
|
|
145
|
+
* must exist. The module is executed exactly like generated modules (trusted projects
|
|
146
|
+
* only); the `default` export wins over a named `config` export. The result is structurally
|
|
147
|
+
* validated — unknown keys and wrongly typed values fail with `ZOPIA_CONFIG_INVALID`.
|
|
148
|
+
*
|
|
149
|
+
* @param options Load options; defaults to running-directory discovery.
|
|
150
|
+
* @returns The validated project config, or `undefined` when discovery finds no file.
|
|
151
|
+
*/
|
|
152
|
+
export async function loadZopiaConfig(options: ZopiaConfigLoadOptions = {}): Promise<ZopiaProjectConfig | undefined> {
|
|
153
|
+
if (!isRecord(options)) invalidConfig('config load options must be an object', 'options', 'pass an options object or omit it');
|
|
154
|
+
validateUnknownKeys(options, ['cwd', 'file'], 'config load');
|
|
155
|
+
const cwd = options.cwd === undefined ? process.cwd() : options.cwd;
|
|
156
|
+
if (typeof cwd !== 'string' || !cwd) invalidConfig('cwd must be a non-empty string', 'cwd', 'provide a working directory');
|
|
157
|
+
if (options.file !== undefined && (typeof options.file !== 'string' || !options.file)) invalidConfig('file must be a non-empty string', 'file', 'provide a config file path');
|
|
158
|
+
|
|
159
|
+
let candidate: string | undefined;
|
|
160
|
+
let explicit = false;
|
|
161
|
+
if (options.file) {
|
|
162
|
+
candidate = isAbsolute(options.file) ? options.file : resolve(cwd, options.file);
|
|
163
|
+
explicit = true;
|
|
164
|
+
if (!await isFile(candidate)) invalidConfig(`config file not found: ${options.file}`, options.file, 'create zopia.config.ts or pass an existing --config path');
|
|
165
|
+
} else {
|
|
166
|
+
for (const name of CONFIG_FILE_NAMES) {
|
|
167
|
+
const discovered = resolve(cwd, name);
|
|
168
|
+
if (await isFile(discovered)) { candidate = discovered; break; }
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
if (candidate === undefined) return undefined;
|
|
172
|
+
|
|
173
|
+
let resolved: string;
|
|
174
|
+
try { resolved = await realpath(candidate); }
|
|
175
|
+
catch (error) { invalidConfig(`Unable to resolve config file ${explicit ? options.file! : candidate}: ${error instanceof Error ? error.message : String(error)}`, explicit ? options.file! : candidate, 'check the config file path and permissions', error); }
|
|
176
|
+
|
|
177
|
+
let module: Record<string, unknown>;
|
|
178
|
+
try {
|
|
179
|
+
const url = pathToFileURL(resolved);
|
|
180
|
+
const digest = createHash('sha256').update(await readFile(resolved)).digest('hex');
|
|
181
|
+
url.searchParams.set('zopia-config', digest);
|
|
182
|
+
module = await import(url.href.replace(/%7B/gi, '{').replace(/%7D/gi, '}').replace(/%7E/gi, '~')) as Record<string, unknown>;
|
|
183
|
+
} catch (error) {
|
|
184
|
+
invalidConfig(`Unable to import config file ${resolved}: ${error instanceof Error ? error.message : String(error)}`, resolved, 'fix the config module; it is executed JavaScript and must evaluate cleanly', error);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const selected = 'default' in module ? module.default : 'config' in module ? module.config : undefined;
|
|
188
|
+
if (selected === undefined) invalidConfig('zopia config must default-export (or named-export `config`) an object', resolved, "export default defineConfig({ … }) or export const config = defineConfig({ … })");
|
|
189
|
+
return validateProjectConfig(selected, resolved);
|
|
190
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { ZopiaError } from '../errors';
|
|
2
|
+
import { OPENAPI_METHODS, type OpenApiMethod } from './openapi-to-api-docs';
|
|
3
|
+
|
|
4
|
+
function propertyName(segment: string): string {
|
|
5
|
+
const raw = segment.replace(/^\{(.*)\}$/, '$1');
|
|
6
|
+
const words = raw.split(/[^A-Za-z0-9]+/).filter(Boolean);
|
|
7
|
+
const name = words.map((word, index) => index === 0 ? word.charAt(0).toLowerCase() + word.slice(1) : word.charAt(0).toUpperCase() + word.slice(1)).join('');
|
|
8
|
+
if (!name || ['__proto__', 'prototype', 'constructor', 'toString', 'valueOf', 'hasOwnProperty', 'isPrototypeOf', 'propertyIsEnumerable'].includes(name)) throw new ZopiaError('ZOPIA_SPEC_INVALID', `Unsafe facade property from path segment: ${segment}`);
|
|
9
|
+
return name;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Return the ergonomic dot-access path for an endpoint facade.
|
|
14
|
+
*
|
|
15
|
+
* @param path OpenAPI path template to convert into property access.
|
|
16
|
+
* @param method Supported endpoint HTTP method.
|
|
17
|
+
* @param root Safe TypeScript identifier used as the facade root.
|
|
18
|
+
* @returns Dot/bracket access expression for the endpoint.
|
|
19
|
+
* @throws {@link ZopiaError} when the root, method, or path is invalid or unsafe.
|
|
20
|
+
*/
|
|
21
|
+
export function apiDocsFacadeAccess(path: string, method: OpenApiMethod, root = 'apiDocs'): string {
|
|
22
|
+
if (typeof root !== 'string' || !/^[$A-Za-z_][$A-Za-z0-9_]*$/.test(root) || ['__proto__', 'prototype', 'constructor', 'eval', 'arguments'].includes(root)) throw new ZopiaError('ZOPIA_CONFIG_INVALID', `Invalid facade root: ${root}`, { at: 'root', hint: 'use a safe TypeScript identifier' });
|
|
23
|
+
if (!(OPENAPI_METHODS as readonly string[]).includes(method)) throw new ZopiaError('ZOPIA_SPEC_INVALID', `Unsupported HTTP method: ${String(method)}`);
|
|
24
|
+
if (typeof path !== 'string' || !path.startsWith('/') || path.includes('?') || path.includes('#') || path.includes('\0') || path.includes('\\')) throw new ZopiaError('ZOPIA_SPEC_INVALID', `Invalid API path: ${path}`);
|
|
25
|
+
const rawSegments = path.split('/').filter(Boolean);
|
|
26
|
+
if (rawSegments.some((segment) => /[{}]/.test(segment) && !/^\{[A-Za-z0-9._-]+\}$/.test(segment))) throw new ZopiaError('ZOPIA_SPEC_INVALID', `Invalid API path template: ${path}`);
|
|
27
|
+
const parameterNames = new Set<string>();
|
|
28
|
+
const parts: Array<{ name: string; parameter: boolean }> = rawSegments.map((segment) => {
|
|
29
|
+
const parameter = /^\{[A-Za-z0-9._-]+\}$/.test(segment);
|
|
30
|
+
if (parameter) {
|
|
31
|
+
const normalized = propertyName(segment);
|
|
32
|
+
if (parameterNames.has(normalized)) throw new ZopiaError('ZOPIA_SPEC_INVALID', `Duplicate path parameter: ${normalized}`);
|
|
33
|
+
parameterNames.add(normalized);
|
|
34
|
+
}
|
|
35
|
+
return { name: parameter ? segment : propertyName(segment), parameter };
|
|
36
|
+
});
|
|
37
|
+
const methodName = propertyName(method);
|
|
38
|
+
return [root, ...parts.map((part) => part.name), methodName].map((name) => {
|
|
39
|
+
if (/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(name)) return `.${name}`;
|
|
40
|
+
return `[${JSON.stringify(name)}]`;
|
|
41
|
+
}).join('').replace(/^\./, '');
|
|
42
|
+
}
|