mirror of
https://github.com/zenstackhq/zenstack
synced 2026-05-24 10:08:55 +00:00
- ZodSchemaFactory.toJSONSchema() now skips makeProcedureArgsSchema for procedures excluded by slicing (includedProcedures / excludedProcedures), preventing dangling component registrations for hidden procedures - RPCApiSpecGenerator.generateSharedSchemas() now emits enum schemas so $ref pointers from model entity schemas and typedef schemas resolve correctly - RPCApiSpecGenerator requestBody.required is now only set when at least one procedure param is non-optional, matching the existing GET q-param behavior - Add tests covering all three fixes Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1149 lines
48 KiB
TypeScript
1149 lines
48 KiB
TypeScript
import { validate } from '@readme/openapi-parser';
|
|
import { createTestClient } from '@zenstackhq/testtools';
|
|
import fs from 'fs';
|
|
import path from 'path';
|
|
import { beforeAll, describe, expect, it } from 'vitest';
|
|
import YAML from 'yaml';
|
|
import { RPCApiHandler } from '../../src/api/rpc';
|
|
|
|
const UPDATE_BASELINE = process.env.UPDATE_BASELINE === '1';
|
|
|
|
async function generateSpec(handler: RPCApiHandler, options?: Parameters<RPCApiHandler['generateSpec']>[0]) {
|
|
const spec = await handler.generateSpec(options);
|
|
await validate(JSON.parse(JSON.stringify(spec)));
|
|
return spec;
|
|
}
|
|
|
|
function loadBaseline(name: string) {
|
|
return YAML.parse(fs.readFileSync(path.join(__dirname, 'baseline', name), 'utf-8'), { maxAliasCount: 10000 });
|
|
}
|
|
|
|
function saveBaseline(name: string, spec: any) {
|
|
fs.writeFileSync(
|
|
path.join(__dirname, 'baseline', name),
|
|
YAML.stringify(spec, { lineWidth: 0, indent: 4, aliasDuplicateObjects: false }),
|
|
);
|
|
}
|
|
|
|
// Shared schema used across most test suites
|
|
const schema = `
|
|
type Address {
|
|
city String
|
|
}
|
|
|
|
model User {
|
|
myId String @id @default(cuid())
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
email String @unique @email
|
|
posts Post[]
|
|
profile Profile?
|
|
address Address? @json
|
|
someJson Json?
|
|
}
|
|
|
|
model Profile {
|
|
id Int @id @default(autoincrement())
|
|
gender String
|
|
user User @relation(fields: [userId], references: [myId])
|
|
userId String @unique
|
|
}
|
|
|
|
model Post {
|
|
id Int @id @default(autoincrement())
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
title String @length(1, 10)
|
|
author User? @relation(fields: [authorId], references: [myId])
|
|
authorId String?
|
|
published Boolean @default(false)
|
|
publishedAt DateTime?
|
|
viewCount Int @default(0)
|
|
comments Comment[]
|
|
setting Setting?
|
|
}
|
|
|
|
model Comment {
|
|
id Int @id @default(autoincrement())
|
|
post Post @relation(fields: [postId], references: [id])
|
|
postId Int
|
|
content String
|
|
}
|
|
|
|
model Setting {
|
|
id Int @id @default(autoincrement())
|
|
boost Int
|
|
post Post @relation(fields: [postId], references: [id])
|
|
postId Int @unique
|
|
}
|
|
|
|
procedure findPostsByUser(userId: String): Post[]
|
|
procedure getPostCount(userId: String, published: Boolean?): Int
|
|
mutation procedure publishPost(postId: Int): Post
|
|
`;
|
|
|
|
describe('RPC OpenAPI spec generation - document structure', () => {
|
|
let handler: RPCApiHandler;
|
|
let spec: any;
|
|
|
|
beforeAll(async () => {
|
|
const client = await createTestClient(schema);
|
|
handler = new RPCApiHandler({ schema: client.$schema });
|
|
spec = await generateSpec(handler);
|
|
});
|
|
|
|
it('has correct openapi version and info', () => {
|
|
expect(spec.openapi).toBe('3.1.0');
|
|
expect(spec.info).toBeDefined();
|
|
expect(spec.info.title).toBe('ZenStack Generated API');
|
|
expect(spec.info.version).toBe('1.0.0');
|
|
});
|
|
|
|
it('has paths and components', () => {
|
|
expect(spec.paths).toBeDefined();
|
|
expect(spec.components).toBeDefined();
|
|
expect(spec.components.schemas).toBeDefined();
|
|
});
|
|
|
|
it('has tags for each model', () => {
|
|
const tagNames = spec.tags.map((t: any) => t.name);
|
|
expect(tagNames).toContain('user');
|
|
expect(tagNames).toContain('post');
|
|
expect(tagNames).toContain('comment');
|
|
});
|
|
|
|
it('custom spec options are reflected in info', async () => {
|
|
const client = await createTestClient(schema);
|
|
const h = new RPCApiHandler({ schema: client.$schema });
|
|
const s = await generateSpec(h, { title: 'My RPC API', version: '2.0.0', description: 'Desc' });
|
|
expect(s.info.title).toBe('My RPC API');
|
|
expect(s.info.version).toBe('2.0.0');
|
|
expect((s.info as any).description).toBe('Desc');
|
|
});
|
|
});
|
|
|
|
describe('RPC OpenAPI spec generation - paths and HTTP methods', () => {
|
|
let spec: any;
|
|
|
|
beforeAll(async () => {
|
|
const client = await createTestClient(schema);
|
|
const handler = new RPCApiHandler({ schema: client.$schema });
|
|
spec = await generateSpec(handler);
|
|
});
|
|
|
|
it('generates paths for all CRUD operations per model', () => {
|
|
const readOps = ['findMany', 'findFirst', 'findUnique', 'count', 'aggregate', 'groupBy', 'exists'];
|
|
const writeOps = ['create', 'createMany', 'createManyAndReturn', 'upsert'];
|
|
const updateOps = ['update', 'updateMany', 'updateManyAndReturn'];
|
|
const deleteOps = ['delete', 'deleteMany'];
|
|
|
|
for (const op of [...readOps, ...writeOps, ...updateOps, ...deleteOps]) {
|
|
expect(spec.paths[`/user/${op}`], `expected /user/${op}`).toBeDefined();
|
|
expect(spec.paths[`/post/${op}`], `expected /post/${op}`).toBeDefined();
|
|
}
|
|
});
|
|
|
|
it('read operations use GET', () => {
|
|
for (const op of ['findMany', 'findFirst', 'findUnique', 'count', 'aggregate', 'groupBy', 'exists']) {
|
|
const path = spec.paths[`/user/${op}`];
|
|
expect(path.get, `GET /user/${op}`).toBeDefined();
|
|
expect(path.post, `no POST /user/${op}`).toBeUndefined();
|
|
}
|
|
});
|
|
|
|
it('create/upsert operations use POST', () => {
|
|
for (const op of ['create', 'createMany', 'createManyAndReturn', 'upsert']) {
|
|
const path = spec.paths[`/user/${op}`];
|
|
expect(path.post, `POST /user/${op}`).toBeDefined();
|
|
expect(path.get, `no GET /user/${op}`).toBeUndefined();
|
|
}
|
|
});
|
|
|
|
it('update operations use PUT', () => {
|
|
for (const op of ['update', 'updateMany', 'updateManyAndReturn']) {
|
|
const path = spec.paths[`/user/${op}`];
|
|
expect(path.put, `PUT /user/${op}`).toBeDefined();
|
|
}
|
|
});
|
|
|
|
it('delete operations use DELETE', () => {
|
|
for (const op of ['delete', 'deleteMany']) {
|
|
const path = spec.paths[`/user/${op}`];
|
|
expect(path.delete, `DELETE /user/${op}`).toBeDefined();
|
|
}
|
|
});
|
|
|
|
it('create operations return 201', () => {
|
|
for (const op of ['create', 'createMany', 'createManyAndReturn', 'upsert']) {
|
|
const path = spec.paths[`/post/${op}`];
|
|
expect(path.post.responses['201'], `201 for /post/${op}`).toBeDefined();
|
|
}
|
|
});
|
|
|
|
it('non-create operations return 200', () => {
|
|
expect(spec.paths['/user/findMany'].get.responses['200']).toBeDefined();
|
|
expect(spec.paths['/user/update'].put.responses['200']).toBeDefined();
|
|
expect(spec.paths['/user/delete'].delete.responses['200']).toBeDefined();
|
|
});
|
|
|
|
it('has transaction endpoint', () => {
|
|
expect(spec.paths['/$transaction/sequential']).toBeDefined();
|
|
expect(spec.paths['/$transaction/sequential'].post).toBeDefined();
|
|
});
|
|
});
|
|
|
|
describe('RPC OpenAPI spec generation - input schemas', () => {
|
|
let spec: any;
|
|
|
|
beforeAll(async () => {
|
|
const client = await createTestClient(schema);
|
|
const handler = new RPCApiHandler({ schema: client.$schema });
|
|
spec = await generateSpec(handler);
|
|
});
|
|
|
|
it('GET operations have q query parameter', () => {
|
|
for (const op of ['findMany', 'findFirst', 'findUnique', 'count', 'aggregate', 'groupBy', 'exists']) {
|
|
const operation = spec.paths[`/user/${op}`].get;
|
|
const qParam = operation.parameters?.find((p: any) => p.name === 'q');
|
|
expect(qParam, `q param on /user/${op}`).toBeDefined();
|
|
expect(qParam.in).toBe('query');
|
|
// OAPI 3.1 content-typed parameter for complex JSON
|
|
expect(qParam.content?.['application/json']?.schema).toBeDefined();
|
|
}
|
|
});
|
|
|
|
it('DELETE operations have q query parameter', () => {
|
|
for (const op of ['delete', 'deleteMany']) {
|
|
const operation = spec.paths[`/user/${op}`].delete;
|
|
const qParam = operation.parameters?.find((p: any) => p.name === 'q');
|
|
expect(qParam, `q param on /user/${op}`).toBeDefined();
|
|
expect(qParam.content?.['application/json']?.schema).toBeDefined();
|
|
}
|
|
});
|
|
|
|
it('POST operations have request body', () => {
|
|
for (const op of ['create', 'createMany', 'createManyAndReturn', 'upsert']) {
|
|
const operation = spec.paths[`/user/${op}`].post;
|
|
expect(operation.requestBody, `requestBody on /user/${op}`).toBeDefined();
|
|
expect(operation.requestBody.required).toBe(true);
|
|
expect(operation.requestBody.content?.['application/json']?.schema).toBeDefined();
|
|
}
|
|
});
|
|
|
|
it('PUT operations have request body', () => {
|
|
for (const op of ['update', 'updateMany', 'updateManyAndReturn']) {
|
|
const operation = spec.paths[`/user/${op}`].put;
|
|
expect(operation.requestBody, `requestBody on /user/${op}`).toBeDefined();
|
|
expect(operation.requestBody.content?.['application/json']?.schema).toBeDefined();
|
|
}
|
|
});
|
|
|
|
it('findUnique q schema contains where field', () => {
|
|
const operation = spec.paths['/user/findUnique'].get;
|
|
const qSchema = operation.parameters.find((p: any) => p.name === 'q').content['application/json'].schema;
|
|
// The schema describes FindUniqueArgs which has a required where field
|
|
expect(qSchema).toBeDefined();
|
|
expect(qSchema.type === 'object' || qSchema.properties || qSchema.$defs || qSchema.$ref).toBeTruthy();
|
|
});
|
|
|
|
it('create request body schema contains data field', () => {
|
|
const operation = spec.paths['/user/create'].post;
|
|
const bodySchema = operation.requestBody.content['application/json'].schema;
|
|
expect(bodySchema).toBeDefined();
|
|
// CreateArgs has a data field
|
|
expect(
|
|
bodySchema.type === 'object' || bodySchema.properties || bodySchema.$defs || bodySchema.$ref,
|
|
).toBeTruthy();
|
|
});
|
|
|
|
it('transaction request body uses shared schema ref', () => {
|
|
const operation = spec.paths['/$transaction/sequential'].post;
|
|
expect(operation.requestBody.content['application/json'].schema.$ref).toBe(
|
|
'#/components/schemas/_rpcTransactionRequest',
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('RPC OpenAPI spec generation - shared schemas', () => {
|
|
let spec: any;
|
|
|
|
beforeAll(async () => {
|
|
const client = await createTestClient(schema);
|
|
const handler = new RPCApiHandler({ schema: client.$schema });
|
|
spec = await generateSpec(handler);
|
|
});
|
|
|
|
it('_rpcSuccessResponse schema exists', () => {
|
|
const schema = spec.components.schemas['_rpcSuccessResponse'];
|
|
expect(schema).toBeDefined();
|
|
expect(schema.type).toBe('object');
|
|
expect(schema.properties.data).toBeDefined();
|
|
});
|
|
|
|
it('_rpcErrorResponse schema exists with required message', () => {
|
|
const schema = spec.components.schemas['_rpcErrorResponse'];
|
|
expect(schema).toBeDefined();
|
|
expect(schema.type).toBe('object');
|
|
expect(schema.properties.error).toBeDefined();
|
|
expect(schema.properties.error.properties.message).toBeDefined();
|
|
expect(schema.properties.error.required).toContain('message');
|
|
});
|
|
|
|
it('_rpcTransactionRequest schema is an array of operation objects', () => {
|
|
const schema = spec.components.schemas['_rpcTransactionRequest'];
|
|
expect(schema).toBeDefined();
|
|
expect(schema.type).toBe('array');
|
|
expect(schema.items).toBeDefined();
|
|
expect(schema.items.properties.model).toBeDefined();
|
|
expect(schema.items.properties.op).toBeDefined();
|
|
expect(schema.items.required).toContain('model');
|
|
expect(schema.items.required).toContain('op');
|
|
});
|
|
|
|
it('success responses for model operations are inline (not _rpcSuccessResponse ref)', () => {
|
|
const findMany = spec.paths['/user/findMany'].get;
|
|
const schema = findMany.responses['200'].content['application/json'].schema;
|
|
// Model operations return operation-specific inline schemas, not the generic ref
|
|
expect(schema.$ref).toBeUndefined();
|
|
expect(schema.type).toBe('object');
|
|
expect(schema.properties.data).toBeDefined();
|
|
});
|
|
|
|
it('error responses reference _rpcErrorResponse', () => {
|
|
const create = spec.paths['/user/create'].post;
|
|
expect(create.responses['400'].content['application/json'].schema.$ref).toBe(
|
|
'#/components/schemas/_rpcErrorResponse',
|
|
);
|
|
expect(create.responses['422'].content['application/json'].schema.$ref).toBe(
|
|
'#/components/schemas/_rpcErrorResponse',
|
|
);
|
|
});
|
|
|
|
it('all operations have 400 and 500 responses', () => {
|
|
for (const [path, item] of Object.entries(spec.paths as Record<string, any>)) {
|
|
for (const method of ['get', 'post', 'put', 'delete'] as const) {
|
|
if (!item[method]) continue;
|
|
expect(item[method].responses['400'], `400 on ${method.toUpperCase()} ${path}`).toBeDefined();
|
|
expect(item[method].responses['500'], `500 on ${method.toUpperCase()} ${path}`).toBeDefined();
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('RPC OpenAPI spec generation - response data shapes', () => {
|
|
let spec: any;
|
|
|
|
beforeAll(async () => {
|
|
const client = await createTestClient(schema);
|
|
const handler = new RPCApiHandler({ schema: client.$schema });
|
|
spec = await generateSpec(handler);
|
|
});
|
|
|
|
it('findUnique and findFirst data is nullable entity ref', () => {
|
|
for (const op of ['findUnique', 'findFirst']) {
|
|
const opSchema = spec.paths[`/user/${op}`].get.responses['200'].content['application/json'].schema;
|
|
const dataSchema = opSchema.properties.data;
|
|
expect(dataSchema.anyOf).toBeDefined();
|
|
const refs = dataSchema.anyOf.map((s: any) => s.$ref ?? s.type);
|
|
expect(refs).toContain('#/components/schemas/User');
|
|
expect(refs).toContain('null');
|
|
}
|
|
});
|
|
|
|
it('findMany data is array of entity refs', () => {
|
|
const opSchema = spec.paths['/user/findMany'].get.responses['200'].content['application/json'].schema;
|
|
const dataSchema = opSchema.properties.data;
|
|
expect(dataSchema.type).toBe('array');
|
|
expect(dataSchema.items.$ref).toBe('#/components/schemas/User');
|
|
});
|
|
|
|
it('createManyAndReturn and updateManyAndReturn data are arrays', () => {
|
|
for (const [op, method] of [
|
|
['createManyAndReturn', 'post'],
|
|
['updateManyAndReturn', 'put'],
|
|
] as const) {
|
|
const responses = spec.paths[`/post/${op}`][method].responses;
|
|
const successCode = method === 'post' ? '201' : '200';
|
|
const dataSchema = responses[successCode].content['application/json'].schema.properties.data;
|
|
expect(dataSchema.type).toBe('array');
|
|
expect(dataSchema.items.$ref).toBe('#/components/schemas/Post');
|
|
}
|
|
});
|
|
|
|
it('create, update, delete, upsert data is direct entity ref', () => {
|
|
expect(
|
|
spec.paths['/user/create'].post.responses['201'].content['application/json'].schema.properties.data.$ref,
|
|
).toBe('#/components/schemas/User');
|
|
expect(
|
|
spec.paths['/user/update'].put.responses['200'].content['application/json'].schema.properties.data.$ref,
|
|
).toBe('#/components/schemas/User');
|
|
expect(
|
|
spec.paths['/user/delete'].delete.responses['200'].content['application/json'].schema.properties.data.$ref,
|
|
).toBe('#/components/schemas/User');
|
|
expect(
|
|
spec.paths['/user/upsert'].post.responses['201'].content['application/json'].schema.properties.data.$ref,
|
|
).toBe('#/components/schemas/User');
|
|
});
|
|
|
|
it('createMany, updateMany, deleteMany data has count property', () => {
|
|
for (const [op, method] of [
|
|
['createMany', 'post'],
|
|
['updateMany', 'put'],
|
|
['deleteMany', 'delete'],
|
|
] as const) {
|
|
const responses = spec.paths[`/user/${op}`][method].responses;
|
|
const successCode = method === 'post' ? '201' : '200';
|
|
const dataSchema = responses[successCode].content['application/json'].schema.properties.data;
|
|
expect(dataSchema.type).toBe('object');
|
|
expect(dataSchema.properties.count.type).toBe('integer');
|
|
expect(dataSchema.required).toContain('count');
|
|
}
|
|
});
|
|
|
|
it('exists data is boolean', () => {
|
|
const opSchema = spec.paths['/user/exists'].get.responses['200'].content['application/json'].schema;
|
|
expect(opSchema.properties.data.type).toBe('boolean');
|
|
});
|
|
|
|
it('model entity schemas are in components/schemas', () => {
|
|
const userSchema = spec.components.schemas['User'];
|
|
expect(userSchema).toBeDefined();
|
|
expect(userSchema.type).toBe('object');
|
|
expect(userSchema.properties).toBeDefined();
|
|
});
|
|
|
|
it('model entity schema has scalar fields with correct types', () => {
|
|
const userSchema = spec.components.schemas['User'];
|
|
expect(userSchema.properties['myId'].type).toBe('string');
|
|
expect(userSchema.properties['email'].type).toBe('string');
|
|
expect(userSchema.properties['createdAt'].type).toBe('string');
|
|
expect(userSchema.properties['createdAt'].format).toBe('date-time');
|
|
});
|
|
|
|
it('model entity schema has optional fields as nullable anyOf', () => {
|
|
// profile is optional (Profile?) on User, address is optional Address? @json
|
|
const userSchema = spec.components.schemas['User'];
|
|
// someJson is Json? — optional scalar
|
|
expect(userSchema.properties['someJson'].anyOf).toBeDefined();
|
|
const types = userSchema.properties['someJson'].anyOf.map((s: any) => s.type);
|
|
expect(types).toContain('null');
|
|
});
|
|
|
|
it('model entity schema has relation fields as optional (not in required)', () => {
|
|
const userSchema = spec.components.schemas['User'];
|
|
// posts and profile are relation fields — should be present in properties but not required
|
|
expect(userSchema.properties['posts']).toBeDefined();
|
|
expect(userSchema.properties['profile']).toBeDefined();
|
|
expect(userSchema.required ?? []).not.toContain('posts');
|
|
expect(userSchema.required ?? []).not.toContain('profile');
|
|
});
|
|
|
|
it('scalar fields are in required', () => {
|
|
const postSchema = spec.components.schemas['Post'];
|
|
expect(postSchema.required).toContain('id');
|
|
expect(postSchema.required).toContain('title');
|
|
expect(postSchema.required).toContain('published');
|
|
});
|
|
|
|
it('enum schemas are present in components and enum fields reference them', async () => {
|
|
const enumSchema = `
|
|
enum Role {
|
|
USER
|
|
ADMIN
|
|
}
|
|
|
|
model User {
|
|
id Int @id @default(autoincrement())
|
|
role Role
|
|
}
|
|
`;
|
|
const client = await createTestClient(enumSchema);
|
|
const handler = new RPCApiHandler({ schema: client.$schema });
|
|
const s = await generateSpec(handler);
|
|
|
|
// Enum component must be registered so the $ref resolves
|
|
expect(s.components?.schemas?.['Role']).toBeDefined();
|
|
expect(s.components?.schemas?.['Role'].type).toBe('string');
|
|
expect(s.components?.schemas?.['Role'].enum).toEqual(['USER', 'ADMIN']);
|
|
|
|
// The entity schema must reference the enum via $ref
|
|
const userSchema = s.components?.schemas?.['User'] as any;
|
|
const roleField = userSchema?.properties?.role;
|
|
expect(roleField?.['$ref']).toBe('#/components/schemas/Role');
|
|
});
|
|
});
|
|
|
|
describe('RPC OpenAPI spec generation - operationIds', () => {
|
|
let spec: any;
|
|
|
|
beforeAll(async () => {
|
|
const client = await createTestClient(schema);
|
|
const handler = new RPCApiHandler({ schema: client.$schema });
|
|
spec = await generateSpec(handler);
|
|
});
|
|
|
|
it('operationIds are unique', () => {
|
|
const ids: string[] = [];
|
|
for (const item of Object.values(spec.paths as Record<string, any>)) {
|
|
for (const method of ['get', 'post', 'put', 'delete'] as const) {
|
|
if (item[method]?.operationId) {
|
|
ids.push(item[method].operationId);
|
|
}
|
|
}
|
|
}
|
|
expect(new Set(ids).size).toBe(ids.length);
|
|
});
|
|
|
|
it('model operation IDs follow {model}_{op} convention', () => {
|
|
expect(spec.paths['/user/findMany'].get.operationId).toBe('user_findMany');
|
|
expect(spec.paths['/post/create'].post.operationId).toBe('post_create');
|
|
expect(spec.paths['/comment/delete'].delete.operationId).toBe('comment_delete');
|
|
});
|
|
});
|
|
|
|
describe('RPC OpenAPI spec generation - queryOptions slicing', () => {
|
|
it('excludedModels removes model paths from spec', async () => {
|
|
const client = await createTestClient(schema);
|
|
const handler = new RPCApiHandler({
|
|
schema: client.$schema,
|
|
queryOptions: { slicing: { excludedModels: ['Post'] as any } },
|
|
});
|
|
const spec = await generateSpec(handler);
|
|
|
|
expect(spec.paths?.['/post/findMany']).toBeUndefined();
|
|
expect(spec.paths?.['/user/findMany']).toBeDefined();
|
|
// Post tag should not be present
|
|
expect(spec.tags?.map((t: any) => t.name)).not.toContain('post');
|
|
});
|
|
|
|
it('includedModels limits spec to those models', async () => {
|
|
const client = await createTestClient(schema);
|
|
const handler = new RPCApiHandler({
|
|
schema: client.$schema,
|
|
queryOptions: { slicing: { includedModels: ['User', 'Post'] as any } },
|
|
});
|
|
const spec = await generateSpec(handler);
|
|
|
|
expect(spec.paths?.['/user/findMany']).toBeDefined();
|
|
expect(spec.paths?.['/post/findMany']).toBeDefined();
|
|
expect(spec.paths?.['/comment/findMany']).toBeUndefined();
|
|
});
|
|
|
|
it('excludedOperations removes paths for those operations', async () => {
|
|
const client = await createTestClient(schema);
|
|
const handler = new RPCApiHandler({
|
|
schema: client.$schema,
|
|
queryOptions: {
|
|
slicing: {
|
|
models: { post: { excludedOperations: ['create', 'delete'] } },
|
|
} as any,
|
|
},
|
|
});
|
|
const spec = await generateSpec(handler);
|
|
|
|
expect(spec.paths?.['/post/findMany']).toBeDefined();
|
|
expect(spec.paths?.['/post/create']).toBeUndefined();
|
|
expect(spec.paths?.['/post/delete']).toBeUndefined();
|
|
expect(spec.paths?.['/post/update']).toBeDefined();
|
|
});
|
|
|
|
it('includedOperations limits to those operations', async () => {
|
|
const client = await createTestClient(schema);
|
|
const handler = new RPCApiHandler({
|
|
schema: client.$schema,
|
|
queryOptions: {
|
|
slicing: {
|
|
models: { user: { includedOperations: ['findMany', 'findUnique'] } },
|
|
} as any,
|
|
},
|
|
});
|
|
const spec = await generateSpec(handler);
|
|
|
|
expect(spec.paths?.['/user/findMany']).toBeDefined();
|
|
expect(spec.paths?.['/user/findUnique']).toBeDefined();
|
|
expect(spec.paths?.['/user/create']).toBeUndefined();
|
|
expect(spec.paths?.['/user/update']).toBeUndefined();
|
|
});
|
|
|
|
it('$all model slicing applies to all models', async () => {
|
|
const client = await createTestClient(schema);
|
|
const handler = new RPCApiHandler({
|
|
schema: client.$schema,
|
|
queryOptions: {
|
|
slicing: {
|
|
models: { $all: { excludedOperations: ['delete', 'deleteMany'] } },
|
|
} as any,
|
|
},
|
|
});
|
|
const spec = await generateSpec(handler);
|
|
|
|
for (const model of ['user', 'post', 'comment', 'setting', 'profile']) {
|
|
expect(spec.paths?.[`/${model}/delete`]).toBeUndefined();
|
|
expect(spec.paths?.[`/${model}/deleteMany`]).toBeUndefined();
|
|
expect(spec.paths?.[`/${model}/findMany`]).toBeDefined();
|
|
}
|
|
});
|
|
|
|
it('excludedModels removes model entity schema from components', async () => {
|
|
const client = await createTestClient(schema);
|
|
const handler = new RPCApiHandler({
|
|
schema: client.$schema,
|
|
queryOptions: { slicing: { excludedModels: ['Post'] as any } },
|
|
});
|
|
const spec = await generateSpec(handler);
|
|
|
|
expect(spec.components?.schemas?.['Post']).toBeUndefined();
|
|
expect(spec.components?.schemas?.['User']).toBeDefined();
|
|
});
|
|
|
|
it('includedModels removes excluded model entity schemas from components', async () => {
|
|
const client = await createTestClient(schema);
|
|
const handler = new RPCApiHandler({
|
|
schema: client.$schema,
|
|
queryOptions: { slicing: { includedModels: ['User'] as any } },
|
|
});
|
|
const spec = await generateSpec(handler);
|
|
|
|
expect(spec.components?.schemas?.['User']).toBeDefined();
|
|
expect(spec.components?.schemas?.['Post']).toBeUndefined();
|
|
expect(spec.components?.schemas?.['Comment']).toBeUndefined();
|
|
});
|
|
|
|
it('excludedModels removes excluded model arg schemas from components', async () => {
|
|
const client = await createTestClient(schema);
|
|
const handler = new RPCApiHandler({
|
|
schema: client.$schema,
|
|
queryOptions: { slicing: { excludedModels: ['Post'] as any } },
|
|
});
|
|
const spec = await generateSpec(handler);
|
|
|
|
expect(spec.components?.schemas?.['PostFindManyArgs']).toBeUndefined();
|
|
expect(spec.components?.schemas?.['PostCreateArgs']).toBeUndefined();
|
|
expect(spec.components?.schemas?.['UserFindManyArgs']).toBeDefined();
|
|
});
|
|
|
|
it('excludedFilterKinds removes filter operators from field filter schema', async () => {
|
|
const client = await createTestClient(schema);
|
|
const handler = new RPCApiHandler({
|
|
schema: client.$schema,
|
|
queryOptions: {
|
|
slicing: {
|
|
models: {
|
|
user: {
|
|
fields: {
|
|
email: { excludedFilterKinds: ['Like'] },
|
|
},
|
|
},
|
|
},
|
|
} as any,
|
|
},
|
|
});
|
|
const spec = await generateSpec(handler);
|
|
|
|
// With Like excluded, email uses a sliced filter schema that has no Like operators
|
|
const userWhereInput = spec.components?.schemas?.['UserWhereInput'] as any;
|
|
// email should reference a sliced filter variant (not the full StringFilter)
|
|
const emailRef = userWhereInput?.properties?.email?.['$ref'] as string;
|
|
expect(emailRef).toBeDefined();
|
|
const slicedFilterId = emailRef.replace('#/components/schemas/', '');
|
|
const slicedFilter = spec.components?.schemas?.[slicedFilterId] as any;
|
|
const filterObj = slicedFilter?.anyOf?.find((s: any) => s.type === 'object');
|
|
// Like operators should be absent
|
|
expect(filterObj?.properties?.contains).toBeUndefined();
|
|
expect(filterObj?.properties?.startsWith).toBeUndefined();
|
|
expect(filterObj?.properties?.endsWith).toBeUndefined();
|
|
// Equality operators should still be present
|
|
expect(filterObj?.properties?.equals).toBeDefined();
|
|
});
|
|
|
|
it('includedFilterKinds limits field filter schema to specified kinds', async () => {
|
|
const client = await createTestClient(schema);
|
|
const handler = new RPCApiHandler({
|
|
schema: client.$schema,
|
|
queryOptions: {
|
|
slicing: {
|
|
models: {
|
|
user: {
|
|
fields: {
|
|
email: { includedFilterKinds: ['Equality'] },
|
|
},
|
|
},
|
|
},
|
|
} as any,
|
|
},
|
|
});
|
|
const spec = await generateSpec(handler);
|
|
|
|
const userWhereInput = spec.components?.schemas?.['UserWhereInput'] as any;
|
|
const emailRef = userWhereInput?.properties?.email?.['$ref'] as string;
|
|
expect(emailRef).toBeDefined();
|
|
const slicedFilterId = emailRef.replace('#/components/schemas/', '');
|
|
const slicedFilter = spec.components?.schemas?.[slicedFilterId] as any;
|
|
const filterObj = slicedFilter?.anyOf?.find((s: any) => s.type === 'object');
|
|
// Only Equality operators should remain
|
|
expect(filterObj?.properties?.equals).toBeDefined();
|
|
expect(filterObj?.properties?.in).toBeDefined();
|
|
// Like operators should be gone
|
|
expect(filterObj?.properties?.contains).toBeUndefined();
|
|
expect(filterObj?.properties?.startsWith).toBeUndefined();
|
|
// Range operators should be gone
|
|
expect(filterObj?.properties?.lt).toBeUndefined();
|
|
expect(filterObj?.properties?.gt).toBeUndefined();
|
|
});
|
|
|
|
it('$all field slicing applies includedFilterKinds to all fields of a model', async () => {
|
|
const client = await createTestClient(schema);
|
|
const handler = new RPCApiHandler({
|
|
schema: client.$schema,
|
|
queryOptions: {
|
|
slicing: {
|
|
models: {
|
|
user: {
|
|
fields: { $all: { includedFilterKinds: ['Equality'] } },
|
|
},
|
|
},
|
|
} as any,
|
|
},
|
|
});
|
|
const spec = await generateSpec(handler);
|
|
|
|
const userWhereInput = spec.components?.schemas?.['UserWhereInput'] as any;
|
|
// Both email and myId should reference a sliced Equality-only filter schema
|
|
for (const field of ['email', 'myId']) {
|
|
const emailRef = userWhereInput?.properties?.[field]?.['$ref'] as string;
|
|
expect(emailRef).toBeDefined();
|
|
const slicedFilterId = emailRef.replace('#/components/schemas/', '');
|
|
const slicedFilter = spec.components?.schemas?.[slicedFilterId] as any;
|
|
const filterObj = slicedFilter?.anyOf?.find((s: any) => s.type === 'object');
|
|
expect(filterObj?.properties?.equals).toBeDefined();
|
|
expect(filterObj?.properties?.contains).toBeUndefined();
|
|
expect(filterObj?.properties?.lt).toBeUndefined();
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('RPC OpenAPI spec generation - procedures', () => {
|
|
const procSchema = `
|
|
model User {
|
|
id Int @id @default(autoincrement())
|
|
name String
|
|
}
|
|
|
|
procedure getUser(id: Int): User
|
|
mutation procedure createUser(name: String): User
|
|
procedure optionalSearch(query: String?): User[]
|
|
`;
|
|
|
|
it('generates GET path for query procedures', async () => {
|
|
const client = await createTestClient(procSchema);
|
|
const handler = new RPCApiHandler({ schema: client.$schema });
|
|
const spec = await generateSpec(handler);
|
|
|
|
expect(spec.paths?.['/$procs/getUser']).toBeDefined();
|
|
expect(spec.paths?.['/$procs/getUser']?.get).toBeDefined();
|
|
expect(spec.paths?.['/$procs/getUser']?.post).toBeUndefined();
|
|
});
|
|
|
|
it('generates POST path for mutation procedures', async () => {
|
|
const client = await createTestClient(procSchema);
|
|
const handler = new RPCApiHandler({ schema: client.$schema });
|
|
const spec = await generateSpec(handler);
|
|
|
|
expect(spec.paths?.['/$procs/createUser']).toBeDefined();
|
|
expect(spec.paths?.['/$procs/createUser']?.post).toBeDefined();
|
|
expect(spec.paths?.['/$procs/createUser']?.get).toBeUndefined();
|
|
});
|
|
|
|
it('query procedure has q parameter with args envelope schema', async () => {
|
|
const client = await createTestClient(procSchema);
|
|
const handler = new RPCApiHandler({ schema: client.$schema });
|
|
const spec = await generateSpec(handler);
|
|
|
|
const operation = spec.paths?.['/$procs/getUser']?.get;
|
|
const qParam: any = operation?.parameters?.find((p: any) => p.name === 'q');
|
|
expect(qParam).toBeDefined();
|
|
expect(qParam?.content?.['application/json']?.schema).toBeDefined();
|
|
// args is a $ref to the registered ProcArgs component schema
|
|
const envelopeSchema = qParam?.content['application/json'].schema;
|
|
const argsRef = envelopeSchema.properties?.args?.$ref;
|
|
expect(argsRef).toBeDefined();
|
|
const argsSchemaName = argsRef.replace('#/components/schemas/', '');
|
|
const argsSchema = spec.components?.schemas?.[argsSchemaName] as any;
|
|
expect(argsSchema?.properties?.id).toBeDefined();
|
|
expect(argsSchema?.required).toContain('id');
|
|
});
|
|
|
|
it('mutation procedure has request body with args envelope schema', async () => {
|
|
const client = await createTestClient(procSchema);
|
|
const handler = new RPCApiHandler({ schema: client.$schema });
|
|
const spec = await generateSpec(handler);
|
|
|
|
const operation = spec?.paths?.['/$procs/createUser']?.post;
|
|
expect(operation?.requestBody).toBeDefined();
|
|
expect((operation?.requestBody as any)?.required).toBe(true);
|
|
const bodySchema = (operation?.requestBody as any)?.content?.['application/json']?.schema;
|
|
// args is a $ref to the registered ProcArgs component schema
|
|
const argsRef = bodySchema?.properties?.args?.$ref;
|
|
expect(argsRef).toBeDefined();
|
|
const argsSchemaName = argsRef.replace('#/components/schemas/', '');
|
|
const argsSchema = spec.components?.schemas?.[argsSchemaName] as any;
|
|
expect(argsSchema?.properties?.name).toBeDefined();
|
|
expect(argsSchema?.required).toContain('name');
|
|
});
|
|
|
|
it('mutation with only optional params does not set requestBody.required', async () => {
|
|
const optionalMutationSchema = `
|
|
model User {
|
|
id Int @id @default(autoincrement())
|
|
name String
|
|
}
|
|
mutation procedure softDelete(id: Int?): User
|
|
`;
|
|
const client = await createTestClient(optionalMutationSchema);
|
|
const handler = new RPCApiHandler({ schema: client.$schema });
|
|
const spec = await generateSpec(handler);
|
|
|
|
const operation = spec?.paths?.['/$procs/softDelete']?.post;
|
|
expect(operation?.requestBody).toBeDefined();
|
|
expect((operation?.requestBody as any)?.required).toBeUndefined();
|
|
});
|
|
|
|
it('optional procedure params are not in required array', async () => {
|
|
const client = await createTestClient(procSchema);
|
|
const handler = new RPCApiHandler({ schema: client.$schema });
|
|
const spec = await generateSpec(handler);
|
|
|
|
const operation = spec?.paths?.['/$procs/optionalSearch']?.get;
|
|
const qParam = operation?.parameters?.find((p: any) => p.name === 'q');
|
|
// args is a $ref to the registered ProcArgs component schema
|
|
const argsRef = (qParam as any)?.content?.['application/json']?.schema?.properties?.args?.$ref;
|
|
expect(argsRef).toBeDefined();
|
|
const argsSchemaName = argsRef.replace('#/components/schemas/', '');
|
|
const argsSchema = spec.components?.schemas?.[argsSchemaName] as any;
|
|
// query is optional so should not appear in required
|
|
expect(argsSchema?.required ?? []).not.toContain('query');
|
|
});
|
|
|
|
it('procedure operationId uses proc_ prefix', async () => {
|
|
const client = await createTestClient(procSchema);
|
|
const handler = new RPCApiHandler({ schema: client.$schema });
|
|
const spec = await generateSpec(handler);
|
|
|
|
expect(spec?.paths?.['/$procs/getUser']?.get?.operationId).toBe('proc_getUser');
|
|
expect(spec?.paths?.['/$procs/createUser']?.post?.operationId).toBe('proc_createUser');
|
|
});
|
|
|
|
it('slicing excludedProcedures removes procedure paths', async () => {
|
|
const client = await createTestClient(procSchema);
|
|
const handler = new RPCApiHandler({
|
|
schema: client.$schema,
|
|
queryOptions: { slicing: { excludedProcedures: ['getUser'] as any } },
|
|
});
|
|
const spec = await generateSpec(handler);
|
|
|
|
expect(spec.paths?.['/$procs/getUser']).toBeUndefined();
|
|
expect(spec.paths?.['/$procs/createUser']).toBeDefined();
|
|
});
|
|
|
|
it('slicing excludedProcedures removes procedure args from components schemas', async () => {
|
|
const client = await createTestClient(procSchema);
|
|
const handler = new RPCApiHandler({
|
|
schema: client.$schema,
|
|
queryOptions: { slicing: { excludedProcedures: ['getUser'] as any } },
|
|
});
|
|
const spec = await generateSpec(handler);
|
|
|
|
const schemaKeys = Object.keys(spec.components?.schemas ?? {});
|
|
expect(schemaKeys.some((k) => k.startsWith('getUser'))).toBe(false);
|
|
expect(schemaKeys.some((k) => k.startsWith('createUser'))).toBe(true);
|
|
});
|
|
|
|
it('slicing includedProcedures removes non-listed procedure args from components schemas', async () => {
|
|
const client = await createTestClient(procSchema);
|
|
const handler = new RPCApiHandler({
|
|
schema: client.$schema,
|
|
queryOptions: { slicing: { includedProcedures: ['createUser'] as any } },
|
|
});
|
|
const spec = await generateSpec(handler);
|
|
|
|
const schemaKeys = Object.keys(spec.components?.schemas ?? {});
|
|
expect(schemaKeys.some((k) => k.startsWith('createUser'))).toBe(true);
|
|
expect(schemaKeys.some((k) => k.startsWith('getUser'))).toBe(false);
|
|
expect(schemaKeys.some((k) => k.startsWith('optionalSearch'))).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('RPC OpenAPI spec generation - respectAccessPolicies', () => {
|
|
it('no 403 responses when respectAccessPolicies is off', async () => {
|
|
const policySchema = `
|
|
model Item {
|
|
id Int @id @default(autoincrement())
|
|
value Int
|
|
@@allow('create', value > 0)
|
|
}
|
|
`;
|
|
const client = await createTestClient(policySchema);
|
|
const handler = new RPCApiHandler({ schema: client.$schema });
|
|
const spec = await generateSpec(handler);
|
|
expect(spec.paths?.['/item/create']?.post?.responses?.['403']).toBeUndefined();
|
|
});
|
|
|
|
it('adds 403 for operations with non-constant-allow policies', async () => {
|
|
const policySchema = `
|
|
model Item {
|
|
id Int @id @default(autoincrement())
|
|
value Int
|
|
@@allow('read', true)
|
|
@@allow('create', value > 0)
|
|
@@allow('update', value > 0)
|
|
@@allow('delete', value > 0)
|
|
}
|
|
`;
|
|
const client = await createTestClient(policySchema);
|
|
const handler = new RPCApiHandler({ schema: client.$schema });
|
|
const spec = await generateSpec(handler, { respectAccessPolicies: true });
|
|
|
|
expect(spec.paths?.['/item/create']?.post?.responses?.['403']).toBeDefined();
|
|
expect(spec.paths?.['/item/update']?.put?.responses?.['403']).toBeDefined();
|
|
expect(spec.paths?.['/item/delete']?.delete?.responses?.['403']).toBeDefined();
|
|
// read is constant-allow → no 403
|
|
expect(spec.paths?.['/item/findMany']?.get?.responses?.['403']).toBeUndefined();
|
|
});
|
|
|
|
it('no 403 for constant-allow operations', async () => {
|
|
const policySchema = `
|
|
model Item {
|
|
id Int @id @default(autoincrement())
|
|
value Int
|
|
@@allow('all', true)
|
|
}
|
|
`;
|
|
const client = await createTestClient(policySchema);
|
|
const handler = new RPCApiHandler({ schema: client.$schema });
|
|
const spec = await generateSpec(handler, { respectAccessPolicies: true });
|
|
|
|
expect(spec.paths?.['/item/create']?.post?.responses?.['403']).toBeUndefined();
|
|
expect(spec.paths?.['/item/update']?.put?.responses?.['403']).toBeUndefined();
|
|
expect(spec.paths?.['/item/delete']?.delete?.responses?.['403']).toBeUndefined();
|
|
});
|
|
|
|
it('403 when deny rule exists even with constant allow', async () => {
|
|
const policySchema = `
|
|
model Item {
|
|
id Int @id @default(autoincrement())
|
|
value Int
|
|
@@allow('create', true)
|
|
@@deny('create', value < 0)
|
|
}
|
|
`;
|
|
const client = await createTestClient(policySchema);
|
|
const handler = new RPCApiHandler({ schema: client.$schema });
|
|
const spec = await generateSpec(handler, { respectAccessPolicies: true });
|
|
expect(spec.paths?.['/item/create']?.post?.responses?.['403']).toBeDefined();
|
|
});
|
|
|
|
it('403 when no policy rules at all (default-deny)', async () => {
|
|
const policySchema = `
|
|
model Item {
|
|
id Int @id @default(autoincrement())
|
|
value Int
|
|
}
|
|
`;
|
|
const client = await createTestClient(policySchema);
|
|
const handler = new RPCApiHandler({ schema: client.$schema });
|
|
const spec = await generateSpec(handler, { respectAccessPolicies: true });
|
|
|
|
expect(spec.paths?.['/item/create']?.post?.responses?.['403']).toBeDefined();
|
|
expect(spec.paths?.['/item/update']?.put?.responses?.['403']).toBeDefined();
|
|
expect(spec.paths?.['/item/delete']?.delete?.responses?.['403']).toBeDefined();
|
|
});
|
|
|
|
it('per-operation granularity: only non-constant ops get 403', async () => {
|
|
const policySchema = `
|
|
model Item {
|
|
id Int @id @default(autoincrement())
|
|
value Int
|
|
@@allow('create,read', true)
|
|
@@allow('update,delete', value > 0)
|
|
}
|
|
`;
|
|
const client = await createTestClient(policySchema);
|
|
const handler = new RPCApiHandler({ schema: client.$schema });
|
|
const spec = await generateSpec(handler, { respectAccessPolicies: true });
|
|
|
|
expect(spec.paths?.['/item/create']?.post?.responses?.['403']).toBeUndefined();
|
|
expect(spec.paths?.['/item/update']?.put?.responses?.['403']).toBeDefined();
|
|
expect(spec.paths?.['/item/delete']?.delete?.responses?.['403']).toBeDefined();
|
|
});
|
|
});
|
|
|
|
describe('RPC OpenAPI spec generation - JSON fields', () => {
|
|
let spec: any;
|
|
|
|
beforeAll(async () => {
|
|
const client = await createTestClient(schema);
|
|
const handler = new RPCApiHandler({ schema: client.$schema });
|
|
spec = await generateSpec(handler);
|
|
});
|
|
|
|
it('JsonValue schema is registered in components', () => {
|
|
const jsonValue = spec.components.schemas['JsonValue'];
|
|
expect(jsonValue).toBeDefined();
|
|
// Should be a union of primitive types
|
|
expect(jsonValue.anyOf).toBeDefined();
|
|
const types = jsonValue.anyOf.map((s: any) => s.type).filter(Boolean);
|
|
expect(types).toContain('string');
|
|
expect(types).toContain('number');
|
|
expect(types).toContain('boolean');
|
|
});
|
|
|
|
it('plain JSON field (someJson Json?) is nullable in entity schema', () => {
|
|
const userSchema = spec.components.schemas['User'];
|
|
const field = userSchema.properties['someJson'];
|
|
expect(field).toBeDefined();
|
|
// Optional Json field should allow null
|
|
expect(field.anyOf).toBeDefined();
|
|
const types = field.anyOf.map((s: any) => s.type).filter(Boolean);
|
|
expect(types).toContain('null');
|
|
});
|
|
|
|
it('typed JSON field (address Address? @json) references typedef schema', () => {
|
|
const userSchema = spec.components.schemas['User'];
|
|
const field = userSchema.properties['address'];
|
|
expect(field).toBeDefined();
|
|
// Optional typed JSON field should be anyOf: [$ref: Address, null]
|
|
expect(field.anyOf).toBeDefined();
|
|
const refs = field.anyOf.map((s: any) => s.$ref ?? s.type).filter(Boolean);
|
|
expect(refs).toContain('#/components/schemas/Address');
|
|
expect(refs).toContain('null');
|
|
});
|
|
|
|
it('JsonFilter schema is registered for filtering plain JSON fields', () => {
|
|
// someJson is optional so JsonFilterOptional is expected
|
|
const jsonFilter = spec.components.schemas['JsonFilterOptional'];
|
|
expect(jsonFilter).toBeDefined();
|
|
expect(jsonFilter.type).toBe('object');
|
|
// Should have standard JSON filter operators
|
|
expect(jsonFilter.properties['equals']).toBeDefined();
|
|
expect(jsonFilter.properties['not']).toBeDefined();
|
|
expect(jsonFilter.properties['string_contains']).toBeDefined();
|
|
expect(jsonFilter.properties['array_contains']).toBeDefined();
|
|
});
|
|
|
|
it('AddressFilter schema is registered for filtering typed JSON fields', () => {
|
|
// address is optional so AddressFilterOptional is expected
|
|
const addressFilter = spec.components.schemas['AddressFilterOptional'];
|
|
expect(addressFilter).toBeDefined();
|
|
// Should be a union (field filter + json filter + is/isNot)
|
|
expect(addressFilter.anyOf).toBeDefined();
|
|
});
|
|
|
|
it('AddressFilter field filter variant includes Address typedef fields', () => {
|
|
const addressFilter = spec.components.schemas['AddressFilterOptional'];
|
|
// One of the anyOf branches should contain the field-level filter for Address.city
|
|
const fieldFilterBranch = addressFilter.anyOf?.find((s: any) => s.type === 'object' && s.properties?.city);
|
|
expect(fieldFilterBranch).toBeDefined();
|
|
});
|
|
|
|
it('typedef schema (Address) is registered in components', () => {
|
|
const addressSchema = spec.components.schemas['Address'];
|
|
expect(addressSchema).toBeDefined();
|
|
});
|
|
});
|
|
|
|
describe('RPC OpenAPI spec generation - meta descriptions', () => {
|
|
const metaSchema = `
|
|
type Address {
|
|
city String @meta(name: "description", value: "The city name")
|
|
zip String? @meta(name: "description", value: "Postal code")
|
|
}
|
|
|
|
model User {
|
|
id Int @id @default(autoincrement())
|
|
name String @meta(name: "description", value: "Full name of the user")
|
|
email String
|
|
|
|
@@meta(name: "description", value: "A platform user")
|
|
}
|
|
`;
|
|
|
|
let spec: any;
|
|
|
|
beforeAll(async () => {
|
|
const client = await createTestClient(metaSchema);
|
|
const handler = new RPCApiHandler({ schema: client.$schema });
|
|
spec = await generateSpec(handler);
|
|
});
|
|
|
|
it('model @@meta description appears on entity component schema', () => {
|
|
const userSchema = spec.components?.schemas?.['User'];
|
|
expect(userSchema?.description).toBe('A platform user');
|
|
});
|
|
|
|
it('field @meta description appears on scalar field schema', () => {
|
|
const userSchema = spec.components?.schemas?.['User'];
|
|
expect(userSchema?.properties?.name?.description).toBe('Full name of the user');
|
|
});
|
|
|
|
it('field without @meta description has no description property', () => {
|
|
const userSchema = spec.components?.schemas?.['User'];
|
|
expect(userSchema?.properties?.email?.description).toBeUndefined();
|
|
});
|
|
|
|
it('typedef field @meta description appears on field schema', () => {
|
|
const addressSchema = spec.components?.schemas?.['Address'];
|
|
expect(addressSchema?.properties?.city?.description).toBe('The city name');
|
|
});
|
|
|
|
it('optional typedef field @meta description appears on field schema', () => {
|
|
const addressSchema = spec.components?.schemas?.['Address'];
|
|
// zip is optional so it's wrapped in anyOf — description is on the base type inside anyOf
|
|
const zipBase = addressSchema?.properties?.zip?.anyOf?.[0];
|
|
expect(zipBase?.description).toBe('Postal code');
|
|
});
|
|
});
|
|
|
|
describe('RPC OpenAPI spec generation - baseline', () => {
|
|
it('matches baseline', async () => {
|
|
const client = await createTestClient(schema, { provider: 'postgresql' });
|
|
const handler = new RPCApiHandler({ schema: client.$schema });
|
|
const spec = await generateSpec(handler);
|
|
const baselineFile = 'rpc.baseline.yaml';
|
|
|
|
if (UPDATE_BASELINE) {
|
|
saveBaseline(baselineFile, spec);
|
|
return;
|
|
}
|
|
|
|
const baseline = loadBaseline(baselineFile);
|
|
expect(spec).toEqual(baseline);
|
|
|
|
await validate(JSON.parse(JSON.stringify(spec)));
|
|
});
|
|
});
|
|
|
|
describe('RPC OpenAPI spec generation - OpenAPI validation', () => {
|
|
it('spec passes OpenAPI 3.1 validation', async () => {
|
|
const client = await createTestClient(schema);
|
|
const handler = new RPCApiHandler({ schema: client.$schema });
|
|
const spec = await generateSpec(handler);
|
|
// Deep clone to avoid validate() mutating $ref strings
|
|
await validate(JSON.parse(JSON.stringify(spec)));
|
|
});
|
|
|
|
it('spec with procedures passes OpenAPI 3.1 validation', async () => {
|
|
const procSchema = `
|
|
model User {
|
|
id Int @id @default(autoincrement())
|
|
name String
|
|
}
|
|
|
|
procedure findByName(name: String): User
|
|
mutation procedure createUser(name: String): User
|
|
`;
|
|
const client = await createTestClient(procSchema);
|
|
const handler = new RPCApiHandler({ schema: client.$schema });
|
|
const spec = await generateSpec(handler);
|
|
await validate(JSON.parse(JSON.stringify(spec)));
|
|
});
|
|
});
|