Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions packages/server/src/api/rpc/openapi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
DEFAULT_SPEC_VERSION,
getIncludedModels,
getMetaDescription,
isModelIncluded,
isOperationIncluded,
isProcedureIncluded,
mayDenyAccess,
Expand Down Expand Up @@ -485,6 +486,10 @@ export class RPCApiSpecGenerator<Schema extends SchemaDef = SchemaDef> {

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] ||
Expand Down Expand Up @@ -685,6 +690,10 @@ export class RPCApiSpecGenerator<Schema extends SchemaDef = SchemaDef> {
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}` };
Expand Down
44 changes: 44 additions & 0 deletions packages/server/test/openapi/rpc-openapi.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -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({
Expand Down
Loading