diff --git a/.changeset/soft-wolves-develop.md b/.changeset/soft-wolves-develop.md new file mode 100644 index 0000000000..457a104b66 --- /dev/null +++ b/.changeset/soft-wolves-develop.md @@ -0,0 +1,5 @@ +--- +"@hey-api/openapi-ts": patch +--- + +feat(parser): input supports ReadMe API Registry with `readme:` prefix diff --git a/docs/openapi-ts/configuration/input.md b/docs/openapi-ts/configuration/input.md index ad4e65b70e..000ad7f08b 100644 --- a/docs/openapi-ts/configuration/input.md +++ b/docs/openapi-ts/configuration/input.md @@ -52,6 +52,42 @@ export default { If you use an HTTPS URL with a self-signed certificate in development, you will need to set [`NODE_TLS_REJECT_UNAUTHORIZED=0`](https://github.com/hey-api/openapi-ts/issues/276#issuecomment-2043143501) in your environment. ::: +### ReadMe API Registry + +You can use ReadMe API Registry UUIDs to fetch OpenAPI specifications directly from ReadMe's platform. This is useful when API providers use ReadMe as their source of truth for API documentation. + +::: code-group + +```js [simple format] +export default { + input: 'readme:abc123def456', // [!code ++] +}; +``` + +```js [full format] +export default { + input: 'readme:@organization/project#abc123def456', // [!code ++] +}; +``` + +```js [object format] +export default { + input: { + path: 'readme:abc123def456', // [!code ++] + // ...other options + }, +}; +``` + +::: + +The ReadMe input formats are: + +- `readme:uuid` - Simple format using only the UUID +- `readme:@organization/project#uuid` - Full format including organization and project names + +Both formats will fetch the OpenAPI specification from `https://dash.readme.com/api/v1/api-registry/{uuid}`. + ### Hey API Platform options You might want to use the [Hey API Platform](/openapi-ts/integrations) to store your specifications. If you do so, the `input` object provides options to help with constructing the correct URL. diff --git a/examples/openapi-ts-openai/src/client/types.gen.ts b/examples/openapi-ts-openai/src/client/types.gen.ts index 9cbdacdb4e..78630ab514 100644 --- a/examples/openapi-ts-openai/src/client/types.gen.ts +++ b/examples/openapi-ts-openai/src/client/types.gen.ts @@ -194,48 +194,48 @@ export type AssistantStreamEvent = } & ErrorEvent); export const AssistantSupportedModels = { + GPT_3_5_TURBO: 'gpt-3.5-turbo', + GPT_3_5_TURBO_0125: 'gpt-3.5-turbo-0125', + GPT_3_5_TURBO_0613: 'gpt-3.5-turbo-0613', + GPT_3_5_TURBO_1106: 'gpt-3.5-turbo-1106', + GPT_3_5_TURBO_16K: 'gpt-3.5-turbo-16k', + GPT_3_5_TURBO_16K_0613: 'gpt-3.5-turbo-16k-0613', + GPT_4: 'gpt-4', GPT_4O: 'gpt-4o', GPT_4O_2024_05_13: 'gpt-4o-2024-05-13', GPT_4O_2024_08_06: 'gpt-4o-2024-08-06', GPT_4O_2024_11_20: 'gpt-4o-2024-11-20', - GPT_4_1: 'gpt-4.1', GPT_4O_MINI: 'gpt-4o-mini', - GPT_4_1_2025_04_14: 'gpt-4.1-2025-04-14', GPT_4O_MINI_2024_07_18: 'gpt-4o-mini-2024-07-18', - GPT_4_1_MINI: 'gpt-4.1-mini', GPT_4_0125_PREVIEW: 'gpt-4-0125-preview', - GPT_4_1_MINI_2025_04_14: 'gpt-4.1-mini-2025-04-14', - GPT_4: 'gpt-4', - GPT_4_1_NANO: 'gpt-4.1-nano', GPT_4_0314: 'gpt-4-0314', - GPT_5: 'gpt-5', GPT_4_0613: 'gpt-4-0613', - GPT_5_2025_08_07: 'gpt-5-2025-08-07', - GPT_3_5_TURBO: 'gpt-3.5-turbo', - GPT_5_MINI: 'gpt-5-mini', - GPT_3_5_TURBO_0613: 'gpt-3.5-turbo-0613', - GPT_5_MINI_2025_08_07: 'gpt-5-mini-2025-08-07', - GPT_3_5_TURBO_0125: 'gpt-3.5-turbo-0125', - GPT_5_NANO: 'gpt-5-nano', - GPT_3_5_TURBO_1106: 'gpt-3.5-turbo-1106', - GPT_3_5_TURBO_16K: 'gpt-3.5-turbo-16k', - GPT_5_NANO_2025_08_07: 'gpt-5-nano-2025-08-07', - GPT_3_5_TURBO_16K_0613: 'gpt-3.5-turbo-16k-0613', - GPT_4_1_NANO_2025_04_14: 'gpt-4.1-nano-2025-04-14', + GPT_4_1: 'gpt-4.1', GPT_4_1106_PREVIEW: 'gpt-4-1106-preview', - O1: 'o1', + GPT_4_1_2025_04_14: 'gpt-4.1-2025-04-14', + GPT_4_1_MINI: 'gpt-4.1-mini', + GPT_4_1_MINI_2025_04_14: 'gpt-4.1-mini-2025-04-14', + GPT_4_1_NANO: 'gpt-4.1-nano', + GPT_4_1_NANO_2025_04_14: 'gpt-4.1-nano-2025-04-14', GPT_4_32K: 'gpt-4-32k', - O3_MINI: 'o3-mini', GPT_4_32K_0314: 'gpt-4-32k-0314', - O3_MINI_2025_01_31: 'o3-mini-2025-01-31', GPT_4_32K_0613: 'gpt-4-32k-0613', - O1_2024_12_17: 'o1-2024-12-17', GPT_4_5_PREVIEW: 'gpt-4.5-preview', GPT_4_5_PREVIEW_2025_02_27: 'gpt-4.5-preview-2025-02-27', GPT_4_TURBO: 'gpt-4-turbo', GPT_4_TURBO_2024_04_09: 'gpt-4-turbo-2024-04-09', GPT_4_TURBO_PREVIEW: 'gpt-4-turbo-preview', GPT_4_VISION_PREVIEW: 'gpt-4-vision-preview', + GPT_5: 'gpt-5', + GPT_5_2025_08_07: 'gpt-5-2025-08-07', + GPT_5_MINI: 'gpt-5-mini', + GPT_5_MINI_2025_08_07: 'gpt-5-mini-2025-08-07', + GPT_5_NANO: 'gpt-5-nano', + GPT_5_NANO_2025_08_07: 'gpt-5-nano-2025-08-07', + O1: 'o1', + O1_2024_12_17: 'o1-2024-12-17', + O3_MINI: 'o3-mini', + O3_MINI_2025_01_31: 'o3-mini-2025-01-31', } as const; export type AssistantSupportedModels = @@ -702,38 +702,58 @@ export type AuditLog = { /** * The details for events with this `type`. */ - 'user.added'?: { - /** - * The user ID. - */ - id?: string; + 'rate_limit.updated'?: { /** - * The payload used to add the user to the project. + * The payload used to update the rate limits. */ - data?: { + changes_requested?: { /** - * The role of the user. Is either `owner` or `member`. + * The maximum batch input tokens per day. Only relevant for certain models. */ - role?: string; + batch_1_day_max_input_tokens?: number; + /** + * The maximum audio megabytes per minute. Only relevant for certain models. + */ + max_audio_megabytes_per_1_minute?: number; + /** + * The maximum images per minute. Only relevant for certain models. + */ + max_images_per_1_minute?: number; + /** + * The maximum requests per day. Only relevant for certain models. + */ + max_requests_per_1_day?: number; + /** + * The maximum requests per minute. + */ + max_requests_per_1_minute?: number; + /** + * The maximum tokens per minute. + */ + max_tokens_per_1_minute?: number; }; + /** + * The rate limit ID + */ + id?: string; }; /** * The details for events with this `type`. */ - 'user.updated'?: { - /** - * The project ID. - */ - id?: string; + 'service_account.created'?: { /** - * The payload used to update the user. + * The payload used to create the service account. */ - changes_requested?: { + data?: { /** - * The role of the user. Is either `owner` or `member`. + * The role of the service account. Is either `owner` or `member`. */ role?: string; }; + /** + * The service account ID. + */ + id?: string; }; /** * The details for events with this `type`. @@ -762,69 +782,49 @@ export type AuditLog = { */ id?: string; }; + type: AuditLogEventType; /** * The details for events with this `type`. */ - 'rate_limit.updated'?: { + 'user.added'?: { /** - * The payload used to update the rate limits. + * The payload used to add the user to the project. */ - changes_requested?: { - /** - * The maximum requests per minute. - */ - max_requests_per_1_minute?: number; - /** - * The maximum tokens per minute. - */ - max_tokens_per_1_minute?: number; - /** - * The maximum images per minute. Only relevant for certain models. - */ - max_images_per_1_minute?: number; - /** - * The maximum audio megabytes per minute. Only relevant for certain models. - */ - max_audio_megabytes_per_1_minute?: number; - /** - * The maximum requests per day. Only relevant for certain models. - */ - max_requests_per_1_day?: number; + data?: { /** - * The maximum batch input tokens per day. Only relevant for certain models. + * The role of the user. Is either `owner` or `member`. */ - batch_1_day_max_input_tokens?: number; + role?: string; }; /** - * The rate limit ID + * The user ID. */ id?: string; }; - type: AuditLogEventType; /** * The details for events with this `type`. */ - 'service_account.created'?: { - /** - * The payload used to create the service account. - */ - data?: { - /** - * The role of the service account. Is either `owner` or `member`. - */ - role?: string; - }; + 'user.deleted'?: { /** - * The service account ID. + * The user ID. */ id?: string; }; /** * The details for events with this `type`. */ - 'user.deleted'?: { + 'user.updated'?: { /** - * The user ID. + * The payload used to update the user. + */ + changes_requested?: { + /** + * The role of the user. Is either `owner` or `member`. + */ + role?: string; + }; + /** + * The project ID. */ id?: string; }; @@ -18135,54 +18135,29 @@ export type MessageContentDelta = } & MessageDeltaContentImageUrlObject); export const ChatModel = { - GPT_4_1: 'gpt-4.1', - GPT_4_1_2025_04_14: 'gpt-4.1-2025-04-14', - GPT_4_1_MINI: 'gpt-4.1-mini', - GPT_4_1_MINI_2025_04_14: 'gpt-4.1-mini-2025-04-14', - GPT_4_1_NANO: 'gpt-4.1-nano', + CHATGPT_4O_LATEST: 'chatgpt-4o-latest', + CODEX_MINI_LATEST: 'codex-mini-latest', + GPT_3_5_TURBO: 'gpt-3.5-turbo', + GPT_3_5_TURBO_0125: 'gpt-3.5-turbo-0125', + GPT_3_5_TURBO_0301: 'gpt-3.5-turbo-0301', + GPT_3_5_TURBO_0613: 'gpt-3.5-turbo-0613', + GPT_3_5_TURBO_1106: 'gpt-3.5-turbo-1106', + GPT_3_5_TURBO_16K: 'gpt-3.5-turbo-16k', + GPT_3_5_TURBO_16K_0613: 'gpt-3.5-turbo-16k-0613', + GPT_4: 'gpt-4', GPT_4O: 'gpt-4o', - GPT_4_1_NANO_2025_04_14: 'gpt-4.1-nano-2025-04-14', - GPT_4O_2024_08_06: 'gpt-4o-2024-08-06', - GPT_5: 'gpt-5', GPT_4O_2024_05_13: 'gpt-4o-2024-05-13', - GPT_5_2025_08_07: 'gpt-5-2025-08-07', + GPT_4O_2024_08_06: 'gpt-4o-2024-08-06', GPT_4O_2024_11_20: 'gpt-4o-2024-11-20', - GPT_5_CHAT_LATEST: 'gpt-5-chat-latest', GPT_4O_AUDIO_PREVIEW: 'gpt-4o-audio-preview', - GPT_5_MINI: 'gpt-5-mini', GPT_4O_AUDIO_PREVIEW_2024_10_01: 'gpt-4o-audio-preview-2024-10-01', - GPT_5_MINI_2025_08_07: 'gpt-5-mini-2025-08-07', GPT_4O_AUDIO_PREVIEW_2024_12_17: 'gpt-4o-audio-preview-2024-12-17', - GPT_5_NANO: 'gpt-5-nano', GPT_4O_AUDIO_PREVIEW_2025_06_03: 'gpt-4o-audio-preview-2025-06-03', - GPT_5_NANO_2025_08_07: 'gpt-5-nano-2025-08-07', - CHATGPT_4O_LATEST: 'chatgpt-4o-latest', - O1: 'o1', - CODEX_MINI_LATEST: 'codex-mini-latest', - O1_2024_12_17: 'o1-2024-12-17', GPT_4O_MINI: 'gpt-4o-mini', - O1_MINI: 'o1-mini', GPT_4O_MINI_2024_07_18: 'gpt-4o-mini-2024-07-18', - O3: 'o3', - GPT_4: 'gpt-4', - O3_2025_04_16: 'o3-2025-04-16', GPT_4O_MINI_AUDIO_PREVIEW: 'gpt-4o-mini-audio-preview', - O4_MINI: 'o4-mini', GPT_4O_MINI_AUDIO_PREVIEW_2024_12_17: 'gpt-4o-mini-audio-preview-2024-12-17', - O4_MINI_2025_04_16: 'o4-mini-2025-04-16', - GPT_3_5_TURBO: 'gpt-3.5-turbo', - O3_MINI: 'o3-mini', - GPT_3_5_TURBO_0301: 'gpt-3.5-turbo-0301', - O3_MINI_2025_01_31: 'o3-mini-2025-01-31', - GPT_3_5_TURBO_0613: 'gpt-3.5-turbo-0613', - O1_PREVIEW: 'o1-preview', - GPT_3_5_TURBO_0125: 'gpt-3.5-turbo-0125', - O1_PREVIEW_2024_09_12: 'o1-preview-2024-09-12', - GPT_3_5_TURBO_1106: 'gpt-3.5-turbo-1106', - O1_MINI_2024_09_12: 'o1-mini-2024-09-12', - GPT_3_5_TURBO_16K: 'gpt-3.5-turbo-16k', GPT_4O_MINI_SEARCH_PREVIEW: 'gpt-4o-mini-search-preview', - GPT_3_5_TURBO_16K_0613: 'gpt-3.5-turbo-16k-0613', GPT_4O_MINI_SEARCH_PREVIEW_2025_03_11: 'gpt-4o-mini-search-preview-2025-03-11', GPT_4O_SEARCH_PREVIEW: 'gpt-4o-search-preview', @@ -18190,7 +18165,13 @@ export const ChatModel = { GPT_4_0125_PREVIEW: 'gpt-4-0125-preview', GPT_4_0314: 'gpt-4-0314', GPT_4_0613: 'gpt-4-0613', + GPT_4_1: 'gpt-4.1', GPT_4_1106_PREVIEW: 'gpt-4-1106-preview', + GPT_4_1_2025_04_14: 'gpt-4.1-2025-04-14', + GPT_4_1_MINI: 'gpt-4.1-mini', + GPT_4_1_MINI_2025_04_14: 'gpt-4.1-mini-2025-04-14', + GPT_4_1_NANO: 'gpt-4.1-nano', + GPT_4_1_NANO_2025_04_14: 'gpt-4.1-nano-2025-04-14', GPT_4_32K: 'gpt-4-32k', GPT_4_32K_0314: 'gpt-4-32k-0314', GPT_4_32K_0613: 'gpt-4-32k-0613', @@ -18198,6 +18179,25 @@ export const ChatModel = { GPT_4_TURBO_2024_04_09: 'gpt-4-turbo-2024-04-09', GPT_4_TURBO_PREVIEW: 'gpt-4-turbo-preview', GPT_4_VISION_PREVIEW: 'gpt-4-vision-preview', + GPT_5: 'gpt-5', + GPT_5_2025_08_07: 'gpt-5-2025-08-07', + GPT_5_CHAT_LATEST: 'gpt-5-chat-latest', + GPT_5_MINI: 'gpt-5-mini', + GPT_5_MINI_2025_08_07: 'gpt-5-mini-2025-08-07', + GPT_5_NANO: 'gpt-5-nano', + GPT_5_NANO_2025_08_07: 'gpt-5-nano-2025-08-07', + O1: 'o1', + O1_2024_12_17: 'o1-2024-12-17', + O1_MINI: 'o1-mini', + O1_MINI_2024_09_12: 'o1-mini-2024-09-12', + O1_PREVIEW: 'o1-preview', + O1_PREVIEW_2024_09_12: 'o1-preview-2024-09-12', + O3: 'o3', + O3_2025_04_16: 'o3-2025-04-16', + O3_MINI: 'o3-mini', + O3_MINI_2025_01_31: 'o3-mini-2025-01-31', + O4_MINI: 'o4-mini', + O4_MINI_2025_04_16: 'o4-mini-2025-04-16', } as const; export type ChatModel = (typeof ChatModel)[keyof typeof ChatModel]; diff --git a/packages/openapi-ts/src/config/__tests__/input.test.ts b/packages/openapi-ts/src/config/__tests__/input.test.ts new file mode 100644 index 0000000000..96f44d8fc2 --- /dev/null +++ b/packages/openapi-ts/src/config/__tests__/input.test.ts @@ -0,0 +1,204 @@ +import { describe, expect, it } from 'vitest'; + +import type { UserConfig } from '../../types/config'; +import { getInput } from '../input'; + +describe('input config', () => { + describe('getInput', () => { + it('should handle string input', () => { + const userConfig: UserConfig = { + input: 'https://example.com/openapi.yaml', + output: 'src/client', + }; + + const result = getInput(userConfig); + expect(result.path).toBe('https://example.com/openapi.yaml'); + }); + + it('should transform ReadMe simple format input', () => { + const userConfig: UserConfig = { + input: 'readme:abc123', + output: 'src/client', + }; + + const result = getInput(userConfig); + expect(result.path).toBe( + 'https://dash.readme.com/api/v1/api-registry/abc123', + ); + }); + + it('should transform ReadMe full format input', () => { + const userConfig: UserConfig = { + input: 'readme:@myorg/myproject#uuid123', + output: 'src/client', + }; + + const result = getInput(userConfig); + expect(result.path).toBe( + 'https://dash.readme.com/api/v1/api-registry/uuid123', + ); + }); + + it('should handle ReadMe input with hyphens', () => { + const userConfig: UserConfig = { + input: 'readme:@my-org/my-project#test-uuid-123', + output: 'src/client', + }; + + const result = getInput(userConfig); + expect(result.path).toBe( + 'https://dash.readme.com/api/v1/api-registry/test-uuid-123', + ); + }); + + it('should handle object input with ReadMe path', () => { + const userConfig: UserConfig = { + input: { + fetch: { + headers: { + Authorization: 'Bearer token', + }, + }, + path: 'readme:abc123', + }, + output: 'src/client', + }; + + const result = getInput(userConfig); + expect(result.path).toBe( + 'https://dash.readme.com/api/v1/api-registry/abc123', + ); + }); + + it('should handle object input with ReadMe full format path', () => { + const userConfig: UserConfig = { + input: { + path: 'readme:@org/project#uuid', + watch: true, + }, + output: 'src/client', + }; + + const result = getInput(userConfig); + expect(result.path).toBe( + 'https://dash.readme.com/api/v1/api-registry/uuid', + ); + expect(result.watch.enabled).toBe(true); + }); + + it('should handle HeyAPI input format (existing functionality)', () => { + const userConfig: UserConfig = { + input: { + organization: 'myorg', + project: 'myproject', + }, + output: 'src/client', + }; + + const result = getInput(userConfig); + expect(result.path).toBe('https://get.heyapi.dev'); + }); + + it('should handle object input (existing functionality)', () => { + const userConfig: UserConfig = { + input: { + info: { title: 'Test API', version: '1.0.0' }, + openapi: '3.0.0', + }, + output: 'src/client', + }; + + const result = getInput(userConfig); + expect(result.path).toEqual({ + info: { title: 'Test API', version: '1.0.0' }, + openapi: '3.0.0', + }); + }); + + it('should not transform non-ReadMe string inputs', () => { + const inputs = [ + 'https://example.com/openapi.yaml', + './local-file.yaml', + '/absolute/path/to/file.json', + 'file.yaml', + ]; + + inputs.forEach((input) => { + const userConfig: UserConfig = { input, output: 'src/client' }; + const result = getInput(userConfig); + expect(result.path).toBe(input); + }); + }); + + it('should handle watch options with ReadMe inputs', () => { + const userConfig: UserConfig = { + input: 'readme:abc123', + output: 'src/client', + watch: { + enabled: true, + interval: 2000, + }, + }; + + const result = getInput(userConfig); + expect(result.path).toBe( + 'https://dash.readme.com/api/v1/api-registry/abc123', + ); + expect(result.watch.enabled).toBe(true); + expect(result.watch.interval).toBe(2000); + }); + + it('should preserve other input object properties when transforming ReadMe path', () => { + const userConfig: UserConfig = { + input: { + fetch: { + headers: { 'X-Custom': 'value' }, + }, + path: 'readme:test123', + watch: { enabled: true, interval: 1500 }, + }, + output: 'src/client', + }; + + const result = getInput(userConfig); + expect(result.path).toBe( + 'https://dash.readme.com/api/v1/api-registry/test123', + ); + // Note: fetch options are preserved in the input object, not in the result + // The watch options should be processed separately + expect(result.watch.enabled).toBe(true); + expect(result.watch.interval).toBe(1500); + }); + }); + + describe('error handling', () => { + it('should throw error for invalid ReadMe format', () => { + const userConfig: UserConfig = { + input: 'readme:', + output: 'src/client', + }; + + expect(() => getInput(userConfig)).toThrow('Invalid ReadMe input format'); + }); + + it('should throw error for invalid ReadMe UUID', () => { + const userConfig: UserConfig = { + input: 'readme:invalid uuid with spaces', + output: 'src/client', + }; + + expect(() => getInput(userConfig)).toThrow('Invalid ReadMe input format'); + }); + + it('should throw error for invalid ReadMe format in object input', () => { + const userConfig: UserConfig = { + input: { + path: 'readme:@org/project', + }, + output: 'src/client', + }; + + expect(() => getInput(userConfig)).toThrow('Invalid ReadMe input format'); + }); + }); +}); diff --git a/packages/openapi-ts/src/config/input.ts b/packages/openapi-ts/src/config/input.ts index 02ef5c758d..0b54e7802b 100644 --- a/packages/openapi-ts/src/config/input.ts +++ b/packages/openapi-ts/src/config/input.ts @@ -1,4 +1,5 @@ import type { Config, UserConfig } from '../types/config'; +import { isReadmeInput, transformReadmeInput } from '../utils/readme'; const defaultWatch: Config['input']['watch'] = { enabled: false, @@ -38,7 +39,12 @@ export const getInput = (userConfig: UserConfig): Config['input'] => { }; if (typeof userConfig.input === 'string') { - input.path = userConfig.input; + // Handle ReadMe input format transformation + if (isReadmeInput(userConfig.input)) { + input.path = transformReadmeInput(userConfig.input); + } else { + input.path = userConfig.input; + } } else if ( userConfig.input && (userConfig.input.path !== undefined || @@ -51,6 +57,11 @@ export const getInput = (userConfig: UserConfig): Config['input'] => { ...userConfig.input, }; + // Handle ReadMe input format transformation when path is specified + if (typeof input.path === 'string' && isReadmeInput(input.path)) { + input.path = transformReadmeInput(input.path); + } + // watch only remote files if (input.watch !== undefined) { input.watch = getWatch(input); diff --git a/packages/openapi-ts/src/types/config.d.ts b/packages/openapi-ts/src/types/config.d.ts index 460079172b..badda21182 100644 --- a/packages/openapi-ts/src/types/config.d.ts +++ b/packages/openapi-ts/src/types/config.d.ts @@ -18,14 +18,20 @@ export interface UserConfig { */ dryRun?: boolean; /** - * Path to the OpenAPI specification. This can be either local or remote path. + * Path to the OpenAPI specification. This can be either: + * - local file + * - remote path + * - ReadMe API Registry UUID (full and simplified formats) + * * Both JSON and YAML file formats are supported. You can also pass the parsed * object directly if you're fetching the file yourself. * * Alternatively, you can define a configuration object with more options. */ input: - | 'https://get.heyapi.dev//' + | `https://get.heyapi.dev/${string}/${string}` + | `readme:@${string}/${string}#${string}` + | `readme:${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 7341294fb1..3e68a00463 100644 --- a/packages/openapi-ts/src/types/input.d.ts +++ b/packages/openapi-ts/src/types/input.d.ts @@ -36,12 +36,18 @@ export type Input = { */ organization?: string; /** - * Path to the OpenAPI specification. This can be either local or remote path. + * Path to the OpenAPI specification. This can be either: + * - local file + * - remote path + * - ReadMe API Registry UUID (full and simplified formats) + * * Both JSON and YAML file formats are supported. You can also pass the parsed * object directly if you're fetching the file yourself. */ path?: - | 'https://get.heyapi.dev//' + | `https://get.heyapi.dev/${string}/${string}` + | `readme:@${string}/${string}#${string}` + | `readme:${string}` | (string & {}) | Record; /** diff --git a/packages/openapi-ts/src/utils/__tests__/readme.test.ts b/packages/openapi-ts/src/utils/__tests__/readme.test.ts new file mode 100644 index 0000000000..a0dc36ab39 --- /dev/null +++ b/packages/openapi-ts/src/utils/__tests__/readme.test.ts @@ -0,0 +1,199 @@ +import { describe, expect, it } from 'vitest'; + +import { + getReadmeApiUrl, + isReadmeInput, + parseReadmeInput, + type ReadmeInput, + transformReadmeInput, +} from '../readme'; + +describe('readme utils', () => { + describe('isReadmeInput', () => { + it('should return true for valid ReadMe formats', () => { + expect(isReadmeInput('readme:abc123')).toBe(true); + expect(isReadmeInput('readme:@org/project#uuid123')).toBe(true); + expect(isReadmeInput('readme:test-uuid-with-hyphens')).toBe(true); + }); + + it('should return false for non-ReadMe inputs', () => { + expect(isReadmeInput('https://example.com')).toBe(false); + expect(isReadmeInput('./local-file.yaml')).toBe(false); + expect(isReadmeInput('random-string')).toBe(false); + expect(isReadmeInput('')).toBe(false); + expect(isReadmeInput('readme')).toBe(false); + expect(isReadmeInput('readmeabc123')).toBe(false); + }); + + it('should handle non-string inputs', () => { + expect(isReadmeInput(123 as any)).toBe(false); + expect(isReadmeInput(null as any)).toBe(false); + expect(isReadmeInput(undefined as any)).toBe(false); + expect(isReadmeInput({} as any)).toBe(false); + }); + }); + + describe('parseReadmeInput', () => { + it('should parse simple UUID format', () => { + const result = parseReadmeInput('readme:abc123'); + expect(result).toEqual({ uuid: 'abc123' }); + }); + + it('should parse UUID with hyphens', () => { + const result = parseReadmeInput('readme:test-uuid-123'); + expect(result).toEqual({ uuid: 'test-uuid-123' }); + }); + + it('should parse full format with organization and project', () => { + const result = parseReadmeInput('readme:@myorg/myproject#uuid123'); + expect(result).toEqual({ + organization: 'myorg', + project: 'myproject', + uuid: 'uuid123', + }); + }); + + it('should parse organization and project with hyphens', () => { + const result = parseReadmeInput('readme:@my-org/my-project#test-uuid'); + expect(result).toEqual({ + organization: 'my-org', + project: 'my-project', + uuid: 'test-uuid', + }); + }); + + it('should throw error for invalid formats', () => { + expect(() => parseReadmeInput('readme:')).toThrow( + 'Invalid ReadMe input format', + ); + expect(() => parseReadmeInput('readme:@org')).toThrow( + 'Invalid ReadMe input format', + ); + expect(() => parseReadmeInput('readme:@org/project')).toThrow( + 'Invalid ReadMe input format', + ); + expect(() => parseReadmeInput('readme:@org/project#')).toThrow( + 'Invalid ReadMe input format', + ); + expect(() => parseReadmeInput('https://example.com')).toThrow( + 'Invalid ReadMe input format', + ); + }); + + it('should throw error for invalid UUID characters', () => { + expect(() => parseReadmeInput('readme:abc@123')).toThrow( + 'Invalid ReadMe input format', + ); + expect(() => parseReadmeInput('readme:abc/123')).toThrow( + 'Invalid ReadMe input format', + ); + expect(() => parseReadmeInput('readme:abc#123')).toThrow( + 'Invalid ReadMe input format', + ); + expect(() => parseReadmeInput('readme:abc 123')).toThrow( + 'Invalid ReadMe input format', + ); + }); + + it('should handle empty UUID', () => { + expect(() => parseReadmeInput('readme:@org/project#')).toThrow( + 'Invalid ReadMe input format', + ); + }); + }); + + describe('getReadmeApiUrl', () => { + it('should generate correct API URL', () => { + expect(getReadmeApiUrl('abc123')).toBe( + 'https://dash.readme.com/api/v1/api-registry/abc123', + ); + expect(getReadmeApiUrl('test-uuid-with-hyphens')).toBe( + 'https://dash.readme.com/api/v1/api-registry/test-uuid-with-hyphens', + ); + }); + }); + + describe('transformReadmeInput', () => { + it('should transform simple UUID format to API URL', () => { + const result = transformReadmeInput('readme:abc123'); + expect(result).toBe('https://dash.readme.com/api/v1/api-registry/abc123'); + }); + + it('should transform full format to API URL', () => { + const result = transformReadmeInput('readme:@myorg/myproject#uuid123'); + expect(result).toBe( + 'https://dash.readme.com/api/v1/api-registry/uuid123', + ); + }); + + it('should throw error for invalid inputs', () => { + expect(() => transformReadmeInput('invalid')).toThrow( + 'Invalid ReadMe input format', + ); + expect(() => transformReadmeInput('readme:')).toThrow( + 'Invalid ReadMe input format', + ); + }); + }); + + describe('integration scenarios', () => { + const validInputs: Array<{ expected: ReadmeInput; input: string }> = [ + { expected: { uuid: 'simple123' }, input: 'readme:simple123' }, + { + expected: { uuid: 'uuid-with-hyphens' }, + input: 'readme:uuid-with-hyphens', + }, + { expected: { uuid: 'UUID123' }, input: 'readme:UUID123' }, + { + expected: { organization: 'org', project: 'proj', uuid: 'uuid' }, + input: 'readme:@org/proj#uuid', + }, + { + expected: { + organization: 'my-org', + project: 'my-project', + uuid: 'my-uuid', + }, + input: 'readme:@my-org/my-project#my-uuid', + }, + ]; + + it.each(validInputs)( + 'should handle $input correctly', + ({ expected, input }) => { + expect(isReadmeInput(input)).toBe(true); + expect(parseReadmeInput(input)).toEqual(expected); + expect(transformReadmeInput(input)).toBe( + `https://dash.readme.com/api/v1/api-registry/${expected.uuid}`, + ); + }, + ); + + const invalidInputs = [ + 'readme:', + 'readme:@', + 'readme:@org', + 'readme:@org/', + 'readme:@org/proj', + 'readme:@org/proj#', + 'readme:uuid with spaces', + 'readme:uuid@invalid', + 'readme:uuid/invalid', + 'readme:uuid#invalid', + 'https://example.com', + './local-file.yaml', + 'random-string', + '', + ]; + + it.each(invalidInputs)('should reject invalid input: %s', (input) => { + if (isReadmeInput(input)) { + expect(() => parseReadmeInput(input)).toThrow(); + } else { + expect(() => parseReadmeInput(input)).toThrow( + 'Invalid ReadMe input format', + ); + } + }); + }); +}); diff --git a/packages/openapi-ts/src/utils/readme.ts b/packages/openapi-ts/src/utils/readme.ts new file mode 100644 index 0000000000..583d276f61 --- /dev/null +++ b/packages/openapi-ts/src/utils/readme.ts @@ -0,0 +1,73 @@ +// Regular expression to match ReadMe input formats: +// readme:@organization/project#uuid or readme:uuid +const readmeInputRegExp = /^readme:(?:@([\w-]+)\/([\w-]+)#)?([\w-]+)$/; + +export interface ReadmeInput { + organization?: string; + project?: string; + uuid: string; +} + +/** + * Checks if the input string is a ReadMe format + * @param input - The input string to check + * @returns true if the input matches ReadMe format patterns + */ +export const isReadmeInput = (input: string): boolean => + typeof input === 'string' && input.startsWith('readme:'); + +/** + * Parses a ReadMe input string and extracts components + * @param input - ReadMe format string (readme:@org/project#uuid or readme:uuid) + * @returns Parsed ReadMe input components + * @throws Error if the input format is invalid + */ +export const parseReadmeInput = (input: string): ReadmeInput => { + if (!isReadmeInput(input)) { + throw new Error( + `Invalid ReadMe input format. Expected "readme:@organization/project#uuid" or "readme:uuid", received: ${input}`, + ); + } + + const match = input.match(readmeInputRegExp); + + if (!match) { + throw new Error( + `Invalid ReadMe input format. Expected "readme:@organization/project#uuid" or "readme:uuid", received: ${input}`, + ); + } + + const [, organization, project, uuid] = match; + + // Validate UUID format (basic validation for alphanumeric + hyphens) + if (!uuid || !/^[\w-]+$/.test(uuid)) { + throw new Error(`Invalid UUID format: ${uuid}`); + } + + const result: ReadmeInput = { uuid }; + + if (organization && project) { + result.organization = organization; + result.project = project; + } + + return result; +}; + +/** + * Generates the ReadMe API Registry URL for a given UUID + * @param uuid - The ReadMe API Registry UUID + * @returns The full API URL + */ +export const getReadmeApiUrl = (uuid: string): string => + `https://dash.readme.com/api/v1/api-registry/${uuid}`; + +/** + * Transforms a ReadMe input string to the corresponding API URL + * @param input - ReadMe format string + * @returns The ReadMe API Registry URL + */ +export const transformReadmeInput = (input: string): string => { + const parsed = parseReadmeInput(input); + return getReadmeApiUrl(parsed.uuid); +};