Skip to content

fix(core): support oneOf/anyOf in multipart/form-data code generation#3245

Merged
melloware merged 7 commits into
orval-labs:masterfrom
nathansogut-sipios:fix/multipart-oneof-codegen
Apr 17, 2026
Merged

fix(core): support oneOf/anyOf in multipart/form-data code generation#3245
melloware merged 7 commits into
orval-labs:masterfrom
nathansogut-sipios:fix/multipart-oneof-codegen

Conversation

@nathansogut-sipios

@nathansogut-sipios nathansogut-sipios commented Apr 17, 2026

Copy link
Copy Markdown
Contributor

Fixes #3242

Two independent bugs in getResReqTypes and getSchemaFormDataAndUrlEncoded when a multipart/form-data request body uses oneOf or anyOf. Full repro, compiler errors, and expected output in the issue.

Solution

  • Bug 1: extend the ref-resolution branch in getResReqTypes with an else if that walks oneOf/anyOf, resolves the $ref(s) inside, and concatenates the resolved names.
  • Bug 2: for oneOf/anyOf (shouldCast === true), replace the N per-variant formData.append branches with a single Object.entries() loop. allOf is unchanged.

Known limitations (Bug 2)

The runtime approach drops some static-codegen features from the per-variant branches: readOnly filtering, encoding map, array handling modes, getFormDataFieldFileType binary/text detection, nullable handling. Only complex specs are affected.

Testing

3 new tests in res-req-types.test.ts covering: oneOf with 1 $ref (Bug 1), oneOf with 2 $refs sharing a field (Bug 2), allOf regression. bun run test 1640 passing, lint + typecheck green. Tests were also run against the pre-fix source to confirm they fail as expected.

Same fix is also deployed as a pnpm patch-package on 8 TypeScript projects (NestJS + zod-to-openapi, 1-2 schema versions) in production - all compile and run correctly.

Summary by CodeRabbit

  • Tests

    • Added regression tests validating FormData generation for root-level compositions (oneOf/allOf/anyOf), optional request bodies, and arrays of binary files; updated snapshots to reflect the new serialization behavior.
  • Bug Fixes

    • Use referenced DTO names when generating FormData entries.
    • Emit robust runtime iteration for mixed-composition request bodies (avoiding per-variant casts) while preserving per-field behavior for allOf.
    • Add guards for optional bodies and correct handling of binary arrays.

Fixes orval-labs#3242

Two independent bugs in getResReqTypes and getSchemaFormDataAndUrlEncoded
when a multipart/form-data request body uses oneOf or anyOf.

Bug 1: effectivePropName was only resolved when the media type schema was
a direct $ref. When wrapped in oneOf/anyOf (common with NestJS decorators
like @nestjs/swagger or zod-to-openapi for versioned schemas), the
FormData code referenced a variable derived from the controller name
instead of the DTO name, which was never declared.

Bug 2: generating a sequential formData.append() block per oneOf/anyOf
variant duplicated fields shared across variants at runtime, because
TypeScript casts are erased at compile-time and every branch executes.
Replaced the per-variant blocks with a single Object.entries() loop.
Adds three tests alongside the existing FormData composition suite:

- oneOf with a single $ref: the generated FormData references a
  variable derived from the DTO name, not the controller-derived name
  that would never be declared (Bug 1).
- oneOf with two $refs sharing a field: the generated FormData uses
  a single Object.entries() loop and does not duplicate per-variant
  appends via TypeScript casts (Bug 2). Variant types stay importable.
- allOf: still emits per-field appends (no regression in the
  composition path that kept the original behavior).
@coderabbitai

coderabbitai Bot commented Apr 17, 2026

Copy link
Copy Markdown
Contributor

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Derive FormData parameter names from inner $ref(s) for oneOf/anyOf and replace per-variant compile-time append emission with a single runtime Object.entries(propName ?? {}) loop for multipart/form-data; preserve allOf static per-field emission and add Vitest regression tests covering these cases.

Changes

Cohort / File(s) Summary
Core tests
packages/core/src/getters/res-req-types.test.ts
Added Vitest regression tests for multipart/form-data request bodies using root-level oneOf/anyOf/allOf: single-ref naming, multi-ref runtime loop, optional-body guard, allOf per-field behavior, and arrays-of-binary handling.
Codegen logic
packages/core/src/getters/res-req-types.ts
When media type schema uses oneOf/anyOf, compute effectivePropName from referenced $ref names; remove per-variant casted/temp-variable form-data emissions and emit a single runtime Object.entries(propName ?? {}) loop handling File/Blob, Buffer→Blob, arrays, objects (JSON.stringify), and primitives. Keep static per-field generation for allOf.
Generated snapshots
tests/__snapshots__/**/form-data*/endpoints.ts, tests/__snapshots__/**/react-query/**, tests/__snapshots__/**/swr/**, tests/__snapshots__/**/vue-query/**, tests/__snapshots__/**/fetch/**, tests/__snapshots__/**/importFromSubdirectory/*, tests/__snapshots__/**/msw.ts
Updated snapshots to reflect the generic Object.entries(... ?? {})-based FormData population; adjusted some type-only import aliasing (Pet/AnotherSchema__Pet/__AnotherSchema) and reorganized type-only imports for PetBase/PetExtended.

