genoc 0.5.1 → 0.5.3
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/README.md +2 -1
- package/dist/analyzer/analyze.js +20 -5
- package/dist/analyzer/discriminator-registry.d.ts +10 -2
- package/dist/analyzer/discriminator-registry.js +98 -7
- package/dist/analyzer/path-analyzer.d.ts +31 -0
- package/dist/analyzer/path-analyzer.js +97 -0
- package/dist/analyzer/schema-mapper.js +24 -1
- package/dist/analyzer/types.d.ts +2 -0
- package/package.json +6 -6
package/README.md
CHANGED
|
@@ -58,10 +58,11 @@ import type { Requester } from 'genoc/runtime';
|
|
|
58
58
|
For the full `Requester` type signature and binary and stream handling, see [Usage](./docs/usage.md).
|
|
59
59
|
|
|
60
60
|
```typescript
|
|
61
|
-
import { createClient } from './client.js';
|
|
62
61
|
import type { Requester } from 'genoc/runtime';
|
|
63
62
|
import { RequesterFailError, ErrorResponse } from 'genoc/runtime';
|
|
64
63
|
|
|
64
|
+
import { createClient } from './client.js';
|
|
65
|
+
|
|
65
66
|
const baseUrl = 'https://api.example.com';
|
|
66
67
|
|
|
67
68
|
const requester: Requester = async (method, path, options) => {
|
package/dist/analyzer/analyze.js
CHANGED
|
@@ -3,7 +3,7 @@ import { buildSchemaRenameMap, buildTypeJsDoc, sanitizeJsDocText, sanitizeTypeNa
|
|
|
3
3
|
import { operationEmissions, RESERVED_TYPE_NAMES } from '../utils/operation-naming.js';
|
|
4
4
|
import { escapeStringLiteral } from '../utils/string.js';
|
|
5
5
|
import { buildDiscriminatorRegistry, renameAwareTypeName } from './discriminator-registry.js';
|
|
6
|
-
import { analyzePathsDetailed } from './path-analyzer.js';
|
|
6
|
+
import { analyzePathsDetailed, collectFormProperties, findBinaryFieldNames, schemaContainsBinary, } from './path-analyzer.js';
|
|
7
7
|
import { SchemaMapper } from './schema-mapper.js';
|
|
8
8
|
/**
|
|
9
9
|
* Indent unit matching `INDENT_UNIT` in schema-mapper.ts (2 spaces, pinned by
|
|
@@ -338,6 +338,7 @@ function analyzeServers(servers) {
|
|
|
338
338
|
export function analyze(doc, opts = {}) {
|
|
339
339
|
const resolver = opts.resolver ?? new RefResolver(doc);
|
|
340
340
|
const strategy = opts.strategy ?? 'path-based';
|
|
341
|
+
const warn = opts.warnSink ?? writeWarning;
|
|
341
342
|
const schemaNameList = doc.components?.schemas ? Object.keys(doc.components.schemas) : [];
|
|
342
343
|
const renameMap = buildSchemaRenameMap(schemaNameList, RESERVED_TYPE_NAMES);
|
|
343
344
|
for (const [original, renamed] of renameMap) {
|
|
@@ -360,7 +361,7 @@ export function analyze(doc, opts = {}) {
|
|
|
360
361
|
// source for discriminator translation: the mapper's ref-translation seam
|
|
361
362
|
// consumes it, and the always-emitted `{Base}Variant` unions below are
|
|
362
363
|
// emitted from it.
|
|
363
|
-
const discriminatorRegistry = buildDiscriminatorRegistry(doc.components?.schemas, resolver, renameMap, allSchemaNames,
|
|
364
|
+
const discriminatorRegistry = buildDiscriminatorRegistry(doc.components?.schemas, resolver, renameMap, allSchemaNames, warn);
|
|
364
365
|
const mapper = new SchemaMapper(resolver, {
|
|
365
366
|
typeNameGenerator: renamingTypeGenerator,
|
|
366
367
|
emittedNames: allSchemaNames,
|
|
@@ -472,14 +473,28 @@ export function analyze(doc, opts = {}) {
|
|
|
472
473
|
if (emissions.headers) {
|
|
473
474
|
opLines.push(`export type ${emissions.headers} = ${buildParamTypeBody(op.headerParams, mapper)};`);
|
|
474
475
|
}
|
|
476
|
+
// A urlencoded body declaring `format: binary` fields cannot transport
|
|
477
|
+
// them — the generated client would silently type the files as plain
|
|
478
|
+
// strings. Warn and name the offending fields (merged through allOf).
|
|
479
|
+
if (op.requestBody?.contentTypes[0] === 'application/x-www-form-urlencoded' &&
|
|
480
|
+
rawBodySchema !== undefined &&
|
|
481
|
+
schemaContainsBinary(rawBodySchema, resolver)) {
|
|
482
|
+
const fieldNames = findBinaryFieldNames(rawBodySchema, resolver);
|
|
483
|
+
const fieldList = fieldNames.length > 0
|
|
484
|
+
? ` declaring binary fields (${fieldNames.map((n) => `"${n}"`).join(', ')})`
|
|
485
|
+
: ' declaring binary data';
|
|
486
|
+
warn(`Warning: the request body of ${op.method.toUpperCase()} ${op.path} uses content type "application/x-www-form-urlencoded"${fieldList}, but urlencoded form data cannot transport binary files. File uploads require "multipart/form-data"; the generated client types these fields as plain strings.\n`);
|
|
487
|
+
}
|
|
475
488
|
// Section 3: Request body types
|
|
476
489
|
const bodyJsDoc = op.requestBody ? buildDescriptionJsDoc(op.requestBody.description) : '';
|
|
477
490
|
if (op.requestBody?.isMultipart && rawBodySchema !== undefined) {
|
|
478
|
-
|
|
479
|
-
|
|
491
|
+
// Form fields come from the allOf-merged walk: a body declaring its
|
|
492
|
+
// fields via `allOf` members (resolving `$ref`s) previously produced an
|
|
493
|
+
// empty property walk — `Record<string, never>` and dropped appends.
|
|
494
|
+
const { properties, required: requiredSet } = collectFormProperties(rawBodySchema, resolver);
|
|
480
495
|
const propLines = [];
|
|
481
496
|
const fileUploadProperties = [];
|
|
482
|
-
for (const [name, propSchema] of
|
|
497
|
+
for (const [name, propSchema] of properties) {
|
|
483
498
|
const resolved = resolver.resolveSchema(propSchema);
|
|
484
499
|
const optional = requiredSet.has(name) ? '' : '?';
|
|
485
500
|
let tsType;
|
|
@@ -49,10 +49,18 @@ export interface DiscriminatorVariantEntry {
|
|
|
49
49
|
isNullable: boolean;
|
|
50
50
|
/** Owning family id — the discriminator base's raw component name. */
|
|
51
51
|
familyId: string;
|
|
52
|
-
/** The exact `$ref` string that produced this entry (mapping value
|
|
52
|
+
/** The exact `$ref` string that produced this entry (mapping value, oneOf/anyOf member ref, canonical base ref, or implicit allOf child ref). */
|
|
53
53
|
refStr: string;
|
|
54
54
|
/** Rename-aware type name for the ref (same derivation as the mapper's `typeNameGenerator`). */
|
|
55
55
|
typeName: string;
|
|
56
|
+
/**
|
|
57
|
+
* True for the family base's own entry — the base participates in its own
|
|
58
|
+
* family union (self-mapped, or no `oneOf`/`anyOf` of its own). Consumers:
|
|
59
|
+
* same-family-spine base-ref translation routes through the entry seam
|
|
60
|
+
* (`Omit<Base, P>`), and the mapper skips the `{ P: string }` placeholder on
|
|
61
|
+
* the base's own definition because the literal replaces it.
|
|
62
|
+
*/
|
|
63
|
+
isBaseMember: boolean;
|
|
56
64
|
}
|
|
57
65
|
/**
|
|
58
66
|
* One discriminator family: a component schema declaring `discriminator`
|
|
@@ -73,7 +81,7 @@ export interface DiscriminatorFamily {
|
|
|
73
81
|
propertyName: string;
|
|
74
82
|
/** Collision-safe `{Base}Variant` union name (D6 — via the reserved-name machinery). */
|
|
75
83
|
variantUnionName: string;
|
|
76
|
-
/** Union member type names (rename-aware, deduped;
|
|
84
|
+
/** Union member type names (rename-aware, deduped; entry insertion order: base, mapping, unmapped oneOf/anyOf refs, implicit allOf children). */
|
|
77
85
|
variantUnionMembers: string[];
|
|
78
86
|
/** Family entries keyed by the FULL `$ref` string (mapping value or oneOf/anyOf member ref). */
|
|
79
87
|
entriesByRef: Map<string, DiscriminatorVariantEntry>;
|
|
@@ -103,9 +103,36 @@ export function buildDiscriminatorRegistry(schemas, resolver, renameMap, allSche
|
|
|
103
103
|
};
|
|
104
104
|
if (!schemas)
|
|
105
105
|
return registry;
|
|
106
|
-
//
|
|
107
|
-
//
|
|
108
|
-
// the
|
|
106
|
+
// Implicit allOf children (issue #93): a schema whose `allOf` references a
|
|
107
|
+
// family base DIRECTLY is a variant of that family even when `mapping` and
|
|
108
|
+
// the base's own oneOf/anyOf leave it out. Index childName → the set of
|
|
109
|
+
// canonical `#/components/schemas/…` refs it allOfs, once, up front —
|
|
110
|
+
// grandchildren (allOf a child, not the base) stay out by construction.
|
|
111
|
+
const allOfBasesByChild = new Map();
|
|
112
|
+
for (const [childName, childSchema] of Object.entries(schemas)) {
|
|
113
|
+
let childResolved;
|
|
114
|
+
try {
|
|
115
|
+
childResolved = resolver.resolve(childSchema);
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
if (!childResolved.allOf)
|
|
121
|
+
continue;
|
|
122
|
+
const bases = new Set();
|
|
123
|
+
for (const member of childResolved.allOf) {
|
|
124
|
+
if (!isRefObject(member) || !member.$ref.startsWith('#/components/schemas/'))
|
|
125
|
+
continue;
|
|
126
|
+
bases.add(`#/components/schemas/${encodePointerSegment(rawRefSegment(member.$ref))}`);
|
|
127
|
+
}
|
|
128
|
+
if (bases.size > 0)
|
|
129
|
+
allOfBasesByChild.set(childName, bases);
|
|
130
|
+
}
|
|
131
|
+
// Pass 1 — discover families; per family, register the base's own entry
|
|
132
|
+
// when it joins its family (self-mapped, or no oneOf/anyOf of its own),
|
|
133
|
+
// then mapping entries (D3: mapping key wins), then implicit oneOf/anyOf
|
|
134
|
+
// variants absent from the mapping (literal = own const > single-value
|
|
135
|
+
// enum > RAW ref segment), then implicit allOf children absent from both.
|
|
109
136
|
for (const [name, schema] of Object.entries(schemas)) {
|
|
110
137
|
const resolved = resolver.resolve(schema);
|
|
111
138
|
const disc = resolved.discriminator;
|
|
@@ -121,12 +148,46 @@ export function buildDiscriminatorRegistry(schemas, resolver, renameMap, allSche
|
|
|
121
148
|
variantUnionMembers: [],
|
|
122
149
|
entriesByRef: new Map(),
|
|
123
150
|
};
|
|
151
|
+
// Base membership (#93): a CONCRETE base (no oneOf/anyOf of its own) is a
|
|
152
|
+
// variant of its own family even when `mapping` omits it — with no other
|
|
153
|
+
// members the family would otherwise reference an empty union. A base
|
|
154
|
+
// listed in its own mapping joins too (its key wins the literal, D3).
|
|
155
|
+
// Insertion order matters: the base entry lands FIRST so union members
|
|
156
|
+
// read `{Base} | <mapped…> | <implicit…>`.
|
|
157
|
+
const baseHasOwnUnion = (resolved.oneOf?.length ?? 0) > 0 || (resolved.anyOf?.length ?? 0) > 0;
|
|
158
|
+
const selfMappingKey = disc.mapping
|
|
159
|
+
? Object.entries(disc.mapping).find(([, refStr]) => refStr === family.baseRefStr)?.[0]
|
|
160
|
+
: undefined;
|
|
161
|
+
if (selfMappingKey !== undefined || !baseHasOwnUnion) {
|
|
162
|
+
const ownBaseLiteral = resolveOwnDiscriminatorLiteral(resolved, disc.propertyName, resolver);
|
|
163
|
+
if (selfMappingKey !== undefined &&
|
|
164
|
+
ownBaseLiteral !== undefined &&
|
|
165
|
+
ownBaseLiteral.value !== selfMappingKey) {
|
|
166
|
+
warn(`Warning: Discriminator mapping key "${selfMappingKey}" for "${family.baseRefStr}" conflicts with its own declared ${ownBaseLiteral.source} literal "${ownBaseLiteral.value}"; the mapping key wins.\n`);
|
|
167
|
+
}
|
|
168
|
+
family.entriesByRef.set(family.baseRefStr, {
|
|
169
|
+
propertyName: disc.propertyName,
|
|
170
|
+
// Base literal precedence: self-mapping key > own const/enum > raw
|
|
171
|
+
// component name (the runtime value, NOT the sanitized type name).
|
|
172
|
+
literalValue: escapeStringLiteral(selfMappingKey ?? ownBaseLiteral?.value ?? name),
|
|
173
|
+
isNamed: true, // the base is by definition an emitted component schema
|
|
174
|
+
isUnion: baseHasOwnUnion,
|
|
175
|
+
isNullable: resolved.nullable === true ||
|
|
176
|
+
(Array.isArray(resolved.type) ? resolved.type.includes('null') : false),
|
|
177
|
+
isBaseMember: true,
|
|
178
|
+
familyId,
|
|
179
|
+
refStr: family.baseRefStr,
|
|
180
|
+
typeName: family.baseTypeName,
|
|
181
|
+
});
|
|
182
|
+
}
|
|
124
183
|
const mappedRefStrs = new Set();
|
|
125
184
|
if (disc.mapping) {
|
|
126
185
|
for (const [mappingKey, refStr] of Object.entries(disc.mapping)) {
|
|
127
186
|
if (mappedRefStrs.has(refStr))
|
|
128
187
|
continue; // two keys → same target: first key wins
|
|
129
188
|
mappedRefStrs.add(refStr);
|
|
189
|
+
if (family.entriesByRef.has(refStr))
|
|
190
|
+
continue; // base self-mapping — the base entry already carries it
|
|
130
191
|
const target = resolveTargetSchema(resolver, refStr);
|
|
131
192
|
const own = resolveOwnDiscriminatorLiteral(target, disc.propertyName, resolver);
|
|
132
193
|
if (own !== undefined && own.value !== mappingKey) {
|
|
@@ -140,6 +201,7 @@ export function buildDiscriminatorRegistry(schemas, resolver, renameMap, allSche
|
|
|
140
201
|
isUnion: (target?.oneOf?.length ?? 0) > 0 || (target?.anyOf?.length ?? 0) > 0,
|
|
141
202
|
isNullable: target?.nullable === true ||
|
|
142
203
|
(Array.isArray(target?.type) ? target.type.includes('null') : false),
|
|
204
|
+
isBaseMember: false,
|
|
143
205
|
familyId,
|
|
144
206
|
refStr,
|
|
145
207
|
typeName: renameAwareTypeName(renameMap, rawSegment),
|
|
@@ -153,8 +215,8 @@ export function buildDiscriminatorRegistry(schemas, resolver, renameMap, allSche
|
|
|
153
215
|
implicitRefs.push(member.$ref);
|
|
154
216
|
}
|
|
155
217
|
for (const refStr of implicitRefs) {
|
|
156
|
-
if (mappedRefStrs.has(refStr))
|
|
157
|
-
continue; // already registered
|
|
218
|
+
if (mappedRefStrs.has(refStr) || family.entriesByRef.has(refStr))
|
|
219
|
+
continue; // already registered
|
|
158
220
|
const target = resolveTargetSchema(resolver, refStr);
|
|
159
221
|
const own = resolveOwnDiscriminatorLiteral(target, disc.propertyName, resolver);
|
|
160
222
|
const rawSegment = rawRefSegment(refStr);
|
|
@@ -168,17 +230,46 @@ export function buildDiscriminatorRegistry(schemas, resolver, renameMap, allSche
|
|
|
168
230
|
isUnion: (target?.oneOf?.length ?? 0) > 0 || (target?.anyOf?.length ?? 0) > 0,
|
|
169
231
|
isNullable: target?.nullable === true ||
|
|
170
232
|
(Array.isArray(target?.type) ? target.type.includes('null') : false),
|
|
233
|
+
isBaseMember: false,
|
|
171
234
|
familyId,
|
|
172
235
|
refStr,
|
|
173
236
|
typeName: renameAwareTypeName(renameMap, rawSegment),
|
|
174
237
|
};
|
|
175
238
|
family.entriesByRef.set(refStr, entry);
|
|
176
239
|
}
|
|
240
|
+
// Implicit allOf children (#93): direct `allOf` refs to the base count as
|
|
241
|
+
// variants even when `mapping` leaves them out; anything already present
|
|
242
|
+
// (mapping target, implicit oneOf/anyOf ref, the base itself) is skipped,
|
|
243
|
+
// so children land in component order after the mapped/implicit members.
|
|
244
|
+
for (const [childName, bases] of allOfBasesByChild) {
|
|
245
|
+
if (!bases.has(family.baseRefStr))
|
|
246
|
+
continue;
|
|
247
|
+
const childRefStr = `#/components/schemas/${encodePointerSegment(childName)}`;
|
|
248
|
+
if (family.entriesByRef.has(childRefStr))
|
|
249
|
+
continue;
|
|
250
|
+
const target = resolveTargetSchema(resolver, childRefStr);
|
|
251
|
+
const own = resolveOwnDiscriminatorLiteral(target, disc.propertyName, resolver);
|
|
252
|
+
const entry = {
|
|
253
|
+
propertyName: disc.propertyName,
|
|
254
|
+
// Same D3 fallback chain as unmapped oneOf/anyOf variants.
|
|
255
|
+
literalValue: escapeStringLiteral(own?.value ?? childName),
|
|
256
|
+
isNamed: allSchemaNames.has(renameAwareTypeName(renameMap, childName)),
|
|
257
|
+
isUnion: (target?.oneOf?.length ?? 0) > 0 || (target?.anyOf?.length ?? 0) > 0,
|
|
258
|
+
isNullable: target?.nullable === true ||
|
|
259
|
+
(Array.isArray(target?.type) ? target.type.includes('null') : false),
|
|
260
|
+
isBaseMember: false,
|
|
261
|
+
familyId,
|
|
262
|
+
refStr: childRefStr,
|
|
263
|
+
typeName: renameAwareTypeName(renameMap, childName),
|
|
264
|
+
};
|
|
265
|
+
family.entriesByRef.set(childRefStr, entry);
|
|
266
|
+
}
|
|
177
267
|
registry.families.set(familyId, family);
|
|
178
268
|
}
|
|
179
269
|
// Pass 2 — flat lookups + per-family name index + union members
|
|
180
|
-
// (
|
|
181
|
-
//
|
|
270
|
+
// (entry insertion order: base, then mapping, then unmapped oneOf/anyOf
|
|
271
|
+
// refs, then implicit allOf children; deduped by refStr and by typeName
|
|
272
|
+
// — alias refs to one emitted name collapse).
|
|
182
273
|
for (const family of registry.families.values()) {
|
|
183
274
|
const memberNames = [];
|
|
184
275
|
const seenNames = new Set();
|
|
@@ -82,6 +82,37 @@ export interface AnalyzedOperation {
|
|
|
82
82
|
/** Raw operation-level `security`; `undefined` = inherit the document default. */
|
|
83
83
|
security?: SecurityRequirementObject[];
|
|
84
84
|
}
|
|
85
|
+
/**
|
|
86
|
+
* Deep, ref-resolving, cycle-safe check: does any schema node reachable from
|
|
87
|
+
* `schema` (through `properties`, `items`, `allOf`/`oneOf`/`anyOf` members and
|
|
88
|
+
* schema-valued `additionalProperties`) declare `format: binary`? Unlike
|
|
89
|
+
* {@link isBinarySchema} this catches binary data buried in composed schemas —
|
|
90
|
+
* e.g. a `files` array of binary strings hidden inside an `allOf` member.
|
|
91
|
+
*/
|
|
92
|
+
export declare function schemaContainsBinary(schema: SchemaObject | ReferenceObject | undefined, resolver: RefResolver): boolean;
|
|
93
|
+
/**
|
|
94
|
+
* The flattened form fields of a request body schema: the root object's own
|
|
95
|
+
* `properties`/`required` merged with those of every `allOf` member,
|
|
96
|
+
* recursively, resolving `$ref`s. Later same-named members do not override
|
|
97
|
+
* earlier ones (first-write wins, matching the mapper's `allOf` array-order
|
|
98
|
+
* rendering); member `required` lists are unioned.
|
|
99
|
+
*
|
|
100
|
+
* Motivation: multipart bodies declaring their fields via `allOf` (a common
|
|
101
|
+
* upstream-spec shape) previously produced an empty property walk — the
|
|
102
|
+
* generated body type collapsed to `Record<string, never>` and every FormData
|
|
103
|
+
* append was silently dropped.
|
|
104
|
+
*/
|
|
105
|
+
export declare function collectFormProperties(schema: SchemaObject | ReferenceObject | undefined, resolver: RefResolver, properties?: Map<string, SchemaObject | ReferenceObject>, required?: Set<string>, visited?: Set<SchemaObject>): {
|
|
106
|
+
properties: Map<string, SchemaObject | ReferenceObject>;
|
|
107
|
+
required: Set<string>;
|
|
108
|
+
};
|
|
109
|
+
/**
|
|
110
|
+
* Names of the (allOf-merged) top-level form fields whose subtree contains
|
|
111
|
+
* `format: binary` — for warnings that name the offending fields. Empty when
|
|
112
|
+
* the binary data sits outside any named field (e.g. the whole body schema
|
|
113
|
+
* is a binary string) or there is no binary data at all.
|
|
114
|
+
*/
|
|
115
|
+
export declare function findBinaryFieldNames(schema: SchemaObject | ReferenceObject | undefined, resolver: RefResolver): string[];
|
|
85
116
|
/**
|
|
86
117
|
* Analyzer-internal analysis result: the analyzed operations plus the raw
|
|
87
118
|
* request body schemas in a PARALLEL ARRAY (same length and order as
|
|
@@ -12,6 +12,103 @@ function isBinarySchema(schema, resolver) {
|
|
|
12
12
|
return false;
|
|
13
13
|
return resolver.resolveSchema(schema).format === 'binary';
|
|
14
14
|
}
|
|
15
|
+
/**
|
|
16
|
+
* Deep, ref-resolving, cycle-safe check: does any schema node reachable from
|
|
17
|
+
* `schema` (through `properties`, `items`, `allOf`/`oneOf`/`anyOf` members and
|
|
18
|
+
* schema-valued `additionalProperties`) declare `format: binary`? Unlike
|
|
19
|
+
* {@link isBinarySchema} this catches binary data buried in composed schemas —
|
|
20
|
+
* e.g. a `files` array of binary strings hidden inside an `allOf` member.
|
|
21
|
+
*/
|
|
22
|
+
export function schemaContainsBinary(schema, resolver) {
|
|
23
|
+
return containsBinary(schema, resolver, new Set());
|
|
24
|
+
}
|
|
25
|
+
function containsBinary(schema, resolver, visited) {
|
|
26
|
+
if (!schema)
|
|
27
|
+
return false;
|
|
28
|
+
const resolved = resolver.resolveSchema(schema);
|
|
29
|
+
// Cycle guard on resolved object identity (a self-referencing $ref chain
|
|
30
|
+
// would otherwise recurse forever; schema-mapper uses the same pattern).
|
|
31
|
+
if (visited.has(resolved))
|
|
32
|
+
return false;
|
|
33
|
+
visited.add(resolved);
|
|
34
|
+
if (resolved.format === 'binary')
|
|
35
|
+
return true;
|
|
36
|
+
if (resolved.items !== undefined) {
|
|
37
|
+
if (containsBinary(resolved.items, resolver, visited))
|
|
38
|
+
return true;
|
|
39
|
+
}
|
|
40
|
+
for (const member of resolved.allOf ?? []) {
|
|
41
|
+
if (containsBinary(member, resolver, visited))
|
|
42
|
+
return true;
|
|
43
|
+
}
|
|
44
|
+
for (const member of resolved.oneOf ?? []) {
|
|
45
|
+
if (containsBinary(member, resolver, visited))
|
|
46
|
+
return true;
|
|
47
|
+
}
|
|
48
|
+
for (const member of resolved.anyOf ?? []) {
|
|
49
|
+
if (containsBinary(member, resolver, visited))
|
|
50
|
+
return true;
|
|
51
|
+
}
|
|
52
|
+
for (const propSchema of Object.values(resolved.properties ?? {})) {
|
|
53
|
+
if (containsBinary(propSchema, resolver, visited))
|
|
54
|
+
return true;
|
|
55
|
+
}
|
|
56
|
+
if (typeof resolved.additionalProperties === 'object' && resolved.additionalProperties !== null) {
|
|
57
|
+
if (containsBinary(resolved.additionalProperties, resolver, visited))
|
|
58
|
+
return true;
|
|
59
|
+
}
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* The flattened form fields of a request body schema: the root object's own
|
|
64
|
+
* `properties`/`required` merged with those of every `allOf` member,
|
|
65
|
+
* recursively, resolving `$ref`s. Later same-named members do not override
|
|
66
|
+
* earlier ones (first-write wins, matching the mapper's `allOf` array-order
|
|
67
|
+
* rendering); member `required` lists are unioned.
|
|
68
|
+
*
|
|
69
|
+
* Motivation: multipart bodies declaring their fields via `allOf` (a common
|
|
70
|
+
* upstream-spec shape) previously produced an empty property walk — the
|
|
71
|
+
* generated body type collapsed to `Record<string, never>` and every FormData
|
|
72
|
+
* append was silently dropped.
|
|
73
|
+
*/
|
|
74
|
+
export function collectFormProperties(schema, resolver, properties = new Map(), required = new Set(), visited = new Set()) {
|
|
75
|
+
if (!schema)
|
|
76
|
+
return { properties, required };
|
|
77
|
+
const resolved = resolver.resolveSchema(schema);
|
|
78
|
+
// Cycle guard on resolved identity (same pattern as containsBinary above).
|
|
79
|
+
if (visited.has(resolved))
|
|
80
|
+
return { properties, required };
|
|
81
|
+
visited.add(resolved);
|
|
82
|
+
// allOf members come first, mirroring the mapper's `A & B` member order.
|
|
83
|
+
for (const member of resolved.allOf ?? []) {
|
|
84
|
+
collectFormProperties(member, resolver, properties, required, visited);
|
|
85
|
+
}
|
|
86
|
+
for (const name of resolved.required ?? []) {
|
|
87
|
+
required.add(name);
|
|
88
|
+
}
|
|
89
|
+
for (const [name, propSchema] of Object.entries(resolved.properties ?? {})) {
|
|
90
|
+
if (!properties.has(name)) {
|
|
91
|
+
properties.set(name, propSchema);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return { properties, required };
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Names of the (allOf-merged) top-level form fields whose subtree contains
|
|
98
|
+
* `format: binary` — for warnings that name the offending fields. Empty when
|
|
99
|
+
* the binary data sits outside any named field (e.g. the whole body schema
|
|
100
|
+
* is a binary string) or there is no binary data at all.
|
|
101
|
+
*/
|
|
102
|
+
export function findBinaryFieldNames(schema, resolver) {
|
|
103
|
+
const { properties } = collectFormProperties(schema, resolver);
|
|
104
|
+
const names = [];
|
|
105
|
+
for (const [name, propSchema] of properties) {
|
|
106
|
+
if (containsBinary(propSchema, resolver, new Set())) {
|
|
107
|
+
names.push(name);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return names;
|
|
111
|
+
}
|
|
15
112
|
function resolveParameter(param, resolver) {
|
|
16
113
|
return resolver.resolve(param);
|
|
17
114
|
}
|
|
@@ -76,6 +76,7 @@ function spineForEntry(entry) {
|
|
|
76
76
|
familyId: entry.familyId,
|
|
77
77
|
propertyName: entry.propertyName,
|
|
78
78
|
literalValue: entry.literalValue,
|
|
79
|
+
isBaseMember: entry.isBaseMember,
|
|
79
80
|
};
|
|
80
81
|
}
|
|
81
82
|
/**
|
|
@@ -266,6 +267,14 @@ export class SchemaMapper {
|
|
|
266
267
|
return result;
|
|
267
268
|
}
|
|
268
269
|
if (s.type === undefined) {
|
|
270
|
+
// OpenAPI permits omitting `type`: a schema carrying `properties`
|
|
271
|
+
// and/or `additionalProperties` is an object schema regardless. Map it
|
|
272
|
+
// through the object branch instead of degrading to `unknown`; a
|
|
273
|
+
// genuinely unconstrained schema (no keywords to infer from) stays
|
|
274
|
+
// `unknown` (any JSON value).
|
|
275
|
+
if (s.properties !== undefined || s.additionalProperties !== undefined) {
|
|
276
|
+
return this.mapObject(s, name, context, visited, indent, spine);
|
|
277
|
+
}
|
|
269
278
|
return { tsType: 'unknown', imports: [] };
|
|
270
279
|
}
|
|
271
280
|
if (Array.isArray(s.type)) {
|
|
@@ -354,9 +363,21 @@ export class SchemaMapper {
|
|
|
354
363
|
const canonical = this.canonicalSchemasRef(refStr);
|
|
355
364
|
const baseFamily = registry.baseRefs.get(refStr) ?? registry.baseRefs.get(canonical);
|
|
356
365
|
if (baseFamily) {
|
|
366
|
+
const baseEntry = baseFamily.entriesByRef.get(baseFamily.baseRefStr);
|
|
367
|
+
if (spine && spine.familyId === baseFamily.familyId && baseEntry) {
|
|
368
|
+
// Member base inside its own family spine (#93): the base joins as a
|
|
369
|
+
// variant through the entry seam — `Omit<Base, P>` — so the child's
|
|
370
|
+
// literal append never collides with the base's own.
|
|
371
|
+
return this.mapSameFamilySpineRef(baseEntry, context, visited, indent);
|
|
372
|
+
}
|
|
357
373
|
if (spine && spine.familyId === baseFamily.familyId) {
|
|
358
374
|
return { tsType: baseFamily.baseTypeName, imports: [baseFamily.baseTypeName] };
|
|
359
375
|
}
|
|
376
|
+
// Empty family (#93): no members at all — refs fall back to the bare
|
|
377
|
+
// base name instead of an undeclared `{Base}Variant` union.
|
|
378
|
+
if (baseFamily.variantUnionMembers.length === 0) {
|
|
379
|
+
return { tsType: baseFamily.baseTypeName, imports: [baseFamily.baseTypeName] };
|
|
380
|
+
}
|
|
360
381
|
return { tsType: baseFamily.variantUnionName, imports: [baseFamily.variantUnionName] };
|
|
361
382
|
}
|
|
362
383
|
const entry = registry.byRef.get(refStr) ?? registry.byRef.get(canonical);
|
|
@@ -650,7 +671,9 @@ export class SchemaMapper {
|
|
|
650
671
|
const memberIndent = indentBy(indent + 1);
|
|
651
672
|
const memberLines = [];
|
|
652
673
|
const allImports = [];
|
|
653
|
-
if (schema.discriminator) {
|
|
674
|
+
if (schema.discriminator && !spine?.isBaseMember) {
|
|
675
|
+
// A member base's placeholder (`"${discPropName}": string;`) is skipped:
|
|
676
|
+
// its literal append replaces the declared property outright (#93).
|
|
654
677
|
const discPropName = schema.discriminator.propertyName;
|
|
655
678
|
const discIndex = filteredPropNames.indexOf(discPropName);
|
|
656
679
|
if (discIndex !== -1) {
|
package/dist/analyzer/types.d.ts
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "genoc",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.3",
|
|
4
4
|
"description": "Generate typed HTTP clients from OpenAPI 3.0 / 3.1 specifications",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"client",
|
|
@@ -48,12 +48,12 @@
|
|
|
48
48
|
},
|
|
49
49
|
"devDependencies": {
|
|
50
50
|
"@changesets/cli": "^2.31.0",
|
|
51
|
-
"@types/node": "^26.6.
|
|
52
|
-
"oxfmt": "^0.
|
|
53
|
-
"oxlint": "^1.
|
|
51
|
+
"@types/node": "^26.6.5",
|
|
52
|
+
"oxfmt": "^0.75.0",
|
|
53
|
+
"oxlint": "^1.90.0",
|
|
54
54
|
"typescript": "^6.0.3",
|
|
55
|
-
"undici": "^8",
|
|
56
|
-
"vitest": "^5.0.
|
|
55
|
+
"undici": "^8.12.3",
|
|
56
|
+
"vitest": "^5.0.5"
|
|
57
57
|
},
|
|
58
58
|
"peerDependencies": {
|
|
59
59
|
"undici": "^6.13.0 || ^7 || ^8"
|