Skip to content

Commit 31f4c49

Browse files
authored
fix(orm,server): fix procedure slicing in toJSONSchema and RPC OpenAPI spec (#2596)
2 parents 3398c87 + e784b67 commit 31f4c49

4 files changed

Lines changed: 113 additions & 3 deletions

File tree

packages/orm/src/client/zod/factory.ts

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -220,9 +220,11 @@ export class ZodSchemaFactory<
220220
this.makeAggregateSchema(m);
221221
this.makeGroupBySchema(m);
222222
}
223-
// Eagerly build args schemas for all procedures.
223+
// Eagerly build args schemas for allowed procedures only.
224224
for (const procName of Object.keys(this.schema.procedures ?? {})) {
225-
this.makeProcedureArgsSchema(procName);
225+
if (this.isProcedureAllowed(procName)) {
226+
this.makeProcedureArgsSchema(procName);
227+
}
226228
}
227229
return z.toJSONSchema(this.schemaRegistry, { unrepresentable: 'any' });
228230
}
@@ -2495,6 +2497,29 @@ export class ZodSchemaFactory<
24952497
return true;
24962498
}
24972499

2500+
private isProcedureAllowed(procName: string): boolean {
2501+
const slicing = this.options.slicing;
2502+
if (!slicing) {
2503+
return true;
2504+
}
2505+
2506+
const { includedProcedures, excludedProcedures } = slicing;
2507+
2508+
if (includedProcedures !== undefined) {
2509+
if (!(includedProcedures as readonly string[]).includes(procName)) {
2510+
return false;
2511+
}
2512+
}
2513+
2514+
if (excludedProcedures !== undefined) {
2515+
if ((excludedProcedures as readonly string[]).includes(procName)) {
2516+
return false;
2517+
}
2518+
}
2519+
2520+
return true;
2521+
}
2522+
24982523
// #endregion
24992524
}
25002525