Sequence Diagram(s)

(omitted)

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Suggested labels

bug, openapi

Poem

🐰 I hopped through code where types had strayed,
One loop now gathers the fields we made,
No duplicate appends, names find their match,
allOf stands firm while oneOf learned to catch,
🥕 Tests now dance — the CI sings "OK!"

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately describes the main change: fixing support for oneOf/anyOf in multipart/form-data code generation, which is the core issue resolved in this PR.
Linked Issues check ✅ Passed All objectives from issue #3242 are met: Bug 1 (effectivePropName derivation from oneOf/anyOf refs) and Bug 2 (runtime Object.entries loop for shouldCast === true) are implemented, allOf behavior is preserved, imports are collected, and tests cover single/multi-entry oneOf and allOf cases.
Out of Scope Changes check ✅ Passed All changes are within scope: core logic fixes in res-req-types.ts, regression test additions in res-req-types.test.ts, and snapshot updates reflecting the new formData generation behavior across multiple test frameworks.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (1)
packages/core/src/getters/res-req-types.test.ts (1)

551-724: Good coverage for the happy-path regressions; consider extending.

The three new cases lock in exactly the behavior described in the PR (DTO-derived propName, single Object.entries loop, allOf unchanged). Two follow-ups worth adding once the companion source-file issues are resolved:

  • A test where the oneOf/anyOf body is optional (required: false) to pin down the guard against Object.entries(undefined).
  • A test where a shared field is a file array (Blob[]) to codify that multi-file fields under shouldCast are not JSON-stringified.

