zopia 0.5.2 β 0.6.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 +50 -0
- package/README.md +1 -1
- package/docs/10-usage.md +1 -1
- package/package.json +1 -1
- package/src/conversions/json-schema-to-zod.ts +8 -0
- package/src/conversions/manifest-to-openapi.ts +114 -13
- package/src/conversions/manifest-writer.ts +1 -1
- package/src/errors.ts +2 -0
- package/src/warnings.ts +1 -0
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,56 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.6.0] - 2026-09-30
|
|
11
|
+
|
|
12
|
+
### π Fixed
|
|
13
|
+
- π **Swagger 2.0 `collectionFormat` was dropped when reversing to OpenAPI
|
|
14
|
+
3.x.** Array parameters generated from a Swagger 2.0 source came back as
|
|
15
|
+
bare 3.x parameters, silently changing the wire format from (for example)
|
|
16
|
+
`?tags=a,b` to `?tags=a&tags=b`. Reverse conversion now maps the recorded
|
|
17
|
+
format onto its 3.x spelling: on `query` parameters `csv` β
|
|
18
|
+
`style: "form", explode: false`, `multi` β `style: "form", explode: true`,
|
|
19
|
+
`pipes` β `style: "pipeDelimited"`, `ssv` β `style: "spaceDelimited"`; on
|
|
20
|
+
`path`/`header` `csv` β `style: "simple", explode: false`. `formData`
|
|
21
|
+
array serialization now lands in the media type's `encoding` map
|
|
22
|
+
(`csv` β `form` + `explode: false`, `multi` β `form` + `explode: true`).
|
|
23
|
+
Inline parameters and shared `#/parameters/<Name>` declarations (which
|
|
24
|
+
reverse into `components.parameters`) take the same mapping. Formats with
|
|
25
|
+
no legal 3.x spelling β `tsv` anywhere, plus `pipes`/`ssv`/`tsv` inside
|
|
26
|
+
`encoding` objects or on `path`/`header` β keep the original value in an
|
|
27
|
+
`x-collectionFormat` extension and emit the new
|
|
28
|
+
`ZOPIA_WARN_COLLECTION_FORMAT` warning at the affected pointer instead of
|
|
29
|
+
discarding it (D-12). Reversing to `'2.0'` still restores every
|
|
30
|
+
`collectionFormat` verbatim and warning-free.
|
|
31
|
+
- π **Swagger 2.0 `type: "file"` lost its binary format in 3.x output.**
|
|
32
|
+
Multipart file properties reversed to a plain `{ "type": "string" }`,
|
|
33
|
+
which describes a text field, because the version-rewrite pass stripped
|
|
34
|
+
the `format: "binary"` the operation builder had already produced.
|
|
35
|
+
Reversing to `'3.0'`/`'3.1'` now yields
|
|
36
|
+
`{ "type": "string", "format": "binary" }`; reversing to `'2.0'` still
|
|
37
|
+
restores `type: "file"`.
|
|
38
|
+
- π **Reversing a `--preset` split root reported a misleading missing
|
|
39
|
+
manifest.** `apiDocsToOpenApi()` (and `zopia reverse`) pointed at a preset
|
|
40
|
+
root β where the manifests live one directory down, one per bucket β
|
|
41
|
+
failed with `ZOPIA_DOCS_MISSING_MANIFEST`, which reads as "you never
|
|
42
|
+
generated anything". It now fails with the new typed
|
|
43
|
+
`ZOPIA_DOCS_PRESET_ROOT` code, naming the bucket directories that actually
|
|
44
|
+
contain a manifest in alphabetical order plus the command for a single
|
|
45
|
+
bucket, e.g. `this directory is a preset split (3 trees: orders,
|
|
46
|
+
untagged, users). Reverse one bucket (zopia reverse api_docs/orders) to
|
|
47
|
+
convert a single tree.` Directories with no bucket manifest anywhere keep
|
|
48
|
+
`ZOPIA_DOCS_MISSING_MANIFEST`.
|
|
49
|
+
- π **Authored numeric bound pairs were lost on the OpenAPI 3.1 round
|
|
50
|
+
trip.** A schema carrying both an inclusive and an exclusive bound on the
|
|
51
|
+
same side (e.g. `{ "minimum": 0, "exclusiveMinimum": 0 }`) generated the
|
|
52
|
+
correct Zod (`.min(0)` β¦ `.gt(0)`), but `z.toJSONSchema()` keeps only the
|
|
53
|
+
tighter keyword, so reverse conversion emitted just
|
|
54
|
+
`{ "exclusiveMinimum": 0 }`. Generation now records the authored pair in
|
|
55
|
+
the manifest overlay β the same mechanism already used for legacy boolean
|
|
56
|
+
exclusive bounds β and reverse conversion restores both keywords verbatim
|
|
57
|
+
for endpoint-local **and** component schemas. Schemas with a single bound
|
|
58
|
+
on a side are unaffected and record no overlay.
|
|
59
|
+
|
|
10
60
|
## [0.5.2] - 2026-09-30
|
|
11
61
|
|
|
12
62
|
### π Fixed
|
package/README.md
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
[](https://bun.sh/)
|
|
14
14
|
[](https://vitest.dev/)
|
|
15
15
|
|
|
16
|
-
β
**Status β Phase 3 complete Β· v0.
|
|
16
|
+
β
**Status β Phase 3 complete Β· v0.6.0 released**
|
|
17
17
|
|
|
18
18
|
</div>
|
|
19
19
|
|
package/docs/10-usage.md
CHANGED
|
@@ -136,7 +136,7 @@ zopia navigate <docs-dir> (--to-code <spec-pointer> | --to-spec <tree-file>) [--
|
|
|
136
136
|
| π© Command | π What it does | π‘ Example |
|
|
137
137
|
| --- | --- | --- |
|
|
138
138
|
| `zopia generate` | generates the endpoint tree and manifest; `--watch` keeps it running and regenerates whenever the spec file changes (survives atomic editor saves; Ctrl+C stops) | `zopia generate swagger.json api_docs`, `zopia generate swagger.yaml api_docs --watch` |
|
|
139
|
-
| `zopia reverse` | imports the manifest's endpoint and emitted component modules, then writes the reconstructed OpenAPI document to stdout or `--out` | `zopia reverse api_docs/.zopia-manifest.json --out openapi.json` |
|
|
139
|
+
| `zopia reverse` | imports the manifest's endpoint and emitted component modules, then writes the reconstructed OpenAPI document to stdout or `--out`. Swagger 2.0 sources keep their wire format in 3.x output (`collectionFormat` β `style`/`explode`/`encoding`, `type: file` β `format: binary`); values 3.x cannot spell are kept as `x-collectionFormat` with a `ZOPIA_WARN_COLLECTION_FORMAT` warning. One tree per run: pointing at a `--preset` split root fails with `ZOPIA_DOCS_PRESET_ROOT` listing the bucket directories to choose from | `zopia reverse api_docs/.zopia-manifest.json --out openapi.json`, `zopia reverse api_docs/users` |
|
|
140
140
|
| `zopia validate` | lints a spec (broken refs, name collisions, cross-namespace duplicate operationIds, unreachable components) or checks a generated tree (manifest validity, reverse dry-run, km-api peer drift); prints sorted diagnostics and a summary line to stdout | `zopia validate openapi.yaml`, `zopia validate api_docs` |
|
|
141
141
|
| `zopia diff` | compares two specs semantically (dialect, info, endpoints with parameter/request-body/response details, webhooks, schema components and named registries (security schemes, reusable parameters/responses, request bodiesβ¦), path-item and webhook-item metadata, document fields, `x-` extensions) β key order is ignored and JSON/YAML inputs mix freely; prints `+`/`-`/`~` lines plus a summary to stdout; differences are data, so a changed pair still exits `0` | `zopia diff v1.json v2.yaml` |
|
|
142
142
|
| `zopia navigate` | manifest-driven jump table between a generated tree and its source spec (S-93): `--to-code '#/paths/~1pets/get'` prints the generated file(s) implementing the pointer (endpoints, webhooks, components, plus custom companions when enabled); `--to-spec pets/get/index.ts` prints the owning pointer β both directions print one stable line per location and exit non-zero with `ZOPIA_CONFIG_INVALID` for unmatched pointers/files or `ZOPIA_DOCS_MISSING_MANIFEST` for generation-less roots | `zopia navigate api_docs --to-spec pets/get/index.ts` |
|
package/package.json
CHANGED
|
@@ -161,6 +161,14 @@ function analyzeSchema(source: JsonSchema): { warnings: AnalysisWarning[]; overl
|
|
|
161
161
|
const inclusive = name === 'exclusiveMinimum' ? 'minimum' : 'maximum';
|
|
162
162
|
addOverlay({ at, set: { [name]: true, ...(Object.prototype.hasOwnProperty.call(node, inclusive) ? { [inclusive]: node[inclusive] } : {}) } });
|
|
163
163
|
}
|
|
164
|
+
// A schema carrying BOTH an inclusive and a numeric exclusive bound emits both Zod
|
|
165
|
+
// checks (`.min(n).gt(m)`), but `z.toJSONSchema()` keeps only the tighter one. Record
|
|
166
|
+
// the authored pair so reverse conversion restores it instead of silently normalizing
|
|
167
|
+
// the document (D-12) β the same overlay pattern the legacy boolean form above uses.
|
|
168
|
+
for (const [exclusive, inclusive] of [['exclusiveMinimum', 'minimum'], ['exclusiveMaximum', 'maximum']] as const) {
|
|
169
|
+
if (typeof node[exclusive] !== 'number' || typeof node[inclusive] !== 'number') continue;
|
|
170
|
+
addOverlay({ at, set: { [exclusive]: node[exclusive], [inclusive]: node[inclusive] } });
|
|
171
|
+
}
|
|
164
172
|
|
|
165
173
|
if (typeof node.format === 'string') {
|
|
166
174
|
const exact = new Set(['email', 'uuid', 'hostname', 'ipv4', 'ipv6', 'date-time', 'date', 'duration', 'uri']);
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { asZopiaError, ZopiaError, type ZopiaErrorCode } from '../errors';
|
|
2
2
|
import { createHash } from 'node:crypto';
|
|
3
|
-
import { readFile, realpath, stat } from 'node:fs/promises';
|
|
4
|
-
import { dirname, isAbsolute, relative, resolve, sep } from 'node:path';
|
|
3
|
+
import { readFile, readdir, realpath, stat } from 'node:fs/promises';
|
|
4
|
+
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
5
5
|
import { pathToFileURL } from 'node:url';
|
|
6
6
|
import { zodSchemasToJsonSchema, zodToJsonSchema } from './zod-to-json-schema';
|
|
7
7
|
import { jsonSchemaToZod } from './json-schema-to-zod';
|
|
@@ -365,17 +365,67 @@ function swaggerInlineSchema(value: Record<string, any>, sourceKind: string, ver
|
|
|
365
365
|
return rewriteSchemaVersion(Object.fromEntries(Object.entries(value).filter(([key]) => SWAGGER_SCHEMA_KEYS.has(key))), sourceKind, version) as Record<string, any>;
|
|
366
366
|
}
|
|
367
367
|
|
|
368
|
-
|
|
368
|
+
/**
|
|
369
|
+
* Swagger 2.0 `collectionFormat` β OpenAPI 3.x parameter serialization (R-660).
|
|
370
|
+
*
|
|
371
|
+
* `query` (and `formData` when it becomes an `encoding` entry) is the only location with a
|
|
372
|
+
* full 3.x vocabulary; `path`/`header` only have `simple`, so everything but `csv` is lossy
|
|
373
|
+
* there. A lossy value is never dropped silently: it keeps `x-collectionFormat` and emits
|
|
374
|
+
* `ZOPIA_WARN_COLLECTION_FORMAT` (D-12).
|
|
375
|
+
*/
|
|
376
|
+
const SWAGGER_QUERY_COLLECTION_STYLES: Record<string, Record<string, unknown>> = {
|
|
377
|
+
csv: { style: 'form', explode: false },
|
|
378
|
+
multi: { style: 'form', explode: true },
|
|
379
|
+
pipes: { style: 'pipeDelimited' },
|
|
380
|
+
ssv: { style: 'spaceDelimited' },
|
|
381
|
+
};
|
|
382
|
+
|
|
383
|
+
function swaggerCollectionFormatSerialization(parameter: Record<string, any>, at: string, warnings?: ZopiaWarningCollector): Record<string, unknown> {
|
|
384
|
+
const format = parameter.collectionFormat;
|
|
385
|
+
if (typeof format !== 'string' || format === '') return {};
|
|
386
|
+
const location = typeof parameter.in === 'string' ? parameter.in : '';
|
|
387
|
+
if (location === 'query') {
|
|
388
|
+
const mapped = SWAGGER_QUERY_COLLECTION_STYLES[format];
|
|
389
|
+
if (mapped) return { ...mapped };
|
|
390
|
+
} else if (format === 'csv') return { style: 'simple', explode: false };
|
|
391
|
+
warnings?.add({
|
|
392
|
+
code: 'ZOPIA_WARN_COLLECTION_FORMAT',
|
|
393
|
+
at,
|
|
394
|
+
message: `collectionFormat \`${format}\` has no OpenAPI 3.x equivalent for an \`in: ${location || 'unknown'}\` parameter; the value is kept in \`x-collectionFormat\``,
|
|
395
|
+
});
|
|
396
|
+
return { 'x-collectionFormat': format };
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/** Multipart/urlencoded `encoding` entries only honor `style: form`, so `pipes`/`ssv`/`tsv` stay lossy. */
|
|
400
|
+
function swaggerCollectionFormatEncoding(parameter: Record<string, any>, at: string, warnings?: ZopiaWarningCollector): Record<string, unknown> | undefined {
|
|
401
|
+
const format = parameter.collectionFormat;
|
|
402
|
+
if (typeof format !== 'string' || format === '') return undefined;
|
|
403
|
+
if (format === 'csv') return { style: 'form', explode: false };
|
|
404
|
+
if (format === 'multi') return { style: 'form', explode: true };
|
|
405
|
+
warnings?.add({
|
|
406
|
+
code: 'ZOPIA_WARN_COLLECTION_FORMAT',
|
|
407
|
+
at,
|
|
408
|
+
message: `collectionFormat \`${format}\` has no OpenAPI 3.x encoding equivalent (encoding objects only serialize \`form\`); the value is kept in \`x-collectionFormat\``,
|
|
409
|
+
});
|
|
410
|
+
return { style: 'form', explode: true, 'x-collectionFormat': format };
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
function swaggerParameterToOpenApi(parameter: Record<string, any>, version: OpenApiReverseVersion, at = '#', warnings?: ZopiaWarningCollector): Record<string, any> {
|
|
369
414
|
if (typeof parameter.$ref === 'string' && parameter.$ref.startsWith('#/parameters/')) return { $ref: `#/components/parameters/${parameter.$ref.slice('#/parameters/'.length)}` };
|
|
370
415
|
const metadata = Object.fromEntries(Object.entries(parameter).filter(([key]) => !SWAGGER_SCHEMA_KEYS.has(key) && key !== 'schema' && key !== 'collectionFormat'));
|
|
371
|
-
return {
|
|
416
|
+
return {
|
|
417
|
+
...metadata,
|
|
418
|
+
...swaggerCollectionFormatSerialization(parameter, at, warnings),
|
|
419
|
+
schema: rewriteSchemaVersion(parameter.schema ?? swaggerInlineSchema(parameter, 'swagger-2.0', version), 'swagger-2.0', version),
|
|
420
|
+
};
|
|
372
421
|
}
|
|
373
422
|
|
|
374
|
-
function swaggerPathsOverlayToOpenApi(pathsOverlay: Record<string, unknown> | undefined, version: OpenApiReverseVersion): Record<string, unknown> | undefined {
|
|
423
|
+
function swaggerPathsOverlayToOpenApi(pathsOverlay: Record<string, unknown> | undefined, version: OpenApiReverseVersion, warnings?: ZopiaWarningCollector): Record<string, unknown> | undefined {
|
|
375
424
|
if (pathsOverlay === undefined) return undefined;
|
|
376
425
|
return Object.fromEntries(Object.entries(pathsOverlay).map(([path, metadata]) => {
|
|
377
426
|
if (!isRecord(metadata) || !Array.isArray(metadata.parameters)) return [path, metadata];
|
|
378
|
-
|
|
427
|
+
const at = `#/paths/${pointerToken(path)}/parameters`;
|
|
428
|
+
return [path, { ...metadata, parameters: metadata.parameters.map((parameter: unknown, index: number) => isRecord(parameter) ? swaggerParameterToOpenApi(parameter, version, `${at}/${index}`, warnings) : parameter) }];
|
|
379
429
|
}));
|
|
380
430
|
}
|
|
381
431
|
|
|
@@ -420,7 +470,7 @@ function collectManifestRefs(value: unknown, at = '', mapEntries = false): Array
|
|
|
420
470
|
return refs;
|
|
421
471
|
}
|
|
422
472
|
|
|
423
|
-
function swaggerOperationToOpenApi(operation: Record<string, any>, manifest: ZopiaManifest, version: OpenApiReverseVersion): Record<string, any> {
|
|
473
|
+
function swaggerOperationToOpenApi(operation: Record<string, any>, manifest: ZopiaManifest, version: OpenApiReverseVersion, at = '#', warnings?: ZopiaWarningCollector): Record<string, any> {
|
|
424
474
|
const consumes = Array.isArray(operation.consumes) ? operation.consumes : manifest.swaggerConsumes ?? [];
|
|
425
475
|
const produces = Array.isArray(operation.produces) ? operation.produces : manifest.swaggerProduces ?? [];
|
|
426
476
|
const requestTypes = consumes.length ? consumes : ['application/json'];
|
|
@@ -428,7 +478,9 @@ function swaggerOperationToOpenApi(operation: Record<string, any>, manifest: Zop
|
|
|
428
478
|
const parameters = (Array.isArray(operation.parameters) ? operation.parameters : []).filter(isRecord).map((raw) => ({ raw, resolved: resolveParameter(raw, manifest) }));
|
|
429
479
|
const body = parameters.find(({ resolved }) => resolved.in === 'body')?.resolved;
|
|
430
480
|
const form = parameters.filter(({ resolved }) => resolved.in === 'formData').map(({ resolved }) => resolved);
|
|
431
|
-
const ordinary = parameters
|
|
481
|
+
const ordinary = parameters
|
|
482
|
+
.filter(({ resolved }) => resolved.in !== 'body' && resolved.in !== 'formData')
|
|
483
|
+
.map(({ raw, resolved }, index) => swaggerParameterToOpenApi(typeof raw.$ref === 'string' ? raw : resolved, version, `${at}/parameters/${index}`, warnings));
|
|
432
484
|
let requestBody: Record<string, any> | undefined;
|
|
433
485
|
if (body) {
|
|
434
486
|
const schema = rewriteSchemaVersion(body.schema ?? {}, 'swagger-2.0', version);
|
|
@@ -438,7 +490,15 @@ function swaggerOperationToOpenApi(operation: Record<string, any>, manifest: Zop
|
|
|
438
490
|
const properties = Object.fromEntries(form.map((parameter) => [parameter.name, rewriteSchemaVersion(parameter.type === 'file' ? { type: 'string', format: 'binary' } : swaggerInlineSchema(parameter, 'swagger-2.0', version), 'swagger-2.0', version)]));
|
|
439
491
|
const required = form.filter((parameter) => parameter.required === true).map((parameter) => parameter.name);
|
|
440
492
|
const schema = { type: 'object', properties, ...(required.length ? { required } : {}) };
|
|
441
|
-
|
|
493
|
+
// `collectionFormat` on a formData parameter is array serialization, which 3.x expresses
|
|
494
|
+
// through the media type's `encoding` map (R-660) β never a property-level keyword.
|
|
495
|
+
const primaryType = requestTypes[0];
|
|
496
|
+
const encoding = Object.fromEntries(form.flatMap((parameter) => {
|
|
497
|
+
const entry = swaggerCollectionFormatEncoding(parameter, `${at}/requestBody/content/${pointerToken(String(primaryType))}/encoding/${pointerToken(String(parameter.name))}`, warnings);
|
|
498
|
+
return entry === undefined ? [] : [[String(parameter.name), entry] as const];
|
|
499
|
+
}));
|
|
500
|
+
const media = { schema, ...(Object.keys(encoding).length ? { encoding } : {}) };
|
|
501
|
+
requestBody = { required: required.length > 0, content: Object.fromEntries(requestTypes.map((type) => [type, media])) };
|
|
442
502
|
}
|
|
443
503
|
const responses = Object.fromEntries(Object.entries(operation.responses ?? {}).map(([status, response]) => [status, swaggerResponseToOpenApi(response, responseTypes, version)]));
|
|
444
504
|
return { ...Object.fromEntries(Object.entries(operation).filter(([key]) => !['parameters', 'responses', 'consumes', 'produces', 'schemes'].includes(key))), ...(ordinary.length ? { parameters: ordinary } : {}), ...(requestBody === undefined ? {} : { requestBody }), responses };
|
|
@@ -800,12 +860,13 @@ function manifestForOutputVersion(manifest: ZopiaManifest, version: OpenApiRever
|
|
|
800
860
|
const host = manifest.swaggerHost;
|
|
801
861
|
const schemes = manifest.swaggerSchemes?.length ? manifest.swaggerSchemes : ['https'];
|
|
802
862
|
const servers = host ? schemes.map((scheme) => ({ url: `${scheme}://${host}${basePath === '/' ? '' : basePath}` })) : [{ url: basePath }];
|
|
803
|
-
const reusableParameters = Object.fromEntries(Object.entries(manifest.swaggerParameters ?? {}).filter(([, parameter]) => !isRecord(parameter) || parameter.in !== 'body' && parameter.in !== 'formData').map(([name, parameter]) => [name, isRecord(parameter) ? swaggerParameterToOpenApi(parameter, version) : parameter]));
|
|
863
|
+
const reusableParameters = Object.fromEntries(Object.entries(manifest.swaggerParameters ?? {}).filter(([, parameter]) => !isRecord(parameter) || parameter.in !== 'body' && parameter.in !== 'formData').map(([name, parameter]) => [name, isRecord(parameter) ? swaggerParameterToOpenApi(parameter, version, `#/components/parameters/${pointerToken(name)}`, warnings) : parameter]));
|
|
804
864
|
const responseTypes = manifest.swaggerProduces?.length ? manifest.swaggerProduces : ['application/json'];
|
|
805
865
|
const reusableResponses = Object.fromEntries(Object.entries(manifest.swaggerResponses ?? {}).map(([name, response]) => [name, swaggerResponseToOpenApi(response, responseTypes, version)]));
|
|
806
866
|
const componentsOverlay = { ...(manifest.componentsOverlay ?? {}), ...(Object.keys(reusableParameters).length ? { parameters: reusableParameters } : {}), ...(Object.keys(reusableResponses).length ? { responses: reusableResponses } : {}) };
|
|
807
867
|
const apis = manifest.apis.map((api) => {
|
|
808
|
-
const
|
|
868
|
+
const apiAt = `#/paths/${pointerToken(api.path)}/${api.method}`;
|
|
869
|
+
const sourceOperation = api.sourceOperation === undefined ? undefined : swaggerOperationToOpenApi(api.sourceOperation, manifest, version, apiAt, warnings);
|
|
809
870
|
const responseOverlay = sourceOperation === undefined ? api.responseOverlay : Object.entries(sourceOperation.responses ?? {}).flatMap(([status, response]) => isRecord(response) && response.headers !== undefined ? [{ status, headers: response.headers }] : []);
|
|
810
871
|
return { ...api, sourceOperation, refs: sourceOperation === undefined ? api.refs : collectManifestRefs(sourceOperation), overlay: rewriteOverlaysVersion(api.overlay, sourceKind, version), responseOverlay };
|
|
811
872
|
});
|
|
@@ -813,7 +874,7 @@ function manifestForOutputVersion(manifest: ZopiaManifest, version: OpenApiRever
|
|
|
813
874
|
...manifest,
|
|
814
875
|
source: { ...manifest.source, kind: targetKind, openapiVersion: `${version}.0` },
|
|
815
876
|
documentOverlay,
|
|
816
|
-
pathsOverlay: swaggerPathsOverlayToOpenApi(manifest.pathsOverlay, version),
|
|
877
|
+
pathsOverlay: swaggerPathsOverlayToOpenApi(manifest.pathsOverlay, version, warnings),
|
|
817
878
|
servers,
|
|
818
879
|
components,
|
|
819
880
|
componentsOverlay,
|
|
@@ -848,12 +909,46 @@ function sweepSwaggerDowngradeDocument(document: Record<string, any>): void {
|
|
|
848
909
|
sweepResponses(isRecord(document.responses) ? document.responses : undefined);
|
|
849
910
|
}
|
|
850
911
|
|
|
912
|
+
/**
|
|
913
|
+
* List the immediate subdirectories of `directory` that carry their own manifest,
|
|
914
|
+
* sorted alphabetically β exactly the shape a `preset` split writes (R-661).
|
|
915
|
+
*
|
|
916
|
+
* @param directory Directory that has no manifest of its own.
|
|
917
|
+
* @returns Sorted bucket directory names, empty when this is not a preset split.
|
|
918
|
+
*/
|
|
919
|
+
async function presetBucketNames(directory: string): Promise<string[]> {
|
|
920
|
+
let entries;
|
|
921
|
+
try { entries = await readdir(directory, { withFileTypes: true }); }
|
|
922
|
+
catch { return []; }
|
|
923
|
+
const buckets: string[] = [];
|
|
924
|
+
for (const entry of entries) {
|
|
925
|
+
if (!entry.isDirectory()) continue;
|
|
926
|
+
try {
|
|
927
|
+
if ((await stat(join(directory, entry.name, ZOPIA_MANIFEST_FILE))).isFile()) buckets.push(entry.name);
|
|
928
|
+
} catch { continue; }
|
|
929
|
+
}
|
|
930
|
+
return buckets.sort((left, right) => left < right ? -1 : left > right ? 1 : 0);
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
/** Distinguish a preset split root from a genuinely manifest-less directory (R-661). */
|
|
934
|
+
async function missingManifestError(file: string): Promise<ZopiaError> {
|
|
935
|
+
const directory = dirname(resolve(file));
|
|
936
|
+
const buckets = await presetBucketNames(directory);
|
|
937
|
+
if (buckets.length === 0) return new ZopiaError('ZOPIA_DOCS_MISSING_MANIFEST', `manifest file not found: ${file}`, { at: file, hint: 'generate api docs first or pass the manifest path' });
|
|
938
|
+
const label = basename(directory) || directory;
|
|
939
|
+
return new ZopiaError(
|
|
940
|
+
'ZOPIA_DOCS_PRESET_ROOT',
|
|
941
|
+
`this directory is a preset split (${buckets.length} trees: ${buckets.join(', ')}). Reverse one bucket (zopia reverse ${label}/${buckets[0]}) to convert a single tree.`,
|
|
942
|
+
{ at: directory, hint: 'reverse one preset bucket directory instead of the preset root' },
|
|
943
|
+
);
|
|
944
|
+
}
|
|
945
|
+
|
|
851
946
|
async function manifestFileToOpenApiInternal(file: string, version: OpenApiReverseVersion | undefined, warnings: ZopiaWarningCollector): Promise<Record<string, unknown>> {
|
|
852
947
|
if (typeof file !== 'string' || !file) throw new ZopiaError('ZOPIA_CONFIG_INVALID', 'manifest file path is required', { at: 'file', hint: 'provide the .zopia-manifest.json path' });
|
|
853
948
|
let source: string;
|
|
854
949
|
try { source = await readFile(file, 'utf8'); }
|
|
855
950
|
catch (error) {
|
|
856
|
-
if (isMissingFileError(error)) throw
|
|
951
|
+
if (isMissingFileError(error)) throw await missingManifestError(file);
|
|
857
952
|
throw asZopiaError(error, 'ZOPIA_MANIFEST_INVALID', 'unable to read manifest file', { at: file, hint: 'check that the manifest is readable JSON' });
|
|
858
953
|
}
|
|
859
954
|
let parsed: unknown;
|
|
@@ -1356,6 +1451,12 @@ function restoreSourceSchemaStructure(generated: unknown, source: unknown): unkn
|
|
|
1356
1451
|
return generated;
|
|
1357
1452
|
}
|
|
1358
1453
|
if (!isRecord(generated) || !isRecord(source)) return generated;
|
|
1454
|
+
// Zod has no binary string type, so a Swagger `type: file` (and an authored 3.x
|
|
1455
|
+
// `format: binary`) serializes back as a bare `z.string()`. The source keeps the
|
|
1456
|
+
// canonical 2.0β3.x mapping, so restore the format rather than change the wire shape.
|
|
1457
|
+
if (source.format === 'binary' && generated.type === 'string' && !Object.prototype.hasOwnProperty.call(generated, 'format')) {
|
|
1458
|
+
Object.defineProperty(generated, 'format', { value: 'binary', enumerable: true, configurable: true, writable: true });
|
|
1459
|
+
}
|
|
1359
1460
|
if (Array.isArray(source.required) && source.required.every((key: unknown) => typeof key === 'string')) {
|
|
1360
1461
|
const generatedRequired = Array.isArray(generated.required) && generated.required.every((key: unknown) => typeof key === 'string') ? generated.required as string[] : [];
|
|
1361
1462
|
const sourceRequired = source.required as string[];
|
|
@@ -16,7 +16,7 @@ export const ZOPIA_MANIFEST_SCHEMA = 'zopia:manifest@1' as const;
|
|
|
16
16
|
export const ZOPIA_MANIFEST_FILE = '.zopia-manifest.json' as const;
|
|
17
17
|
|
|
18
18
|
/** Package version recorded by the current manifest writer. */
|
|
19
|
-
export const ZOPIA_VERSION = '0.
|
|
19
|
+
export const ZOPIA_VERSION = '0.6.0' as const;
|
|
20
20
|
|
|
21
21
|
/** Supported source dialect labels stored in a manifest. */
|
|
22
22
|
export type ZopiaManifestSourceKind = 'swagger-2.0' | 'openapi-3.0' | 'openapi-3.1';
|
package/src/errors.ts
CHANGED
|
@@ -4,6 +4,7 @@ export const ZOPIA_ERROR_CODES = Object.freeze([
|
|
|
4
4
|
'ZOPIA_DOCS_IMPORT_FAILED',
|
|
5
5
|
'ZOPIA_DOCS_MANIFEST_MISMATCH',
|
|
6
6
|
'ZOPIA_DOCS_MISSING_MANIFEST',
|
|
7
|
+
'ZOPIA_DOCS_PRESET_ROOT',
|
|
7
8
|
'ZOPIA_FS_OUTSIDE_OUTDIR',
|
|
8
9
|
'ZOPIA_FS_WRITE_FAILED',
|
|
9
10
|
'ZOPIA_MANIFEST_INVALID',
|
|
@@ -54,6 +55,7 @@ const DEFAULT_ERROR_HINTS: Record<ZopiaErrorCode, string> = {
|
|
|
54
55
|
ZOPIA_REF_EXTERNAL: 'replace the external reference with a local reference',
|
|
55
56
|
ZOPIA_MANIFEST_INVALID: 'regenerate the manifest or fix its invalid metadata',
|
|
56
57
|
ZOPIA_DOCS_MISSING_MANIFEST: 'generate api docs first or pass the manifest path',
|
|
58
|
+
ZOPIA_DOCS_PRESET_ROOT: 'reverse one preset bucket directory instead of the preset root',
|
|
57
59
|
ZOPIA_DOCS_MANIFEST_MISMATCH: 'regenerate the api-docs tree or restore its generated files',
|
|
58
60
|
ZOPIA_DOCS_IMPORT_FAILED: 'fix or regenerate the affected generated module',
|
|
59
61
|
ZOPIA_FS_OUTSIDE_OUTDIR: 'keep generated paths inside the output directory',
|
package/src/warnings.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { asZopiaError, ZopiaError } from './errors';
|
|
|
2
2
|
|
|
3
3
|
/** Stable warning codes emitted by zopia's conversion pipelines. */
|
|
4
4
|
export const ZOPIA_WARNING_CODES = [
|
|
5
|
+
'ZOPIA_WARN_COLLECTION_FORMAT',
|
|
5
6
|
'ZOPIA_WARN_CONTENT_ENCODING',
|
|
6
7
|
'ZOPIA_WARN_CUSTOM_FORMAT',
|
|
7
8
|
'ZOPIA_WARN_DEFAULT_INFO',
|