packages/server/src/api/rpc/openapi.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { lowerCaseFirst, upperCaseFirst } from '@zenstackhq/common-helpers';
22
import { CoreCrudOperations, createQuerySchemaFactory, type ZodSchemaFactory } from '@zenstackhq/orm';
3-
import type { BuiltinType, ModelDef, ProcedureDef, SchemaDef, TypeDefDef } from '@zenstackhq/orm/schema';
3+
import type { BuiltinType, EnumDef, ModelDef, ProcedureDef, SchemaDef, TypeDefDef } from '@zenstackhq/orm/schema';
44
import type { OpenAPIV3_1 } from 'openapi-types';
55
import type { RPCApiHandlerOptions } from '.';
66
import { PROCEDURE_ROUTE_PREFIXES } from '../common/procedures';
@@ -468,6 +468,7 @@ export class RPCApiSpecGenerator<Schema extends SchemaDef = SchemaDef> {
468468
} else {
469469
if (hasParams) {
470470
op['requestBody'] = {
471+
...(hasRequiredParams && { required: true }),
471472
content: {
472473
[JSON_CT]: { schema: envelopeSchema },
473474
},
@@ -529,6 +530,12 @@ export class RPCApiSpecGenerator<Schema extends SchemaDef = SchemaDef> {
529530
}
530531

531532
private generateSharedSchemas(): Record<string, SchemaObject> {
533+
// Generate schemas for enums
534+
const enumSchemas: Record<string, SchemaObject> = {};
535+
for (const [enumName, enumDef] of Object.entries(this.schema.enums ?? {})) {
536+
enumSchemas[enumName] = this.buildEnumSchema(enumDef);
537+
}
538+
532539
// Generate schemas for typedefs (e.g. `type Address { city String }`)
533540
const typeDefSchemas: Record<string, SchemaObject> = {};
534541
for (const [typeName, typeDef] of Object.entries(this.schema.typeDefs ?? {})) {
@@ -542,6 +549,7 @@ export class RPCApiSpecGenerator<Schema extends SchemaDef = SchemaDef> {
542549
}
543550

544551
return {
552+
...enumSchemas,
545553
...typeDefSchemas,
546554
...modelEntitySchemas,
547555
_integer: { type: 'integer', minimum: -9007199254740991, maximum: 9007199254740991 },
@@ -637,6 +645,10 @@ export class RPCApiSpecGenerator<Schema extends SchemaDef = SchemaDef> {
637645
/**
638646
* Builds a JSON Schema object describing a custom typedef
639647
*/
648+
private buildEnumSchema(enumDef: EnumDef): SchemaObject {
649+
return { type: 'string', enum: Object.values(enumDef.values) };
650+
}
651+
640652
private buildTypeDefSchema(typeDef: TypeDefDef): SchemaObject {
641653
const properties: Record<string, SchemaObject | ReferenceObject> = {};
642654
const required: string[] = [];

packages/server/test/openapi/baseline/rpc.baseline.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4225,6 +4225,7 @@ paths:
42254225
schema:
42264226
$ref: "#/components/schemas/_rpcErrorResponse"
42274227
requestBody:
4228+
required: true
42284229
content:
42294230
application/json:
42304231
schema:

packages/server/test/openapi/rpc-openapi.test.ts

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -444,6 +444,33 @@ describe('RPC OpenAPI spec generation - response data shapes', () => {
444444
expect(postSchema.required).toContain('title');
445445
expect(postSchema.required).toContain('published');
446446
});
447+
448+
it('enum schemas are present in components and enum fields reference them', async () => {
449+
const enumSchema = `
450+
enum Role {
451+
USER
452+
ADMIN
453+
}
454+
455+
model User {
456+
id Int @id @default(autoincrement())
457+
role Role
458+
}
459+
`;
460+
const client = await createTestClient(enumSchema);
461+
const handler = new RPCApiHandler({ schema: client.$schema });
462+
const s = await generateSpec(handler);
463+
464+
// Enum component must be registered so the $ref resolves
465+
expect(s.components?.schemas?.['Role']).toBeDefined();
466+
expect(s.components?.schemas?.['Role'].type).toBe('string');
467+
expect(s.components?.schemas?.['Role'].enum).toEqual(['USER', 'ADMIN']);
468+
469+
// The entity schema must reference the enum via $ref
470+
const userSchema = s.components?.schemas?.['User'] as any;
471+
const roleField = userSchema?.properties?.role;
472+
expect(roleField?.['$ref']).toBe('#/components/schemas/Role');
473+
});
447474
});
448475

449476
describe('RPC OpenAPI spec generation - operationIds', () => {
@@ -753,6 +780,7 @@ procedure optionalSearch(query: String?): User[]
753780

754781
const operation = spec?.paths?.['/$procs/createUser']?.post;
755782
expect(operation?.requestBody).toBeDefined();
783+
expect((operation?.requestBody as any)?.required).toBe(true);
756784
const bodySchema = (operation?.requestBody as any)?.content?.['application/json']?.schema;
757785
// args is a $ref to the registered ProcArgs component schema
758786
const argsRef = bodySchema?.properties?.args?.$ref;
@@ -763,6 +791,23 @@ procedure optionalSearch(query: String?): User[]
763791
expect(argsSchema?.required).toContain('name');
764792
});
765793

794+
it('mutation with only optional params does not set requestBody.required', async () => {
795+
const optionalMutationSchema = `
796+
model User {
797+
id Int @id @default(autoincrement())
798+
name String
799+
}
800+
mutation procedure softDelete(id: Int?): User
801+
`;
802+
const client = await createTestClient(optionalMutationSchema);
803+
const handler = new RPCApiHandler({ schema: client.$schema });
804+
const spec = await generateSpec(handler);
805+
806+
const operation = spec?.paths?.['/$procs/softDelete']?.post;
807+
expect(operation?.requestBody).toBeDefined();
808+
expect((operation?.requestBody as any)?.required).toBeUndefined();
809+
});
810+
766811
it('optional procedure params are not in required array', async () => {
767812
const client = await createTestClient(procSchema);
768813
const handler = new RPCApiHandler({ schema: client.$schema });
@@ -799,6 +844,33 @@ procedure optionalSearch(query: String?): User[]
799844
expect(spec.paths?.['/$procs/getUser']).toBeUndefined();
800845
expect(spec.paths?.['/$procs/createUser']).toBeDefined();
801846
});
847+
848+
it('slicing excludedProcedures removes procedure args from components schemas', async () => {
849+
const client = await createTestClient(procSchema);
850+
const handler = new RPCApiHandler({
851+
schema: client.$schema,
852+
queryOptions: { slicing: { excludedProcedures: ['getUser'] as any } },
853+
});
854+
const spec = await generateSpec(handler);
855+
856+
const schemaKeys = Object.keys(spec.components?.schemas ?? {});
857+
expect(schemaKeys.some((k) => k.startsWith('getUser'))).toBe(false);
858+
expect(schemaKeys.some((k) => k.startsWith('createUser'))).toBe(true);
859+
});
860+
861+
it('slicing includedProcedures removes non-listed procedure args from components schemas', async () => {
862+
const client = await createTestClient(procSchema);
863+
const handler = new RPCApiHandler({
864+
schema: client.$schema,
865+
queryOptions: { slicing: { includedProcedures: ['createUser'] as any } },
866+
});
867+
const spec = await generateSpec(handler);
868+
869+
const schemaKeys = Object.keys(spec.components?.schemas ?? {});
870+
expect(schemaKeys.some((k) => k.startsWith('createUser'))).toBe(true);
871+
expect(schemaKeys.some((k) => k.startsWith('getUser'))).toBe(false);
872+
expect(schemaKeys.some((k) => k.startsWith('optionalSearch'))).toBe(false);
873+
});
802874
});
803875

804876
describe('RPC OpenAPI spec generation - respectAccessPolicies', () => {

0 commit comments

Comments
 (0)