Skip to content

generate-client: writeOnly not modeled (all languages), mocks violate the description, generated code fails stricter type checks #3226

Closed

Description

@Marshevskyy

Describe the bug

generate-client output is wrong or fails strict checks in three places.
The first one affects every target language (TypeScript, Python, Go, PHP), because it starts in the intermediate representation.

  • A. writeOnly is not modeled. Write-only properties stay in response types and models in every language, so a response that follows the description can crash the Python and PHP clients.
  • B. The mock generator produces data that breaks the description. Numeric bounds are ignored, write-only fields appear in responses, and the default list handlers return a cursor that never ends.
    The result is that the generated mocks fail the generated Zod schemas for the same API.
  • C. The generated code does not pass stricter type checks. TypeScript noUncheckedIndexedAccess and exactOptionalPropertyTypes fail, and so does Python mypy --strict.

The three parts are independent and can ship as separate pull requests.
Each part below has a reproduction, the source location, and acceptance criteria.

Redocly version(s)

@redocly/cli 2.62.0 (bundles @redocly/client-generator 0.5.1). The source references below are from main at fdffe75.

Node.js version(s)

Node 24.21.0, pnpm 10.17.1. TypeScript 5.9.3, zod 4.1.12, msw 2.11.6, Python 3.14 + httpx + mypy 2.4.0, PHP 8.5.

OS, environment

macOS 26 (Darwin 25.6).


Reproduction setup

mkdir gc-repro && cd gc-repro
npm init -y && npm pkg set type=module
npm i -D @redocly/cli@2.62.0 typescript@5.9.3 zod@4 msw@2 tsx

users.yaml (used by A and C):

