diff --git a/.changeset/twelve-mice-cheer.md b/.changeset/twelve-mice-cheer.md new file mode 100644 index 0000000000..368bd36791 --- /dev/null +++ b/.changeset/twelve-mice-cheer.md @@ -0,0 +1,5 @@ +--- +'@hey-api/openapi-ts': patch +--- + +feat(parser): input supports Scalar API Registry with `scalar:` prefix diff --git a/docs/openapi-ts/configuration/input.md b/docs/openapi-ts/configuration/input.md index 84343b0c39..3c8183cef5 100644 --- a/docs/openapi-ts/configuration/input.md +++ b/docs/openapi-ts/configuration/input.md @@ -104,6 +104,17 @@ export default { We also provide shorthands for other registries: +::: details Scalar +Prefix your input with `scalar:` to use the Scalar API Registry. + +```js [long] +export default { + input: 'scalar:@scalar/access-service', // [!code ++] +}; +``` + +::: + ::: details ReadMe Prefix your input with `readme:` to use the ReadMe API Registry. diff --git a/examples/openapi-ts-axios/src/client/client/client.ts b/examples/openapi-ts-axios/src/client/client/client.gen.ts similarity index 82% rename from examples/openapi-ts-axios/src/client/client/client.ts rename to examples/openapi-ts-axios/src/client/client/client.gen.ts index 96ecc0773a..b2f7b118fa 100644 --- a/examples/openapi-ts-axios/src/client/client/client.ts +++ b/examples/openapi-ts-axios/src/client/client/client.gen.ts @@ -1,21 +1,29 @@ -import type { AxiosError, RawAxiosRequestHeaders } from 'axios'; +// This file is auto-generated by @hey-api/openapi-ts + +import type { AxiosError, AxiosInstance, RawAxiosRequestHeaders } from 'axios'; import axios from 'axios'; -import type { Client, Config } from './types'; +import type { Client, Config } from './types.gen'; import { buildUrl, createConfig, mergeConfigs, mergeHeaders, setAuthParams, -} from './utils'; +} from './utils.gen'; export const createClient = (config: Config = {}): Client => { let _config = mergeConfigs(createConfig(), config); - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { auth, ...configWithoutAuth } = _config; - const instance = axios.create(configWithoutAuth); + let instance: AxiosInstance; + + if (_config.axios && !('Axios' in _config.axios)) { + instance = _config.axios; + } else { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { auth, ...configWithoutAuth } = _config; + instance = axios.create(configWithoutAuth); + } const getConfig = (): Config => ({ ..._config }); @@ -46,6 +54,10 @@ export const createClient = (config: Config = {}): Client => { }); } + if (opts.requestValidator) { + await opts.requestValidator(opts); + } + if (opts.body && opts.bodySerializer) { opts.body = opts.bodySerializer(opts.body); } diff --git a/examples/openapi-ts-axios/src/client/client/index.ts b/examples/openapi-ts-axios/src/client/client/index.ts index 15d37422af..8ddc04f425 100644 --- a/examples/openapi-ts-axios/src/client/client/index.ts +++ b/examples/openapi-ts-axios/src/client/client/index.ts @@ -1,12 +1,14 @@ -export type { Auth } from '../core/auth'; -export type { QuerySerializerOptions } from '../core/bodySerializer'; +// This file is auto-generated by @hey-api/openapi-ts + +export type { Auth } from '../core/auth.gen'; +export type { QuerySerializerOptions } from '../core/bodySerializer.gen'; export { formDataBodySerializer, jsonBodySerializer, urlSearchParamsBodySerializer, -} from '../core/bodySerializer'; -export { buildClientParams } from '../core/params'; -export { createClient } from './client'; +} from '../core/bodySerializer.gen'; +export { buildClientParams } from '../core/params.gen'; +export { createClient } from './client.gen'; export type { Client, ClientOptions, @@ -17,5 +19,5 @@ export type { RequestOptions, RequestResult, TDataShape, -} from './types'; -export { createConfig } from './utils'; +} from './types.gen'; +export { createConfig } from './utils.gen'; diff --git a/examples/openapi-ts-axios/src/client/client/types.ts b/examples/openapi-ts-axios/src/client/client/types.gen.ts similarity index 92% rename from examples/openapi-ts-axios/src/client/client/types.ts rename to examples/openapi-ts-axios/src/client/client/types.gen.ts index 16e2492f82..b28841acc7 100644 --- a/examples/openapi-ts-axios/src/client/client/types.ts +++ b/examples/openapi-ts-axios/src/client/client/types.gen.ts @@ -1,24 +1,30 @@ +// This file is auto-generated by @hey-api/openapi-ts + import type { AxiosError, AxiosInstance, + AxiosRequestHeaders, AxiosResponse, AxiosStatic, CreateAxiosDefaults, } from 'axios'; -import type { Auth } from '../core/auth'; -import type { Client as CoreClient, Config as CoreConfig } from '../core/types'; +import type { Auth } from '../core/auth.gen'; +import type { + Client as CoreClient, + Config as CoreConfig, +} from '../core/types.gen'; export interface Config extends Omit, CoreConfig { /** - * Axios implementation. You can use this option to provide a custom - * Axios instance. + * Axios implementation. You can use this option to provide either an + * `AxiosStatic` or an `AxiosInstance`. * * @default axios */ - axios?: AxiosStatic; + axios?: AxiosStatic | AxiosInstance; /** * Base URL for all requests made by this client. */ @@ -30,7 +36,7 @@ export interface Config * {@link https://developer.mozilla.org/docs/Web/API/Headers/Headers#init See more} */ headers?: - | CreateAxiosDefaults['headers'] + | AxiosRequestHeaders | Record< string, | string diff --git a/examples/openapi-ts-axios/src/client/client/utils.ts b/examples/openapi-ts-axios/src/client/client/utils.gen.ts similarity index 89% rename from examples/openapi-ts-axios/src/client/client/utils.ts rename to examples/openapi-ts-axios/src/client/client/utils.gen.ts index f0eab724d7..8f20fa8536 100644 --- a/examples/openapi-ts-axios/src/client/client/utils.ts +++ b/examples/openapi-ts-axios/src/client/client/utils.gen.ts @@ -1,15 +1,22 @@ -import { getAuthToken } from '../core/auth'; +// This file is auto-generated by @hey-api/openapi-ts + +import { getAuthToken } from '../core/auth.gen'; import type { QuerySerializer, QuerySerializerOptions, -} from '../core/bodySerializer'; -import type { ArraySeparatorStyle } from '../core/pathSerializer'; +} from '../core/bodySerializer.gen'; +import type { ArraySeparatorStyle } from '../core/pathSerializer.gen'; import { serializeArrayParam, serializeObjectParam, serializePrimitiveParam, -} from '../core/pathSerializer'; -import type { Client, ClientOptions, Config, RequestOptions } from './types'; +} from '../core/pathSerializer.gen'; +import type { + Client, + ClientOptions, + Config, + RequestOptions, +} from './types.gen'; interface PathSerializer { path: Record; @@ -138,6 +145,28 @@ export const createQuerySerializer = ({ return querySerializer; }; +const checkForExistence = ( + options: Pick & { + headers: Record; + }, + name?: string, +): boolean => { + if (!name) { + return false; + } + if (name in options.headers || options.query?.[name]) { + return true; + } + if ( + 'Cookie' in options.headers && + options.headers['Cookie'] && + typeof options.headers['Cookie'] === 'string' + ) { + return options.headers['Cookie'].includes(`${name}=`); + } + return false; +}; + export const setAuthParams = async ({ security, ...options @@ -146,6 +175,9 @@ export const setAuthParams = async ({ headers: Record; }) => { for (const auth of security) { + if (checkForExistence(options, auth.name)) { + continue; + } const token = await getAuthToken(auth, options.auth); if (!token) { @@ -175,8 +207,6 @@ export const setAuthParams = async ({ options.headers[name] = token; break; } - - return; } }; diff --git a/examples/openapi-ts-axios/src/client/core/auth.ts b/examples/openapi-ts-axios/src/client/core/auth.gen.ts similarity index 93% rename from examples/openapi-ts-axios/src/client/core/auth.ts rename to examples/openapi-ts-axios/src/client/core/auth.gen.ts index 451c7f30f9..f8a73266f9 100644 --- a/examples/openapi-ts-axios/src/client/core/auth.ts +++ b/examples/openapi-ts-axios/src/client/core/auth.gen.ts @@ -1,3 +1,5 @@ +// This file is auto-generated by @hey-api/openapi-ts + export type AuthToken = string | undefined; export interface Auth { diff --git a/examples/openapi-ts-axios/src/client/core/bodySerializer.ts b/examples/openapi-ts-axios/src/client/core/bodySerializer.gen.ts similarity index 82% rename from examples/openapi-ts-axios/src/client/core/bodySerializer.ts rename to examples/openapi-ts-axios/src/client/core/bodySerializer.gen.ts index fab971b666..49cd8925e3 100644 --- a/examples/openapi-ts-axios/src/client/core/bodySerializer.ts +++ b/examples/openapi-ts-axios/src/client/core/bodySerializer.gen.ts @@ -1,8 +1,10 @@ +// This file is auto-generated by @hey-api/openapi-ts + import type { ArrayStyle, ObjectStyle, SerializerOptions, -} from './pathSerializer'; +} from './pathSerializer.gen'; export type QuerySerializer = (query: Record) => string; @@ -14,9 +16,15 @@ export interface QuerySerializerOptions { object?: SerializerOptions; } -const serializeFormDataPair = (data: FormData, key: string, value: unknown) => { +const serializeFormDataPair = ( + data: FormData, + key: string, + value: unknown, +): void => { if (typeof value === 'string' || value instanceof Blob) { data.append(key, value); + } else if (value instanceof Date) { + data.append(key, value.toISOString()); } else { data.append(key, JSON.stringify(value)); } @@ -26,7 +34,7 @@ const serializeUrlSearchParamsPair = ( data: URLSearchParams, key: string, value: unknown, -) => { +): void => { if (typeof value === 'string') { data.append(key, value); } else { @@ -37,7 +45,7 @@ const serializeUrlSearchParamsPair = ( export const formDataBodySerializer = { bodySerializer: | Array>>( body: T, - ) => { + ): FormData => { const data = new FormData(); Object.entries(body).forEach(([key, value]) => { @@ -56,8 +64,8 @@ export const formDataBodySerializer = { }; export const jsonBodySerializer = { - bodySerializer: (body: T) => - JSON.stringify(body, (key, value) => + bodySerializer: (body: T): string => + JSON.stringify(body, (_key, value) => typeof value === 'bigint' ? value.toString() : value, ), }; @@ -65,7 +73,7 @@ export const jsonBodySerializer = { export const urlSearchParamsBodySerializer = { bodySerializer: | Array>>( body: T, - ) => { + ): string => { const data = new URLSearchParams(); Object.entries(body).forEach(([key, value]) => { diff --git a/examples/openapi-ts-axios/src/client/core/params.ts b/examples/openapi-ts-axios/src/client/core/params.gen.ts similarity index 89% rename from examples/openapi-ts-axios/src/client/core/params.ts rename to examples/openapi-ts-axios/src/client/core/params.gen.ts index 7559bbb8c0..71c88e852b 100644 --- a/examples/openapi-ts-axios/src/client/core/params.ts +++ b/examples/openapi-ts-axios/src/client/core/params.gen.ts @@ -1,13 +1,25 @@ +// This file is auto-generated by @hey-api/openapi-ts + type Slot = 'body' | 'headers' | 'path' | 'query'; export type Field = | { in: Exclude; + /** + * Field name. This is the name we want the user to see and use. + */ key: string; + /** + * Field mapped name. This is the name we want to use in the request. + * If omitted, we use the same value as `key`. + */ map?: string; } | { in: Extract; + /** + * Key isn't required for bodies. + */ key?: string; map?: string; }; diff --git a/examples/openapi-ts-axios/src/client/core/pathSerializer.ts b/examples/openapi-ts-axios/src/client/core/pathSerializer.gen.ts similarity index 98% rename from examples/openapi-ts-axios/src/client/core/pathSerializer.ts rename to examples/openapi-ts-axios/src/client/core/pathSerializer.gen.ts index d692cf0a39..8d99931047 100644 --- a/examples/openapi-ts-axios/src/client/core/pathSerializer.ts +++ b/examples/openapi-ts-axios/src/client/core/pathSerializer.gen.ts @@ -1,3 +1,5 @@ +// This file is auto-generated by @hey-api/openapi-ts + interface SerializeOptions extends SerializePrimitiveOptions, SerializerOptions {} diff --git a/examples/openapi-ts-axios/src/client/core/types.ts b/examples/openapi-ts-axios/src/client/core/types.gen.ts similarity index 78% rename from examples/openapi-ts-axios/src/client/core/types.ts rename to examples/openapi-ts-axios/src/client/core/types.gen.ts index 1f8688099f..5bfae35c0a 100644 --- a/examples/openapi-ts-axios/src/client/core/types.ts +++ b/examples/openapi-ts-axios/src/client/core/types.gen.ts @@ -1,9 +1,11 @@ -import type { Auth, AuthToken } from './auth'; +// This file is auto-generated by @hey-api/openapi-ts + +import type { Auth, AuthToken } from './auth.gen'; import type { BodySerializer, QuerySerializer, QuerySerializerOptions, -} from './bodySerializer'; +} from './bodySerializer.gen'; export interface Client< RequestFn = never, @@ -84,6 +86,12 @@ export interface Config { * {@link https://swagger.io/docs/specification/serialization/#query View examples} */ querySerializer?: QuerySerializer | QuerySerializerOptions; + /** + * A function validating request data. This is useful if you want to ensure + * the request conforms to the desired shape, so it can be safely sent to + * the server. + */ + requestValidator?: (data: unknown) => Promise; /** * A function transforming response data before it's returned. This is useful * for post-processing data, e.g. converting ISO strings into Date objects. @@ -96,3 +104,17 @@ export interface Config { */ responseValidator?: (data: unknown) => Promise; } + +type IsExactlyNeverOrNeverUndefined = [T] extends [never] + ? true + : [T] extends [never | undefined] + ? [undefined] extends [T] + ? false + : true + : false; + +export type OmitNever> = { + [K in keyof T as IsExactlyNeverOrNeverUndefined extends true + ? never + : K]: T[K]; +}; diff --git a/examples/openapi-ts-axios/src/client/sdk.gen.ts b/examples/openapi-ts-axios/src/client/sdk.gen.ts index 494ecfc7ba..59074a4f4e 100644 --- a/examples/openapi-ts-axios/src/client/sdk.gen.ts +++ b/examples/openapi-ts-axios/src/client/sdk.gen.ts @@ -138,9 +138,9 @@ export const updatePet = ( * Multiple status values can be provided with comma separated strings. */ export const findPetsByStatus = ( - options?: Options, + options: Options, ) => - (options?.client ?? _heyApiClient).get< + (options.client ?? _heyApiClient).get< FindPetsByStatusResponses, FindPetsByStatusErrors, ThrowOnError @@ -161,9 +161,9 @@ export const findPetsByStatus = ( * Multiple tags can be provided with comma separated strings. Use tag1, tag2, tag3 for testing. */ export const findPetsByTags = ( - options?: Options, + options: Options, ) => - (options?.client ?? _heyApiClient).get< + (options.client ?? _heyApiClient).get< FindPetsByTagsResponses, FindPetsByTagsErrors, ThrowOnError @@ -410,7 +410,7 @@ export const loginUser = ( LoginUserErrors, ThrowOnError >({ - responseType: 'blob', + responseType: 'json', url: '/user/login', ...options, }); diff --git a/examples/openapi-ts-axios/src/client/types.gen.ts b/examples/openapi-ts-axios/src/client/types.gen.ts index a96262b537..257d5446af 100644 --- a/examples/openapi-ts-axios/src/client/types.gen.ts +++ b/examples/openapi-ts-axios/src/client/types.gen.ts @@ -136,11 +136,11 @@ export type UpdatePetResponse = UpdatePetResponses[keyof UpdatePetResponses]; export type FindPetsByStatusData = { body?: never; path?: never; - query?: { + query: { /** * Status values that need to be considered for filter */ - status?: 'available' | 'pending' | 'sold'; + status: 'available' | 'pending' | 'sold'; }; url: '/pet/findByStatus'; }; @@ -169,11 +169,11 @@ export type FindPetsByStatusResponse = export type FindPetsByTagsData = { body?: never; path?: never; - query?: { + query: { /** * Tags to filter by */ - tags?: Array; + tags: Array; }; url: '/pet/findByTags'; }; @@ -560,7 +560,7 @@ export type LoginUserResponses = { /** * successful operation */ - 200: Blob | File; + 200: string; }; export type LoginUserResponse = LoginUserResponses[keyof LoginUserResponses]; diff --git a/packages/openapi-ts-tests/main/test/openapi-ts.config.ts b/packages/openapi-ts-tests/main/test/openapi-ts.config.ts index ff6897cdba..a7cbb2e241 100644 --- a/packages/openapi-ts-tests/main/test/openapi-ts.config.ts +++ b/packages/openapi-ts-tests/main/test/openapi-ts.config.ts @@ -39,6 +39,8 @@ export default defineConfig(() => { // 'full.yaml', // 'validators-circular-ref.json', ), + // https://registry.scalar.com/@lubos-heyapi-dev-team/apis/demo-api-scalar-galaxy/latest?format=json + // path: 'scalar:@lubos-heyapi-dev-team/demo-api-scalar-galaxy', // path: 'hey-api/backend', // path: 'hey-api/backend?branch=main&version=1.0.0', // path: 'https://get.heyapi.dev/hey-api/backend?branch=main&version=1.0.0', diff --git a/packages/openapi-ts/src/config/input.ts b/packages/openapi-ts/src/config/input.ts index c7a5bec31a..b1d18adb87 100644 --- a/packages/openapi-ts/src/config/input.ts +++ b/packages/openapi-ts/src/config/input.ts @@ -1,10 +1,7 @@ import type { Config, UserConfig } from '../types/config'; import type { Input } from '../types/input'; -import { - heyApiRegistryBaseUrl, - inputToHeyApiPath, -} from '../utils/input/heyApi'; -import { inputToReadmePath } from '../utils/input/readme'; +import { inputToApiRegistry } from '../utils/input'; +import { heyApiRegistryBaseUrl } from '../utils/input/heyApi'; const defaultWatch: Config['input']['watch'] = { enabled: false, @@ -69,20 +66,7 @@ export const getInput = (userConfig: UserConfig): Config['input'] => { } if (typeof input.path === 'string') { - if (input.path.startsWith('readme:')) { - input.path = inputToReadmePath(input.path); - } else if (!input.path.startsWith('.')) { - if (input.path.startsWith(heyApiRegistryBaseUrl)) { - input.path = input.path.slice(heyApiRegistryBaseUrl.length + 1); - input.path = inputToHeyApiPath(input as Input & { path: string }); - } else { - const parts = input.path.split('/'); - const cleanParts = parts.filter(Boolean); - if (parts.length === 2 && cleanParts.length === 2) { - input.path = inputToHeyApiPath(input as Input & { path: string }); - } - } - } + inputToApiRegistry(input as Input & { path: string }); } if ( diff --git a/packages/openapi-ts/src/types/config.d.ts b/packages/openapi-ts/src/types/config.d.ts index 22f034e0ce..a762bc8c37 100644 --- a/packages/openapi-ts/src/types/config.d.ts +++ b/packages/openapi-ts/src/types/config.d.ts @@ -33,6 +33,7 @@ export interface UserConfig { | `${string}/${string}` | `readme:@${string}/${string}#${string}` | `readme:${string}` + | `scalar:@${string}/${string}` | (string & {}) | (Record & { path?: never }) | Input; diff --git a/packages/openapi-ts/src/types/input.d.ts b/packages/openapi-ts/src/types/input.d.ts index 0f409aa04f..e5b26e5ff0 100644 --- a/packages/openapi-ts/src/types/input.d.ts +++ b/packages/openapi-ts/src/types/input.d.ts @@ -49,6 +49,7 @@ export type Input = { | `${string}/${string}` | `readme:@${string}/${string}#${string}` | `readme:${string}` + | `scalar:@${string}/${string}` | (string & {}) | Record; /** diff --git a/packages/openapi-ts/src/utils/input/__tests__/scalar.test.ts b/packages/openapi-ts/src/utils/input/__tests__/scalar.test.ts new file mode 100644 index 0000000000..80d591073f --- /dev/null +++ b/packages/openapi-ts/src/utils/input/__tests__/scalar.test.ts @@ -0,0 +1,136 @@ +import { describe, expect, it } from 'vitest'; + +import { + getRegistryUrl, + inputToScalarPath, + type Parsed, + parseShorthand, +} from '../scalar'; + +describe('readme utils', () => { + describe('parseShorthand', () => { + it('should parse full format with organization and project', () => { + const result = parseShorthand('@myorg/myproject'); + expect(result).toEqual({ + organization: '@myorg', + project: 'myproject', + }); + }); + + it('should parse organization and project with hyphens', () => { + const result = parseShorthand('@my-org/my-project'); + expect(result).toEqual({ + organization: '@my-org', + project: 'my-project', + }); + }); + + it('should throw error for invalid formats', () => { + expect(() => parseShorthand('')).toThrow( + 'Invalid Scalar shorthand format', + ); + expect(() => parseShorthand('@org')).toThrow( + 'Invalid Scalar shorthand format', + ); + expect(() => parseShorthand('@org/project#')).toThrow( + 'Invalid Scalar shorthand format', + ); + expect(() => parseShorthand('https://example.com')).toThrow( + 'Invalid Scalar shorthand format', + ); + }); + + it('should throw error for invalid UUID characters', () => { + expect(() => parseShorthand('abc@123')).toThrow( + 'Invalid Scalar shorthand format', + ); + expect(() => parseShorthand('abc/123')).toThrow( + 'Invalid Scalar shorthand format', + ); + expect(() => parseShorthand('abc#123')).toThrow( + 'Invalid Scalar shorthand format', + ); + expect(() => parseShorthand('abc 123')).toThrow( + 'Invalid Scalar shorthand format', + ); + }); + + it('should handle empty UUID', () => { + expect(() => parseShorthand('@org/project#')).toThrow( + 'Invalid Scalar shorthand format', + ); + }); + }); + + describe('getRegistryUrl', () => { + it('should generate correct URL', () => { + expect(getRegistryUrl('@foo', 'bar')).toBe( + 'https://registry.scalar.com/@foo/apis/bar/latest?format=json', + ); + expect(getRegistryUrl('@foo-with-hyphens', 'bar')).toBe( + 'https://registry.scalar.com/@foo-with-hyphens/apis/bar/latest?format=json', + ); + }); + }); + + describe('inputToScalarPath', () => { + it('should transform full format to API URL', () => { + const result = inputToScalarPath('scalar:@foo/bar'); + expect(result).toBe( + 'https://registry.scalar.com/@foo/apis/bar/latest?format=json', + ); + }); + + it('should throw error for invalid inputs', () => { + expect(() => inputToScalarPath('invalid')).toThrow( + 'Invalid Scalar shorthand format', + ); + expect(() => inputToScalarPath('')).toThrow( + 'Invalid Scalar shorthand format', + ); + }); + }); + + describe('integration scenarios', () => { + const validInputs: ReadonlyArray<{ expected: Parsed; input: string }> = [ + { + expected: { organization: '@org', project: 'proj' }, + input: '@org/proj', + }, + { + expected: { + organization: '@my-org', + project: 'my-project', + }, + input: '@my-org/my-project', + }, + ]; + + it.each(validInputs)( + 'should handle $input correctly', + ({ expected, input }) => { + expect(parseShorthand(input)).toEqual(expected); + expect(inputToScalarPath(`scalar:${input}`)).toBe( + `https://registry.scalar.com/${expected.organization}/apis/${expected.project}/latest?format=json`, + ); + }, + ); + + const invalidInputs = [ + '', + '@', + '@org', + '@org/', + 'uuid with spaces', + 'uuid@invalid', + 'uuid/invalid', + 'uuid#invalid', + 'https://example.com', + './local-file.yaml', + ]; + + it.each(invalidInputs)('should reject invalid input: %s', (input) => { + expect(() => parseShorthand(input)).toThrow(); + }); + }); +}); diff --git a/packages/openapi-ts/src/utils/input/heyApi.ts b/packages/openapi-ts/src/utils/input/heyApi.ts index 48fc2a925e..4eae02ff05 100644 --- a/packages/openapi-ts/src/utils/input/heyApi.ts +++ b/packages/openapi-ts/src/utils/input/heyApi.ts @@ -8,12 +8,12 @@ const registryRegExp = /^([\w-]+)\/([\w-]+)(?:\?([\w=&.-]*))?$/; export const heyApiRegistryBaseUrl = 'https://get.heyapi.dev'; /** - * Generates the Hey API Registry URL for a given UUID. + * Creates a full Hey API Registry URL. * - * @param organization - Organization slug - * @param project - Project slug + * @param organization - Hey API organization slug + * @param project - Hey API project slug * @param queryParams - Optional query parameters - * @returns The full API registry URL + * @returns The full Hey API registry URL. */ export const getRegistryUrl = ( organization: string, @@ -63,7 +63,7 @@ export const parseShorthand = ( } if (!project) { - throw new Error('The Hey API organization cannot be empty.'); + throw new Error('The Hey API project cannot be empty.'); } const result: Parsed = { diff --git a/packages/openapi-ts/src/utils/input/index.ts b/packages/openapi-ts/src/utils/input/index.ts new file mode 100644 index 0000000000..1da55b0b58 --- /dev/null +++ b/packages/openapi-ts/src/utils/input/index.ts @@ -0,0 +1,36 @@ +import type { Input } from '../../types/input'; +import { heyApiRegistryBaseUrl, inputToHeyApiPath } from './heyApi'; +import { inputToReadmePath } from './readme'; +import { inputToScalarPath } from './scalar'; + +export const inputToApiRegistry = ( + input: Input & { + path: string; + }, +) => { + if (input.path.startsWith('readme:')) { + input.path = inputToReadmePath(input.path); + return; + } + + if (input.path.startsWith('scalar:')) { + input.path = inputToScalarPath(input.path); + return; + } + + if (input.path.startsWith('.')) { + return; + } + + if (input.path.startsWith(heyApiRegistryBaseUrl)) { + input.path = input.path.slice(heyApiRegistryBaseUrl.length + 1); + input.path = inputToHeyApiPath(input as Input & { path: string }); + return; + } + + const parts = input.path.split('/'); + const cleanParts = parts.filter(Boolean); + if (parts.length === 2 && cleanParts.length === 2) { + input.path = inputToHeyApiPath(input as Input & { path: string }); + } +}; diff --git a/packages/openapi-ts/src/utils/input/readme.ts b/packages/openapi-ts/src/utils/input/readme.ts index 8a9c4f009f..cbdb8fb956 100644 --- a/packages/openapi-ts/src/utils/input/readme.ts +++ b/packages/openapi-ts/src/utils/input/readme.ts @@ -4,10 +4,10 @@ const registryRegExp = /^(@([\w-]+)\/([\w\-.]+)#)?([\w-]+)$/; /** - * Generates the Hey API Registry URL for a given UUID. + * Creates a full ReadMe API Registry URL. * - * @param uuid - The Hey API Registry UUID - * @returns The full API registry URL + * @param uuid - ReadMe UUID + * @returns The full ReadMe API registry URL. */ export const getRegistryUrl = (uuid: string): string => `https://dash.readme.com/api/v1/api-registry/${uuid}`; @@ -39,7 +39,7 @@ export const parseShorthand = (shorthand: string): Parsed => { const [, , organization, project, uuid] = match; if (!uuid) { - throw new Error('The ReadMe shorthand UUID cannot be empty.'); + throw new Error('The ReadMe UUID cannot be empty.'); } const result: Parsed = { diff --git a/packages/openapi-ts/src/utils/input/scalar.ts b/packages/openapi-ts/src/utils/input/scalar.ts new file mode 100644 index 0000000000..f8d16b38b4 --- /dev/null +++ b/packages/openapi-ts/src/utils/input/scalar.ts @@ -0,0 +1,66 @@ +// Regular expression to match Scalar API Registry input formats: +// - @{organization}/{project} +const registryRegExp = /^(@[\w-]+)\/([\w.-]+)$/; + +/** + * Creates a full Scalar API Registry URL. + * + * @param organization - Scalar organization slug + * @param project - Scalar project slug + * @returns The full Scalar API registry URL. + */ +export const getRegistryUrl = (organization: string, project: string): string => + `https://registry.scalar.com/${organization}/apis/${project}/latest?format=json`; + +export interface Parsed { + organization: string; + project: string; +} + +const namespace = 'scalar'; + +/** + * Parses a Scalar input string and extracts components. + * + * @param shorthand - Scalar format string (@org/project) + * @returns Parsed Scalar input components + * @throws Error if the input format is invalid + */ +export const parseShorthand = (shorthand: string): Parsed => { + const match = shorthand.match(registryRegExp); + + if (!match) { + throw new Error( + `Invalid Scalar shorthand format. Expected "${namespace}:@organization/project", received: ${namespace}:${shorthand}`, + ); + } + + const [, organization, project] = match; + + if (!organization) { + throw new Error('The Scalar organization cannot be empty.'); + } + + if (!project) { + throw new Error('The Scalar project cannot be empty.'); + } + + const result: Parsed = { + organization, + project, + }; + + return result; +}; + +/** + * Transforms a Scalar shorthand string to the corresponding API URL. + * + * @param input - Scalar format string + * @returns The Scalar API Registry URL + */ +export const inputToScalarPath = (input: string): string => { + const shorthand = input.slice(`${namespace}:`.length); + const parsed = parseShorthand(shorthand); + return getRegistryUrl(parsed.organization, parsed.project); +};