zenstack/packages/server/test/openapi/rest-openapi.test.ts
Yiming Cao dc26257553
feat(server): add OpenAPI spec generation for RPC API (#2574)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 21:35:24 -07:00

1036 lines
40 KiB
TypeScript

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 { validate } from '@readme/openapi-parser';
import { RestApiHandler } from '../../src/api/rest';
const UPDATE_BASELINE = process.env.UPDATE_BASELINE === '1';
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 }),
);
}
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[]
likes PostLike[]
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[]
likes PostLike[]
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
}
model PostLike {
postId Int
userId String
superLike Boolean
post Post @relation(fields: [postId], references: [id])
user User @relation(fields: [userId], references: [myId])
likeInfos PostLikeInfo[]
@@id([postId, userId])
}
model PostLikeInfo {
id Int @id @default(autoincrement())
text String
postId Int
userId String
postLike PostLike @relation(fields: [postId, userId], references: [postId, userId])
}
`;
describe('REST OpenAPI spec generation', () => {
let handler: RestApiHandler;
let spec: any;
beforeAll(async () => {
const client = await createTestClient(schema);
handler = new RestApiHandler({
schema: client.$schema,
endpoint: 'http://localhost/api',
});
spec = await handler.generateSpec();
});
it('document structure is valid', () => {
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');
expect(spec.paths).toBeDefined();
expect(spec.components).toBeDefined();
expect(spec.components.schemas).toBeDefined();
});
it('model paths exist', () => {
expect(spec.paths['/user']).toBeDefined();
expect(spec.paths['/user/{id}']).toBeDefined();
expect(spec.paths['/post']).toBeDefined();
expect(spec.paths['/post/{id}']).toBeDefined();
expect(spec.paths['/comment']).toBeDefined();
expect(spec.paths['/comment/{id}']).toBeDefined();
});
it('HTTP methods on collection path', () => {
expect(spec.paths['/user'].get).toBeDefined();
expect(spec.paths['/user'].post).toBeDefined();
});
it('HTTP methods on single resource path', () => {
expect(spec.paths['/user/{id}'].get).toBeDefined();
expect(spec.paths['/user/{id}'].patch).toBeDefined();
expect(spec.paths['/user/{id}'].delete).toBeDefined();
});
it('relation paths exist', () => {
expect(spec.paths['/user/{id}/posts']).toBeDefined();
expect(spec.paths['/user/{id}/relationships/posts']).toBeDefined();
expect(spec.paths['/post/{id}/comments']).toBeDefined();
});
it('fetch related path has response schema', () => {
// Collection relation: should reference ListResponse
const collectionPath = spec.paths['/user/{id}/posts'] as any;
const collectionSchema = collectionPath.get.responses['200'].content['application/vnd.api+json'].schema;
expect(collectionSchema.$ref).toBe('#/components/schemas/PostListResponse');
// Singular relation: should reference Response
const singularPath = spec.paths['/post/{id}/setting'] as any;
const singularSchema = singularPath.get.responses['200'].content['application/vnd.api+json'].schema;
expect(singularSchema.$ref).toBe('#/components/schemas/SettingResponse');
});
it('relationship path has correct methods', () => {
const relPath = spec.paths['/user/{id}/relationships/posts'];
expect(relPath.get).toBeDefined();
expect(relPath.put).toBeDefined();
expect(relPath.patch).toBeDefined();
// posts is a collection, so should have POST
expect(relPath.post).toBeDefined();
});
it('model schemas exist', () => {
expect(spec.components.schemas['User']).toBeDefined();
expect(spec.components.schemas['UserCreateRequest']).toBeDefined();
expect(spec.components.schemas['UserUpdateRequest']).toBeDefined();
expect(spec.components.schemas['UserResponse']).toBeDefined();
expect(spec.components.schemas['UserListResponse']).toBeDefined();
expect(spec.components.schemas['Post']).toBeDefined();
expect(spec.components.schemas['PostCreateRequest']).toBeDefined();
});
it('field types in schemas', () => {
const userSchema = spec.components.schemas['User'];
const userAttrs = userSchema.properties.attributes.properties;
expect(userAttrs).toBeDefined();
// email -> string
expect(userAttrs['email']).toMatchObject({ type: 'string' });
// myId -> string
expect(userAttrs['myId']).toMatchObject({ type: 'string' });
const postSchema = spec.components.schemas['Post'];
const postAttrs = postSchema.properties.attributes.properties;
expect(postAttrs).toBeDefined();
// viewCount -> integer
expect(postAttrs['viewCount']).toMatchObject({ type: 'integer' });
// published -> boolean
expect(postAttrs['published']).toMatchObject({ type: 'boolean' });
// createdAt -> date-time
expect(postAttrs['createdAt']).toMatchObject({ type: 'string', format: 'date-time' });
});
it('required fields marked in create schema', () => {
const createReq = spec.components.schemas['CommentCreateRequest'];
expect(createReq).toBeDefined();
const dataProps = createReq.properties?.data?.properties;
expect(dataProps).toBeDefined();
// content is required, non-optional non-default
const attrRequired = createReq.properties?.data?.properties?.attributes?.required ?? [];
expect(attrRequired).toContain('content');
});
it('shared component schemas exist', () => {
expect(spec.components.schemas['_jsonapi']).toBeDefined();
expect(spec.components.schemas['_errors']).toBeDefined();
expect(spec.components.schemas['_errorResponse']).toBeDefined();
expect(spec.components.schemas['_resourceIdentifier']).toBeDefined();
expect(spec.components.schemas['_toOneRelationship']).toBeDefined();
expect(spec.components.schemas['_toManyRelationship']).toBeDefined();
});
it('shared parameters exist', () => {
expect(spec.components.parameters['id']).toBeDefined();
expect(spec.components.parameters['include']).toBeDefined();
expect(spec.components.parameters['sort']).toBeDefined();
expect(spec.components.parameters['pageOffset']).toBeDefined();
expect(spec.components.parameters['pageLimit']).toBeDefined();
});
it('filter parameters appear on list operations', () => {
const listOp = spec.paths['/post'].get;
expect(listOp).toBeDefined();
const paramNames = listOp.parameters.map((p: any) => ('name' in p ? p.name : p.$ref));
expect(paramNames).toContain('filter[viewCount]');
expect(paramNames).toContain('filter[published]');
expect(paramNames).toContain('filter[title]');
// String ops
expect(paramNames).toContain('filter[title][$contains]');
// Numeric ops
expect(paramNames).toContain('filter[viewCount][$lt]');
expect(paramNames).toContain('filter[viewCount][$gt]');
});
it('modelNameMapping is reflected in paths', async () => {
const client = await createTestClient(schema);
const mappedHandler = new RestApiHandler({
schema: client.$schema,
endpoint: 'http://localhost/api',
modelNameMapping: { User: 'users', Post: 'posts' },
});
const mappedSpec = await mappedHandler.generateSpec();
expect(mappedSpec.paths?.['/users']).toBeDefined();
expect(mappedSpec.paths?.['/posts']).toBeDefined();
// Original paths should not exist
expect(mappedSpec.paths?.['/user']).toBeUndefined();
expect(mappedSpec.paths?.['/post']).toBeUndefined();
});
it('compound ID model paths exist', () => {
// PostLike has @@id([postId, userId])
expect(spec.paths['/postLike']).toBeDefined();
expect(spec.paths['/postLike/{id}']).toBeDefined();
});
it('custom openApiOptions are reflected in info', async () => {
const client = await createTestClient(schema);
const customHandler = new RestApiHandler({
schema: client.$schema,
endpoint: 'http://localhost/api',
});
const customSpec = await customHandler.generateSpec({
title: 'My Custom API',
version: '2.0.0',
description: 'A custom description',
});
expect(customSpec.info.title).toBe('My Custom API');
expect(customSpec.info.version).toBe('2.0.0');
expect((customSpec.info as any).description).toBe('A custom description');
});
});
describe('REST OpenAPI spec generation - queryOptions', () => {
it('omit excludes fields from read schema', async () => {
const client = await createTestClient(schema);
const handler = new RestApiHandler({
schema: client.$schema,
endpoint: 'http://localhost/api',
queryOptions: { omit: { User: { email: true } } },
});
const s = await handler.generateSpec();
const userSchema = s.components?.schemas?.['User'] as any;
const userAttrs = userSchema.properties.attributes.properties;
expect(userAttrs['myId']).toBeDefined();
expect(userAttrs['email']).toBeUndefined();
});
it('slicing excludedModels removes model from spec', async () => {
const client = await createTestClient(schema);
const handler = new RestApiHandler({
schema: client.$schema,
endpoint: 'http://localhost/api',
queryOptions: { slicing: { excludedModels: ['Post'] as any } },
});
const s = await handler.generateSpec();
expect(s.paths?.['/user']).toBeDefined();
expect(s.paths?.['/post']).toBeUndefined();
expect(s.components?.schemas?.['Post']).toBeUndefined();
// Relation paths to excluded model should not exist
expect(s.paths?.['/user/{id}/posts']).toBeUndefined();
expect(s.paths?.['/user/{id}/relationships/posts']).toBeUndefined();
// Relation fields to excluded model should not appear in read schema
const userSchema = s.components?.schemas?.['User'] as any;
const userRels = userSchema.properties.relationships?.properties ?? {};
expect(userRels['posts']).toBeUndefined();
const userAttrs = userSchema.properties.attributes.properties;
expect(userAttrs['email']).toBeDefined();
});
it('slicing includedModels limits models in spec', async () => {
const client = await createTestClient(schema);
const handler = new RestApiHandler({
schema: client.$schema,
endpoint: 'http://localhost/api',
queryOptions: { slicing: { includedModels: ['User'] as any } },
});
const s = await handler.generateSpec();
expect(s.paths?.['/user']).toBeDefined();
expect(s.paths?.['/post']).toBeUndefined();
});
it('slicing excludedOperations removes HTTP methods from paths', async () => {
const client = await createTestClient(schema);
const handler = new RestApiHandler({
schema: client.$schema,
endpoint: 'http://localhost/api',
queryOptions: {
slicing: {
models: {
post: { excludedOperations: ['create', 'delete'] },
},
} as any,
},
});
const s = await handler.generateSpec();
// Collection path: GET (findMany) should exist, POST (create) should not
expect((s.paths as any)['/post'].get).toBeDefined();
expect((s.paths as any)['/post'].post).toBeUndefined();
// Single path: GET (findUnique) and PATCH (update) should exist, DELETE should not
expect((s.paths as any)['/post/{id}'].get).toBeDefined();
expect((s.paths as any)['/post/{id}'].patch).toBeDefined();
expect((s.paths as any)['/post/{id}'].delete).toBeUndefined();
});
it('slicing excludedOperations on all CRUD ops removes paths entirely', async () => {
const client = await createTestClient(schema);
const handler = new RestApiHandler({
schema: client.$schema,
endpoint: 'http://localhost/api',
queryOptions: {
slicing: {
models: {
post: { excludedOperations: ['findMany', 'create', 'findUnique', 'update', 'delete'] },
},
} as any,
},
});
const s = await handler.generateSpec();
// Both collection and single paths should be absent (empty path objects are not emitted)
expect(s.paths?.['/post']).toBeUndefined();
expect(s.paths?.['/post/{id}']).toBeUndefined();
});
it('slicing includedOperations limits HTTP methods in paths', async () => {
const client = await createTestClient(schema);
const handler = new RestApiHandler({
schema: client.$schema,
endpoint: 'http://localhost/api',
queryOptions: {
slicing: {
models: {
post: { includedOperations: ['findMany', 'findUnique'] },
},
} as any,
},
});
const s = await handler.generateSpec();
// Collection: only GET
expect((s.paths as any)['/post'].get).toBeDefined();
expect((s.paths as any)['/post'].post).toBeUndefined();
// Single: only GET
expect((s.paths as any)['/post/{id}'].get).toBeDefined();
expect((s.paths as any)['/post/{id}'].patch).toBeUndefined();
expect((s.paths as any)['/post/{id}'].delete).toBeUndefined();
});
it('slicing $all excludedOperations applies to all models', async () => {
const client = await createTestClient(schema);
const handler = new RestApiHandler({
schema: client.$schema,
endpoint: 'http://localhost/api',
queryOptions: {
slicing: {
models: {
$all: { excludedOperations: ['delete'] },
},
} as any,
},
});
const s = await handler.generateSpec();
// DELETE should be absent on all models
expect((s.paths as any)['/user/{id}'].delete).toBeUndefined();
expect((s.paths as any)['/post/{id}'].delete).toBeUndefined();
expect((s.paths as any)['/comment/{id}'].delete).toBeUndefined();
// Other methods should still exist
expect((s.paths as any)['/user/{id}'].get).toBeDefined();
expect((s.paths as any)['/user/{id}'].patch).toBeDefined();
});
it('slicing excludedFilterKinds removes filter params for a field', async () => {
const client = await createTestClient(schema);
const handler = new RestApiHandler({
schema: client.$schema,
endpoint: 'http://localhost/api',
queryOptions: {
slicing: {
models: {
post: {
fields: {
title: { excludedFilterKinds: ['Like'] },
viewCount: { excludedFilterKinds: ['Range'] },
},
},
},
} as any,
},
});
const s = await handler.generateSpec();
const listOp = (s.paths as any)['/post'].get;
const paramNames = listOp.parameters.map((p: any) => ('name' in p ? p.name : p.$ref));
// Equality filters should still exist
expect(paramNames).toContain('filter[title]');
expect(paramNames).toContain('filter[viewCount]');
// Like filters for title should be excluded
expect(paramNames).not.toContain('filter[title][$contains]');
expect(paramNames).not.toContain('filter[title][$startsWith]');
// Range filters for viewCount should be excluded
expect(paramNames).not.toContain('filter[viewCount][$lt]');
expect(paramNames).not.toContain('filter[viewCount][$gt]');
});
it('slicing includedFilterKinds limits filter params for a field', async () => {
const client = await createTestClient(schema);
const handler = new RestApiHandler({
schema: client.$schema,
endpoint: 'http://localhost/api',
queryOptions: {
slicing: {
models: {
post: {
fields: {
title: { includedFilterKinds: ['Equality'] },
},
},
},
} as any,
},
});
const s = await handler.generateSpec();
const listOp = (s.paths as any)['/post'].get;
const paramNames = listOp.parameters.map((p: any) => ('name' in p ? p.name : p.$ref));
// Equality filter should exist
expect(paramNames).toContain('filter[title]');
// Like filters should be excluded (not in includedFilterKinds)
expect(paramNames).not.toContain('filter[title][$contains]');
expect(paramNames).not.toContain('filter[title][$startsWith]');
});
it('slicing $all field applies filter kind restriction to all fields', async () => {
const client = await createTestClient(schema);
const handler = new RestApiHandler({
schema: client.$schema,
endpoint: 'http://localhost/api',
queryOptions: {
slicing: {
models: {
post: {
fields: {
$all: { includedFilterKinds: ['Equality'] },
},
},
},
} as any,
},
});
const s = await handler.generateSpec();
const listOp = (s.paths as any)['/post'].get;
const paramNames = listOp.parameters.map((p: any) => ('name' in p ? p.name : p.$ref));
// Equality filters should exist
expect(paramNames).toContain('filter[title]');
expect(paramNames).toContain('filter[viewCount]');
// Like and Range filters should be excluded
expect(paramNames).not.toContain('filter[title][$contains]');
expect(paramNames).not.toContain('filter[viewCount][$lt]');
});
it('slicing field-specific overrides $all for filter kinds', async () => {
const client = await createTestClient(schema);
const handler = new RestApiHandler({
schema: client.$schema,
endpoint: 'http://localhost/api',
queryOptions: {
slicing: {
models: {
post: {
fields: {
$all: { includedFilterKinds: ['Equality'] },
title: { includedFilterKinds: ['Equality', 'Like'] },
},
},
},
} as any,
},
});
const s = await handler.generateSpec();
const listOp = (s.paths as any)['/post'].get;
const paramNames = listOp.parameters.map((p: any) => ('name' in p ? p.name : p.$ref));
// title should have both Equality and Like
expect(paramNames).toContain('filter[title]');
expect(paramNames).toContain('filter[title][$contains]');
// viewCount should only have Equality (from $all)
expect(paramNames).toContain('filter[viewCount]');
expect(paramNames).not.toContain('filter[viewCount][$lt]');
});
it('slicing excludedFilterKinds on Equality removes basic filter param', async () => {
const client = await createTestClient(schema);
const handler = new RestApiHandler({
schema: client.$schema,
endpoint: 'http://localhost/api',
queryOptions: {
slicing: {
models: {
post: {
fields: {
title: { excludedFilterKinds: ['Equality'] },
},
},
},
} as any,
},
});
const s = await handler.generateSpec();
const listOp = (s.paths as any)['/post'].get;
const paramNames = listOp.parameters.map((p: any) => ('name' in p ? p.name : p.$ref));
// Basic equality filter should be excluded
expect(paramNames).not.toContain('filter[title]');
// Like filters should still exist
expect(paramNames).toContain('filter[title][$contains]');
});
});
describe('REST OpenAPI spec generation - nestedRoutes', () => {
let handler: RestApiHandler;
let spec: any;
beforeAll(async () => {
const client = await createTestClient(schema);
handler = new RestApiHandler({
schema: client.$schema,
endpoint: 'http://localhost/api',
nestedRoutes: true,
});
spec = await handler.generateSpec();
});
it('does not generate nested single paths when nestedRoutes is false', async () => {
const client = await createTestClient(schema);
const plainHandler = new RestApiHandler({
schema: client.$schema,
endpoint: 'http://localhost/api',
});
const plainSpec = await plainHandler.generateSpec();
expect(plainSpec.paths?.['/user/{id}/posts/{childId}']).toBeUndefined();
expect(plainSpec.paths?.['/post/{id}/comments/{childId}']).toBeUndefined();
// fetch-related path should not have POST on plain handler
expect((plainSpec.paths as any)['/user/{id}/posts']?.post).toBeUndefined();
// fetch-related path should not have PATCH for to-one on plain handler
expect((plainSpec.paths as any)['/post/{id}/setting']?.patch).toBeUndefined();
});
it('generates nested single paths for collection relations', () => {
// User -> posts (collection)
expect(spec.paths['/user/{id}/posts/{childId}']).toBeDefined();
// Post -> comments (collection)
expect(spec.paths['/post/{id}/comments/{childId}']).toBeDefined();
// User -> likes (collection, compound-ID child: PostLike has @@id([postId, userId]))
expect(spec.paths['/user/{id}/likes/{childId}']).toBeDefined();
});
it('does not generate nested single paths for to-one relations', () => {
// Post -> setting (to-one)
expect(spec.paths['/post/{id}/setting/{childId}']).toBeUndefined();
// Post -> author (to-one)
expect(spec.paths['/post/{id}/author/{childId}']).toBeUndefined();
});
it('nested single path has GET, PATCH, DELETE', () => {
const path = spec.paths['/user/{id}/posts/{childId}'];
expect(path.get).toBeDefined();
expect(path.patch).toBeDefined();
expect(path.delete).toBeDefined();
});
it('nested single path GET returns single resource response', () => {
const getOp = spec.paths['/user/{id}/posts/{childId}'].get;
const schema = getOp.responses['200'].content['application/vnd.api+json'].schema;
expect(schema.$ref).toBe('#/components/schemas/PostResponse');
});
it('nested single path PATCH uses UpdateRequest body', () => {
const patchOp = spec.paths['/user/{id}/posts/{childId}'].patch;
const schema = patchOp.requestBody.content['application/vnd.api+json'].schema;
expect(schema.$ref).toBe('#/components/schemas/PostUpdateRequest');
});
it('nested single path has childId path parameter', () => {
const getOp = spec.paths['/user/{id}/posts/{childId}'].get;
const params = getOp.parameters;
const childIdParam = params.find((p: any) => p.name === 'childId');
expect(childIdParam).toBeDefined();
expect(childIdParam.in).toBe('path');
expect(childIdParam.required).toBe(true);
});
it('fetch-related path has POST for collection relation when nestedRoutes enabled', () => {
const postsPath = spec.paths['/user/{id}/posts'];
expect(postsPath.get).toBeDefined();
expect(postsPath.post).toBeDefined();
});
it('fetch-related POST uses CreateRequest body', () => {
const postOp = spec.paths['/user/{id}/posts'].post;
const schema = postOp.requestBody.content['application/vnd.api+json'].schema;
expect(schema.$ref).toBe('#/components/schemas/PostCreateRequest');
});
it('fetch-related POST returns 201 with resource response', () => {
const postOp = spec.paths['/user/{id}/posts'].post;
const schema = postOp.responses['201'].content['application/vnd.api+json'].schema;
expect(schema.$ref).toBe('#/components/schemas/PostResponse');
});
it('fetch-related path has PATCH for to-one relation when nestedRoutes enabled', () => {
// Post -> setting is to-one
const settingPath = spec.paths['/post/{id}/setting'];
expect(settingPath.get).toBeDefined();
expect(settingPath.patch).toBeDefined();
// to-one should not get POST (no nested create for to-one)
expect(settingPath.post).toBeUndefined();
});
it('fetch-related PATCH for to-one uses UpdateRequest body', () => {
const patchOp = spec.paths['/post/{id}/setting'].patch;
const schema = patchOp.requestBody.content['application/vnd.api+json'].schema;
expect(schema.$ref).toBe('#/components/schemas/SettingUpdateRequest');
});
it('fetch-related path does not have PATCH for to-many (collection) relation', () => {
// User -> posts is a to-many relation; PATCH should only be generated for to-one
const postsPath = spec.paths['/user/{id}/posts'];
expect(postsPath.patch).toBeUndefined();
});
it('spec passes OpenAPI 3.1 validation', async () => {
// Deep clone to avoid validate() mutating $ref strings in the shared spec object
await validate(JSON.parse(JSON.stringify(spec)));
});
it('operationIds are unique for nested paths', () => {
const allOperationIds: string[] = [];
for (const pathItem of Object.values(spec.paths as Record<string, any>)) {
for (const method of ['get', 'post', 'patch', 'put', 'delete']) {
if (pathItem[method]?.operationId) {
allOperationIds.push(pathItem[method].operationId);
}
}
}
const unique = new Set(allOperationIds);
expect(unique.size).toBe(allOperationIds.length);
});
it('nestedRoutes respects queryOptions slicing excludedOperations', async () => {
const client = await createTestClient(schema);
const slicedHandler = new RestApiHandler({
schema: client.$schema,
endpoint: 'http://localhost/api',
nestedRoutes: true,
queryOptions: {
slicing: {
models: {
post: { excludedOperations: ['create', 'delete', 'update'] },
},
} as any,
},
});
const s = await slicedHandler.generateSpec();
// Nested create (POST /user/{id}/posts) should be absent
expect((s.paths as any)['/user/{id}/posts']?.post).toBeUndefined();
// Nested single GET should still exist (findUnique not excluded)
expect((s.paths as any)['/user/{id}/posts/{childId}']?.get).toBeDefined();
// Nested single DELETE should be absent
expect((s.paths as any)['/user/{id}/posts/{childId}']?.delete).toBeUndefined();
// Nested single PATCH (update) should be absent
expect((s.paths as any)['/user/{id}/posts/{childId}']?.patch).toBeUndefined();
});
});
describe('REST OpenAPI spec generation - @meta description', () => {
const metaSchema = `
model User {
id String @id @default(cuid())
email String @unique @meta("description", "The user's email address")
@@meta("description", "A user of the system")
}
model Post {
id Int @id @default(autoincrement())
title String
}
`;
it('model @@meta description is used as schema description', async () => {
const client = await createTestClient(metaSchema);
const handler = new RestApiHandler({
schema: client.$schema,
endpoint: 'http://localhost/api',
});
const s = await handler.generateSpec();
const userSchema = s.components?.schemas?.['User'] as any;
expect(userSchema.description).toBe('A user of the system');
// Post has no @@meta description
const postSchema = s.components?.schemas?.['Post'] as any;
expect(postSchema.description).toBeUndefined();
});
it('field @meta description is used as field schema description', async () => {
const client = await createTestClient(metaSchema);
const handler = new RestApiHandler({
schema: client.$schema,
endpoint: 'http://localhost/api',
});
const s = await handler.generateSpec();
const userSchema = s.components?.schemas?.['User'] as any;
const userAttrs = userSchema.properties.attributes.properties;
expect(userAttrs['email'].description).toBe("The user's email address");
// id has no @meta description
expect(userAttrs['id'].description).toBeUndefined();
});
});
describe('REST OpenAPI spec generation - baseline', () => {
it('matches baseline', async () => {
const client = await createTestClient(schema);
const handler = new RestApiHandler({
schema: client.$schema,
endpoint: 'http://localhost/api',
});
const spec = await handler.generateSpec();
const baselineFile = 'rest.baseline.yaml';
if (UPDATE_BASELINE) {
saveBaseline(baselineFile, spec);
return;
}
const baseline = loadBaseline(baselineFile);
expect(spec).toEqual(baseline);
await validate(spec);
});
});
describe('REST OpenAPI spec generation - with enum schema', () => {
it('enum schemas exist in components', async () => {
const enumSchema = `
model Post {
id Int @id @default(autoincrement())
title String
status PostStatus @default(DRAFT)
}
enum PostStatus {
DRAFT
PUBLISHED
ARCHIVED
}
`;
const client = await createTestClient(enumSchema);
const h = new RestApiHandler({
schema: client.$schema,
endpoint: 'http://localhost/api',
});
const s = await h.generateSpec();
expect(s.components?.schemas?.['PostStatus']).toBeDefined();
expect((s.components?.schemas?.['PostStatus'] as any).type).toBe('string');
expect((s.components?.schemas?.['PostStatus'] as any).enum).toContain('DRAFT');
expect((s.components?.schemas?.['PostStatus'] as any).enum).toContain('PUBLISHED');
});
describe('respectAccessPolicies', () => {
it('no 403 when respectAccessPolicies is off', async () => {
const policySchema = `
model Item {
id Int @id @default(autoincrement())
value Int
@@allow('read', true)
@@allow('create', value > 0)
}
`;
const client = await createTestClient(policySchema);
const h = new RestApiHandler({
schema: client.$schema,
endpoint: 'http://localhost/api',
});
const s = await h.generateSpec();
expect(s.paths?.['/item']?.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 h = new RestApiHandler({
schema: client.$schema,
endpoint: 'http://localhost/api',
});
const s = await h.generateSpec({ respectAccessPolicies: true });
// create has a non-constant condition → 403
expect(s.paths?.['/item']?.post?.responses?.['403']).toBeDefined();
// update has a non-constant condition → 403
expect(s.paths?.['/item/{id}']?.patch?.responses?.['403']).toBeDefined();
// delete has a non-constant condition → 403
expect(s.paths?.['/item/{id}']?.delete?.responses?.['403']).toBeDefined();
});
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 h = new RestApiHandler({
schema: client.$schema,
endpoint: 'http://localhost/api',
});
const s = await h.generateSpec({ respectAccessPolicies: true });
// all operations are constant-allow → no 403
expect(s.paths?.['/item']?.post?.responses?.['403']).toBeUndefined();
expect(s.paths?.['/item/{id}']?.patch?.responses?.['403']).toBeUndefined();
expect(s.paths?.['/item/{id}']?.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 h = new RestApiHandler({
schema: client.$schema,
endpoint: 'http://localhost/api',
});
const s = await h.generateSpec({ respectAccessPolicies: true });
// deny rule overrides → 403
expect(s.paths?.['/item']?.post?.responses?.['403']).toBeDefined();
});
it('no 403 when deny condition is literal false', async () => {
const policySchema = `
model Item {
id Int @id @default(autoincrement())
value Int
@@allow('create', true)
@@deny('create', false)
}
`;
const client = await createTestClient(policySchema);
const h = new RestApiHandler({
schema: client.$schema,
endpoint: 'http://localhost/api',
});
const s = await h.generateSpec({ respectAccessPolicies: true });
// @@deny('create', false) is a no-op → no 403
expect(s.paths?.['/item']?.post?.responses?.['403']).toBeUndefined();
});
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 h = new RestApiHandler({
schema: client.$schema,
endpoint: 'http://localhost/api',
});
const s = await h.generateSpec({ respectAccessPolicies: true });
// no rules = default deny → 403
expect(s.paths?.['/item']?.post?.responses?.['403']).toBeDefined();
expect(s.paths?.['/item/{id}']?.patch?.responses?.['403']).toBeDefined();
expect(s.paths?.['/item/{id}']?.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 h = new RestApiHandler({
schema: client.$schema,
endpoint: 'http://localhost/api',
});
const s = await h.generateSpec({ respectAccessPolicies: true });
// create is constant-allow → no 403
expect(s.paths?.['/item']?.post?.responses?.['403']).toBeUndefined();
// update/delete are non-constant → 403
expect(s.paths?.['/item/{id}']?.patch?.responses?.['403']).toBeDefined();
expect(s.paths?.['/item/{id}']?.delete?.responses?.['403']).toBeDefined();
});
it('relationship mutations get 403 when update may be denied', async () => {
const policySchema = `
model Parent {
id Int @id @default(autoincrement())
children Child[]
@@allow('read', true)
@@allow('update', false)
}
model Child {
id Int @id @default(autoincrement())
parent Parent @relation(fields: [parentId], references: [id])
parentId Int
@@allow('all', true)
}
`;
const client = await createTestClient(policySchema);
const h = new RestApiHandler({
schema: client.$schema,
endpoint: 'http://localhost/api',
});
const s = await h.generateSpec({ respectAccessPolicies: true });
const relPath = s.paths?.['/parent/{id}/relationships/children'] as any;
expect(relPath.put.responses['403']).toBeDefined();
expect(relPath.patch.responses['403']).toBeDefined();
expect(relPath.post.responses['403']).toBeDefined();
// GET should not have 403
expect(relPath.get.responses['403']).toBeUndefined();
});
it('relationship mutations have no 403 when update is constant-allow', async () => {
const policySchema = `
model Parent {
id Int @id @default(autoincrement())
children Child[]
@@allow('all', true)
}
model Child {
id Int @id @default(autoincrement())
parent Parent @relation(fields: [parentId], references: [id])
parentId Int
@@allow('all', true)
}
`;
const client = await createTestClient(policySchema);
const h = new RestApiHandler({
schema: client.$schema,
endpoint: 'http://localhost/api',
});
const s = await h.generateSpec({ respectAccessPolicies: true });
const relPath = s.paths?.['/parent/{id}/relationships/children'] as any;
expect(relPath.put.responses['403']).toBeUndefined();
expect(relPath.patch.responses['403']).toBeUndefined();
expect(relPath.post.responses['403']).toBeUndefined();
});
});
});