diff --git a/.changeset/stupid-pens-bow.md b/.changeset/stupid-pens-bow.md new file mode 100644 index 000000000..a230bd442 --- /dev/null +++ b/.changeset/stupid-pens-bow.md @@ -0,0 +1,5 @@ +--- +"openapi-typescript": patch +--- + +Fix remote path item object $refs diff --git a/packages/openapi-typescript/examples/digital-ocean-api.ts b/packages/openapi-typescript/examples/digital-ocean-api.ts index 23a225851..c1f3191ab 100644 --- a/packages/openapi-typescript/examples/digital-ocean-api.ts +++ b/packages/openapi-typescript/examples/digital-ocean-api.ts @@ -3984,7 +3984,7 @@ export interface external { */ type?: "custom" | "lets_encrypt"; }; - /** Custom Certificate Request */ certificate_request_custom: certificate_create_base[] & { + /** Custom Certificate Request */ certificate_request_custom: external["resources/certificates/models/certificate_create.yml"]["certificate_create_base"] & { /** * @description The contents of a PEM-formatted private-key corresponding to the SSL certificate. * @example -----BEGIN PRIVATE KEY----- @@ -4113,7 +4113,7 @@ export interface external { */ certificate_chain?: string; }; - /** Let's Encrypt Certificate Request */ certificate_request_lets_encrypt: certificate_create_base[] & { + /** Let's Encrypt Certificate Request */ certificate_request_lets_encrypt: external["resources/certificates/models/certificate_create.yml"]["certificate_create_base"] & { /** * @description An array of fully qualified domain names (FQDNs) for which the certificate was issued. A certificate covering all subdomains can be issued using a wildcard (e.g. `*.example.com`). * @example [ @@ -7686,14 +7686,14 @@ export interface external { */ type: "enable_backups" | "disable_backups" | "reboot" | "power_cycle" | "shutdown" | "power_off" | "power_on" | "restore" | "password_reset" | "resize" | "rebuild" | "rename" | "change_kernel" | "enable_ipv6" | "snapshot"; }; - droplet_action_restore: droplet_action[] & { + droplet_action_restore: external["resources/droplets/models/droplet_actions.yml"]["droplet_action"] & { /** * @description The ID of a backup of the current Droplet instance to restore from. * @example 12389723 */ image?: number; }; - droplet_action_resize: droplet_action[] & { + droplet_action_resize: external["resources/droplets/models/droplet_actions.yml"]["droplet_action"] & { /** * @description When `true`, the Droplet's disk will be resized in addition to its RAM and CPU. This is a permanent change and cannot be reversed as a Droplet's disk size cannot be decreased. * @example true @@ -7705,28 +7705,28 @@ export interface external { */ size?: string; }; - droplet_action_rebuild: droplet_action[] & ({ + droplet_action_rebuild: external["resources/droplets/models/droplet_actions.yml"]["droplet_action"] & ({ /** * @description The image ID of a public or private image or the slug identifier for a public image. The Droplet will be rebuilt using this image as its base. * @example ubuntu-20-04-x64 */ image?: string | number; }); - droplet_action_rename: droplet_action[] & { + droplet_action_rename: external["resources/droplets/models/droplet_actions.yml"]["droplet_action"] & { /** * @description The new name for the Droplet. * @example nifty-new-name */ name?: string; }; - droplet_action_change_kernel: droplet_action[] & { + droplet_action_change_kernel: external["resources/droplets/models/droplet_actions.yml"]["droplet_action"] & { /** * @description A unique number used to identify and reference a specific kernel. * @example 12389723 */ kernel?: number; }; - droplet_action_snapshot: droplet_action[] & { + droplet_action_snapshot: external["resources/droplets/models/droplet_actions.yml"]["droplet_action"] & { /** * @description The name to give the new snapshot of the Droplet. * @example Nifty New Snapshot @@ -9037,10 +9037,10 @@ export interface external { }; floating_ip_action_unassign: { type: undefined; - } & Omit & Record; + } & Omit & Record; floating_ip_action_assign: { type: undefined; - } & Omit & { + } & Omit & { /** * @description The ID of the Droplet that the floating IP will be assigned to. * @example 758604968 @@ -9850,7 +9850,7 @@ export interface external { */ type: "convert" | "transfer"; }; - image_action_transfer: image_action_base[] & { + image_action_transfer: external["resources/images/models/image_action.yml"]["image_action_base"] & { region: external["shared/attributes/region_slug.yml"]; }; }; @@ -10672,7 +10672,7 @@ export interface external { * } * ] */ - load_balancers?: associated_kubernetes_resource[][]; + load_balancers?: external["resources/kubernetes/models/associated_kubernetes_resources.yml"]["associated_kubernetes_resource"][]; /** * @description A list of names and IDs for associated volumes that can be destroyed along with the cluster. * @example [ @@ -10682,7 +10682,7 @@ export interface external { * } * ] */ - volumes?: associated_kubernetes_resource[][]; + volumes?: external["resources/kubernetes/models/associated_kubernetes_resources.yml"]["associated_kubernetes_resource"][]; /** * @description A list of names and IDs for associated volume snapshots that can be destroyed along with the cluster. * @example [ @@ -10692,7 +10692,7 @@ export interface external { * } * ] */ - volume_snapshots?: associated_kubernetes_resource[][]; + volume_snapshots?: external["resources/kubernetes/models/associated_kubernetes_resources.yml"]["associated_kubernetes_resource"][]; }; associated_kubernetes_resource: { /** @@ -13771,10 +13771,10 @@ export interface external { }; reserved_ip_action_unassign: { type: undefined; - } & Omit & Record; + } & Omit & Record; reserved_ip_action_assign: { type: undefined; - } & Omit & { + } & Omit & { /** * @description The ID of the Droplet that the reserved IP will be assigned to. * @example 758604968 @@ -15984,7 +15984,7 @@ export interface external { } "shared/pages.yml": { pagination: { - links?: page_links[]; + links?: external["shared/pages.yml"]["page_links"]; }; page_links: { /** @@ -15997,8 +15997,8 @@ export interface external { */ pages?: unknown; }; - backward_links: link_to_first_page[] & link_to_prev_page[]; - forward_links: link_to_last_page[] & link_to_next_page[]; + backward_links: external["shared/pages.yml"]["link_to_first_page"] & external["shared/pages.yml"]["link_to_prev_page"]; + forward_links: external["shared/pages.yml"]["link_to_last_page"] & external["shared/pages.yml"]["link_to_next_page"]; link_to_first_page: { /** * @description URI of the first page of the results. diff --git a/packages/openapi-typescript/examples/digital-ocean-api/resources/kubernetes/models/node_pool.yml b/packages/openapi-typescript/examples/digital-ocean-api/resources/kubernetes/models/node_pool.yml index 2f2fe6f2d..7f08d6773 100644 --- a/packages/openapi-typescript/examples/digital-ocean-api/resources/kubernetes/models/node_pool.yml +++ b/packages/openapi-typescript/examples/digital-ocean-api/resources/kubernetes/models/node_pool.yml @@ -66,8 +66,10 @@ kubernetes_node_pool_base: type: object nullable: true example: null - description: An object containing a set of Kubernetes labels. The keys and - are values are both user-defined. + description: An object of key/value mappings specifying labels to apply + to all nodes in a pool. Labels will automatically be applied to all + existing nodes and any subsequent nodes added to the pool. Note that + when a label is removed, it is not deleted from the nodes in the pool. taints: type: array @@ -75,7 +77,7 @@ kubernetes_node_pool_base: $ref: "#/kubernetes_node_pool_taint" description: An array of taints to apply to all nodes in a pool. Taints will automatically be applied to all existing nodes and any subsequent - nodes added to the pool. When a taint is removed, it is removed from + nodes added to the pool. When a taint is removed, it is deleted from all nodes in the pool. auto_scale: diff --git a/packages/openapi-typescript/examples/stripe-api.ts b/packages/openapi-typescript/examples/stripe-api.ts index 4f1dfda4a..1039de6fc 100644 --- a/packages/openapi-typescript/examples/stripe-api.ts +++ b/packages/openapi-typescript/examples/stripe-api.ts @@ -2107,6 +2107,7 @@ export interface components { account_business_profile: { /** @description [The merchant category code for the account](https://stripe.com/docs/connect/setting-mcc). MCCs are used to classify businesses based on the goods or services they provide. */ mcc?: string | null; + monthly_estimated_revenue?: components["schemas"]["account_monthly_estimated_revenue"]; /** @description The customer-facing business name. */ name?: string | null; /** @description Internal-only description of the product sold or service provided by the business. It's used by Stripe for risk and underwriting purposes. */ @@ -2433,6 +2434,13 @@ export interface components { /** @description The URL for the account link. */ url: string; }; + /** AccountMonthlyEstimatedRevenue */ + account_monthly_estimated_revenue: { + /** @description A non-negative integer representing how much to charge in the [smallest currency unit](https://stripe.com/docs/currencies#zero-decimal). */ + amount: number; + /** @description Three-letter [ISO currency code](https://www.iso.org/iso-4217-currency-codes.html), in lowercase. Must be a [supported currency](https://stripe.com/docs/currencies). */ + currency: string; + }; /** AccountPaymentsSettings */ account_payments_settings: { /** @description The default text that appears on credit card statements when a charge is made. This field prefixes any dynamic `statement_descriptor` specified on the charge. */ @@ -16326,6 +16334,11 @@ export interface operations { */ business_profile?: { mcc?: string; + /** monthly_estimated_revenue_specs */ + monthly_estimated_revenue?: { + amount: number; + currency: string; + }; name?: string; product_description?: string; /** address_specs */ @@ -16849,6 +16862,11 @@ export interface operations { */ business_profile?: { mcc?: string; + /** monthly_estimated_revenue_specs */ + monthly_estimated_revenue?: { + amount: number; + currency: string; + }; name?: string; product_description?: string; /** address_specs */ diff --git a/packages/openapi-typescript/examples/stripe-api.yaml b/packages/openapi-typescript/examples/stripe-api.yaml index dea365c36..ea46d03fa 100644 --- a/packages/openapi-typescript/examples/stripe-api.yaml +++ b/packages/openapi-typescript/examples/stripe-api.yaml @@ -250,6 +250,8 @@ components: maxLength: 5000 nullable: true type: string + monthly_estimated_revenue: + $ref: '#/components/schemas/account_monthly_estimated_revenue' name: description: The customer-facing business name. maxLength: 5000 @@ -291,6 +293,7 @@ components: title: AccountBusinessProfile type: object x-expandableFields: + - monthly_estimated_revenue - support_address account_capabilities: description: '' @@ -1032,6 +1035,28 @@ components: type: object x-expandableFields: [] x-resourceId: account_link + account_monthly_estimated_revenue: + description: '' + properties: + amount: + description: >- + A non-negative integer representing how much to charge in the + [smallest currency + unit](https://stripe.com/docs/currencies#zero-decimal). + type: integer + currency: + description: >- + Three-letter [ISO currency + code](https://www.iso.org/iso-4217-currency-codes.html), in + lowercase. Must be a [supported + currency](https://stripe.com/docs/currencies). + type: string + required: + - amount + - currency + title: AccountMonthlyEstimatedRevenue + type: object + x-expandableFields: [] account_payments_settings: description: '' properties: @@ -39941,6 +39966,17 @@ paths: mcc: maxLength: 4 type: string + monthly_estimated_revenue: + properties: + amount: + type: integer + currency: + type: string + required: + - amount + - currency + title: monthly_estimated_revenue_specs + type: object name: maxLength: 5000 type: string @@ -41086,6 +41122,17 @@ paths: mcc: maxLength: 4 type: string + monthly_estimated_revenue: + properties: + amount: + type: integer + currency: + type: string + required: + - amount + - currency + title: monthly_estimated_revenue_specs + type: object name: maxLength: 5000 type: string diff --git a/packages/openapi-typescript/package.json b/packages/openapi-typescript/package.json index b1799caf8..3695a3036 100644 --- a/packages/openapi-typescript/package.json +++ b/packages/openapi-typescript/package.json @@ -49,7 +49,7 @@ "test": "run-p -s test:*", "test:js": "vitest run", "test:ts": "tsc --noEmit", - "update:examples": "vite-node ./scripts/update-examples.ts", + "update:examples": "pnpm run download:schemas && vite-node ./scripts/update-examples.ts", "prepublish": "pnpm run build", "version": "pnpm run build" }, diff --git a/packages/openapi-typescript/scripts/download-schemas.ts b/packages/openapi-typescript/scripts/download-schemas.ts index 0523cd48c..eb3c16bd6 100644 --- a/packages/openapi-typescript/scripts/download-schemas.ts +++ b/packages/openapi-typescript/scripts/download-schemas.ts @@ -4,26 +4,18 @@ import { fileURLToPath } from "node:url"; import degit from "degit"; import { fetch } from "undici"; import { error } from "../src/utils.js"; - -export const singleFile = { - "github-api": "https://raw.githubusercontent.com/github/rest-api-description/main/descriptions/api.github.com/api.github.com.yaml", - "github-api-next": "https://raw.githubusercontent.com/github/rest-api-description/main/descriptions-next/api.github.com/api.github.com.yaml", - "octokit-ghes-3.6-diff-to-api": "https://raw.githubusercontent.com/octokit/octokit-next.js/main/cache/types-openapi/ghes-3.6-diff-to-api.github.com.json", - "stripe-api": "https://raw.githubusercontent.com/stripe/openapi/master/openapi/spec3.yaml", -}; -export const multiFile = { - "digital-ocean-api": { - repo: "https://github.com/digitalocean/openapi/specification", - entry: "./DigitalOcean-public.v2.yaml", - }, -}; +import { multiFile, singleFile } from "./schemas.js"; const ONE_DAY = 1000 * 60 * 60 * 24; const EXAMPLES_DIR = new URL("../examples/", import.meta.url); export async function download() { + const allSchemas = Object.keys({ ...singleFile, ...multiFile }); + let done = 0; + console.log("Downloading schemas..."); // eslint-disable-line no-console await Promise.all([ ...Object.entries(singleFile).map(async ([k, url]) => { + const start = performance.now(); const ext = path.extname(url); const dest = new URL(`${k}${ext}`, EXAMPLES_DIR); if (fs.existsSync(dest)) { @@ -37,8 +29,11 @@ export async function download() { } fs.mkdirSync(new URL(".", dest), { recursive: true }); fs.writeFileSync(dest, await result.text()); + done++; + console.log(`✔︎ [${done}/${allSchemas.length}] Downloaded ${k} (${Math.round(performance.now() - start)}ms)`); // eslint-disable-line no-console }), ...Object.entries(multiFile).map(async ([k, meta]) => { + const start = performance.now(); const dest = new URL(k, EXAMPLES_DIR); if (fs.existsSync(dest)) { const { mtime } = fs.statSync(dest); @@ -48,8 +43,11 @@ export async function download() { force: true, }); await emitter.clone(fileURLToPath(new URL(k, EXAMPLES_DIR))); + done++; + console.log(`✔︎ [${done}/${allSchemas.length}] Downloaded ${k} (${Math.round(performance.now() - start)}ms)`); // eslint-disable-line no-console }), ]); + console.log("Downloading schemas done."); // eslint-disable-line no-console } download(); diff --git a/packages/openapi-typescript/scripts/schemas.ts b/packages/openapi-typescript/scripts/schemas.ts new file mode 100644 index 000000000..3ff9f36a6 --- /dev/null +++ b/packages/openapi-typescript/scripts/schemas.ts @@ -0,0 +1,12 @@ +export const singleFile = { + "github-api": "https://raw.githubusercontent.com/github/rest-api-description/main/descriptions/api.github.com/api.github.com.yaml", + "github-api-next": "https://raw.githubusercontent.com/github/rest-api-description/main/descriptions-next/api.github.com/api.github.com.yaml", + "octokit-ghes-3.6-diff-to-api": "https://raw.githubusercontent.com/octokit/octokit-next.js/main/cache/types-openapi/ghes-3.6-diff-to-api.github.com.json", + "stripe-api": "https://raw.githubusercontent.com/stripe/openapi/master/openapi/spec3.yaml", +}; +export const multiFile = { + "digital-ocean-api": { + repo: "https://github.com/digitalocean/openapi/specification", + entry: "./DigitalOcean-public.v2.yaml", + }, +}; diff --git a/packages/openapi-typescript/scripts/update-examples.ts b/packages/openapi-typescript/scripts/update-examples.ts index 151176259..3f508c61f 100644 --- a/packages/openapi-typescript/scripts/update-examples.ts +++ b/packages/openapi-typescript/scripts/update-examples.ts @@ -1,20 +1,30 @@ import { execa } from "execa"; import path from "node:path"; import { URL } from "node:url"; -import { download, singleFile, multiFile } from "./download-schemas.js"; +import { multiFile, singleFile } from "./schemas.js"; async function generateSchemas() { - await download(); const cwd = new URL("../", import.meta.url); + const allSchemas = Object.keys({ ...singleFile, ...multiFile }); + + let done = 0; + console.log("Updating examples..."); // eslint-disable-line no-console await Promise.all([ ...Object.keys(singleFile).map(async (name) => { + const start = performance.now(); const ext = path.extname(singleFile[name as keyof typeof singleFile]); await execa("node", ["./bin/cli.js", `./examples/${name}${ext}`, "-o", `./examples/${name}.ts`], { cwd }); + done++; + console.log(`✔︎ [${done}/${allSchemas.length}] Updated ${name} (${Math.round(performance.now() - start)}ms)`); // eslint-disable-line no-console }), ...Object.entries(multiFile).map(async ([name, meta]) => { + const start = performance.now(); await execa("node", ["./bin/cli.js", `./examples/${name}${meta.entry.substring(1)}`, "-o", `./examples/${name}.ts`], { cwd }); + done++; + console.log(`✔︎ [${done}/${allSchemas.length}] Updated ${name} (${Math.round(performance.now() - start)}ms)`); // eslint-disable-line no-console }), ]); + console.log("Updating examples done."); // eslint-disable-line no-console } generateSchemas(); diff --git a/packages/openapi-typescript/src/index.ts b/packages/openapi-typescript/src/index.ts index 2e4d3b845..d8d2f1c09 100644 --- a/packages/openapi-typescript/src/index.ts +++ b/packages/openapi-typescript/src/index.ts @@ -11,6 +11,7 @@ import transformRequestBodyObject from "./transform/request-body-object.js"; import transformResponseObject from "./transform/response-object.js"; import transformSchemaObject from "./transform/schema-object.js"; import { error, escObjKey, getDefaultFetch, getEntries, getSchemaObjectComment, indent } from "./utils.js"; +import transformPathItemObject, { Method } from "./transform/path-item-object.js"; export * from "./types.js"; // expose all types to consumers const EMPTY_OBJECT_RE = /^\s*\{?\s*\}?\s*$/; @@ -177,11 +178,26 @@ async function openapiTS(schema: string | URL | OpenAPI3 | Readable, options: Op case "SchemaMap": { subschemaOutput += "{\n"; indentLv++; - for (const [name, schemaObject] of getEntries(subschema.schema!)) { - const c = getSchemaObjectComment(schemaObject, indentLv); + + outer: for (const [name, schemaObject] of getEntries(subschema.schema!)) { + if (!schemaObject || typeof schemaObject !== "object") continue; + const c = getSchemaObjectComment(schemaObject as any, indentLv); if (c) subschemaOutput += indent(c, indentLv); + + // This might be a Path Item Object; only way to test is if top-level contains a method (not allowed on Schema Object) + if (!("type" in schemaObject) && !("$ref" in schemaObject)) { + for (const method of ["get", "put", "post", "delete", "options", "head", "patch", "trace"] as Method[]) { + if (method in schemaObject) { + subschemaOutput += indent(`${escObjKey(name)}: ${transformPathItemObject(schemaObject as any, { path: `${path}${name}`, ctx: { ...ctx, indentLv } })};\n`, indentLv); + continue outer; + } + } + } + + // Otherwise, this is a Schema Object subschemaOutput += indent(`${escObjKey(name)}: ${transformSchemaObject(schemaObject, { path: `${path}${name}`, ctx: { ...ctx, indentLv } })};\n`, indentLv); } + indentLv--; subschemaOutput += indent("};", indentLv); break; diff --git a/packages/openapi-typescript/src/load.ts b/packages/openapi-typescript/src/load.ts index d3e2cfc58..7031dfd92 100644 --- a/packages/openapi-typescript/src/load.ts +++ b/packages/openapi-typescript/src/load.ts @@ -11,6 +11,7 @@ interface SchemaMap { [id: string]: Subschema; } +const EXT_RE = /\.(yaml|yml|json)$/i; export const VIRTUAL_JSON_URL = `file:///_json`; // fake URL reserved for dynamic JSON function parseYAML(schema: any): any { @@ -208,7 +209,9 @@ export default async function load(schema: URL | Subschema | Readable, options: const node = rawNode as unknown as ReferenceObject; const ref = parseRef(node.$ref); - if (ref.filename === ".") return; // local $ref; ignore + if (ref.filename === ".") { + return; // local $ref; ignore + } // $ref with custom "x-*" property if (ref.path.some((i) => i.startsWith("x-"))) { delete (node as any).$ref; @@ -261,7 +264,11 @@ export default async function load(schema: URL | Subschema | Readable, options: // local $ref: convert into TS path if (ref.filename === ".") { - node.$ref = makeTSIndex(ref.path); + if (subschemaID === ".") { + node.$ref = makeTSIndex(ref.path); + } else { + node.$ref = makeTSIndex(["external", subschemaID, ...ref.path]); + } } // external $ref else { @@ -330,8 +337,13 @@ export function getHint({ path, external, startFrom }: GetHintOptions): Subschem } } switch (path[0] as keyof OpenAPI3) { - case "paths": + case "paths": { + // if entire path item object is $ref’d, treat as schema map + if (EXT_RE.test(path[2])) { + return "SchemaMap"; + } return getHintFromPathItemObject(path.slice(2), external); // skip URL at [1] + } case "components": return getHintFromComponentsObject(path.slice(1), external); } diff --git a/packages/openapi-typescript/src/transform/path-item-object.ts b/packages/openapi-typescript/src/transform/path-item-object.ts index b9c79c93e..2ad1a3035 100644 --- a/packages/openapi-typescript/src/transform/path-item-object.ts +++ b/packages/openapi-typescript/src/transform/path-item-object.ts @@ -7,7 +7,7 @@ export interface TransformPathItemObjectOptions { ctx: GlobalContext; } -type Method = "get" | "put" | "post" | "delete" | "options" | "head" | "patch" | "trace"; +export type Method = "get" | "put" | "post" | "delete" | "options" | "head" | "patch" | "trace"; export default function transformPathItemObject(pathItem: PathItemObject, { path, ctx }: TransformPathItemObjectOptions): string { let { indentLv } = ctx; diff --git a/packages/openapi-typescript/src/transform/paths-object.ts b/packages/openapi-typescript/src/transform/paths-object.ts index 4dac4d3a8..5f9938a61 100644 --- a/packages/openapi-typescript/src/transform/paths-object.ts +++ b/packages/openapi-typescript/src/transform/paths-object.ts @@ -1,5 +1,5 @@ import type { GlobalContext, PathsObject, PathItemObject, ParameterObject, ReferenceObject, OperationObject } from "../types.js"; -import { escStr, getEntries, indent } from "../utils.js"; +import { escStr, getEntries, getSchemaObjectComment, indent } from "../utils.js"; import transformParameterObject from "./parameter-object.js"; import transformPathItemObject from "./path-item-object.js"; @@ -20,8 +20,17 @@ export default function transformPathsObject(pathsObject: PathsObject, ctx: Glob const output: string[] = ["{"]; indentLv++; for (const [url, pathItemObject] of getEntries(pathsObject, ctx.alphabetize, ctx.excludeDeprecated)) { + if (!pathItemObject || typeof pathItemObject !== "object") continue; let path = url; + // handle $ref + if ("$ref" in pathItemObject) { + const c = getSchemaObjectComment(pathItemObject, indentLv); + if (c) output.push(indent(c, indentLv)); + output.push(indent(`${escStr(path)}: ${pathItemObject.$ref};`, indentLv)); + continue; + } + const pathParams = new Map([...extractPathParams(pathItemObject), ...OPERATIONS.flatMap((op) => Array.from(extractPathParams(pathItemObject[op as keyof PathItemObject])))]); // build dynamic string template literal index diff --git a/packages/openapi-typescript/src/types.ts b/packages/openapi-typescript/src/types.ts index 31970a0db..b9ac42a81 100644 --- a/packages/openapi-typescript/src/types.ts +++ b/packages/openapi-typescript/src/types.ts @@ -138,7 +138,7 @@ export interface ComponentsObject extends Extensable { * Holds the relative paths to the individual endpoints and their operations. The path is appended to the URL from the Server Object in order to construct the full URL. The Paths MAY be empty, due to Access Control List (ACL) constraints. */ export interface PathsObject { - [pathname: string]: PathItemObject; + [pathname: string]: PathItemObject | ReferenceObject; // note: paths object does support $refs; the schema just defines it in a weird way } /** @@ -673,7 +673,7 @@ export type Subschema = } | { hint: "RequestBodyObject"; schema: RequestBodyObject } | { hint: "ResponseObject"; schema: ResponseObject } - | { hint: "SchemaMap"; schema: NonNullable } + | { hint: "SchemaMap"; schema: Record } // subschemas are less structured | { hint: "SchemaObject"; schema: SchemaObject }; /** Context passed to all submodules */ diff --git a/packages/openapi-typescript/test/fixtures/_path-object-refs-paths.yaml b/packages/openapi-typescript/test/fixtures/_path-object-refs-paths.yaml new file mode 100644 index 000000000..6cf780d94 --- /dev/null +++ b/packages/openapi-typescript/test/fixtures/_path-object-refs-paths.yaml @@ -0,0 +1,19 @@ +GetItemOperation: + get: + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/Item" +Item: + type: object + required: + - id + - name + properties: + id: + type: string + name: + type: string diff --git a/packages/openapi-typescript/test/fixtures/path-object-refs.yaml b/packages/openapi-typescript/test/fixtures/path-object-refs.yaml new file mode 100644 index 000000000..bbfa0d8c1 --- /dev/null +++ b/packages/openapi-typescript/test/fixtures/path-object-refs.yaml @@ -0,0 +1,8 @@ +openapi: "3.0" +info: + title: Test + version: "1.0" +paths: + /get-item: + description: Remote Ref + $ref: "_path-object-refs-paths.yaml#/GetItemOperation" diff --git a/packages/openapi-typescript/test/index.test.ts b/packages/openapi-typescript/test/index.test.ts index 5ac7925af..c0556ce38 100644 --- a/packages/openapi-typescript/test/index.test.ts +++ b/packages/openapi-typescript/test/index.test.ts @@ -352,6 +352,44 @@ export interface external { }; } +export type operations = Record; +`); + }); + + /** test that path item objects accept $refs at the top level */ + test("path object $refs", async () => { + const generated = await openapiTS(new URL("./fixtures/path-object-refs.yaml", import.meta.url)); + expect(generated).toBe(`${BOILERPLATE} +export interface paths { + /** @description Remote Ref */ + "/get-item": external["_path-object-refs-paths.yaml"]["GetItemOperation"]; +} + +export type webhooks = Record; + +export type components = Record; + +export interface external { + "_path-object-refs-paths.yaml": { + GetItemOperation: { + get: { + responses: { + /** @description OK */ + 200: { + content: { + "application/json": external["_path-object-refs-paths.yaml"]["Item"]; + }; + }; + }; + }; + }; + Item: { + id: string; + name: string; + }; + }; +} + export type operations = Record; `); }); diff --git a/packages/openapi-typescript/test/path-item-object.test.ts b/packages/openapi-typescript/test/path-item-object.test.ts index ca1d0d914..5d60330e7 100644 --- a/packages/openapi-typescript/test/path-item-object.test.ts +++ b/packages/openapi-typescript/test/path-item-object.test.ts @@ -162,4 +162,16 @@ describe("Path Item Object", () => { }, }); }); + + test("$ref", () => { + const schema: PathItemObject = { + get: { + $ref: 'components["schemas"]["GetUserOperation"]', + }, + }; + const generated = transformPathItemObject(schema, options); + expect(generated).toBe(`{ + get: components["schemas"]["GetUserOperation"] +}`); + }); });