diff --git a/packages/server/src/api/rpc/openapi.ts b/packages/server/src/api/rpc/openapi.ts index 6bd6e2c5c..35ff95649 100644 --- a/packages/server/src/api/rpc/openapi.ts +++ b/packages/server/src/api/rpc/openapi.ts @@ -9,6 +9,7 @@ import { DEFAULT_SPEC_VERSION, getIncludedModels, getMetaDescription, + isModelIncluded, isOperationIncluded, isProcedureIncluded, mayDenyAccess, @@ -485,6 +486,10 @@ export class RPCApiSpecGenerator { if (this.isBuiltinType(returnType)) { base = this.builtinTypeToJsonSchema(returnType as BuiltinType); + } else if (this.schema.models?.[returnType] && !isModelIncluded(returnType, this.queryOptions)) { + // Return type is a model that's sliced away — its entity schema isn't emitted, + // so reference it with a generic schema instead of a dangling `$ref`. + base = {}; } else if ( this.schema.enums?.[returnType] || this.schema.models?.[returnType] || @@ -685,6 +690,10 @@ export class RPCApiSpecGenerator { if (fieldDef.omit) continue; if (fieldDef.relation) { + // Skip relations pointing to a model that's sliced away — otherwise we'd emit + // a dangling `$ref` to a schema that's not in the spec. + if (!isModelIncluded(fieldDef.type, this.queryOptions)) continue; + // Relation fields appear only with `include` — mark as optional. // To-one optional relations are nullable (the ORM returns null when not found). const refSchema: ReferenceObject = { $ref: `#/components/schemas/${fieldDef.type}` }; diff --git a/packages/server/test/openapi/rpc-openapi.test.ts b/packages/server/test/openapi/rpc-openapi.test.ts index 64d4b1ca4..6e25cb270 100644 --- a/packages/server/test/openapi/rpc-openapi.test.ts +++ b/packages/server/test/openapi/rpc-openapi.test.ts @@ -596,6 +596,26 @@ describe('RPC OpenAPI spec generation - queryOptions slicing', () => { expect(spec.components?.schemas?.['User']).toBeDefined(); }); + it('drops relation fields pointing to excluded models from entity schemas', async () => { + const client = await createTestClient(schema); + const handler = new RPCApiHandler({ + schema: client.$schema, + queryOptions: { slicing: { excludedModels: ['Post'] as any } }, + }); + // `generateSpec` also validates the document, so a dangling `$ref` to the + // excluded `Post` schema would fail here. + const spec = await generateSpec(handler); + + // referencing models keep their other fields but lose relations to the excluded model + const userProps = (spec.components?.schemas?.['User'] as any)?.properties; + expect(userProps?.['email']).toBeDefined(); + expect(userProps?.['posts']).toBeUndefined(); + + const commentProps = (spec.components?.schemas?.['Comment'] as any)?.properties; + expect(commentProps?.['content']).toBeDefined(); + expect(commentProps?.['post']).toBeUndefined(); + }); + it('includedModels removes excluded model entity schemas from components', async () => { const client = await createTestClient(schema); const handler = new RPCApiHandler({ @@ -858,6 +878,30 @@ mutation procedure softDelete(id: Int?): User expect(schemaKeys.some((k) => k.startsWith('createUser'))).toBe(true); }); + it('procedure returning an excluded model does not emit a dangling ref', async () => { + const client = await createTestClient(procSchema); + const handler = new RPCApiHandler({ + schema: client.$schema, + queryOptions: { slicing: { excludedModels: ['User'] as any } }, + }); + // `getUser`/`createUser`/`optionalSearch` all return `User`, which is excluded. + // `generateSpec` validates, so a dangling `$ref` to the absent `User` schema would fail. + const spec = await generateSpec(handler); + + expect(spec.components?.schemas?.['User']).toBeUndefined(); + + // the procedures themselves are still exposed, but their result shape is generic + const dataSchema = (spec.paths?.['/$procs/getUser']?.get as any)?.responses?.['200']?.content?.[ + 'application/json' + ]?.schema?.properties?.data; + expect(dataSchema).toEqual({}); + + const listDataSchema = (spec.paths?.['/$procs/optionalSearch']?.get as any)?.responses?.['200']?.content?.[ + 'application/json' + ]?.schema?.properties?.data; + expect(listDataSchema).toEqual({ type: 'array', items: {} }); + }); + it('slicing includedProcedures removes non-listed procedure args from components schemas', async () => { const client = await createTestClient(procSchema); const handler = new RPCApiHandler({