mirror of
https://github.com/zenstackhq/zenstack
synced 2026-05-24 10:08:55 +00:00
1036 lines
40 KiB
TypeScript
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();
|
|
});
|
|
});
|
|
});
|