Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/happy-pans-run.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"openapi-typescript": minor
"openapi-typescript-helpers": minor
"openapi-fetch": minor
---

Add readOnly/writeOnly support via `--read-write-markers` flag. When enabled, readOnly properties are wrapped with `$Read<T>` and writeOnly properties with `$Write<T>`. openapi-fetch uses `Readable<T>` and `Writable<T>` helpers to exclude these properties from responses and request bodies respectively.
78 changes: 77 additions & 1 deletion docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@ The following flags are supported in the CLI:
| `--root-types-no-schema-prefix` | | `false` | Do not add "Schema" prefix to types at the root level (should only be used with --root-types) |
| `--make-paths-enum` | | `false` | Generate ApiPaths enum for all paths |
| `--generate-path-params` | | `false` | Generate path parameters for all paths where they are undefined by schema |
| `--read-write-markers` | | `false` | Generate `$Read<T>`/`$Write<T>` markers for readOnly/writeOnly properties |

### pathParamsAsTypes

Expand Down Expand Up @@ -232,5 +233,80 @@ export enum ApiPaths {
### generatePathParams

This option is useful for generating path params optimistically when the schema has flaky path parameter definitions.
Checks the path for opening and closing brackets and extracts them as path parameters.
Checks the path for opening and closing brackets and extracts them as path parameters.
Does not override already defined by schema path parameters.

### readWriteMarkers

This option enables proper handling of OpenAPI's `readOnly` and `writeOnly` property modifiers. When enabled, properties are wrapped with marker types that allow [openapi-fetch](/openapi-fetch/) to enforce visibility rules at compile time.

For example, given the following schema:

::: code-group

```yaml [my-openapi-3-schema.yaml]
components:
schemas:
User:
type: object
properties:
id:
type: integer
readOnly: true
name:
type: string
password:
type: string
writeOnly: true
```

:::

Enabling `--read-write-markers` would generate:

::: code-group

```ts [my-openapi-3-schema.d.ts]
// Helper types generated inline when readWriteMarkers is enabled
type $Read<T> = { readonly $read: T };
type $Write<T> = { readonly $write: T };
type Readable<T> = /* ... strips $Write properties, unwraps $Read */;
type Writable<T> = /* ... strips $Read properties, unwraps $Write */;

export interface components {
schemas: {
User: {
id?: $Read<number>;
name?: string;
password?: $Write<string>;
};
};
}
```

:::

When used with [openapi-fetch](/openapi-fetch/), the `Readable<T>` and `Writable<T>` helper types automatically:

- **Exclude `readOnly` properties from request bodies** - You can't accidentally send `id` in a POST/PUT request
- **Exclude `writeOnly` properties from responses** - You can't access `password` on response data

::: code-group

```ts [src/my-project.ts]
import createClient from "openapi-fetch";
import type { paths } from "./my-openapi-3-schema";

const client = createClient<paths>({ baseUrl: "https://api.example.com" });

// ✅ TypeScript error: 'id' is readOnly and not allowed in request body
await client.POST("/users", {
body: { id: 123, name: "Alice", password: "secret" },
});

// ✅ TypeScript error: 'password' is writeOnly and not available in response
const { data } = await client.GET("/users/{id}", { params: { path: { id: 1 } } });
console.log(data?.password); // Error!
```

:::
14 changes: 9 additions & 5 deletions packages/openapi-fetch/src/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,11 @@ import type {
MediaType,
OperationRequestBodyContent,
PathsWithMethod,
Readable,
RequiredKeysOf,
ResponseObjectMap,
SuccessResponse,
Writable,
} from "openapi-typescript-helpers";

/** Options for each client instance */
Expand Down Expand Up @@ -92,23 +94,25 @@ export type ParamsOption<T> = T extends {
: { params: T["parameters"] }
: DefaultParamsOption;

export type RequestBodyOption<T> = OperationRequestBodyContent<T> extends never
// Writable<T> strips $Read markers (readOnly properties excluded from request body)
export type RequestBodyOption<T> = Writable<OperationRequestBodyContent<T>> extends never
? { body?: never }
: IsOperationRequestBodyOptional<T> extends true
? { body?: OperationRequestBodyContent<T> }
: { body: OperationRequestBodyContent<T> };
? { body?: Writable<OperationRequestBodyContent<T>> }
: { body: Writable<OperationRequestBodyContent<T>> };

export type FetchOptions<T> = RequestOptions<T> & Omit<RequestInit, "body" | "headers">;

// Readable<T> strips $Write markers (writeOnly properties excluded from response)
export type FetchResponse<T extends Record<string | number, any>, Options, Media extends MediaType> =
| {
data: ParseAsResponse<SuccessResponse<ResponseObjectMap<T>, Media>, Options>;
data: ParseAsResponse<Readable<SuccessResponse<ResponseObjectMap<T>, Media>>, Options>;
error?: never;
response: Response;
}
| {
data?: never;
error: ErrorResponse<ResponseObjectMap<T>, Media>;
error: Readable<ErrorResponse<ResponseObjectMap<T>, Media>>;
response: Response;
};

Expand Down
113 changes: 113 additions & 0 deletions packages/openapi-fetch/test/read-write-visibility/read-write.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import { describe, expect, expectTypeOf, test } from "vitest";
import { createObservedClient } from "../helpers.js";
import type { paths } from "./schemas/read-write.js";

describe("readOnly/writeOnly", () => {
describe("deeply nested $Read unwrapping through $Read<Object>", () => {
test("$Read should continue recursion when unwrapping $Read<ObjectWithReadProperties>", async () => {
// This tests the fix for a bug where Readable<$Read<U>> returned U directly
// instead of Readable<U>, causing nested $Read markers to not be unwrapped.
// Example: nested: $Read<NestedObject> where NestedObject contains
// entries: $Read<Entry[]> - the inner $Read was not stripped.
const client = createObservedClient<paths>({}, async () =>
Response.json({
id: 1,
items: [
{
id: 1,
nested: {
entries: [{ code: "A1", label: "Label1" }],
},
},
],
}),
);

const { data } = await client.GET("/resources/{id}", {
params: { path: { id: 1 } },
});

// nested is $Read<NestedObject> - should be unwrapped
// NestedObject.entries is $Read<Entry[]> - should ALSO be unwrapped
// Entry.label is $Read<string> - should ALSO be unwrapped

// This would fail before the fix: "Property '0' does not exist on type '$Read<Entry[]>'"
const entries = data?.items[0]?.nested.entries;
expect(entries?.[0]?.code).toBe("A1");

// Type assertions to ensure proper unwrapping at all levels
type EntriesType = NonNullable<typeof data>["items"][number]["nested"]["entries"];
// Should be Entry[] (array), not $Read<Entry[]>
expectTypeOf<EntriesType>().toMatchTypeOf<{ code: string; label: string }[]>();

type LabelType = NonNullable<typeof data>["items"][number]["nested"]["entries"][number]["label"];
// Should be string, not $Read<string>
expectTypeOf<LabelType>().toEqualTypeOf<string>();
});
});

describe("request body (POST)", () => {
test("CANNOT include readOnly properties", async () => {
const client = createObservedClient<paths>({});

await client.POST("/users", {
body: {
// @ts-expect-error - id is readOnly, should NOT be allowed in request
id: 123,
name: "Alice",
password: "secret",
},
});
});

test("CAN include writeOnly properties", async () => {
const client = createObservedClient<paths>({});

// No error - password (writeOnly) is allowed in request
await client.POST("/users", {
body: {
name: "Alice",
password: "secret",
},
});
});

test("CAN include normal properties", async () => {
const client = createObservedClient<paths>({});

// No error - name (normal) is allowed everywhere
await client.POST("/users", {
body: { name: "Alice" },
});
});
});

describe("response body (GET/POST response)", () => {
test("CAN access readOnly properties", async () => {
const client = createObservedClient<paths>({}, async () => Response.json({ id: 1, name: "Alice" }));

const { data } = await client.GET("/users");
// No error - id (readOnly) is available in response
const id: number | undefined = data?.id;
expect(id).toBe(1);
});

test("CANNOT access writeOnly properties", async () => {
const client = createObservedClient<paths>({}, async () => Response.json({ id: 1, name: "Alice" }));

const { data } = await client.GET("/users");
// @ts-expect-error - password is writeOnly, should NOT be in response
const password = data?.password;
expect(password).toBeUndefined();
});

test("CAN access normal properties", async () => {
const client = createObservedClient<paths>({}, async () => Response.json({ id: 1, name: "Alice" }));

const { data } = await client.GET("/users");
// No error - name (normal) is available everywhere
const name: string | undefined = data?.name;
expect(name).toBe("Alice");
});
});
});
Loading