From 9fcb8f0361026b0936aca94d8f324ffe2dd6ca9e Mon Sep 17 00:00:00 2001 From: Naoki Ikeguchi Date: Tue, 20 Aug 2024 03:12:56 +0900 Subject: [PATCH] feat: Support readOnly / writeOnly properties Signed-off-by: Naoki Ikeguchi --- packages/openapi-fetch/package.json | 2 +- packages/openapi-fetch/src/index.d.ts | 7 ++-- packages/openapi-fetch/test/fixtures/api.d.ts | 6 +++ packages/openapi-fetch/test/fixtures/api.yaml | 8 ++++ packages/openapi-fetch/test/index.test.ts | 2 + packages/openapi-react-query/package.json | 2 +- .../test/fixtures/api.d.ts | 6 +++ .../test/fixtures/api.yaml | 8 ++++ .../openapi-typescript-helpers/index.d.ts | 36 +++++++++++++++-- packages/openapi-typescript/bin/cli.js | 2 + packages/openapi-typescript/src/index.ts | 1 + packages/openapi-typescript/src/lib/utils.ts | 8 ++++ .../src/transform/schema-object.ts | 31 ++++++++++----- packages/openapi-typescript/src/types.ts | 3 ++ .../openapi-typescript/test/test-helpers.ts | 1 + .../transform/schema-object/object.test.ts | 39 +++++++++++++++++++ 16 files changed, 144 insertions(+), 18 deletions(-) diff --git a/packages/openapi-fetch/package.json b/packages/openapi-fetch/package.json index c0d31554c..994c6fccb 100644 --- a/packages/openapi-fetch/package.json +++ b/packages/openapi-fetch/package.json @@ -54,7 +54,7 @@ "build:cjs": "esbuild --bundle src/index.js --format=cjs --outfile=dist/cjs/index.cjs && cp dist/index.d.ts dist/cjs/index.d.cts", "format": "biome format . --write", "lint": "biome check .", - "generate-types": "openapi-typescript -c test/redocly.yaml", + "generate-types": "openapi-typescript -c test/redocly.yaml --experimental-visibility", "pretest": "pnpm run generate-types", "test": "pnpm run \"/^test:/\"", "test:js": "vitest run", diff --git a/packages/openapi-fetch/src/index.d.ts b/packages/openapi-fetch/src/index.d.ts index c2e9ebda3..a2fdc0bb6 100644 --- a/packages/openapi-fetch/src/index.d.ts +++ b/packages/openapi-fetch/src/index.d.ts @@ -9,6 +9,7 @@ import type { ResponseObjectMap, RequiredKeysOf, SuccessResponse, + Writable, } from "openapi-typescript-helpers"; /** Options for each client instance */ @@ -29,7 +30,7 @@ export type HeadersOptions = | Record; export type QuerySerializer = ( - query: T extends { parameters: any } ? NonNullable : Record, + query: T extends { parameters: any } ? Writable> : Record, ) => string; /** @see https://swagger.io/docs/specification/serialization/#query */ @@ -84,8 +85,8 @@ export type ParamsOption = T extends { parameters: any; } ? RequiredKeysOf extends never - ? { params?: T["parameters"] } - : { params: T["parameters"] } + ? { params?: Writable } + : { params: Writable } : DefaultParamsOption; export type RequestBodyOption = OperationRequestBodyContent extends never diff --git a/packages/openapi-fetch/test/fixtures/api.d.ts b/packages/openapi-fetch/test/fixtures/api.d.ts index b1675777c..fca4d2a11 100644 --- a/packages/openapi-fetch/test/fixtures/api.d.ts +++ b/packages/openapi-fetch/test/fixtures/api.d.ts @@ -859,9 +859,15 @@ export type webhooks = Record; export interface components { schemas: { Post: { + id: { + $read: string; + }; title: string; body: string; publish_date?: number; + password: { + $write: string; + }; }; StringArray: string[]; User: { diff --git a/packages/openapi-fetch/test/fixtures/api.yaml b/packages/openapi-fetch/test/fixtures/api.yaml index 011fcc768..4ae762a93 100644 --- a/packages/openapi-fetch/test/fixtures/api.yaml +++ b/packages/openapi-fetch/test/fixtures/api.yaml @@ -495,15 +495,23 @@ components: Post: type: object properties: + id: + type: string + readOnly: true title: type: string body: type: string publish_date: type: number + password: + type: string + writeOnly: true required: + - id - title - body + - password StringArray: type: array items: diff --git a/packages/openapi-fetch/test/index.test.ts b/packages/openapi-fetch/test/index.test.ts index 14c7a9438..ab7fb13ca 100644 --- a/packages/openapi-fetch/test/index.test.ts +++ b/packages/openapi-fetch/test/index.test.ts @@ -114,6 +114,7 @@ describe("client", () => { updated_at: number; } | { + id: string; title: string; body: string; publish_date?: number; @@ -721,6 +722,7 @@ describe("client", () => { title: "", publish_date: 3, body: "", + password: "", }, }); }); diff --git a/packages/openapi-react-query/package.json b/packages/openapi-react-query/package.json index 6be76032f..6640120a5 100644 --- a/packages/openapi-react-query/package.json +++ b/packages/openapi-react-query/package.json @@ -54,7 +54,7 @@ "dev": "tsc -p tsconfig.build.json --watch", "format": "biome format . --write", "lint": "biome check .", - "generate-types": "openapi-typescript test/fixtures/api.yaml -o test/fixtures/api.d.ts", + "generate-types": "openapi-typescript test/fixtures/api.yaml -o test/fixtures/api.d.ts --experimental-visibility", "pretest": "pnpm run generate-types", "test": "pnpm run \"/^test:/\"", "test:js": "vitest run", diff --git a/packages/openapi-react-query/test/fixtures/api.d.ts b/packages/openapi-react-query/test/fixtures/api.d.ts index cad5160d4..03a00c048 100644 --- a/packages/openapi-react-query/test/fixtures/api.d.ts +++ b/packages/openapi-react-query/test/fixtures/api.d.ts @@ -819,9 +819,15 @@ export type webhooks = Record; export interface components { schemas: { Post: { + id: { + $read: string; + }; title: string; body: string; publish_date?: number; + password: { + $write: string; + }; }; StringArray: string[]; User: { diff --git a/packages/openapi-react-query/test/fixtures/api.yaml b/packages/openapi-react-query/test/fixtures/api.yaml index 8994e1ba8..b750db2ea 100644 --- a/packages/openapi-react-query/test/fixtures/api.yaml +++ b/packages/openapi-react-query/test/fixtures/api.yaml @@ -475,15 +475,23 @@ components: Post: type: object properties: + id: + type: string + readOnly: true title: type: string body: type: string publish_date: type: number + password: + type: string + writeOnly: true required: + - id - title - body + - password StringArray: type: array items: diff --git a/packages/openapi-typescript-helpers/index.d.ts b/packages/openapi-typescript-helpers/index.d.ts index fad9b4ca6..9cd1327aa 100644 --- a/packages/openapi-typescript-helpers/index.d.ts +++ b/packages/openapi-typescript-helpers/index.d.ts @@ -91,11 +91,17 @@ export type PathItemObject = { [M in HttpMethod]: OperationObject; } & { parameters?: any }; +/** Return `content` for a RequestBody or Response Object */ +type MediaContent = T extends { content: any } ? T["content"] : unknown; + /** Return `responses` for an Operation Object */ export type ResponseObjectMap = T extends { responses: any } ? T["responses"] : unknown; /** Return `content` for a Response Object */ -export type ResponseContent = T extends { content: any } ? T["content"] : unknown; +export type ResponseContent = Readable>; + +/** Return `content` for a RequestBody Object */ +export type RequestBodyContent = Writable>; /** Return type of `requestBody` for an Operation Object */ export type OperationRequestBody = "requestBody" extends keyof T ? T["requestBody"] : never; @@ -108,8 +114,8 @@ export type IsOperationRequestBodyOptional = RequiredKeysOf = IsOperationRequestBodyOptional extends true - ? ResponseContent>> | undefined - : ResponseContent>; + ? RequestBodyContent>> | undefined + : RequestBodyContent>; /** Return first `content` from a Request Object Mapping, allowing any media type */ export type OperationRequestBodyContent = FilterKeys, MediaType> extends never @@ -139,6 +145,30 @@ export type ErrorResponseJSON = JSONLike = JSONLike, "content">>; +/** Read-only property type */ +export type ReadOnly = { $read: T }; + +/** Write-only property type */ +export type WriteOnly = { $write: T }; + +type ReadOnlyKey = { [K in keyof T]: T[K] extends ReadOnly ? K : never }[keyof T]; + +type WriteOnlyKey = { [K in keyof T]: T[K] extends WriteOnly ? K : never }[keyof T]; + +/** Recursively remove write-only properties */ +export type Readable = Omit< + { [K in keyof T]: T[K] extends ReadOnly ? R : T[K] extends Record ? Readable : T[K] }, + WriteOnlyKey +>; + +/** Recursively remove read-only properties */ +export type Writable = Omit< + { + [K in keyof T]: T[K] extends WriteOnly ? W : T[K] extends Record ? Writable : T[K]; + }, + ReadOnlyKey +>; + // Generic TS utils /** Find first match of multiple keys */ diff --git a/packages/openapi-typescript/bin/cli.js b/packages/openapi-typescript/bin/cli.js index 28c481ade..4a8ad2a51 100755 --- a/packages/openapi-typescript/bin/cli.js +++ b/packages/openapi-typescript/bin/cli.js @@ -26,6 +26,7 @@ Options --default-non-nullable Set to \`false\` to ignore default values when generating non-nullable types --properties-required-by-default Treat schema objects as if \`required\` is set to all properties by default + --experimental-visibility Enable experimental visibility support (readOnly, writeOnly) --array-length Generate tuples using array minItems / maxItems --path-params-as-types Convert paths to template literal types --alphabetize Sort object keys alphabetically @@ -133,6 +134,7 @@ async function generateSchema(schema, { redocly, silent = false }) { exportType: flags.exportType, immutable: flags.immutable, pathParamsAsTypes: flags.pathParamsAsTypes, + experimentalVisibility: flags.experimentalVisibility, redocly, silent, }), diff --git a/packages/openapi-typescript/src/index.ts b/packages/openapi-typescript/src/index.ts index 70c6757eb..7ddbc6ba4 100644 --- a/packages/openapi-typescript/src/index.ts +++ b/packages/openapi-typescript/src/index.ts @@ -83,6 +83,7 @@ export default async function openapiTS( pathParamsAsTypes: options.pathParamsAsTypes ?? false, postTransform: typeof options.postTransform === "function" ? options.postTransform : undefined, propertiesRequiredByDefault: options.propertiesRequiredByDefault ?? false, + experimentalVisibility: options.experimentalVisibility ?? false, redoc, silent: options.silent ?? false, inject: options.inject ?? undefined, diff --git a/packages/openapi-typescript/src/lib/utils.ts b/packages/openapi-typescript/src/lib/utils.ts index 4b764b385..c51f97732 100644 --- a/packages/openapi-typescript/src/lib/utils.ts +++ b/packages/openapi-typescript/src/lib/utils.ts @@ -389,3 +389,11 @@ export function warn(msg: string, silent = false) { console.warn(c.yellow(` ⚠ ${msg}`)); } } + +export function createReadOnly(type: ts.TypeNode): ts.TypeNode { + return ts.factory.createTypeLiteralNode([ts.factory.createPropertySignature(undefined, "$read", undefined, type)]); +} + +export function createWriteOnly(type: ts.TypeNode): ts.TypeNode { + return ts.factory.createTypeLiteralNode([ts.factory.createPropertySignature(undefined, "$write", undefined, type)]); +} diff --git a/packages/openapi-typescript/src/transform/schema-object.ts b/packages/openapi-typescript/src/transform/schema-object.ts index 4ad571523..afa2d734d 100644 --- a/packages/openapi-typescript/src/transform/schema-object.ts +++ b/packages/openapi-typescript/src/transform/schema-object.ts @@ -24,7 +24,7 @@ import { tsUnion, tsWithRequired, } from "../lib/ts.js"; -import { createDiscriminatorProperty, createRef, getEntries } from "../lib/utils.js"; +import { createDiscriminatorProperty, createReadOnly, createRef, createWriteOnly, getEntries } from "../lib/utils.js"; import type { ReferenceObject, SchemaObject, TransformNodeOptions } from "../types.js"; /** @@ -464,13 +464,24 @@ function transformSchemaObjectCore(schemaObject: SchemaObject, options: Transfor !options.path?.includes("requestBodies")) // can’t be required, even with defaults ? undefined : QUESTION_TOKEN; - let type = - "$ref" in v - ? oapiRef(v.$ref) - : transformSchemaObject(v, { - ...options, - path: createRef([options.path, k]), - }); + + let type: ts.TypeNode; + if ("$ref" in v) { + type = oapiRef(v.$ref); + } else { + type = transformSchemaObject(v, { + ...options, + path: createRef([options.path, k]), + }); + + if (options.ctx.experimentalVisibility) { + if (v.readOnly && !v.writeOnly) { + type = createReadOnly(type); + } else if (v.writeOnly && !v.readOnly) { + type = createWriteOnly(type); + } + } + } if (typeof options.ctx.transform === "function") { const result = options.ctx.transform(v as SchemaObject, options); @@ -486,7 +497,7 @@ function transformSchemaObjectCore(schemaObject: SchemaObject, options: Transfor const property = ts.factory.createPropertySignature( /* modifiers */ tsModifiers({ - readonly: options.ctx.immutable || ("readOnly" in v && !!v.readOnly), + readonly: options.ctx.immutable || (!options.ctx.experimentalVisibility && "readOnly" in v && !!v.readOnly), }), /* name */ tsPropertyIndex(k), /* questionToken */ optional, @@ -503,7 +514,7 @@ function transformSchemaObjectCore(schemaObject: SchemaObject, options: Transfor for (const [k, v] of Object.entries(schemaObject.$defs)) { const property = ts.factory.createPropertySignature( /* modifiers */ tsModifiers({ - readonly: options.ctx.immutable || ("readonly" in v && !!v.readOnly), + readonly: options.ctx.immutable || (!options.ctx.experimentalVisibility && "readonly" in v && !!v.readOnly), }), /* name */ tsPropertyIndex(k), /* questionToken */ undefined, diff --git a/packages/openapi-typescript/src/types.ts b/packages/openapi-typescript/src/types.ts index 73a163973..78eaf2969 100644 --- a/packages/openapi-typescript/src/types.ts +++ b/packages/openapi-typescript/src/types.ts @@ -657,6 +657,8 @@ export interface OpenAPITSOptions { pathParamsAsTypes?: boolean; /** Treat all objects as if they have \`required\` set to all properties by default (default: false) */ propertiesRequiredByDefault?: boolean; + /** Enable experimental visibility support (readOnly, writeOnly) */ + experimentalVisibility?: boolean; /** * Configure Redocly for validation, schema fetching, and bundling * @see https://redocly.com/docs/cli/configuration/ @@ -688,6 +690,7 @@ export interface GlobalContext { pathParamsAsTypes: boolean; postTransform: OpenAPITSOptions["postTransform"]; propertiesRequiredByDefault: boolean; + experimentalVisibility: boolean; redoc: RedoclyConfig; silent: boolean; transform: OpenAPITSOptions["transform"]; diff --git a/packages/openapi-typescript/test/test-helpers.ts b/packages/openapi-typescript/test/test-helpers.ts index f6330045c..b09f8395b 100644 --- a/packages/openapi-typescript/test/test-helpers.ts +++ b/packages/openapi-typescript/test/test-helpers.ts @@ -23,6 +23,7 @@ export const DEFAULT_CTX: GlobalContext = { pathParamsAsTypes: false, postTransform: undefined, propertiesRequiredByDefault: false, + experimentalVisibility: false, redoc: await createConfig({}, { extends: ["minimal"] }), resolve($ref) { return resolveRef({}, $ref, { silent: false }); diff --git a/packages/openapi-typescript/test/transform/schema-object/object.test.ts b/packages/openapi-typescript/test/transform/schema-object/object.test.ts index 60290fc57..5e15725bf 100644 --- a/packages/openapi-typescript/test/transform/schema-object/object.test.ts +++ b/packages/openapi-typescript/test/transform/schema-object/object.test.ts @@ -411,6 +411,45 @@ describe("transformSchemaObject > object", () => { }, }, ], + [ + "options > experimentalVisibility", + { + given: { + type: "object", + required: ["id", "name", "password"], + properties: { + id: { + type: "string", + format: "uuid", + readOnly: true, + }, + name: { + type: "string", + }, + password: { + type: "string", + format: "password", + writeOnly: true, + }, + }, + }, + want: `{ + /** Format: uuid */ + id: { + $read: string; + }; + name: string; + /** Format: password */ + password: { + $write: string; + }; +}`, + options: { + ...DEFAULT_OPTIONS, + ctx: { ...DEFAULT_OPTIONS.ctx, experimentalVisibility: true }, + }, + }, + ], ]; for (const [testName, { given, want, options = DEFAULT_OPTIONS, ci }] of tests) {