Not blocking for this PR — flagging so the regressions discussed on the source file are also caught here.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/core/src/getters/res-req-types.test.ts` around lines 551 - 724, Add
two additional tests to packages/core/src/getters/res-req-types.test.ts using
the existing getResReqTypes test patterns: (1) a oneOf/anyOf multipart/form-data
case where the requestBody has required: false to assert the generated formData
code guards against Object.entries(undefined) (look for result =
getResReqTypes(...)[0], then inspect formData for absence of unguarded
Object.entries calls); (2) a oneOf case where a shared field is an array of
files (e.g., type: 'array' items: { type: 'string', format: 'binary' } / Blob[])
and assert the generated formData does not JSON.stringify that multi-file field
(inspect formData string for no JSON.stringify(uploadRequestBody.<field>) and
that file append logic is present). Use the same ctx/context, reqBody, result,
and formData variables and assertions style as the neighboring tests.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/core/src/getters/res-req-types.ts`:
- Around line 596-601: The loop inside getSchemaFormDataAndUrlEncoded that
pushes imports from resolveSchemaRef into the local additionalImports array (for
combinedSchemas) produces dead write-only code because additionalImports is
never used or returned; either remove that loop and the unused additionalImports
variable, or extend the function's return contract to include and return the
collected additionalImports so callers (or getFormDataAdditionalImports) can
consume them; locate getSchemaFormDataAndUrlEncoded, the additionalImports
declaration, the combinedSchemas loop, and resolveSchemaRef usage and either
delete the unused accumulation or add the additionalImports to the returned
object and update callers (e.g., getFormDataAdditionalImports call sites) to
handle the new return shape.
- Around line 259-277: The oneOf/anyOf handling in getResReqTypes silently falls
back when variants are non-$ref which can leave effectivePropName pointing to a
non-existent identifier and also shadows the outer name parameter; fix by only
assigning effectivePropName when at least one resolved ref yields a valid
identifier, rename the inner const name (from
resolveSchemaRef(...).imports[0]?.name) to avoid shadowing (e.g., refName), and
otherwise leave effectivePropName as propName (or explicitly comment that
mixed/inline variants fall back to the old path); update the branch around
getSchemaOneOf/getSchemaAnyOf, isReference loop, and resolveSchemaRef usage and
add a unit test covering mixed $ref + inline oneOf/anyOf to ensure no invalid
prop names are produced.
- Around line 582-594: The generated Object.entries block improperly references
File, ignores optional bodies, converts Blob/File elements to "{}" when inside
arrays, and turns null into the string "null"; fix by guarding the top-level
iteration with a nullish/default check (use isRequestBodyOptional to use
optional chaining or fallback to {} so Object.entries won't receive undefined),
change the value check to skip both undefined and null (value != null), guard
the File instanceof check with typeof File !== 'undefined' (keep the existing
Blob and Buffer.isBuffer checks), and in the Array.isArray(value) branch append
Blob/File/Buffer elements directly instead of JSON.stringify them while
serializing only plain object elements and primitives (so Blob/File elements are
preserved).

---

Nitpick comments:
In `@packages/core/src/getters/res-req-types.test.ts`:
- Around line 551-724: Add two additional tests to
packages/core/src/getters/res-req-types.test.ts using the existing
getResReqTypes test patterns: (1) a oneOf/anyOf multipart/form-data case where
the requestBody has required: false to assert the generated formData code guards
against Object.entries(undefined) (look for result = getResReqTypes(...)[0],
then inspect formData for absence of unguarded Object.entries calls); (2) a
oneOf case where a shared field is an array of files (e.g., type: 'array' items:
{ type: 'string', format: 'binary' } / Blob[]) and assert the generated formData
does not JSON.stringify that multi-file field (inspect formData string for no
JSON.stringify(uploadRequestBody.<field>) and that file append logic is
present). Use the same ctx/context, reqBody, result, and formData variables and
assertions style as the neighboring tests.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 70eae977-fd97-423a-8cc4-62780a2d7a7a

📥 Commits

Reviewing files that changed from the base of the PR and between f2f0658 and 0a6c1e8.

📒 Files selected for processing (2)
  • packages/core/src/getters/res-req-types.test.ts
  • packages/core/src/getters/res-req-types.ts

Comment on lines +259 to 277
} else if (mediaType.schema) {
// When schema is a oneOf/anyOf of $refs, concat schema names for consistent param naming
const combinedRefs =
getSchemaOneOf(mediaType.schema) ??
getSchemaAnyOf(mediaType.schema);
if (combinedRefs) {
const names: string[] = [];
for (const ref of combinedRefs) {
if (!isReference(ref)) continue;
const name = resolveSchemaRef(ref, context).imports[0]?.name;
if (name) {
names.push(name);
}
}
if (names.length > 0) {
effectivePropName = names.join('');
}
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Silent fallback when oneOf/anyOf contains non-$ref variants.

The branch only collects names for $ref entries inside oneOf/anyOf and silently falls back to propName if no refs are found (e.g., inline object variants, or a oneOf combining a $ref with an inline variant). That can still leave effectivePropName referring to a variable that doesn't exist in the generated signature, re-creating a softer version of the bug this PR fixes.

Also, const name = resolveSchemaRef(ref, context).imports[0]?.name shadows the outer name parameter of getResReqTypes, which is easy to misread.

Not a blocker given the explicit scope of the fix (OpenAPI specs where oneOf/anyOf wraps named DTOs, like NestJS), but worth adding a test or a comment noting that mixed/inline oneOf variants still take the old path.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/core/src/getters/res-req-types.ts` around lines 259 - 277, The
oneOf/anyOf handling in getResReqTypes silently falls back when variants are
non-$ref which can leave effectivePropName pointing to a non-existent identifier
and also shadows the outer name parameter; fix by only assigning
effectivePropName when at least one resolved ref yields a valid identifier,
rename the inner const name (from resolveSchemaRef(...).imports[0]?.name) to
avoid shadowing (e.g., refName), and otherwise leave effectivePropName as
propName (or explicitly comment that mixed/inline variants fall back to the old
path); update the branch around getSchemaOneOf/getSchemaAnyOf, isReference loop,
and resolveSchemaRef usage and add a unit test covering mixed $ref + inline
oneOf/anyOf to ensure no invalid prop names are produced.

Comment thread packages/core/src/getters/res-req-types.ts Outdated
Comment on lines +596 to +601
for (const subSchema of combinedSchemas) {
const { imports } = resolveSchemaRef(subSchema, context);
if (imports[0]) {
additionalImports.push(imports[0]);
newPropName = `${propName}${pascal(imports[0].name)}`;
newPropDefinition = `const ${newPropName} = (${propName} as ${imports[0].name}${isRequestBodyOptional ? ' | undefined' : ''});\n`;
}

return (
newPropDefinition +
resolveSchemaPropertiesToFormData({
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

additionalImports is write-only dead code in this function.

additionalImports is declared at line 564 but never returned or read — getSchemaFormDataAndUrlEncoded returns only form. The imports for oneOf/anyOf variants are actually collected by getFormDataAdditionalImports at the call sites (lines 217-220, 351-354), which is what the new tests assert against.

This mirrors pre-existing dead code for the combinedSchemas path, but since the shadowing-free loop here is newly added, consider either removing it or wiring additionalImports into the return contract. Current version just allocates and discards objects.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/core/src/getters/res-req-types.ts` around lines 596 - 601, The loop
inside getSchemaFormDataAndUrlEncoded that pushes imports from resolveSchemaRef
into the local additionalImports array (for combinedSchemas) produces dead
write-only code because additionalImports is never used or returned; either
remove that loop and the unused additionalImports variable, or extend the
function's return contract to include and return the collected additionalImports
so callers (or getFormDataAdditionalImports) can consume them; locate
getSchemaFormDataAndUrlEncoded, the additionalImports declaration, the
combinedSchemas loop, and resolveSchemaRef usage and either delete the unused
accumulation or add the additionalImports to the returned object and update
callers (e.g., getFormDataAdditionalImports call sites) to handle the new return
shape.

Address review feedback on orval-labs#3245.

- Guard `File` reference with `typeof File !== 'undefined'` so the generated
  code does not throw a ReferenceError in non-DOM environments.
- Wrap the iterated body with `?? {}` to defend against `Object.entries(undefined)`
  when the request body is optional.
- In the `Array.isArray` branch, append `Blob`/`File`/`Buffer` elements directly
  instead of serializing them with `JSON.stringify`, which would otherwise turn
  binary array items into the string "{}".
- Skip both `null` and `undefined` values so `null` is no longer coerced to the
  string "null".
- Drop the dead push to the local `additionalImports` array; variant imports are
  already collected by `getFormDataAdditionalImports`.
- Rename inner const to `refName` to avoid shadowing the outer `name` parameter.
- Add two regression tests covering the optional-body and array-of-files cases.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
packages/core/src/getters/res-req-types.test.ts (1)

660-662: Minor: assertion is weaker than intended.

(formData.match(/\file`/g) ?? []).lengthcounts occurrences of the literal backtick-quoted key ``file ``. In the pre-fix output, per-variant appends would have emitted `` formData.append(file, ...) `` for each variant, so this check is meaningful. However, since the fix-path emits Object.entriesusingkey(not ``file``), this count is trivially0regardless of whether shared-field duplication was actually deduplicated. TheObject.entries(+not.toMatch(/as UploadDtoV[12]/)assertions already cover the intent; consider dropping theappendFileCountcheck or replacing it with an assertion that counts.append(calls for the shared field under the old per-variant path (e.g., assert nouploadDtoV1.file/uploadDtoV2.file` references) to make the regression guard meaningful on any future refactor.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/core/src/getters/res-req-types.test.ts` around lines 660 - 662, The
existing appendFileCount assertion is too specific to the old backtick-quoted
key and is now meaningless; remove the appendFileCount calculation and its
expect, or replace it with an assertion that actually checks for per-variant
`.append(` usages (e.g., assert formData does not contain references like
`uploadDtoV1.file` or `uploadDtoV2.file`) so the test for shared-field
deduplication in the res-req-types.test (the formData string and the existing
not.toMatch(/as UploadDtoV[12]/) checks) remains a meaningful regression guard.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@packages/core/src/getters/res-req-types.test.ts`:
- Around line 660-662: The existing appendFileCount assertion is too specific to
the old backtick-quoted key and is now meaningless; remove the appendFileCount
calculation and its expect, or replace it with an assertion that actually checks
for per-variant `.append(` usages (e.g., assert formData does not contain
references like `uploadDtoV1.file` or `uploadDtoV2.file`) so the test for
shared-field deduplication in the res-req-types.test (the formData string and
the existing not.toMatch(/as UploadDtoV[12]/) checks) remains a meaningful
regression guard.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 38a0b757-b26a-4a61-a477-df5822ae6310

📥 Commits

Reviewing files that changed from the base of the PR and between 0a6c1e8 and 69dd4e9.

📒 Files selected for processing (2)
  • packages/core/src/getters/res-req-types.test.ts
  • packages/core/src/getters/res-req-types.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/core/src/getters/res-req-types.ts

@melloware melloware left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good feedback from the AI code review please implement if possible

- Update nine orval-tests file snapshots to reflect the new Object.entries
  codegen for multipart/form-data with oneOf/anyOf request bodies:
  fetch/form-data-optional-request, fetch/form-data-with-custom-fetch,
  fetch/importFromSubdirectory, react-query/form-data,
  react-query/form-data-with-hook, react-query/importFromSubdirectory
  (endpoints.ts and endpoints.msw.ts), swr/form-data-optional-request,
  vue-query/form-data.
- Replace the weak `file` backtick count assertion with checks against
  per-variant references (uploadDtoV1.file, uploadDtoV2.file), which
  remain meaningful if a future refactor reintroduces the cast-based
  append pattern.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
tests/__snapshots__/vue-query/form-data/endpoints.ts (1)

92-133: ⚠️ Potential issue | 🔴 Critical

Duplicate FormData entries when outer schema has both oneOf/anyOf and direct properties.

The generated code in this snapshot—and identically in the other five updated snapshots (fetch/form-data-optional-request, fetch/form-data-with-custom-fetch, react-query/form-data, react-query/form-data-with-hook, swr/form-data-optional-request)—results from two independent code paths in getSchemaFormDataAndUrlEncoded (packages/core/src/getters/res-req-types.ts):

  1. When oneOf or anyOf is present, the shouldCast branch (line 576) emits Object.entries(pet ?? {}).forEach(...), which appends every own enumerable property.
  2. When schema.properties exists, a subsequent branch (line 621) emits explicit appends for @id, email, callingCode, country.

Because the Pet schema declares both oneOf: [PetBase, PetExtended] and these four fields as direct properties, both code paths execute. The result is that each property is appended twice to the multipart body—once generically in the loop and once explicitly afterward.

The fix requires modifying the shouldCast branch to skip keys that will be handled explicitly downstream. Suggested approach:

  • Extract the set of keys from schema.properties and skip them in the Object.entries loop, or
  • Use conditional logic to emit only one code path when direct properties exist alongside oneOf/anyOf.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/__snapshots__/vue-query/form-data/endpoints.ts` around lines 92 - 133,
The Object.entries loop emitted in getSchemaFormDataAndUrlEncoded (the
shouldCast branch) appends every pet property and then the later
schema.properties branch appends the same keys again, causing duplicates; modify
the shouldCast/Object.entries(pet ?? {}) emission to skip any keys present in
schema.properties (extract property keys from schema.properties into a Set and
filter them out before appending) or alternatively gate the entire shouldCast
loop to not run when schema.properties exists so only one code-path (the
explicit schema.properties appenders) emits those fields.
🧹 Nitpick comments (1)
tests/__snapshots__/react-query/form-data-with-hook/endpoints.ts (1)

17-25: Import grouping could be consolidated.

Error/Pet (L17) and PetBase/PetExtended (L24) come from the same ./model module; splitting them across two separate import type statements separated by unrelated imports is a codegen artifact worth cleaning up later. Not blocking.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/__snapshots__/react-query/form-data-with-hook/endpoints.ts` around
lines 17 - 25, Consolidate the split type imports from the same module by
merging the two separate "import type" statements for Error, Pet, PetBase, and
PetExtended into a single import type from './model' (so replace the separate
`import type { Error, Pet } from './model';` and `import type { PetBase,
PetExtended } from './model';` with one `import type { Error, Pet, PetBase,
PetExtended } from './model';`), removing the unrelated imports that currently
sit between them so all model types are imported together and the remaining
unrelated imports (faker, msw) remain unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Outside diff comments:
In `@tests/__snapshots__/vue-query/form-data/endpoints.ts`:
- Around line 92-133: The Object.entries loop emitted in
getSchemaFormDataAndUrlEncoded (the shouldCast branch) appends every pet
property and then the later schema.properties branch appends the same keys
again, causing duplicates; modify the shouldCast/Object.entries(pet ?? {})
emission to skip any keys present in schema.properties (extract property keys
from schema.properties into a Set and filter them out before appending) or
alternatively gate the entire shouldCast loop to not run when schema.properties
exists so only one code-path (the explicit schema.properties appenders) emits
those fields.

---

Nitpick comments:
In `@tests/__snapshots__/react-query/form-data-with-hook/endpoints.ts`:
- Around line 17-25: Consolidate the split type imports from the same module by
merging the two separate "import type" statements for Error, Pet, PetBase, and
PetExtended into a single import type from './model' (so replace the separate
`import type { Error, Pet } from './model';` and `import type { PetBase,
PetExtended } from './model';` with one `import type { Error, Pet, PetBase,
PetExtended } from './model';`), removing the unrelated imports that currently
sit between them so all model types are imported together and the remaining
unrelated imports (faker, msw) remain unchanged.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 1a45e030-834d-4f40-98e6-bbdd31b8bed2

📥 Commits

Reviewing files that changed from the base of the PR and between 69dd4e9 and 4624a7e.

📒 Files selected for processing (10)
  • packages/core/src/getters/res-req-types.test.ts
  • tests/__snapshots__/fetch/form-data-optional-request/endpoints.ts
  • tests/__snapshots__/fetch/form-data-with-custom-fetch/endpoints.ts
  • tests/__snapshots__/fetch/importFromSubdirectory/endpoints.ts
  • tests/__snapshots__/react-query/form-data-with-hook/endpoints.ts
  • tests/__snapshots__/react-query/form-data/endpoints.ts
  • tests/__snapshots__/react-query/importFromSubdirectory/endpoints.msw.ts
  • tests/__snapshots__/react-query/importFromSubdirectory/endpoints.ts
  • tests/__snapshots__/swr/form-data-optional-request/endpoints.ts
  • tests/__snapshots__/vue-query/form-data/endpoints.ts
✅ Files skipped from review due to trivial changes (3)
  • tests/snapshots/react-query/importFromSubdirectory/endpoints.ts
  • tests/snapshots/fetch/importFromSubdirectory/endpoints.ts
  • packages/core/src/getters/res-req-types.test.ts

@melloware

Copy link
Copy Markdown
Collaborator

looks like build failure now?

Two issues in the multipart oneOf/anyOf Object.entries codegen surfaced
by the CodeRabbit review on orval-labs#3245 and the upstream "Verify generated
code can build" CI job.

- When the outer schema declares both `oneOf`/`anyOf` AND direct
  `properties`, both code paths ran: the runtime loop appended every
  key, then the properties branch re-appended each declared key. Shared
  keys (e.g. `@id`, `email`) were duplicated in the FormData body.
  The loop now skips keys that the properties branch will handle.

- The previous Buffer branch handed the Buffer directly to
  `FormData.append`, which `@types/node` rejects against the DOM
  `FormData.append(name, Blob | string)` overload. The Buffer path now
  wraps the value in a `Blob` before appending and casts through
  `BlobPart` to bridge the `ArrayBufferLike`/`ArrayBuffer` generic
  mismatch that `@types/node` introduces.

Add a regression test covering oneOf combined with direct properties,
and refresh the six orval-tests file snapshots that exercise the
codegen (fetch/form-data-optional-request, fetch/form-data-with-custom-fetch,
react-query/form-data, react-query/form-data-with-hook,
swr/form-data-optional-request, vue-query/form-data).

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (3)
packages/core/src/getters/res-req-types.ts (2)

259-277: Name derivation looks correct; silent fallback for inline/mixed variants is a known limitation.

Renaming the inner identifier to refName avoids shadowing the outer name parameter, and concatenating resolved names preserves the imported-type contract exercised at packages/core/src/getters/res-req-types.test.ts lines 667-669.

As previously noted, if a oneOf/anyOf contains only inline object variants (no $refs), names stays empty and effectivePropName falls back to the pascal(name) + pascal(key) form that triggered the original bug. This is acceptable given the PR's explicit scope (NestJS/zod-to-openapi-style named DTOs), but a short inline comment here would help future readers understand the intended boundary.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/core/src/getters/res-req-types.ts` around lines 259 - 277, The loop
over combinedRefs is shadowing the outer parameter name and can be clearer:
rename the inner identifier to refName when extracting resolveSchemaRef(ref,
context).imports[0]?.name and push refName into the names array, then join into
effectivePropName as implemented; also add a short inline comment near this
block (around getSchemaOneOf/getSchemaAnyOf handling and effectivePropName
assignment) stating that if only inline variants exist (no $ref) names remains
empty and the code intentionally falls back to the pascal(name)+pascal(key)
form.

591-614: Runtime loop intentionally drops per-field codegen features — worth an inline note.

For oneOf/anyOf the emitted loop uses String(value) / JSON.stringify(value) uniformly, which — per the PR description — loses:

  • encoding contentType wrapping (new Blob([value], { type: 'text/plain' }) etc.)
  • nullable-vs-null distinction (null is treated the same as "skip")
  • per-field array handling modes (FormDataArrayHandling.EXPLODE/SERIALIZE_WITH_BRACKETS)
  • text-file detection (getFormDataFieldFileType)

Given this is a deliberate tradeoff, a short comment above the form += 'Object.entries(...)' block documenting these losses (and pointing at the static resolveSchemaPropertiesToFormData path used by allOf/direct properties for comparison) would save the next reader a spelunk through the PR history.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/core/src/getters/res-req-types.ts` around lines 591 - 614, Add an
inline comment above the generated Object.entries loop (the block that builds
form using variables propName and variableName) explaining that this runtime
codegen intentionally simplifies oneOf/anyOf handling and therefore loses
per-field behaviors: no contentType wrapping (e.g., new Blob([...], {type})),
nullable vs null distinction, per-field array modes
(FormDataArrayHandling.EXPLODE / SERIALIZE_WITH_BRACKETS), and text-file
detection via getFormDataFieldFileType; also point readers to the static path
resolveSchemaPropertiesToFormData (used for allOf/direct properties) for the
full-featured implementation and why this tradeoff was made.
packages/core/src/getters/res-req-types.test.ts (1)

551-905: Regression tests comprehensively cover both bugs and the documented edge cases.

The block covers single-$ref naming (Bug 1), two-$refs shared-field loop (Bug 2), allOf non-regression, oneOf + direct-properties deduping, optional-body guard (?? {}), and array-of-binary element handling — a good complement to the existing anyOf at root test at lines 436-549.

One small nit: the two-$refs test at lines 663-664 guards against reintroducing per-variant uploadDtoV*.file appends, but it doesn't assert which identifier the runtime loop iterates over (e.g., the concatenated uploadDtoV1UploadDtoV2 vs a fallback name). Consider adding expect(formData).toMatch(/Object\.entries\(uploadDtoV1UploadDtoV2\s*\?\?/) if you want to lock in the concatenation contract documented in the PR.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/core/src/getters/res-req-types.test.ts` around lines 551 - 905, Add
a tightened assertion to the two-$refs test so the runtime loop identifier is
locked to the concatenated name: after obtaining result.formData (from
getResReqTypes) assert that the Object.entries call iterates over the
concatenated variable (e.g., match
/Object\.entries\(uploadDtoV1UploadDtoV2\s*\?\?/) and keep the existing checks
that the loop exists and that UploadDtoV1/UploadDtoV2 remain imported; this
ensures the test verifies the specific concatenation contract rather than only
asserting absence of per-variant appends.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/core/src/getters/res-req-types.ts`:
- Around line 582-614: The loop unconditionally skips keys from directKeys
(built from getSchemaProperties(schema)) even when a direct property is readOnly
but a variant (oneOf/anyOf) makes it writable; change how directKeys is built by
filtering out properties whose direct schema has readOnly === true so only truly
direct writable properties are skipped—locate getSchemaProperties, directKeys,
propName and variableName in the form-building code and replace the current
Object.keys(directProperties) population with a filtered version that excludes
directProperties[key].readOnly.

---

Nitpick comments:
In `@packages/core/src/getters/res-req-types.test.ts`:
- Around line 551-905: Add a tightened assertion to the two-$refs test so the
runtime loop identifier is locked to the concatenated name: after obtaining
result.formData (from getResReqTypes) assert that the Object.entries call
iterates over the concatenated variable (e.g., match
/Object\.entries\(uploadDtoV1UploadDtoV2\s*\?\?/) and keep the existing checks
that the loop exists and that UploadDtoV1/UploadDtoV2 remain imported; this
ensures the test verifies the specific concatenation contract rather than only
asserting absence of per-variant appends.

In `@packages/core/src/getters/res-req-types.ts`:
- Around line 259-277: The loop over combinedRefs is shadowing the outer
parameter name and can be clearer: rename the inner identifier to refName when
extracting resolveSchemaRef(ref, context).imports[0]?.name and push refName into
the names array, then join into effectivePropName as implemented; also add a
short inline comment near this block (around getSchemaOneOf/getSchemaAnyOf
handling and effectivePropName assignment) stating that if only inline variants
exist (no $ref) names remains empty and the code intentionally falls back to the
pascal(name)+pascal(key) form.
- Around line 591-614: Add an inline comment above the generated Object.entries
loop (the block that builds form using variables propName and variableName)
explaining that this runtime codegen intentionally simplifies oneOf/anyOf
handling and therefore loses per-field behaviors: no contentType wrapping (e.g.,
new Blob([...], {type})), nullable vs null distinction, per-field array modes
(FormDataArrayHandling.EXPLODE / SERIALIZE_WITH_BRACKETS), and text-file
detection via getFormDataFieldFileType; also point readers to the static path
resolveSchemaPropertiesToFormData (used for allOf/direct properties) for the
full-featured implementation and why this tradeoff was made.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 2ec08a91-b6d1-4702-a9bd-9fdc477e9384

📥 Commits

Reviewing files that changed from the base of the PR and between 4624a7e and 9a4fb8d.

📒 Files selected for processing (8)
  • packages/core/src/getters/res-req-types.test.ts
  • packages/core/src/getters/res-req-types.ts
  • tests/__snapshots__/fetch/form-data-optional-request/endpoints.ts
  • tests/__snapshots__/fetch/form-data-with-custom-fetch/endpoints.ts
  • tests/__snapshots__/react-query/form-data-with-hook/endpoints.ts
  • tests/__snapshots__/react-query/form-data/endpoints.ts
  • tests/__snapshots__/swr/form-data-optional-request/endpoints.ts
  • tests/__snapshots__/vue-query/form-data/endpoints.ts
✅ Files skipped from review due to trivial changes (1)
  • tests/snapshots/swr/form-data-optional-request/endpoints.ts
🚧 Files skipped from review as they are similar to previous changes (2)
  • tests/snapshots/react-query/form-data-with-hook/endpoints.ts
  • tests/snapshots/react-query/form-data/endpoints.ts

Comment thread packages/core/src/getters/res-req-types.ts
The previous Buffer wrapping emitted `new Blob([value as unknown as BlobPart])`.
`BlobPart` only lives in `lib.dom.d.ts`, so a consumer that generates
Orval clients in a pure-Node project without the DOM lib would see
"Cannot find name 'BlobPart'" on the generated file.

Replace the cast with `new Blob([Uint8Array.from(value)])`. `Uint8Array`
is in the default ES lib, and its `.from()` overload returns
`Uint8Array<ArrayBuffer>`, which satisfies the DOM/Node `BlobPart`
signature without needing a cast. Runtime copies the Buffer bytes into
a fresh Uint8Array once per value.
@nathansogut-sipios

Copy link
Copy Markdown
Contributor Author

Thanks for the thorough review @coderabbitai. Addressed all feedback in the latest commits:

  • Duplicate FormData entries (critical, outside-diff on 4624a7ed5): fixed in 9a4fb8de7. The shouldCast branch now collects the outer schema's direct property keys and emits an early-return at the top of the Object.entries loop so those keys are handled exclusively by the properties branch that follows. Added a dedicated regression test ('oneOf alongside direct properties: loop skips direct keys to avoid duplicate appends') that pins down the exact pattern (oneOf: [VariantA, VariantB] + properties: {name, owner}) and asserts each direct key is appended exactly once.
  • Buffer TS compile (surfaced by the upstream Verify generated code can build job on the same commit): fixed in 9a4fb8de7 and refined in c25b3e394. The Buffer path now wraps the value as new Blob([Uint8Array.from(value)]). I initially tried a as unknown as BlobPart cast but dropped it because BlobPart only lives in lib.dom.d.ts and would break consumers that compile the generated client without the DOM lib. Uint8Array.from is in the default ES lib and its signature returns Uint8Array<ArrayBuffer>, which satisfies the strict BlobPart signature without a cast.
  • Appendix nitpick (weak appendFileCount assertion, review 2): replaced with per-variant reference checks (not.toContain('uploadDtoV1.file'), not.toContain('uploadDtoV2.file')) in 4624a7ed5.

Local verification on the equivalent Node 22 env:

  • bun run test — 1643 passing
  • bun run --filter orval-tests build — all 14 clients pass (this was the failing CI job)
  • bun run test:snapshots — 3586 passing
  • bun run lint / typecheck — clean

The one nitpick I did not act on is the import consolidation in the generated snapshots (import type { Error, Pet } from './model' + import type { PetBase, PetExtended } from './model' appearing as two separate statements). That's orthogonal to this fix and pre-existed in the codegen's import ordering, happy to split into a follow-up if you want it addressed here.

@coderabbitai

coderabbitai Bot commented Apr 17, 2026

Copy link
Copy Markdown
Contributor

Tip

For best results, initiate chat on the files or code changes.

@nathansogut-sipios Thank you for the detailed follow-up — all three concerns have been addressed cleanly:

  • Duplicate appends: The direct-property key exclusion approach is exactly right. The dedicated regression test (oneOf alongside direct properties: loop skips direct keys to avoid duplicate appends) locks in the expected behavior precisely.
  • Buffer → Blob: new Blob([Uint8Array.from(value)]) is the right call here. Uint8Array.from is in the base ES lib, so generated clients that compile without lib.dom.d.ts won't break — better than the as unknown as BlobPart cast that would have introduced a hidden DOM lib requirement.
  • Assertion strength: Switching to explicit not.toContain('uploadDtoV1.file') / not.toContain('uploadDtoV2.file') per-variant checks is much more precise than a raw count.

The import consolidation deferral is fine — it's a pre-existing codegen ordering behavior and clearly out of scope here. A follow-up PR would be the right place for it.

The local verification numbers (1643 unit + 3586 snapshot tests passing, all 14 build clients green) are reassuring given the breadth of snapshot changes across react-query, swr, vue-query, and fetch targets.


✏️ Learnings added
Learnt from: nathansogut-sipios
URL: https://github.com/orval-labs/orval/pull/3245

Timestamp: 2026-04-17T15:44:51.168Z
Learning: In the orval repo's form-data codegen (`packages/core/src/getters/res-req-types.ts`), when emitting a Buffer-to-Blob conversion inside a generated `Object.entries` loop, use `new Blob([Uint8Array.from(value)])` rather than casting through `BlobPart`. `BlobPart` only exists in `lib.dom.d.ts`, so using it directly would break consumers that compile generated clients without the DOM lib. `Uint8Array.from` is in the default ES lib and its `Uint8Array<ArrayBuffer>` return type satisfies the strict `Blob` constructor signature without any cast.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.


🧠 Learnings used
Learnt from: wadakatu
Repo: orval-labs/orval PR: 3243
File: tests/configs/react-query.config.ts:378-397
Timestamp: 2026-04-17T02:36:37.418Z
Learning: In `packages/query/src/client.ts` (orval repo), there is a pre-existing incompatibility between `optionsParamRequired: true` and the fetch httpClient + custom mutator combination when using React Query hooks. When `optionsParamRequired: true` is set, the base request function generates a required `options` parameter (e.g., `options: SecondParameter<typeof customFetch>`), but `getHooksOptionImplementation` destructures `requestOptions` from an optional options bag with a `request: undefined` fallback, causing a TypeScript error: `Argument of type 'RequestInit | undefined' is not assignable to parameter of type 'RequestInit'`. Fixing this requires changes to the mutation builder logic in `packages/query/src/client.ts`. As a result, snapshot tests for fetch + custom mutator configs (e.g., `fetchCustomMutatorTagsSplit` in `tests/configs/react-query.config.ts`) intentionally do NOT set `optionsParamRequired: true`.

Learnt from: snebjorn
Repo: orval-labs/orval PR: 3069
File: samples/angular-app/src/orval/mutator/response-type.ts:13-15
Timestamp: 2026-03-05T18:54:09.833Z
Learning: In the orval repository, files under `samples/` (e.g., `samples/angular-app/src/orval/mutator/response-type.ts`) are sample/demo data. Review comments about type strictness, unused destructured fields, or incomplete forwarding of options in these files should be ignored, as they are not production code.

- Document the intentional fallback when a oneOf/anyOf union contains
  only inline variants (no $ref). The existing `pascal(name) +
  pascal(key)` path is reused for that shape; it's the original bug's
  naming, but this PR's scope is DTO-based unions (NestJS / zod-to-openapi).
- Exclude readOnly direct properties from the skip list on the
  Object.entries loop. The previous filter dropped every key present in
  `schema.properties`, so a readOnly direct key that a oneOf variant
  also declared as writable would be skipped by both paths and never
  appended. Using the resolved schema's `readOnly` flag keeps the
  runtime loop as a fallback for that niche combination.
@melloware melloware merged commit fcb0f4d into orval-labs:master Apr 17, 2026
4 checks passed
melloware pushed a commit that referenced this pull request Apr 26, 2026
…$ref types (#3279)

Closes #3269

Two distinct root causes regressed in 8.8.1 for the OpenAPI 3.1
`anyOf: [{$ref: X}, {type: 'null'}]` shape (the default emitted by FastAPI
and Pydantic for `Optional[Model]`) when used with tags-split + msw mode:

1. `packages/core/src/getters/res-req-types.ts`: the oneOf/anyOf branch
   added in #3245 was firing for every media type. Outside form-data and
   url-encoded paths, the schema-derived `effectivePropName` collided with
   the schema import in split mode and produced a `Foo as __Foo` alias
   that did not need to exist. Scoped the branch to
   `(isFormData || isFormUrlEncoded)`. Both share the downstream
   `getSchemaFormDataAndUrlEncoded` path and both need the schema name to
   align with the runtime variable iterated over by the generated code.
   Other content types only seed type aliases.

2. `packages/mock/src/msw/index.ts`: the import filter searched
   `\b${name}\b` against the generated mock code. When an import is
   aliased, the code references the alias (`__Foo`), but the filter
   looked for the bare name. Since `_` is a JS regex word-character,
   `\bFoo\b` does not match `__Foo`, so the aliased import was filtered
   out and the mock file referenced an undeclared identifier (TS2304).
   Switched the filter to match alias OR name with `escapeRegExp`,
   mirroring the `addDependency` pattern in
   `core/src/generators/imports.ts`.

Tests:
- 3 new unit tests in `mock/src/msw/index.test.ts` covering the alias
  filter (alias-only reference, dropped unused alias with positive
  control, dual entry with both bare and aliased forms).
- New regression fixture `tests/specifications/issue-3269.yaml` plus a
  `issue3269` config entry exercising response and request body
  anyOf-nullable in tags-split + msw mode end-to-end.
- Three `importFromSubdirectory` snapshots refreshed; the aliased imports
  they previously contained existed solely because of #3245's overreach
  in non-form-data paths and are no longer emitted.

Existing #3245 multipart tests remain green; the `Object.entries(... ?? {})`
runtime loop and DTO-derived FormData variable name are intact.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Broken TypeScript generated for multipart/form-data request body with oneOf/anyOf

3 participants