openapi: 3.1.0
info: { title: Users, version: 1.0.0 }
servers: [{ url: https://api.example.com }]
paths:
  /users:
    post:
      operationId: createUser
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/User' }
      responses:
        '201':
          description: Created
          content:
            application/json:
              schema: { $ref: '#/components/schemas/User' }
components:
  schemas:
    User:
      type: object
      required: [id, email, password]
      properties:
        id: { type: string, readOnly: true }
        email: { type: string }
        password: { type: string, writeOnly: true, minLength: 8 }
        age: { type: integer, minimum: 18 }

items.yaml (used by B):

openapi: 3.1.0
info: { title: Repro, version: 1.0.0 }
servers: [{ url: https://api.example.com }]
paths:
  /items:
    get:
      operationId: listItems
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ItemList' }
components:
  schemas:
    ItemList:
      type: object
      required: [items]
      properties:
        items: { type: array, items: { $ref: '#/components/schemas/Item' } }
    Item:
      oneOf:
        - $ref: '#/components/schemas/Beverage'
        - $ref: '#/components/schemas/Dessert'
    Base:
      type: object
      required: [name]
      properties:
        name: { type: string }
        photo: { writeOnly: true, type: [string, 'null'], format: binary }
    Beverage:
      allOf:
        - type: object
          required: [category, volume]
          properties:
            category: { type: string, const: beverage }
            volume: { type: number, exclusiveMinimum: 0 }
        - $ref: '#/components/schemas/Base'
    Dessert:
      allOf:
        - type: object
          required: [category, calories]
          properties:
            category: { type: string, const: dessert }
            calories: { type: number, exclusiveMinimum: 0 }
        - $ref: '#/components/schemas/Base'

The in-repo fixture tests/e2e/generate-client/fixtures/cafe.yaml has the same shapes (writeOnly: true on photo, exclusiveMinimum: 0 on volume and calories), so it works as the regression fixture for A and B.


A. writeOnly is not modeled in the intermediate representation (all languages)

OpenAPI says a writeOnly property is sent in requests and never returned in responses.
The intermediate representation tracks readOnly (PropertyModel.readOnly in src/intermediate-representation/model.ts, and stripReadOnlyFromRequestBodies in src/intermediate-representation/build.ts) but has no writeOnly counterpart.
grep -rn writeOnly packages/client-generator/src finds nothing.
As a result, every generator treats a write-only property as part of the response.

To reproduce

for g in typescript python go php; do
  npx redocly generate-client users.yaml --output out-$g/client.ts --generator $g
done
npx redocly generate-client users.yaml --output out-ts/client.ts --generator typescript --generator zod --generator mock

Then decode a response that matches the description (password is write-only, so it is absent):

# Python (needs httpx)
cd out-python && python3 -c "import client; print(client.decode(client.User, {'id': 'u1', 'email': 'a@b.c'}))"
# TypeError: User.__init__() missing 1 required positional argument: 'password'

# PHP
cd out-php && php -r "require 'client.php'; var_dump(Users\User::fromArray(['id' => 'u1', 'email' => 'a@b.c']));"
# Warning: Undefined array key "password"
# TypeError: Users\User::__construct(): Argument #3 ($password) must be of type string, null given

Actual, per language

Target What is generated Effect on a spec-correct response
Python password: str (required dataclass field); create_user returns decode(User, …) TypeError, the call fails
PHP public string $password in the constructor; fromArray reads $data['password'] TypeError, the call fails
Go Password string with the tag json:"password" (no omitempty, not a pointer) Decodes silently to "", and the type claims a value the API never returns
TypeScript password: string in User, and the result type is User The type claims a value that is never there
zod Response schema requires password: z.string().min(8) zodValidation() reports drift on every correct response (false positive)
mock createUser() returns password: "string"; items.yaml mocks return photo: new Blob([]), which serializes to {} Mocks contradict the description and fail the zod schema

Expected behavior

Write-only properties are handled symmetrically to how readOnly is handled today:

  1. Intermediate representation. Add writeOnly?: boolean to PropertyModel, set it in build.ts next to readOnly, including the 3.0 $ref-sibling handling around build.ts:1034.
    Add the response-side equivalent of stripReadOnlyFromRequestBodies: strip write-only properties from success response schemas, as Omit<Name, …writeOnly> for a named schema and filtered properties for an inline one.
  2. TypeScript. Operation result types exclude write-only properties.
  3. zod. The response schemas used by zodValidation() do not require or validate write-only properties. Request schemas keep them.
  4. mock. Response factories and handlers never emit write-only properties.
  5. Python, Go, PHP. These generators share one model class per schema between requests and responses, and use the base type for request bodies.
    For responses, a required write-only property must not make decoding fail.
    The minimal fix is to make write-only properties optional in the model, as is already done for non-required ones: Optional[...] = None in Python, ?type ... = null with $data[...] ?? null in PHP, and a pointer with omitempty in Go.
    The Go change keeps request bodies valid and stops claiming a value in responses.
    Use dedicated response types instead if that matches the per-language design better. See src/generators/*/AGENTS.md.

Acceptance criteria

  • New e2e tests (alongside python.test.ts, php.test.ts, go.test.ts and zod.test.ts) that decode a response without the write-only property for every language, using users.yaml or fixtures/cafe.yaml.
  • The same tests check that a request body still carries the write-only property.
  • Snapshot updates are limited to the write-only properties.

B. The mock generator produces data that breaks the description

All three problems are in src/generators/mock/ (TypeScript only today).
Any future mock generator for another language should share the same value synthesis.

B1. Numeric bounds are ignored.

  • scalarSample in src/generators/mock/sample.ts (around line 293) returns 0 for every integer and number, whatever minimum, exclusiveMinimum, maximum, exclusiveMaximum or multipleOf say.
  • In faker mode, boundsArg in src/generators/mock/faker.ts (around line 172) passes minimum and maximum but ignores exclusiveMinimum and exclusiveMaximum.
  • With users.yaml, createUser() returns age: 0 against minimum: 18.
  • With items.yaml, createBeverage() returns volume: 0 and createDessert() returns calories: 0 against exclusiveMinimum: 0.

B2. Write-only properties are emitted in response mocks. This is the mock side of A.

B3. The default paginated list handlers never terminate.
The default page sets the next cursor to a non-null placeholder (nextPage: "string"), and the handler returns the same page on every request.
With pagination configured (the Cafe convention is style: cursor, cursorParam: after, nextCursor: /page/nextPage, items: /items), .pages() against the default handlers throws on the second page:

Error: Pagination did not advance: operation returned the same cursor twice

To reproduce B1 and B2

npx redocly generate-client items.yaml --output src/client.ts --generator typescript --generator zod --generator mock
// check.ts — run with: npx tsx check.ts
import { setupServer } from 'msw/node';
import { createClient, OPERATIONS, type Ops } from './src/client.ts';
import { handlers, createItemList } from './src/client.mocks.ts';
import { zodValidation, operationSchemas } from './src/client.zod.ts';

const direct = operationSchemas.listItems.response!.safeParse(createItemList());
console.log(direct.success, direct.success || direct.error.issues.map((i) => `${i.path.join('.')}: ${i.message}`));

setupServer(...handlers).listen();
const client = createClient<Ops>(OPERATIONS, {
  serverUrl: 'https://api.example.com',
  middleware: [zodValidation({ onViolation: (e) => console.log(e.violations.map((v) => v.path)) })],
});
await client.listItems();

Actual output

false [ 'items.0.volume: Too small: expected number to be >0' ]
[ 'items.0.volume', 'items.0.photo', 'items.0.photo', 'items.0.category', 'items.0.calories', 'items.0.photo', 'items.0.photo' ]

Expected behavior

The generated mocks satisfy the generated schemas for the same description.

  • Numeric samples respect minimum, exclusiveMinimum, maximum, exclusiveMaximum and multipleOf, in static and faker modes.
    A reasonable rule is to pick the smallest value that satisfies the bounds, for example minimum, or exclusiveMinimum + 1 for integers and + multipleOf-aligned values otherwise.
    A spec example or default that satisfies the bounds still wins, as it does today.
  • Response mocks omit write-only properties.
  • The default page of a paginated operation is the last page: the next cursor is null (or hasMore is false), so .pages() and .items() over the default handlers end after one page.

Acceptance criteria

  • A property-style test over the e2e fixtures (at least cafe.yaml, base.yaml and pagination.yaml): for every named schema X that has a zod schema and a factory, XSchema.safeParse(createX()).success === true.
    For every operation, its default handler's response passes operationSchemas[op].response.
  • A test that .pages() over the default handlers of a paginated operation yields exactly one page.
  • Unit tests for the bound arithmetic in sample.ts and faker.ts, including integer and number, inclusive and exclusive bounds, and multipleOf.

C. The generated code does not pass stricter type checks

The e2e bar strictTypecheck (tests/e2e/generate-client/helpers.ts, STRICT_TSCONFIG) uses strict: true only.
Many projects also enable noUncheckedIndexedAccess and exactOptionalPropertyTypes, and the generated client is compiled as part of their sources, so skipLibCheck does not help.

To reproduce (TypeScript)

Generate any client (here items.yaml with --generator typescript), then:

npx tsc --noEmit --strict --noUncheckedIndexedAccess --skipLibCheck --target es2022 --module esnext --moduleResolution bundler --lib es2022,dom src/client.ts

Actual

With noUncheckedIndexedAccess, the errors are in the embedded runtime, src/generators/typescript/runtime/:

paginate.ts:144  /;\s*rel\s*=.../i.exec(target[2])          error TS2345: Argument of type 'string | undefined' is not assignable to parameter of type 'string'.
paginate.ts:146  rel[1].split(/\s+/)                       error TS2532: Object is possibly 'undefined'.
send.ts:209      middleware[i].onResponse                  error TS2532: Object is possibly 'undefined'.

The paginate.ts errors appear only when the API uses pagination.
With exactOptionalPropertyTypes (and noUncheckedIndexedAccess off), there are 3 errors.
The first is in the middleware assembly in create-client.ts: an object literal { onRequest, onResponse, onError } with possibly undefined members is assigned to Middleware[].

Python, same class of problem.
mypy --strict client.py on the Python output for users.yaml reports 10 errors.
They include no-any-return (the decode(...) returns), arg-type (resolve_auth receives str | list[...]), no-untyped-def, and Cannot redefine "T" as a type variable.
The current Python bar is import client / py_compile, which does not run a type checker.

PHP. Not checked. The current bar is php -l plus require. PHPStan at a high level is the equivalent check.

Go. Already covered by go vet in the e2e bars.

Expected behavior

Generated code type-checks under the strictest common settings of each language, so users can compile it with their own settings.

Acceptance criteria

  • Fix the runtime sources: use explicit guards instead of non-null assertions where possible, and conditional spreads for the middleware object.
  • Add noUncheckedIndexedAccess: true and exactOptionalPropertyTypes: true to STRICT_TSCONFIG.
  • Add a mypy --strict (or pyright --strict) step to the Python e2e bar.
  • Add a PHPStan step to the PHP bar, with the level decided by the maintainers. Each step is skipped when the tool is missing, like the existing hasPython and hasPhp gates in large-descriptions.test.ts.

Additional context

For B, the end-user workaround is to override the affected fields in every factory call, for example createBeverage({ volume: 250, photo: undefined }), and to pass createPage({ nextPage: null }) to list handlers.
For C, it is to turn off noUncheckedIndexedAccess for the package that compiles the generated client.
Both are easy to miss, and the B workaround has to be repeated in every test.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type: BugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions