diff --git a/web/gen/orpc.gen.ts b/web/gen/orpc.gen.ts index cb9202d5be..9a94671183 100644 --- a/web/gen/orpc.gen.ts +++ b/web/gen/orpc.gen.ts @@ -2,7 +2,8 @@ import { oc } from '@orpc/contract' import { z } from 'zod' -import { zAudioToTextData, zAudioToTextResponse, zCreateAnnotationData, zCreateAnnotationResponse, zDeleteAnnotationData, zDeleteConversationData, zGetAnnotationListData, zGetAnnotationListResponse, zGetChatAppFeedbacksData, zGetChatAppFeedbacksResponse, zGetChatAppInfoResponse, zGetChatAppMetaResponse, zGetChatAppParametersData, zGetChatAppParametersResponse, zGetChatWebAppSettingsResponse, zGetConversationHistoryData, zGetConversationHistoryResponse, zGetConversationsListData, zGetConversationsListResponse, zGetConversationVariablesData, zGetConversationVariablesResponse, zGetInitialAnnotationReplySettingsStatusData, zGetInitialAnnotationReplySettingsStatusResponse, zGetSuggestedQuestionsData, zGetSuggestedQuestionsResponse, zInitialAnnotationReplySettingsData, zInitialAnnotationReplySettingsResponse, zPostChatMessageFeedbackData, zPostChatMessageFeedbackResponse, zPreviewChatFileData, zPreviewChatFileResponse, zRenameConversationData, zRenameConversationResponse, zSendChatMessageData, zSendChatMessageResponse, zStopChatMessageGenerationData, zStopChatMessageGenerationResponse, zTextToAudioChatData, zTextToAudioChatResponse, zUpdateAnnotationData, zUpdateAnnotationResponse, zUploadChatFileData, zUploadChatFileResponse } from './zod.gen' + +import { zAudioToTextData, zAudioToTextResponse2, zCreateAnnotationData, zCreateAnnotationResponse, zDeleteAnnotationData, zDeleteConversationData, zGetAnnotationListData, zGetAnnotationListResponse, zGetChatAppFeedbacksData, zGetChatAppFeedbacksResponse, zGetChatAppInfoResponse, zGetChatAppMetaResponse, zGetChatAppParametersData, zGetChatAppParametersResponse, zGetChatWebAppSettingsResponse, zGetConversationHistoryData, zGetConversationHistoryResponse, zGetConversationsListData, zGetConversationsListResponse, zGetConversationVariablesData, zGetConversationVariablesResponse, zGetInitialAnnotationReplySettingsStatusData, zGetInitialAnnotationReplySettingsStatusResponse, zGetSuggestedQuestionsData, zGetSuggestedQuestionsResponse, zInitialAnnotationReplySettingsData, zInitialAnnotationReplySettingsResponse2, zPostChatMessageFeedbackData, zPostChatMessageFeedbackResponse, zPreviewChatFileData, zPreviewChatFileResponse, zRenameConversationData, zRenameConversationResponse, zSendChatMessageData, zSendChatMessageResponse, zStopChatMessageGenerationData, zStopChatMessageGenerationResponse, zTextToAudioChatData, zTextToAudioChatResponse, zUpdateAnnotationData, zUpdateAnnotationResponse, zUploadChatFileData, zUploadChatFileResponse } from './zod.gen' export const base = oc.$route({ inputStructure: 'detailed', outputStructure: 'detailed' }) @@ -186,7 +187,7 @@ export const audioToTextContract = base.route({ summary: 'Speech to Text', description: 'Convert audio file to text. Supported formats: mp3, mp4, mpeg, mpga, m4a, wav, webm. File size limit: 15MB.', tags: ['TTS'], -}).input(zAudioToTextData).output(z.object({ body: zAudioToTextResponse, status: z.literal(200) })) +}).input(zAudioToTextData).output(z.object({ body: zAudioToTextResponse2, status: z.literal(200) })) /** * Text to Audio @@ -326,7 +327,7 @@ export const initialAnnotationReplySettingsContract = base.route({ summary: 'Initial Annotation Reply Settings', description: 'Enable or disable annotation reply settings and configure embedding models. This interface is executed asynchronously.', tags: ['Annotations'], -}).input(zInitialAnnotationReplySettingsData).output(z.object({ body: zInitialAnnotationReplySettingsResponse, status: z.literal(200) })) +}).input(zInitialAnnotationReplySettingsData).output(z.object({ body: zInitialAnnotationReplySettingsResponse2, status: z.literal(200) })) /** * Query Initial Annotation Reply Settings Task Status diff --git a/web/openapi-ts.config.ts b/web/openapi-ts.config.ts index 4f3a977b73..a8fc3e3ac2 100644 --- a/web/openapi-ts.config.ts +++ b/web/openapi-ts.config.ts @@ -22,6 +22,7 @@ export default defineConfig({ }, defineOrpcConfig({ output: 'orpc', + // fileStrategy: 'byTags', // Uncomment to split files by tag }), ], }) diff --git a/web/plugins/hey-api-orpc/config.ts b/web/plugins/hey-api-orpc/config.ts index 608522fb33..347ee6eeb6 100644 --- a/web/plugins/hey-api-orpc/config.ts +++ b/web/plugins/hey-api-orpc/config.ts @@ -17,6 +17,10 @@ export const defaultConfig: OrpcPlugin['Config'] = { plugin.config.groupBy = plugin.config.groupBy ?? 'tag' plugin.config.contractNameBuilder = plugin.config.contractNameBuilder ?? ((id: string) => `${id}Contract`) + plugin.config.fileStrategy = plugin.config.fileStrategy ?? 'single' + plugin.config.filePathBuilder = plugin.config.filePathBuilder + ?? ((tag: string) => `orpc/${tag.toLowerCase()}`) + plugin.config.defaultTag = plugin.config.defaultTag ?? 'default' }, } diff --git a/web/plugins/hey-api-orpc/plugin.ts b/web/plugins/hey-api-orpc/plugin.ts index afacc3c8be..459b08e4c1 100644 --- a/web/plugins/hey-api-orpc/plugin.ts +++ b/web/plugins/hey-api-orpc/plugin.ts @@ -28,7 +28,7 @@ type OperationInfo = { zodResponseSchema: string } -function collectOperation(operation: IR.OperationObject): OperationInfo { +function collectOperation(operation: IR.OperationObject, defaultTag: string): OperationInfo { const id = operation.id || `${operation.method}_${operation.path.replace(/[{}/]/g, '_')}` const hasPathParams = Boolean(operation.parameters?.path && Object.keys(operation.parameters.path).length > 0) @@ -63,31 +63,46 @@ function collectOperation(operation: IR.OperationObject): OperationInfo { path: operation.path, successStatusCode, summary: operation.summary, - tags: operation.tags ? [...operation.tags] : [], + tags: operation.tags && operation.tags.length > 0 ? [...operation.tags] : [defaultTag], zodDataSchema: toZodSchemaName(id, 'data'), zodResponseSchema: toZodSchemaName(id, 'response'), } } export const handler: OrpcPlugin['Handler'] = ({ plugin }) => { - const { contractNameBuilder, groupBy } = plugin.config + const { + contractNameBuilder, + defaultTag, + filePathBuilder, + fileStrategy, + groupBy, + output, + } = plugin.config + const operations: OperationInfo[] = [] - const zodImports = new Set() // Collect all operations using hey-api's forEach plugin.forEach('operation', (event) => { - const info = collectOperation(event.operation) + const info = collectOperation(event.operation, defaultTag) operations.push(info) - - // Collect zod imports - if (info.hasInput) { - zodImports.add(info.zodDataSchema) - } - if (info.hasOutput) { - zodImports.add(info.zodResponseSchema) - } }) + // Helper to get file path for a tag + const getFilePathForTag = (tag: string) => { + if (fileStrategy === 'single') { + return output + } + return filePathBuilder(tag) + } + + // Get all unique tags + const allTags = new Set() + for (const op of operations) { + for (const tag of op.tags) { + allTags.add(tag) + } + } + // Register external symbols for imports const symbolOc = plugin.symbol('oc', { exported: false, @@ -95,35 +110,59 @@ export const handler: OrpcPlugin['Handler'] = ({ plugin }) => { }) const symbolZ = plugin.external('zod.z') - // Register zod schema symbols (they come from zod plugin) - const zodSchemaSymbols: Record> = {} - for (const schemaName of zodImports) { - zodSchemaSymbols[schemaName] = plugin.symbol(schemaName, { - exported: false, - external: './zod.gen', - }) + // Create base contract symbol - one per file when using byTags + const baseSymbols: Record> = {} + + if (fileStrategy === 'byTags') { + // Create base symbol for each tag file + for (const tag of allTags) { + const filePath = getFilePathForTag(tag) + baseSymbols[tag] = plugin.symbol('base', { + exported: true, + getFilePath: () => filePath, + meta: { + category: 'schema', + tag, + }, + }) + + const baseNode = $.const(baseSymbols[tag]) + .export() + .assign( + $(symbolOc) + .attr('$route') + .call( + $.object() + .prop('inputStructure', $.literal('detailed')) + .prop('outputStructure', $.literal('detailed')), + ), + ) + plugin.node(baseNode) + } } + else { + // Single base symbol for all operations + const baseSymbol = plugin.symbol('base', { + exported: true, + meta: { + category: 'schema', + }, + }) + baseSymbols.__default__ = baseSymbol - // Create base contract: export const base = oc.$route({ inputStructure: 'detailed', outputStructure: 'detailed' }) - const baseSymbol = plugin.symbol('base', { - exported: true, - meta: { - category: 'schema', - }, - }) - - const baseNode = $.const(baseSymbol) - .export() - .assign( - $(symbolOc) - .attr('$route') - .call( - $.object() - .prop('inputStructure', $.literal('detailed')) - .prop('outputStructure', $.literal('detailed')), - ), - ) - plugin.node(baseNode) + const baseNode = $.const(baseSymbol) + .export() + .assign( + $(symbolOc) + .attr('$route') + .call( + $.object() + .prop('inputStructure', $.literal('detailed')) + .prop('outputStructure', $.literal('detailed')), + ), + ) + plugin.node(baseNode) + } // Create contract for each operation // Store symbols for later use in contracts object @@ -131,16 +170,26 @@ export const handler: OrpcPlugin['Handler'] = ({ plugin }) => { for (const op of operations) { const contractName = contractNameBuilder(op.id) + const primaryTag = op.tags[0] + const filePath = getFilePathForTag(primaryTag) + const contractSymbol = plugin.symbol(contractName, { exported: true, + getFilePath: fileStrategy === 'byTags' ? () => filePath : undefined, meta: { category: 'schema', resource: 'operation', resourceId: op.id, + tag: primaryTag, }, }) contractSymbols[op.id] = contractSymbol + // Get the appropriate base symbol + const baseSymbol = fileStrategy === 'byTags' + ? baseSymbols[primaryTag] + : baseSymbols.__default__ + // Build the route config object with all available properties const routeConfig = $.object() .prop('path', $.literal(op.path)) @@ -173,15 +222,31 @@ export const handler: OrpcPlugin['Handler'] = ({ plugin }) => { // .input(zodDataSchema) if has input if (op.hasInput) { + // Reference zod schema symbol dynamically from zod plugin + const zodDataSymbol = plugin.referenceSymbol({ + category: 'schema', + resource: 'operation', + resourceId: op.id, + role: 'data', + tool: 'zod', + }) expression = expression .attr('input') - .call($(zodSchemaSymbols[op.zodDataSchema])) + .call($(zodDataSymbol)) } // .output(z.object({ status: z.literal(200), body: zodResponseSchema })) if has output (detailed outputStructure) if (op.hasOutput) { + // Reference zod response schema symbol dynamically from zod plugin + const zodResponseSymbol = plugin.referenceSymbol({ + category: 'schema', + resource: 'operation', + resourceId: op.id, + role: 'responses', + tool: 'zod', + }) const outputObject = $.object() - .prop('body', $(zodSchemaSymbols[op.zodResponseSchema])) + .prop('body', $(zodResponseSymbol)) // Add status code if available if (op.successStatusCode) { @@ -220,72 +285,120 @@ export const handler: OrpcPlugin['Handler'] = ({ plugin }) => { plugin.node(contractNode) } - // Create contracts object export - const contractsSymbol = plugin.symbol('contracts', { - exported: true, - meta: { - category: 'schema', - }, - }) + // Create contracts object export (only for single file strategy) + // For byTags, each file has its own exports + if (fileStrategy === 'single') { + const contractsSymbol = plugin.symbol('contracts', { + exported: true, + meta: { + category: 'schema', + }, + }) - if (groupBy === 'tag') { - // Group operations by tag - const operationsByTag = new Map() - for (const op of operations) { - const tag = op.tags[0] - if (!operationsByTag.has(tag)) { - operationsByTag.set(tag, []) + if (groupBy === 'tag') { + // Group operations by tag + const operationsByTag = new Map() + for (const op of operations) { + const tag = op.tags[0] + if (!operationsByTag.has(tag)) { + operationsByTag.set(tag, []) + } + operationsByTag.get(tag)!.push(op) } - operationsByTag.get(tag)!.push(op) + + // Build contracts object grouped by tag + const contractsObject = $.object() + for (const [tag, tagOps] of operationsByTag) { + const tagKey = tag.charAt(0).toLowerCase() + tag.slice(1) + const tagObject = $.object() + for (const op of tagOps) { + const contractSymbol = contractSymbols[op.id] + if (contractSymbol) { + const contractName = contractNameBuilder(op.id) + tagObject.prop(contractName, $(contractSymbol)) + } + } + contractsObject.prop(tagKey, tagObject) + } + + const contractsNode = $.const(contractsSymbol) + .export() + .assign(contractsObject) + plugin.node(contractsNode) + } + else { + // Flat structure without grouping + const contractsObject = $.object() + for (const op of operations) { + const contractSymbol = contractSymbols[op.id] + if (contractSymbol) { + const contractName = contractNameBuilder(op.id) + contractsObject.prop(contractName, $(contractSymbol)) + } + } + + const contractsNode = $.const(contractsSymbol) + .export() + .assign(contractsObject) + plugin.node(contractsNode) } - // Build contracts object grouped by tag - const contractsObject = $.object() - for (const [tag, tagOps] of operationsByTag) { - const tagKey = tag.charAt(0).toLowerCase() + tag.slice(1) - const tagObject = $.object() + // Create type export: export type Contracts = typeof contracts + const contractsTypeSymbol = plugin.symbol('Contracts', { + exported: true, + meta: { + category: 'type', + }, + }) + + const contractsTypeNode = $.type.alias(contractsTypeSymbol) + .export() + .type($.type.query($(contractsSymbol))) + plugin.node(contractsTypeNode) + } + else { + // For byTags strategy, create contracts object per file + for (const tag of allTags) { + const filePath = getFilePathForTag(tag) + const tagOps = operations.filter(op => op.tags[0] === tag) + + const contractsSymbol = plugin.symbol('contracts', { + exported: true, + getFilePath: () => filePath, + meta: { + category: 'schema', + tag, + }, + }) + + const contractsObject = $.object() for (const op of tagOps) { const contractSymbol = contractSymbols[op.id] if (contractSymbol) { const contractName = contractNameBuilder(op.id) - tagObject.prop(contractName, $(contractSymbol)) + contractsObject.prop(contractName, $(contractSymbol)) } } - contractsObject.prop(tagKey, tagObject) + + const contractsNode = $.const(contractsSymbol) + .export() + .assign(contractsObject) + plugin.node(contractsNode) + + // Create type export per file + const contractsTypeSymbol = plugin.symbol('Contracts', { + exported: true, + getFilePath: () => filePath, + meta: { + category: 'type', + tag, + }, + }) + + const contractsTypeNode = $.type.alias(contractsTypeSymbol) + .export() + .type($.type.query($(contractsSymbol))) + plugin.node(contractsTypeNode) } - - const contractsNode = $.const(contractsSymbol) - .export() - .assign(contractsObject) - plugin.node(contractsNode) } - else { - // Flat structure without grouping - const contractsObject = $.object() - for (const op of operations) { - const contractSymbol = contractSymbols[op.id] - if (contractSymbol) { - const contractName = contractNameBuilder(op.id) - contractsObject.prop(contractName, $(contractSymbol)) - } - } - - const contractsNode = $.const(contractsSymbol) - .export() - .assign(contractsObject) - plugin.node(contractsNode) - } - - // Create type export: export type Contracts = typeof contracts - const contractsTypeSymbol = plugin.symbol('Contracts', { - exported: true, - meta: { - category: 'type', - }, - }) - - const contractsTypeNode = $.type.alias(contractsTypeSymbol) - .export() - .type($.type.query($(contractsSymbol))) - plugin.node(contractsTypeNode) } diff --git a/web/plugins/hey-api-orpc/types.ts b/web/plugins/hey-api-orpc/types.ts index 75460380c7..5afdd9ba65 100644 --- a/web/plugins/hey-api-orpc/types.ts +++ b/web/plugins/hey-api-orpc/types.ts @@ -1,8 +1,10 @@ import type { DefinePlugin } from '@hey-api/openapi-ts' +export type FileStrategy = 'single' | 'byTags' + export type Config = { name: 'orpc' } & { /** - * Name of the generated file. + * Name of the generated file (when fileStrategy is 'single'). * @default 'orpc' */ output?: string @@ -23,6 +25,23 @@ export type Config = { name: 'orpc' } & { * @default 'tag' */ groupBy?: 'tag' | 'none' + /** + * File generation strategy. + * - 'single': All contracts in one file (default) + * - 'byTags': One file per tag (e.g., orpc/chat.ts, orpc/files.ts) + * @default 'single' + */ + fileStrategy?: FileStrategy + /** + * Custom file path builder when fileStrategy is 'byTags'. + * @default (tag) => `orpc/${tag}` + */ + filePathBuilder?: (tag: string) => string + /** + * Default tag name for operations without tags. + * @default 'default' + */ + defaultTag?: string } export type ResolvedConfig = { @@ -31,6 +50,9 @@ export type ResolvedConfig = { exportFromIndex: boolean contractNameBuilder: (operationId: string) => string groupBy: 'tag' | 'none' + fileStrategy: FileStrategy + filePathBuilder: (tag: string) => string + defaultTag: string } export type OrpcPlugin = DefinePlugin