From b52d3636b83cdc16b4d0bc50f6fe9a16c22a8adb Mon Sep 17 00:00:00 2001 From: ymc9 <104139426+ymc9@users.noreply.github.com> Date: Fri, 26 May 2023 10:46:13 +0900 Subject: [PATCH 1/3] fix: misc fixes about server adapters and more tests --- packages/server/src/api/rest/index.ts | 47 +++++++----- packages/server/src/express/middleware.ts | 14 +++- packages/server/src/fastify/plugin.ts | 2 - packages/server/src/next/request-handler.ts | 3 - packages/server/src/sveltekit/handler.ts | 1 - packages/server/src/types.ts | 5 -- .../server/tests/adapter/express-rest.test.ts | 57 --------------- .../{express-rpc.test.ts => express.test.ts} | 60 ++++++++++++++- .../{fastify-rpc.test.ts => fastify.test.ts} | 72 ++++++++++++++++-- .../{next-rpc.test.ts => next.test.ts} | 73 ++++++++++++++++++- ...veltekit-rpc.test.ts => sveltekit.test.ts} | 64 +++++++++++++++- packages/server/tests/api/rest.test.ts | 26 ++++++- 12 files changed, 318 insertions(+), 106 deletions(-) delete mode 100644 packages/server/tests/adapter/express-rest.test.ts rename packages/server/tests/adapter/{express-rpc.test.ts => express.test.ts} (79%) rename packages/server/tests/adapter/{fastify-rpc.test.ts => fastify.test.ts} (79%) rename packages/server/tests/adapter/{next-rpc.test.ts => next.test.ts} (81%) rename packages/server/tests/adapter/{sveltekit-rpc.test.ts => sveltekit.test.ts} (76%) diff --git a/packages/server/src/api/rest/index.ts b/packages/server/src/api/rest/index.ts index c9e1d6021..cf51ff521 100644 --- a/packages/server/src/api/rest/index.ts +++ b/packages/server/src/api/rest/index.ts @@ -99,7 +99,7 @@ class RequestHandler { // error responses private readonly errors: Record = { unsupportedModel: { - status: 400, + status: 404, title: 'Unsupported model type', detail: 'The model type is not supported', }, @@ -227,6 +227,9 @@ class RequestHandler { } method = method.toUpperCase(); + if (!path.startsWith('/')) { + path = '/' + path; + } try { switch (method) { @@ -406,7 +409,7 @@ class RequestHandler { const relationInfo = typeInfo.relationships[relationship]; if (!relationInfo) { - return this.makeUnsupportedRelationshipError(type, relationship); + return this.makeUnsupportedRelationshipError(type, relationship, 404); } let select: any; @@ -482,7 +485,7 @@ class RequestHandler { const relationInfo = typeInfo.relationships[relationship]; if (!relationInfo) { - return this.makeUnsupportedRelationshipError(type, relationship); + return this.makeUnsupportedRelationshipError(type, relationship, 404); } const args: any = { @@ -688,7 +691,7 @@ class RequestHandler { const relationInfo = typeInfo.relationships[key]; if (!relationInfo) { - return this.makeUnsupportedRelationshipError(type, key); + return this.makeUnsupportedRelationshipError(type, key, 400); } if (relationInfo.isCollection) { @@ -737,7 +740,7 @@ class RequestHandler { const relationInfo = typeInfo.relationships[relationship]; if (!relationInfo) { - return this.makeUnsupportedRelationshipError(type, relationship); + return this.makeUnsupportedRelationshipError(type, relationship, 404); } if (!relationInfo.isCollection && mode !== 'update') { @@ -866,7 +869,7 @@ class RequestHandler { const relationInfo = typeInfo.relationships[key]; if (!relationInfo) { - return this.makeUnsupportedRelationshipError(type, key); + return this.makeUnsupportedRelationshipError(type, key, 400); } if (relationInfo.isCollection) { @@ -1005,6 +1008,7 @@ class RequestHandler { } const serializer = new Serializer(model, { + version: '1.1', idKey: ids[0].name, linkers: { resource: linker, @@ -1241,6 +1245,7 @@ class RequestHandler { } const items: any[] = []; + let currType = typeInfo; for (const [key, value] of Object.entries(query)) { if (!value) { @@ -1287,8 +1292,8 @@ class RequestHandler { const fieldInfo = filterKey === 'id' - ? Object.values(typeInfo.fields).find((f) => f.isId) - : typeInfo.fields[filterKey]; + ? Object.values(currType.fields).find((f) => f.isId) + : currType.fields[filterKey]; if (!fieldInfo) { return { filter: undefined, error: this.makeError('invalidFilter') }; } @@ -1306,7 +1311,14 @@ class RequestHandler { curr[fieldInfo.name] = this.makeFilterValue(fieldInfo, filterValue, filterOp); } else { // keep going - curr = curr[fieldInfo.name] = {}; + if (fieldInfo.isArray) { + // collection filtering implies "some" operation + curr[fieldInfo.name] = { some: {} }; + curr = curr[fieldInfo.name].some; + } else { + curr = curr[fieldInfo.name] = {}; + } + currType = this.typeMap[lowerCaseFirst(fieldInfo.type)]; } } } @@ -1418,7 +1430,7 @@ class RequestHandler { const relation = parts[i]; const relationInfo = currType.relationships[relation]; if (!relationInfo) { - return { select: undefined, error: this.makeUnsupportedRelationshipError(type, relation) }; + return { select: undefined, error: this.makeUnsupportedRelationshipError(type, relation, 400) }; } currType = this.typeMap[lowerCaseFirst(relationInfo.type)]; @@ -1534,13 +1546,13 @@ class RequestHandler { } } - private makeError(code: keyof typeof this.errors, detail?: string) { + private makeError(code: keyof typeof this.errors, detail?: string, status?: number) { return { - status: this.errors[code].status, + status: status ?? this.errors[code].status, body: { errors: [ { - status: this.errors[code].status, + status: status ?? this.errors[code].status, code: paramCase(code), title: this.errors[code].title, detail: detail || this.errors[code].detail, @@ -1551,14 +1563,11 @@ class RequestHandler { } private makeUnsupportedModelError(model: string) { - return this.makeError('unsupportedModel', `Model ${model} doesn't exist or doesn't have a single ID field`); + return this.makeError('unsupportedModel', `Model ${model} doesn't exist`); } - private makeUnsupportedRelationshipError(model: string, relationship: string) { - return this.makeError( - 'unsupportedRelationship', - `Relationship ${model}.${relationship} doesn't exist or its type doesn't have a single ID field` - ); + private makeUnsupportedRelationshipError(model: string, relationship: string, status: number) { + return this.makeError('unsupportedRelationship', `Relationship ${model}.${relationship} doesn't exist`, status); } //#endregion diff --git a/packages/server/src/express/middleware.ts b/packages/server/src/express/middleware.ts index 347cc183e..4056cb0c0 100644 --- a/packages/server/src/express/middleware.ts +++ b/packages/server/src/express/middleware.ts @@ -41,17 +41,25 @@ const factory = (options: MiddlewareOptions): Handler => { let query: Record = {}; try { - query = buildUrlQuery(request.query, useSuperJson); + // express converts query parameters with square brackets into object + // e.g.: filter[foo]=bar is parsed to { filter: { foo: 'bar' } } + // we need to revert this behavior and reconstruct params from original URL + const url = request.protocol + '://' + request.get('host') + request.originalUrl; + const searchParams = new URL(url).searchParams; + const rawQuery: Record = {}; + for (const key of searchParams.keys()) { + const values = searchParams.getAll(key); + rawQuery[key] = values.length === 1 ? values[0] : values; + } + query = buildUrlQuery(rawQuery, useSuperJson); } catch { response.status(400).json(marshalToObject({ message: 'invalid query parameters' }, useSuperJson)); return; } try { - const url = request.protocol + '://' + request.get('host') + request.originalUrl; const r = await requestHandler({ method: request.method, - url: new URL(url), path: request.path, query, requestBody: unmarshalFromObject(request.body, useSuperJson), diff --git a/packages/server/src/fastify/plugin.ts b/packages/server/src/fastify/plugin.ts index 99edfd664..023a510cc 100644 --- a/packages/server/src/fastify/plugin.ts +++ b/packages/server/src/fastify/plugin.ts @@ -58,11 +58,9 @@ const pluginHandler: FastifyPluginCallback = (fastify, options, d } try { - const url = request.protocol + '://' + request.hostname + request.url; const response = await requestHanler({ method: request.method, path: (request.params as any)['*'], - url: new URL(url), query, requestBody: unmarshalFromObject(request.body, useSuperJson), prisma, diff --git a/packages/server/src/next/request-handler.ts b/packages/server/src/next/request-handler.ts index 6d3274266..9a3e0515e 100644 --- a/packages/server/src/next/request-handler.ts +++ b/packages/server/src/next/request-handler.ts @@ -57,12 +57,9 @@ export default function factory( const path = (req.query.path as string[]).join('/'); try { - const protocol = req.headers['x-forwarded-proto'] ?? 'http'; - const url = `${protocol}://${req.headers['host']}${req.url}`; const r = await requestHandler({ method: req.method!, path, - url: new URL(url), query, requestBody: unmarshalFromObject(req.body, useSuperJson), prisma, diff --git a/packages/server/src/sveltekit/handler.ts b/packages/server/src/sveltekit/handler.ts index f6c05a5e5..c48d0a711 100644 --- a/packages/server/src/sveltekit/handler.ts +++ b/packages/server/src/sveltekit/handler.ts @@ -84,7 +84,6 @@ export default function createHandler(options: HandlerOptions): Handle { const r = await requestHanler({ method: event.request.method, path, - url: event.url, query, requestBody, prisma, diff --git a/packages/server/src/types.ts b/packages/server/src/types.ts index 52944f728..ae3f926c3 100644 --- a/packages/server/src/types.ts +++ b/packages/server/src/types.ts @@ -33,11 +33,6 @@ export type RequestContext = { */ path: string; - /** - * The request URL - */ - url: URL; - /** * The query parameters */ diff --git a/packages/server/tests/adapter/express-rest.test.ts b/packages/server/tests/adapter/express-rest.test.ts deleted file mode 100644 index 5d3ca49c5..000000000 --- a/packages/server/tests/adapter/express-rest.test.ts +++ /dev/null @@ -1,57 +0,0 @@ -/// - -import { loadSchema } from '@zenstackhq/testtools'; -import bodyParser from 'body-parser'; -import express from 'express'; -import request from 'supertest'; -import { ZenStackMiddleware } from '../../src/express'; -import { makeUrl, schema } from '../utils'; -import RESTAPIHandler from '../../src/api/rest'; - -describe('Express adapter tests - rest handler', () => { - it('run middleware', async () => { - const { prisma, zodSchemas, modelMeta } = await loadSchema(schema); - - const app = express(); - app.use(bodyParser.json()); - app.use( - '/api', - ZenStackMiddleware({ - getPrisma: () => prisma, - modelMeta, - zodSchemas, - handler: RESTAPIHandler({ endpoint: 'http://localhost/api' }), - }) - ); - - let r = await request(app).get(makeUrl('/api/post/1')); - expect(r.status).toBe(404); - - r = await request(app) - .post('/api/user') - .send({ - data: { - type: 'user', - attributes: { - id: 'user1', - email: 'user1@abc.com', - }, - }, - }); - - expect(r.status).toBe(201); - expect(r.body).toMatchObject({ - jsonapi: { version: '1.0' }, - data: { type: 'user', id: 'user1', attributes: { email: 'user1@abc.com' } }, - }); - - r = await request(app) - .put('/api/user/user1') - .send({ data: { type: 'user', attributes: { email: 'user1@def.com' } } }); - expect(r.status).toBe(200); - expect(r.body.data.attributes.email).toBe('user1@def.com'); - - r = await request(app).delete(makeUrl('/api/user/user1', { where: { id: 'user1' } })); - expect(r.status).toBe(204); - }); -}); diff --git a/packages/server/tests/adapter/express-rpc.test.ts b/packages/server/tests/adapter/express.test.ts similarity index 79% rename from packages/server/tests/adapter/express-rpc.test.ts rename to packages/server/tests/adapter/express.test.ts index 944611697..df96ec7a2 100644 --- a/packages/server/tests/adapter/express-rpc.test.ts +++ b/packages/server/tests/adapter/express.test.ts @@ -4,10 +4,11 @@ import { loadSchema } from '@zenstackhq/testtools'; import bodyParser from 'body-parser'; import express from 'express'; +import superjson from 'superjson'; import request from 'supertest'; +import RESTAPIHandler from '../../src/api/rest'; import { ZenStackMiddleware } from '../../src/express'; import { makeUrl, schema } from '../utils'; -import superjson from 'superjson'; describe('Express adapter tests - rpc handler', () => { it('run plugin regular json', async () => { @@ -193,3 +194,60 @@ describe('Express adapter tests - rpc handler', () => { function unmarshal(value: any) { return superjson.parse(JSON.stringify(value)) as any; } + +describe('Express adapter tests - rest handler', () => { + it('run middleware', async () => { + const { prisma, zodSchemas, modelMeta } = await loadSchema(schema); + + const app = express(); + app.use(bodyParser.json()); + app.use( + '/api', + ZenStackMiddleware({ + getPrisma: () => prisma, + modelMeta, + zodSchemas, + handler: RESTAPIHandler({ endpoint: 'http://localhost/api' }), + }) + ); + + let r = await request(app).get(makeUrl('/api/post/1')); + expect(r.status).toBe(404); + + r = await request(app) + .post('/api/user') + .send({ + data: { + type: 'user', + attributes: { + id: 'user1', + email: 'user1@abc.com', + }, + }, + }); + expect(r.status).toBe(201); + expect(r.body).toMatchObject({ + jsonapi: { version: '1.1' }, + data: { type: 'user', id: 'user1', attributes: { email: 'user1@abc.com' } }, + }); + + r = await request(app).get('/api/user?filter[id]=user1'); + expect(r.body.data).toHaveLength(1); + + r = await request(app).get('/api/user?filter[id]=user2'); + expect(r.body.data).toHaveLength(0); + + r = await request(app).get('/api/user?filter[id]=user1&filter[email]=xyz'); + expect(r.body.data).toHaveLength(0); + + r = await request(app) + .put('/api/user/user1') + .send({ data: { type: 'user', attributes: { email: 'user1@def.com' } } }); + expect(r.status).toBe(200); + expect(r.body.data.attributes.email).toBe('user1@def.com'); + + r = await request(app).delete(makeUrl('/api/user/user1')); + expect(r.status).toBe(204); + expect(await prisma.user.findMany()).toHaveLength(0); + }); +}); diff --git a/packages/server/tests/adapter/fastify-rpc.test.ts b/packages/server/tests/adapter/fastify.test.ts similarity index 79% rename from packages/server/tests/adapter/fastify-rpc.test.ts rename to packages/server/tests/adapter/fastify.test.ts index f00192ea5..3e0c12a6a 100644 --- a/packages/server/tests/adapter/fastify-rpc.test.ts +++ b/packages/server/tests/adapter/fastify.test.ts @@ -4,11 +4,12 @@ import { loadSchema } from '@zenstackhq/testtools'; import fastify from 'fastify'; import superjson from 'superjson'; -import Prisma from '../../src/api/rpc'; +import Rest from '../../src/api/rest'; +import RPC from '../../src/api/rpc'; import { ZenStackFastifyPlugin } from '../../src/fastify'; import { makeUrl, schema } from '../utils'; -describe('Fastify adapter tests', () => { +describe('Fastify adapter tests - rpc handler', () => { it('run plugin regular json', async () => { const { prisma, zodSchemas } = await loadSchema(schema); @@ -17,7 +18,7 @@ describe('Fastify adapter tests', () => { prefix: '/api', getPrisma: () => prisma, zodSchemas, - handler: Prisma(), + handler: RPC(), }); let r = await app.inject({ @@ -125,7 +126,7 @@ describe('Fastify adapter tests', () => { prefix: '/api', getPrisma: () => prisma, zodSchemas, - handler: Prisma(), + handler: RPC(), }); let r = await app.inject({ @@ -155,7 +156,7 @@ describe('Fastify adapter tests', () => { prefix: '/api', getPrisma: () => prisma, zodSchemas, - handler: Prisma(), + handler: RPC(), useSuperJson: true, }); @@ -260,3 +261,64 @@ describe('Fastify adapter tests', () => { function unmarshal(value: any) { return superjson.parse(JSON.stringify(value)) as any; } + +describe('Fastify adapter tests - rest handler', () => { + it('run plugin regular json', async () => { + const { prisma, zodSchemas, modelMeta } = await loadSchema(schema); + + const app = fastify(); + app.register(ZenStackFastifyPlugin, { + prefix: '/api', + getPrisma: () => prisma, + modelMeta, + zodSchemas, + handler: Rest({ endpoint: 'http://localhost/api' }), + }); + + let r = await app.inject({ + method: 'GET', + url: '/api/post/1', + }); + expect(r.statusCode).toBe(404); + + r = await app.inject({ + method: 'POST', + url: '/api/user', + payload: { + data: { + type: 'user', + attributes: { + id: 'user1', + email: 'user1@abc.com', + }, + }, + }, + }); + expect(r.statusCode).toBe(201); + expect(r.json()).toMatchObject({ + jsonapi: { version: '1.1' }, + data: { type: 'user', id: 'user1', attributes: { email: 'user1@abc.com' } }, + }); + + r = await app.inject({ method: 'GET', url: '/api/user?filter[id]=user1' }); + expect(r.json().data).toHaveLength(1); + + r = await app.inject({ method: 'GET', url: '/api/user?filter[id]=user2' }); + expect(r.json().data).toHaveLength(0); + + r = await app.inject({ method: 'GET', url: '/api/user?filter[id]=user1&filter[email]=xyz' }); + expect(r.json().data).toHaveLength(0); + + r = await app.inject({ + method: 'PUT', + url: '/api/user/user1', + payload: { data: { type: 'user', attributes: { email: 'user1@def.com' } } }, + }); + expect(r.statusCode).toBe(200); + expect(r.json().data.attributes.email).toBe('user1@def.com'); + + r = await app.inject({ method: 'DELETE', url: '/api/user/user1' }); + expect(r.statusCode).toBe(204); + expect(await prisma.user.findMany()).toHaveLength(0); + }); +}); diff --git a/packages/server/tests/adapter/next-rpc.test.ts b/packages/server/tests/adapter/next.test.ts similarity index 81% rename from packages/server/tests/adapter/next-rpc.test.ts rename to packages/server/tests/adapter/next.test.ts index c13f657ed..013468403 100644 --- a/packages/server/tests/adapter/next-rpc.test.ts +++ b/packages/server/tests/adapter/next.test.ts @@ -5,13 +5,15 @@ import { apiResolver } from 'next/dist/server/api-utils/node'; import superjson from 'superjson'; import request from 'supertest'; import { NextRequestHandler, RequestHandlerOptions } from '../../src/next'; +import Rest from '../../src/api/rest'; -function makeTestClient(apiPath: string, options: RequestHandlerOptions, queryArgs?: unknown) { +function makeTestClient(apiPath: string, options: RequestHandlerOptions, qArg?: unknown, otherArgs?: any) { const pathParts = apiPath.split('/').filter((p) => p); const query = { path: pathParts, - ...(queryArgs ? { q: superjson.stringify(queryArgs) } : {}), + ...(qArg ? { q: superjson.stringify(qArg) } : {}), + ...otherArgs, }; const handler = NextRequestHandler(options); @@ -34,7 +36,7 @@ function makeTestClient(apiPath: string, options: RequestHandlerOptions, queryAr return request(createServer(listener)); } -describe('request handler tests', () => { +describe('Next.js adapter tests - rpc handler', () => { let origDir: string; beforeEach(() => { @@ -274,6 +276,71 @@ model M { }); }); +describe('Next.js adapter tests - rest handler', () => { + let origDir: string; + + beforeEach(() => { + origDir = process.cwd(); + }); + + afterEach(() => { + process.chdir(origDir); + }); + + it('adapter test - rest', async () => { + const model = ` +model M { + id String @id @default(cuid()) + value Int +} + `; + + const { prisma, modelMeta } = await loadSchema(model); + + const options = { getPrisma: () => prisma, handler: Rest({ endpoint: 'http://localhost/api' }), modelMeta }; + + await makeTestClient('/m', options) + .post('/') + .send({ data: { type: 'm', attributes: { id: '1', value: 1 } } }) + .expect(201) + .expect((resp) => { + expect(resp.body.data.attributes.value).toBe(1); + }); + + await makeTestClient('/m/1', options) + .get('/') + .expect(200) + .expect((resp) => { + expect(resp.body.data.id).toBe('1'); + }); + + await makeTestClient('/m', options, undefined, { 'filter[value]': '1' }) + .get('/') + .expect(200) + .expect((resp) => { + expect(resp.body.data).toHaveLength(1); + }); + + await makeTestClient('/m', options, undefined, { 'filter[value]': '2' }) + .get('/') + .expect(200) + .expect((resp) => { + expect(resp.body.data).toHaveLength(0); + }); + + await makeTestClient('/m/1', options) + .put('/') + .send({ data: { type: 'm', attributes: { value: 2 } } }) + .expect(200) + .expect((resp) => { + expect(resp.body.data.attributes.value).toBe(2); + }); + + await makeTestClient('/m/1', options).del('/').expect(204); + expect(await prisma.m.count()).toBe(0); + }); +}); + function marshal(data: unknown) { return JSON.parse(superjson.stringify(data)); } diff --git a/packages/server/tests/adapter/sveltekit-rpc.test.ts b/packages/server/tests/adapter/sveltekit.test.ts similarity index 76% rename from packages/server/tests/adapter/sveltekit-rpc.test.ts rename to packages/server/tests/adapter/sveltekit.test.ts index a7fa0934f..ae7a9e20b 100644 --- a/packages/server/tests/adapter/sveltekit-rpc.test.ts +++ b/packages/server/tests/adapter/sveltekit.test.ts @@ -5,8 +5,9 @@ import { SvelteKitHandler } from '../../src/sveltekit'; import { schema, makeUrl } from '../utils'; import 'isomorphic-fetch'; import superjson from 'superjson'; +import Rest from '../../src/api/rest'; -describe('SvelteKit adapter tests', () => { +describe('SvelteKit adapter tests - rpc handler', () => { it('run hooks regular json', async () => { const { prisma, zodSchemas } = await loadSchema(schema); @@ -31,7 +32,6 @@ describe('SvelteKit adapter tests', () => { }, }) ); - // console.log(JSON.stringify(await r.json(), null, 2)); expect(r.status).toBe(201); expect(await unmarshal(r)).toMatchObject({ email: 'user1@abc.com', @@ -105,7 +105,6 @@ describe('SvelteKit adapter tests', () => { }, }) ); - // console.log(JSON.stringify(await r.json(), null, 2)); expect(r.status).toBe(201); expect(await unmarshal(r, true)).toMatchObject({ email: 'user1@abc.com', @@ -154,6 +153,65 @@ describe('SvelteKit adapter tests', () => { }); }); +describe('SvelteKit adapter tests - rest handler', () => { + it('run hooks', async () => { + const { prisma, modelMeta, zodSchemas } = await loadSchema(schema); + + const handler = SvelteKitHandler({ + prefix: '/api', + getPrisma: () => prisma, + handler: Rest({ endpoint: 'http://localhost/api' }), + modelMeta, + zodSchemas, + }); + + let r = await handler(makeRequest('GET', makeUrl('/api/post/1'))); + expect(r.status).toBe(404); + + r = await handler( + makeRequest('POST', '/api/user', { + data: { + type: 'user', + attributes: { id: 'user1', email: 'user1@abc.com' }, + }, + }) + ); + expect(r.status).toBe(201); + expect(await unmarshal(r)).toMatchObject({ + data: { + id: 'user1', + attributes: { + email: 'user1@abc.com', + }, + }, + }); + + r = await handler(makeRequest('GET', makeUrl('/api/user?filter[id]=user1'))); + expect(r.status).toBe(200); + expect((await unmarshal(r)).data).toHaveLength(1); + + r = await handler(makeRequest('GET', makeUrl('/api/user?filter[id]=user2'))); + expect(r.status).toBe(200); + expect((await unmarshal(r)).data).toHaveLength(0); + + r = await handler(makeRequest('GET', makeUrl('/api/user?filter[id]=user1&filter[email]=xyz'))); + expect(r.status).toBe(200); + expect((await unmarshal(r)).data).toHaveLength(0); + + r = await handler( + makeRequest('PUT', makeUrl('/api/user/user1'), { + data: { type: 'user', attributes: { email: 'user1@def.com' } }, + }) + ); + expect(r.status).toBe(200); + expect((await unmarshal(r)).data.attributes.email).toBe('user1@def.com'); + + r = await handler(makeRequest('DELETE', makeUrl(makeUrl('/api/user/user1')))); + expect(r.status).toBe(204); + expect(await prisma.user.findMany()).toHaveLength(0); + }); +}); + function makeRequest(method: string, path: string, body?: any) { const payload = body ? JSON.stringify(body) : undefined; return { diff --git a/packages/server/tests/api/rest.test.ts b/packages/server/tests/api/rest.test.ts index b84cafb43..eb1647732 100644 --- a/packages/server/tests/api/rest.test.ts +++ b/packages/server/tests/api/rest.test.ts @@ -437,6 +437,24 @@ describe('REST server tests', () => { expect((r.body as any).data).toHaveLength(1); expect((r.body as any).data[0]).toMatchObject({ id: 2 }); + // deep to-one filter + r = await handler({ + method: 'get', + path: '/post', + query: { ['filter[author][email]']: 'user1@abc.com' }, + prisma, + }); + expect((r.body as any).data).toHaveLength(1); + + // deep to-many filter + r = await handler({ + method: 'get', + path: '/user', + query: { ['filter[posts][published]']: 'true' }, + prisma, + }); + expect((r.body as any).data).toHaveLength(1); + // filter to empty r = await handler({ method: 'get', @@ -1222,7 +1240,7 @@ describe('REST server tests', () => { expect(r.status).toBe(201); expect(r.body).toMatchObject({ - jsonapi: { version: '1.0' }, + jsonapi: { version: '1.1' }, data: { type: 'user', id: 'user1', attributes: { email: 'user1@abc.com' } }, }); }); @@ -1258,7 +1276,7 @@ describe('REST server tests', () => { expect(r.status).toBe(201); expect(r.body).toMatchObject({ - jsonapi: { version: '1.0' }, + jsonapi: { version: '1.1' }, data: { type: 'user', id: 'user1', @@ -1542,7 +1560,7 @@ describe('REST server tests', () => { expect(r.status).toBe(200); expect(r.body).toMatchObject({ jsonapi: { - version: '1.0', + version: '1.1', }, links: { self: 'http://localhost/api/post/1/relationships/author', @@ -1751,7 +1769,7 @@ describe('REST server tests', () => { expect(r.status).toBe(200); expect(r.body).toMatchObject({ jsonapi: { - version: '1.0', + version: '1.1', }, links: { self: 'http://localhost/api/user/user1/relationships/posts', From 589efd8bbc8b791b32fa41f70282b58b76d3a99f Mon Sep 17 00:00:00 2001 From: ymc9 <104139426+ymc9@users.noreply.github.com> Date: Fri, 26 May 2023 11:12:18 +0900 Subject: [PATCH 2/3] more tests --- packages/server/tests/api/rest.test.ts | 40 ++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/packages/server/tests/api/rest.test.ts b/packages/server/tests/api/rest.test.ts index eb1647732..f07466726 100644 --- a/packages/server/tests/api/rest.test.ts +++ b/packages/server/tests/api/rest.test.ts @@ -75,6 +75,46 @@ describe('REST server tests', () => { describe('CRUD', () => { describe('GET', () => { + it('invalid type, id, relationship', async () => { + let r = await handler({ + method: 'get', + path: '/foo', + prisma, + }); + expect(r.status).toBe(404); + + r = await handler({ + method: 'get', + path: '/user/user1/posts', + prisma, + }); + expect(r.status).toBe(404); + + await prisma.user.create({ + data: { + myId: 'user1', + email: 'user1@abc.com', + posts: { + create: { title: 'Post1' }, + }, + }, + }); + + r = await handler({ + method: 'get', + path: '/user/user1/relationships/foo', + prisma, + }); + expect(r.status).toBe(404); + + r = await handler({ + method: 'get', + path: '/user/user1/foo', + prisma, + }); + expect(r.status).toBe(404); + }); + it('returns an empty array when no item exists', async () => { const r = await handler({ method: 'get', From f475547437f6088929a2ae2fbfe933a08a3fa03b Mon Sep 17 00:00:00 2001 From: ymc9 <104139426+ymc9@users.noreply.github.com> Date: Fri, 26 May 2023 16:40:25 +0900 Subject: [PATCH 3/3] fixes --- packages/server/src/api/rest/index.ts | 53 +++++++++++-------------- packages/server/tests/api/rest.test.ts | 55 +++++++++++++++++++++++++- 2 files changed, 77 insertions(+), 31 deletions(-) diff --git a/packages/server/src/api/rest/index.ts b/packages/server/src/api/rest/index.ts index cf51ff521..c91d91df4 100644 --- a/packages/server/src/api/rest/index.ts +++ b/packages/server/src/api/rest/index.ts @@ -158,6 +158,10 @@ class RequestHandler { status: 400, title: 'Invalid value for type', }, + forbidden: { + status: 403, + title: 'Operation is forbidden', + }, unknownError: { status: 400, title: 'Unknown error', @@ -349,8 +353,7 @@ class RequestHandler { if (err instanceof InvalidValueError) { return this.makeError('invalidValue', err.message); } else { - const _err = err as Error; - return this.makeError('unknownError', `${_err.message}\n${_err.stack}`); + return this.handlePrismaError(err); } } } @@ -752,7 +755,6 @@ class RequestHandler { where: this.makeIdFilter(typeInfo.idField, typeInfo.idFieldType, resourceId), select: { [typeInfo.idField]: true, [relationship]: { select: { [relationInfo.idField]: true } } }, }; - let entity: any; if (!relationInfo.isCollection) { // zod-parse payload @@ -800,11 +802,7 @@ class RequestHandler { }; } - try { - entity = await prisma[type].update(updateArgs); - } catch (err) { - return this.handlePrismaError(err); - } + const entity: any = await prisma[type].update(updateArgs); const serialized: any = await this.serializeItems(relationInfo.type, entity[relationship], { linkers: { @@ -893,15 +891,11 @@ class RequestHandler { } } - try { - const entity = await prisma[type].update(updatePayload); - return { - status: 200, - body: await this.serializeItems(type, entity), - }; - } catch (err) { - return this.handlePrismaError(err); - } + const entity = await prisma[type].update(updatePayload); + return { + status: 200, + body: await this.serializeItems(type, entity), + }; } private async processDelete(prisma: DbClientContract, type: any, resourceId: string): Promise { @@ -910,17 +904,13 @@ class RequestHandler { return this.makeUnsupportedModelError(type); } - try { - await prisma[type].delete({ - where: this.makeIdFilter(typeInfo.idField, typeInfo.idFieldType, resourceId), - }); - return { - status: 204, - body: undefined, - }; - } catch (err) { - return this.handlePrismaError(err); - } + await prisma[type].delete({ + where: this.makeIdFilter(typeInfo.idField, typeInfo.idFieldType, resourceId), + }); + return { + status: 204, + body: undefined, + }; } //#region utilities @@ -1531,7 +1521,9 @@ class RequestHandler { private handlePrismaError(err: unknown) { if (isPrismaClientKnownRequestError(err)) { - if (err.code === 'P2025' || err.code === 'P2018') { + if (err.code === 'P2004') { + return this.makeError('forbidden'); + } else if (err.code === 'P2025' || err.code === 'P2018') { return this.makeError('notFound'); } else { return { @@ -1542,7 +1534,8 @@ class RequestHandler { }; } } else { - throw err; + const _err = err as Error; + return this.makeError('unknownError', `${_err.message}\n${_err.stack}`); } } diff --git a/packages/server/tests/api/rest.test.ts b/packages/server/tests/api/rest.test.ts index f07466726..583dcd2a6 100644 --- a/packages/server/tests/api/rest.test.ts +++ b/packages/server/tests/api/rest.test.ts @@ -5,6 +5,7 @@ import { loadSchema, run } from '@zenstackhq/testtools'; import { ModelMeta } from '@zenstackhq/runtime/enhancements/types'; import makeHandler from '../../src/api/rest'; import { Response } from '../../src/types'; +import { withPolicy } from '@zenstackhq/runtime'; let prisma: any; let zodSchemas: any; @@ -56,7 +57,7 @@ model Setting { } `; -describe('REST server tests', () => { +describe('REST server tests - regular prisma', () => { beforeAll(async () => { const params = await loadSchema(schema); @@ -1833,3 +1834,55 @@ describe('REST server tests', () => { }); }); }); + +export const schemaWithPolicy = ` +model Foo { + id Int @id + value Int + + @@allow('create,read', true) + @@allow('update', value > 0) +} +`; + +describe('REST server tests - enhanced prisma', () => { + beforeAll(async () => { + const params = await loadSchema(schemaWithPolicy); + + prisma = withPolicy(params.prisma, undefined, params.policy, params.modelMeta); + zodSchemas = params.zodSchemas; + modelMeta = params.modelMeta; + + const _handler = makeHandler({ endpoint: 'http://localhost/api', pageSize: 5 }); + handler = (args) => _handler({ ...args, zodSchemas, modelMeta, url: new URL(`http://localhost/${args.path}`) }); + }); + + beforeEach(async () => { + run('npx prisma migrate reset --force'); + run('npx prisma db push'); + }); + + it('policy rejection test', async () => { + let r = await handler({ + method: 'post', + path: '/foo', + query: {}, + requestBody: { + data: { type: 'foo', attributes: { id: 1, value: 0 } }, + }, + prisma, + }); + expect(r.status).toBe(201); + + r = await handler({ + method: 'put', + path: '/foo/1', + query: {}, + requestBody: { + data: { type: 'foo', attributes: { value: 1 } }, + }, + prisma, + }); + expect(r.status).toBe(403); + }); +});