diff --git a/.changeset/shaggy-otters-walk.md b/.changeset/shaggy-otters-walk.md new file mode 100644 index 000000000..4d782a9f7 --- /dev/null +++ b/.changeset/shaggy-otters-walk.md @@ -0,0 +1,5 @@ +--- +"swagger-typescript-api": minor +--- + +Add support for multiple request/response types to be defined as unions diff --git a/src/schema-routes/schema-routes.ts b/src/schema-routes/schema-routes.ts index b570502e9..c99dae999 100644 --- a/src/schema-routes/schema-routes.ts +++ b/src/schema-routes/schema-routes.ts @@ -331,16 +331,55 @@ export class SchemaRoutes { /* content: { "multipart/form-data": { schema: {...} }, "application/json": { schema: {...} } } */ - /* for example: dataType = "multipart/form-data" */ + const contentTypes = Object.keys(content); + + // if there's only one content type, return it + if (contentTypes.length === 1 && content[contentTypes[0]]?.schema) { + return { + ...content[contentTypes[0]].schema, + dataType: contentTypes[0], + }; + } + + // Check if there are multiple media types with schemas + const schemasWithDataTypes = []; for (const dataType in content) { if (content[dataType]?.schema) { - return { + schemasWithDataTypes.push({ ...content[dataType].schema, dataType, - }; + }); } } + // If there's only one schema, return it directly + if (schemasWithDataTypes.length === 1) { + return schemasWithDataTypes[0]; + } + + // If there are multiple schemas with different structures, create a oneOf schema to generate a union type + if (schemasWithDataTypes.length > 1) { + // Check if all schemas are structurally the same + // If they are, just return the first one + const firstSchema = schemasWithDataTypes[0]; + const allSchemasAreSame = schemasWithDataTypes.every((schema) => + lodash.isEqual( + lodash.omit(schema, "dataType"), + lodash.omit(firstSchema, "dataType"), + ), + ); + + if (allSchemasAreSame) { + return firstSchema; + } + + // Otherwise, create a union type + return { + oneOf: schemasWithDataTypes, + dataType: schemasWithDataTypes[0].dataType, // Use the first dataType for compatibility + }; + } + return null; }; @@ -357,24 +396,47 @@ export class SchemaRoutes { this.schemaParserFabric.schemaUtils.getSchemaRefType(requestInfo); if (schema) { - const content = this.schemaParserFabric.getInlineParseContent( - schema, - typeName, - [operationId], - ); - const foundedSchemaByName = parsedSchemas.find( - (parsedSchema) => - this.typeNameFormatter.format(parsedSchema.name) === content, - ); - const foundSchemaByContent = parsedSchemas.find((parsedSchema) => - lodash.isEqual(parsedSchema.content, content), - ); + // If we have a oneOf schema (multiple media types), handle it specially + if (schema.oneOf) { + // Process each schema in the oneOf array + const unionTypes = schema.oneOf.map((subSchema) => { + return this.schemaParserFabric.getInlineParseContent( + subSchema, + typeName, + [operationId], + ); + }); - const foundSchema = foundedSchemaByName || foundSchemaByContent; + // Filter out any duplicates or Any types + const filteredTypes = + this.schemaParserFabric.schemaUtils.filterSchemaContents( + unionTypes, + (content) => content !== this.config.Ts.Keyword.Any, + ); - return foundSchema - ? this.typeNameFormatter.format(foundSchema.name) - : content; + // Create a union type + return this.config.Ts.UnionType(filteredTypes); + } else { + // Handle single schema as before + const content = this.schemaParserFabric.getInlineParseContent( + schema, + typeName, + [operationId], + ); + const foundedSchemaByName = parsedSchemas.find( + (parsedSchema) => + this.typeNameFormatter.format(parsedSchema.name) === content, + ); + const foundSchemaByContent = parsedSchemas.find((parsedSchema) => + lodash.isEqual(parsedSchema.content, content), + ); + + const foundSchema = foundedSchemaByName || foundSchemaByContent; + + return foundSchema + ? this.typeNameFormatter.format(foundSchema.name) + : content; + } } if (refTypeInfo) { diff --git a/tests/__snapshots__/extended.test.ts.snap b/tests/__snapshots__/extended.test.ts.snap index e95bc232a..015ae1608 100644 --- a/tests/__snapshots__/extended.test.ts.snap +++ b/tests/__snapshots__/extended.test.ts.snap @@ -217,7 +217,7 @@ export interface BlockFeed { id?: string; } -export interface ChartDataData { +export type ChartDataData = { /** The names of the columns returned as data. */ columns?: string[]; /** The actual chart data. */ @@ -228,7 +228,7 @@ export interface ChartDataData { name?: string; }; parameters?: object; -} +}; export interface ChartDataParams { /** @@ -678,12 +678,12 @@ export interface GetBlockParams { username: string; } -export interface GetCurrentUserThrottleData { +export type GetCurrentUserThrottleData = { /** Actions taken inside the time window. */ active_data_rate?: number; /** Max possible actions inside the time window (usually 1 minute). */ data_rate_limit?: number; -} +}; export interface GetCurrentUserThrottleParams { /** a valid username string */ @@ -8732,13 +8732,12 @@ export interface SignRequestParams { test?: number; } -/** JWT */ -export interface SignRetrieveData { +export type SignRetrieveData = { exp?: number; field?: string; /** base64safe encoded public signing key */ sub?: string; -} +}; export type SignRetrieveError = Error; @@ -13323,7 +13322,9 @@ export interface ActivityListRepoNotificationsForAuthenticatedUserParams { since?: string; } -export type ActivityListReposStarredByAuthenticatedUserData = Repository[]; +export type ActivityListReposStarredByAuthenticatedUserData = + | Repository[] + | StarredRepository[]; export interface ActivityListReposStarredByAuthenticatedUserParams { /** @@ -13366,7 +13367,9 @@ export enum ActivityListReposStarredByAuthenticatedUserParams1SortEnum { Updated = "updated", } -export type ActivityListReposStarredByUserData = Repository[]; +export type ActivityListReposStarredByUserData = + | Repository[] + | StarredRepository[]; export interface ActivityListReposStarredByUserParams { /** @@ -13426,7 +13429,7 @@ export interface ActivityListReposWatchedByUserParams { username: string; } -export type ActivityListStargazersForRepoData = SimpleUser[]; +export type ActivityListStargazersForRepoData = SimpleUser[] | Stargazer[]; export interface ActivityListStargazersForRepoParams { owner: string; @@ -28162,7 +28165,9 @@ export interface ReposGetCommunityProfileMetricsParams { repo: string; } -export type ReposGetContentData = ContentTree; +export type ReposGetContentData = + | ContentTree + | (ContentDirectory | ContentFile | ContentSymlink | ContentSubmodule); export interface ReposGetContentParams { owner: string; @@ -60092,7 +60097,10 @@ export class Api< data: ReposCreateForkPayload, params: RequestParams = {}, ) => - this.request({ + this.request< + ReposCreateForkData, + (BasicError | ScimError) | BasicError | ValidationError + >({ path: \`/repos/\${owner}/\${repo}/forks\`, method: "POST", body: data, @@ -61604,13 +61612,15 @@ export class Api< { owner, repo, ...query }: ReposListCommitsParams, params: RequestParams = {}, ) => - this.request({ - path: \`/repos/\${owner}/\${repo}/commits\`, - method: "GET", - query: query, - format: "json", - ...params, - }), + this.request( + { + path: \`/repos/\${owner}/\${repo}/commits\`, + method: "GET", + query: query, + format: "json", + ...params, + }, + ), /** * @description Users with pull access in a repository can view commit statuses for a given ref. The ref can be a SHA, a branch name, or a tag name. Statuses are returned in reverse chronological order. The first status in the list will be the latest one. This resource is also available via a legacy route: \`GET /repos/:owner/:repo/statuses/:ref\`. @@ -61729,7 +61739,7 @@ export class Api< { owner, repo, ...query }: ReposListForksParams, params: RequestParams = {}, ) => - this.request({ + this.request({ path: \`/repos/\${owner}/\${repo}/forks\`, method: "GET", query: query, @@ -62414,7 +62424,7 @@ export class Api< ) => this.request< ReposUpdateInformationAboutPagesSiteData, - BasicError | ValidationError + (BasicError | ScimError) | ValidationError >({ path: \`/repos/\${owner}/\${repo}/pages\`, method: "PUT", @@ -64787,7 +64797,7 @@ export class Api< ) => this.request< ReposCreateForAuthenticatedUserData, - BasicError | ValidationError + (BasicError | ScimError) | BasicError | ValidationError >({ path: \`/user/repos\`, method: "POST", diff --git a/tests/__snapshots__/simple.test.ts.snap b/tests/__snapshots__/simple.test.ts.snap index 710b23008..c98defb3b 100644 --- a/tests/__snapshots__/simple.test.ts.snap +++ b/tests/__snapshots__/simple.test.ts.snap @@ -20642,7 +20642,8 @@ export class Api< documentation_url: string; message: string; } - | (ValidationError | ValidationErrorSimple) + | ValidationError + | ValidationErrorSimple >({ path: \`/orgs/\${org}\`, method: "PATCH", @@ -24462,7 +24463,8 @@ export class Api< this.request< ProjectCard, | BasicError - | (ValidationError | ValidationErrorSimple) + | ValidationError + | ValidationErrorSimple | { code?: string; documentation_url?: string; @@ -27970,7 +27972,7 @@ export class Api< }, params: RequestParams = {}, ) => - this.request({ + this.request({ path: \`/repos/\${owner}/\${repo}/commits\`, method: "GET", query: query, @@ -28365,7 +28367,11 @@ export class Api< }, params: RequestParams = {}, ) => - this.request({ + this.request< + | ContentTree + | (ContentDirectory | ContentFile | ContentSymlink | ContentSubmodule), + BasicError + >({ path: \`/repos/\${owner}/\${repo}/contents/\${path}\`, method: "GET", query: query, @@ -28895,7 +28901,7 @@ export class Api< }, params: RequestParams = {}, ) => - this.request({ + this.request({ path: \`/repos/\${owner}/\${repo}/forks\`, method: "GET", query: query, @@ -28920,7 +28926,10 @@ export class Api< }, params: RequestParams = {}, ) => - this.request({ + this.request< + Repository, + (BasicError | ScimError) | BasicError | ValidationError + >({ path: \`/repos/\${owner}/\${repo}/forks\`, method: "POST", body: data, @@ -31601,7 +31610,7 @@ export class Api< }, params: RequestParams = {}, ) => - this.request({ + this.request({ path: \`/repos/\${owner}/\${repo}/pages\`, method: "PUT", body: data, @@ -33275,7 +33284,7 @@ export class Api< }, params: RequestParams = {}, ) => - this.request({ + this.request({ path: \`/repos/\${owner}/\${repo}/stargazers\`, method: "GET", query: query, @@ -37318,7 +37327,10 @@ export class Api< }, params: RequestParams = {}, ) => - this.request({ + this.request< + Repository, + (BasicError | ScimError) | BasicError | ValidationError + >({ path: \`/user/repos\`, method: "POST", body: data, @@ -37424,7 +37436,7 @@ export class Api< }, params: RequestParams = {}, ) => - this.request({ + this.request({ path: \`/user/starred\`, method: "GET", query: query, @@ -38191,7 +38203,7 @@ export class Api< }, params: RequestParams = {}, ) => - this.request({ + this.request({ path: \`/users/\${username}/starred\`, method: "GET", query: query, diff --git a/tests/spec/multiple-media-types/__snapshots__/index.spec.ts.snap b/tests/spec/multiple-media-types/__snapshots__/index.spec.ts.snap new file mode 100644 index 000000000..116608e3a --- /dev/null +++ b/tests/spec/multiple-media-types/__snapshots__/index.spec.ts.snap @@ -0,0 +1,316 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`multiple media types in request body > should generate union type for request body with multiple media types 1`] = ` +"/* eslint-disable */ +/* tslint:disable */ +// @ts-nocheck +/* + * --------------------------------------------------------------- + * ## THIS FILE WAS GENERATED VIA SWAGGER-TYPESCRIPT-API ## + * ## ## + * ## AUTHOR: acacode ## + * ## SOURCE: https://github.com/acacode/swagger-typescript-api ## + * --------------------------------------------------------------- + */ + +export interface Pet { + /** @format int64 */ + id?: number; + name?: string; +} + +export interface Cat { + name: string; + meow?: boolean; +} + +export interface Dog { + name: string; + bark?: boolean; +} + +export type QueryParamsType = Record; +export type ResponseFormat = keyof Omit; + +export interface FullRequestParams extends Omit { + /** set parameter to \`true\` for call \`securityWorker\` for this request */ + secure?: boolean; + /** request path */ + path: string; + /** content type of request body */ + type?: ContentType; + /** query params */ + query?: QueryParamsType; + /** format of response (i.e. response.json() -> format: "json") */ + format?: ResponseFormat; + /** request body */ + body?: unknown; + /** base url */ + baseUrl?: string; + /** request cancellation token */ + cancelToken?: CancelToken; +} + +export type RequestParams = Omit< + FullRequestParams, + "body" | "method" | "query" | "path" +>; + +export interface ApiConfig { + baseUrl?: string; + baseApiParams?: Omit; + securityWorker?: ( + securityData: SecurityDataType | null, + ) => Promise | RequestParams | void; + customFetch?: typeof fetch; +} + +export interface HttpResponse + extends Response { + data: D; + error: E; +} + +type CancelToken = Symbol | string | number; + +export enum ContentType { + Json = "application/json", + JsonApi = "application/vnd.api+json", + FormData = "multipart/form-data", + UrlEncoded = "application/x-www-form-urlencoded", + Text = "text/plain", +} + +export class HttpClient { + public baseUrl: string = ""; + private securityData: SecurityDataType | null = null; + private securityWorker?: ApiConfig["securityWorker"]; + private abortControllers = new Map(); + private customFetch = (...fetchParams: Parameters) => + fetch(...fetchParams); + + private baseApiParams: RequestParams = { + credentials: "same-origin", + headers: {}, + redirect: "follow", + referrerPolicy: "no-referrer", + }; + + constructor(apiConfig: ApiConfig = {}) { + Object.assign(this, apiConfig); + } + + public setSecurityData = (data: SecurityDataType | null) => { + this.securityData = data; + }; + + protected encodeQueryParam(key: string, value: any) { + const encodedKey = encodeURIComponent(key); + return \`\${encodedKey}=\${encodeURIComponent(typeof value === "number" ? value : \`\${value}\`)}\`; + } + + protected addQueryParam(query: QueryParamsType, key: string) { + return this.encodeQueryParam(key, query[key]); + } + + protected addArrayQueryParam(query: QueryParamsType, key: string) { + const value = query[key]; + return value.map((v: any) => this.encodeQueryParam(key, v)).join("&"); + } + + protected toQueryString(rawQuery?: QueryParamsType): string { + const query = rawQuery || {}; + const keys = Object.keys(query).filter( + (key) => "undefined" !== typeof query[key], + ); + return keys + .map((key) => + Array.isArray(query[key]) + ? this.addArrayQueryParam(query, key) + : this.addQueryParam(query, key), + ) + .join("&"); + } + + protected addQueryParams(rawQuery?: QueryParamsType): string { + const queryString = this.toQueryString(rawQuery); + return queryString ? \`?\${queryString}\` : ""; + } + + private contentFormatters: Record any> = { + [ContentType.Json]: (input: any) => + input !== null && (typeof input === "object" || typeof input === "string") + ? JSON.stringify(input) + : input, + [ContentType.JsonApi]: (input: any) => + input !== null && (typeof input === "object" || typeof input === "string") + ? JSON.stringify(input) + : input, + [ContentType.Text]: (input: any) => + input !== null && typeof input !== "string" + ? JSON.stringify(input) + : input, + [ContentType.FormData]: (input: any) => { + if (input instanceof FormData) { + return input; + } + + return Object.keys(input || {}).reduce((formData, key) => { + const property = input[key]; + formData.append( + key, + property instanceof Blob + ? property + : typeof property === "object" && property !== null + ? JSON.stringify(property) + : \`\${property}\`, + ); + return formData; + }, new FormData()); + }, + [ContentType.UrlEncoded]: (input: any) => this.toQueryString(input), + }; + + protected mergeRequestParams( + params1: RequestParams, + params2?: RequestParams, + ): RequestParams { + return { + ...this.baseApiParams, + ...params1, + ...(params2 || {}), + headers: { + ...(this.baseApiParams.headers || {}), + ...(params1.headers || {}), + ...((params2 && params2.headers) || {}), + }, + }; + } + + protected createAbortSignal = ( + cancelToken: CancelToken, + ): AbortSignal | undefined => { + if (this.abortControllers.has(cancelToken)) { + const abortController = this.abortControllers.get(cancelToken); + if (abortController) { + return abortController.signal; + } + return void 0; + } + + const abortController = new AbortController(); + this.abortControllers.set(cancelToken, abortController); + return abortController.signal; + }; + + public abortRequest = (cancelToken: CancelToken) => { + const abortController = this.abortControllers.get(cancelToken); + + if (abortController) { + abortController.abort(); + this.abortControllers.delete(cancelToken); + } + }; + + public request = async ({ + body, + secure, + path, + type, + query, + format, + baseUrl, + cancelToken, + ...params + }: FullRequestParams): Promise> => { + const secureParams = + ((typeof secure === "boolean" ? secure : this.baseApiParams.secure) && + this.securityWorker && + (await this.securityWorker(this.securityData))) || + {}; + const requestParams = this.mergeRequestParams(params, secureParams); + const queryString = query && this.toQueryString(query); + const payloadFormatter = this.contentFormatters[type || ContentType.Json]; + const responseFormat = format || requestParams.format; + + return this.customFetch( + \`\${baseUrl || this.baseUrl || ""}\${path}\${queryString ? \`?\${queryString}\` : ""}\`, + { + ...requestParams, + headers: { + ...(requestParams.headers || {}), + ...(type && type !== ContentType.FormData + ? { "Content-Type": type } + : {}), + }, + signal: + (cancelToken + ? this.createAbortSignal(cancelToken) + : requestParams.signal) || null, + body: + typeof body === "undefined" || body === null + ? null + : payloadFormatter(body), + }, + ).then(async (response) => { + const r = response as HttpResponse; + r.data = null as unknown as T; + r.error = null as unknown as E; + + const responseToParse = responseFormat ? response.clone() : response; + const data = !responseFormat + ? r + : await responseToParse[responseFormat]() + .then((data) => { + if (r.ok) { + r.data = data; + } else { + r.error = data; + } + return r; + }) + .catch((e) => { + r.error = e; + return r; + }); + + if (cancelToken) { + this.abortControllers.delete(cancelToken); + } + + if (!response.ok) throw data; + return data; + }); + }; +} + +/** + * @title Multiple Media Types API + * @version 1.0.0 + * + * API with multiple media types in request bodies + */ +export class Api< + SecurityDataType extends unknown, +> extends HttpClient { + pets = { + /** + * No description + * + * @name AddPet + * @summary Add a new pet + * @request POST:/pets + */ + addPet: (data: Cat | Dog, params: RequestParams = {}) => + this.request({ + path: \`/pets\`, + method: "POST", + body: data, + type: ContentType.Json, + format: "json", + ...params, + }), + }; +} +" +`; diff --git a/tests/spec/multiple-media-types/index.spec.ts b/tests/spec/multiple-media-types/index.spec.ts new file mode 100644 index 000000000..888b13574 --- /dev/null +++ b/tests/spec/multiple-media-types/index.spec.ts @@ -0,0 +1,40 @@ +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; +import { afterAll, beforeAll, describe, expect, test } from "vitest"; +import { generateApi } from "../../../src/index.js"; + +describe("multiple media types in request body", async () => { + let tmpdir: string; + + beforeAll(async () => { + tmpdir = await fs.mkdtemp(path.join(os.tmpdir(), "swagger-typescript-api")); + }); + + afterAll(async () => { + await fs.rm(tmpdir, { recursive: true }); + }); + + test("should generate union type for request body with multiple media types", async () => { + const fileName = "MultipleMediaTypesApi"; + + await generateApi({ + fileName, + input: path.resolve(__dirname, "./schema.json"), + output: tmpdir, + silent: true, + generateClient: true, + }); + + const content = await fs.readFile(path.join(tmpdir, `${fileName}.ts`), { + encoding: "utf8", + }); + + // Save the snapshot for future comparison + expect(content).toMatchSnapshot(); + + // Additional specific checks + expect(content).toContain("Cat | Dog"); + expect(content).toContain("addPet"); + }); +}); diff --git a/tests/spec/multiple-media-types/schema.json b/tests/spec/multiple-media-types/schema.json new file mode 100644 index 000000000..152004747 --- /dev/null +++ b/tests/spec/multiple-media-types/schema.json @@ -0,0 +1,84 @@ +{ + "openapi": "3.0.0", + "info": { + "title": "Multiple Media Types API", + "version": "1.0.0", + "description": "API with multiple media types in request bodies" + }, + "paths": { + "/pets": { + "post": { + "summary": "Add a new pet", + "operationId": "addPet", + "requestBody": { + "description": "Pet object that needs to be added", + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Cat" + } + }, + "application/foo.bar+json": { + "schema": { + "$ref": "#/components/schemas/Dog" + } + } + } + }, + "responses": { + "200": { + "description": "Successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Pet" + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "Pet": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "name": { + "type": "string" + } + } + }, + "Cat": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "meow": { + "type": "boolean" + } + }, + "required": ["name"] + }, + "Dog": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "bark": { + "type": "boolean" + } + }, + "required": ["name"] + } + } + } +}