diff --git a/packages/server/src/api/rest/index.ts b/packages/server/src/api/rest/index.ts index 75b2f738b..c2385b95e 100644 --- a/packages/server/src/api/rest/index.ts +++ b/packages/server/src/api/rest/index.ts @@ -32,6 +32,8 @@ const urlPatterns = { relationship: new UrlPattern('/:type/:id/relationships/:relationship'), }; +export const idDivider = '_'; + /** * Request handler options */ @@ -53,15 +55,13 @@ export type Options = { type RelationshipInfo = { type: string; - idField: string; - idFieldType: string; + idFields: FieldInfo[]; isCollection: boolean; isOptional: boolean; }; type ModelInfo = { - idField: string; - idFieldType: string; + idFields: FieldInfo[]; fields: Record; relationships: Record; }; @@ -129,10 +129,6 @@ class RequestHandler extends APIHandlerBase { status: 400, title: 'Model without an ID field is not supported', }, - multiId: { - status: 400, - title: 'Model with multiple ID fields is not supported', - }, invalidId: { status: 400, title: 'Resource ID is invalid', @@ -268,8 +264,7 @@ class RequestHandler extends APIHandlerBase { match.type, match.id, match.relationship, - query, - modelMeta + query ); } @@ -290,7 +285,7 @@ class RequestHandler extends APIHandlerBase { let match = urlPatterns.collection.match(path); if (match) { // resource creation - return await this.processCreate(prisma, match.type, query, requestBody, zodSchemas); + return await this.processCreate(prisma, match.type, query, requestBody, modelMeta, zodSchemas); } match = urlPatterns.relationship.match(path); @@ -320,7 +315,15 @@ class RequestHandler extends APIHandlerBase { let match = urlPatterns.single.match(path); if (match) { // resource update - return await this.processUpdate(prisma, match.type, match.id, query, requestBody, zodSchemas); + return await this.processUpdate( + prisma, + match.type, + match.id, + query, + requestBody, + modelMeta, + zodSchemas + ); } match = urlPatterns.relationship.match(path); @@ -387,7 +390,7 @@ class RequestHandler extends APIHandlerBase { return this.makeUnsupportedModelError(type); } - const args: any = { where: this.makeIdFilter(typeInfo.idField, typeInfo.idFieldType, resourceId) }; + const args: any = { where: this.makeIdFilter(typeInfo.idFields, resourceId) }; // include IDs of relation fields so that they can be serialized this.includeRelationshipIds(type, args, 'include'); @@ -406,6 +409,7 @@ class RequestHandler extends APIHandlerBase { } const entity = await prisma[type].findUnique(args); + if (entity) { return { status: 200, @@ -451,7 +455,7 @@ class RequestHandler extends APIHandlerBase { select = select ?? { [relationship]: true }; const args: any = { - where: this.makeIdFilter(typeInfo.idField, typeInfo.idFieldType, resourceId), + where: this.makeIdFilter(typeInfo.idFields, resourceId), select, }; @@ -496,8 +500,7 @@ class RequestHandler extends APIHandlerBase { type: string, resourceId: string, relationship: string, - query: Record | undefined, - modelMeta: ModelMeta + query: Record | undefined ): Promise { const typeInfo = this.typeMap[type]; if (!typeInfo) { @@ -510,13 +513,13 @@ class RequestHandler extends APIHandlerBase { } const args: any = { - where: this.makeIdFilter(typeInfo.idField, typeInfo.idFieldType, resourceId), - select: this.makeIdSelect(type, modelMeta), + where: this.makeIdFilter(typeInfo.idFields, resourceId), + select: this.makeIdSelect(typeInfo.idFields), }; // include IDs of relation fields so that they can be serialized // this.includeRelationshipIds(type, args, 'select'); - args.select = { ...args.select, [relationship]: { select: this.makeIdSelect(relationInfo.type, modelMeta) } }; + args.select = { ...args.select, [relationship]: { select: this.makeIdSelect(relationInfo.idFields) } }; let paginator: Paginator | undefined; @@ -720,6 +723,7 @@ class RequestHandler extends APIHandlerBase { type: string, _query: Record | undefined, requestBody: unknown, + modelMeta: ModelMeta, zodSchemas?: ZodSchemas ): Promise { const typeInfo = this.typeMap[type]; @@ -749,7 +753,7 @@ class RequestHandler extends APIHandlerBase { if (relationInfo.isCollection) { createPayload.data[key] = { connect: enumerate(data.data).map((item: any) => ({ - [relationInfo.idField]: this.coerce(relationInfo.idFieldType, item.id), + [this.makeIdKey(relationInfo.idFields)]: item.id, })), }; } else { @@ -757,14 +761,16 @@ class RequestHandler extends APIHandlerBase { return this.makeError('invalidRelationData'); } createPayload.data[key] = { - connect: { [relationInfo.idField]: this.coerce(relationInfo.idFieldType, data.data.id) }, + connect: { + [this.makeIdKey(relationInfo.idFields)]: data.data.id, + }, }; } // make sure ID fields are included for result serialization createPayload.include = { ...createPayload.include, - [key]: { select: { [relationInfo.idField]: true } }, + [key]: { select: { [this.makeIdKey(relationInfo.idFields)]: true } }, }; } } @@ -801,8 +807,11 @@ class RequestHandler extends APIHandlerBase { } const updateArgs: any = { - where: this.makeIdFilter(typeInfo.idField, typeInfo.idFieldType, resourceId), - select: { [typeInfo.idField]: true, [relationship]: { select: { [relationInfo.idField]: true } } }, + where: this.makeIdFilter(typeInfo.idFields, resourceId), + select: { + ...typeInfo.idFields.reduce((acc, field) => ({ ...acc, [field.name]: true }), {}), + [relationship]: { select: this.makeIdSelect(relationInfo.idFields) }, + }, }; if (!relationInfo.isCollection) { @@ -833,7 +842,7 @@ class RequestHandler extends APIHandlerBase { updateArgs.data = { [relationship]: { connect: { - [relationInfo.idField]: this.coerce(relationInfo.idFieldType, parsed.data.data.id), + [this.makeIdKey(relationInfo.idFields)]: parsed.data.data.id, }, }, }; @@ -856,9 +865,9 @@ class RequestHandler extends APIHandlerBase { updateArgs.data = { [relationship]: { - [relationVerb]: enumerate(parsed.data.data).map((item: any) => ({ - [relationInfo.idField]: this.coerce(relationInfo.idFieldType, item.id), - })), + [relationVerb]: enumerate(parsed.data.data).map((item: any) => + this.makeIdFilter(relationInfo.idFields, item.id) + ), }, }; } @@ -884,6 +893,7 @@ class RequestHandler extends APIHandlerBase { resourceId: string, _query: Record | undefined, requestBody: unknown, + modelMeta: ModelMeta, zodSchemas?: ZodSchemas ): Promise { const typeInfo = this.typeMap[type]; @@ -897,7 +907,7 @@ class RequestHandler extends APIHandlerBase { } const updatePayload: any = { - where: this.makeIdFilter(typeInfo.idField, typeInfo.idFieldType, resourceId), + where: this.makeIdFilter(typeInfo.idFields, resourceId), data: { ...attributes }, }; @@ -916,7 +926,7 @@ class RequestHandler extends APIHandlerBase { if (relationInfo.isCollection) { updatePayload.data[key] = { set: enumerate(data.data).map((item: any) => ({ - [relationInfo.idField]: this.coerce(relationInfo.idFieldType, item.id), + [this.makeIdKey(relationInfo.idFields)]: item.id, })), }; } else { @@ -924,12 +934,14 @@ class RequestHandler extends APIHandlerBase { return this.makeError('invalidRelationData'); } updatePayload.data[key] = { - set: { [relationInfo.idField]: this.coerce(relationInfo.idFieldType, data.data.id) }, + set: { + [this.makeIdKey(relationInfo.idFields)]: data.data.id, + }, }; } updatePayload.include = { ...updatePayload.include, - [key]: { select: { [relationInfo.idField]: true } }, + [key]: { select: { [this.makeIdKey(relationInfo.idFields)]: true } }, }; } } @@ -948,7 +960,7 @@ class RequestHandler extends APIHandlerBase { } await prisma[type].delete({ - where: this.makeIdFilter(typeInfo.idField, typeInfo.idFieldType, resourceId), + where: this.makeIdFilter(typeInfo.idFields, resourceId), }); return { status: 204, @@ -966,14 +978,9 @@ class RequestHandler extends APIHandlerBase { logWarning(logger, `Not including model ${model} in the API because it has no ID field`); continue; } - if (idFields.length > 1) { - logWarning(logger, `Not including model ${model} in the API because it has multiple ID fields`); - continue; - } this.typeMap[model] = { - idField: idFields[0].name, - idFieldType: idFields[0].type, + idFields, relationships: {}, fields, }; @@ -990,18 +997,10 @@ class RequestHandler extends APIHandlerBase { ); continue; } - if (fieldTypeIdFields.length > 1) { - logWarning( - logger, - `Not including relation ${model}.${field} in the API because it has multiple ID fields` - ); - continue; - } this.typeMap[model].relationships[field] = { type: fieldInfo.type, - idField: fieldTypeIdFields[0].name, - idFieldType: fieldTypeIdFields[0].type, + idFields: fieldTypeIdFields, isCollection: !!fieldInfo.isArray, isOptional: !!fieldInfo.isOptional, }; @@ -1019,7 +1018,8 @@ class RequestHandler extends APIHandlerBase { for (const model of Object.keys(modelMeta.models)) { const ids = getIdFields(modelMeta, model); - if (ids.length !== 1) { + + if (ids.length < 1) { continue; } @@ -1042,7 +1042,7 @@ class RequestHandler extends APIHandlerBase { const serializer = new Serializer(model, { version: '1.1', - idKey: ids[0].name, + idKey: this.makeIdKey(ids), linkers: { resource: linker, document: linker, @@ -1069,7 +1069,7 @@ class RequestHandler extends APIHandlerBase { continue; } const fieldIds = getIdFields(modelMeta, fieldMeta.type); - if (fieldIds.length === 1) { + if (fieldIds.length > 0) { const relator = new Relator( async (data) => { return (data as any)[field]; @@ -1107,10 +1107,10 @@ class RequestHandler extends APIHandlerBase { return undefined; } const ids = getIdFields(modelMeta, model); - if (ids.length === 1) { - return data[ids[0].name]; - } else { + if (ids.length === 0) { return undefined; + } else { + return data[ids.map((id) => id.name).join(idDivider)]; } } @@ -1121,8 +1121,25 @@ class RequestHandler extends APIHandlerBase { throw new Error(`serializer not found for model ${model}`); } + const typeInfo = this.typeMap[model]; + + let itemsWithId: any = items; + if (typeInfo.idFields.length > 1 && Array.isArray(items)) { + itemsWithId = items.map((item: any) => { + return { + ...item, + [this.makeIdKey(typeInfo.idFields)]: this.makeCompoundId(typeInfo.idFields, item), + }; + }); + } else if (typeInfo.idFields.length > 1 && typeof items === 'object') { + itemsWithId = { + ...items, + [this.makeIdKey(typeInfo.idFields)]: this.makeCompoundId(typeInfo.idFields, items), + }; + } + // serialize to JSON:API structure - const serialized = await serializer.serialize(items, options); + const serialized = await serializer.serialize(itemsWithId, options); // convert the serialization result to plain object otherwise SuperJSON won't work const plainResult = this.toPlainObject(serialized); @@ -1178,18 +1195,35 @@ class RequestHandler extends APIHandlerBase { return r.toString(); } - private makeIdFilter(idField: string, idFieldType: string, resourceId: string) { - return { [idField]: this.coerce(idFieldType, resourceId) }; + private makeIdFilter(idFields: FieldInfo[], resourceId: string) { + if (idFields.length === 1) { + return { [idFields[0].name]: this.coerce(idFields[0].type, resourceId) }; + } else { + return { + [idFields.map((idf) => idf.name).join(idDivider)]: idFields.reduce( + (acc, curr, idx) => ({ + ...acc, + [curr.name]: this.coerce(curr.type, resourceId.split(idDivider)[idx]), + }), + {} + ), + }; + } } - private makeIdSelect(model: string, modelMeta: ModelMeta) { - const idFields = getIdFields(modelMeta, model); + private makeIdSelect(idFields: FieldInfo[]) { if (idFields.length === 0) { throw this.errors.noId; - } else if (idFields.length > 1) { - throw this.errors.multiId; } - return { [idFields[0].name]: true }; + return idFields.reduce((acc, curr) => ({ ...acc, [curr.name]: true }), {}); + } + + private makeIdKey(idFields: FieldInfo[]) { + return idFields.map((idf) => idf.name).join(idDivider); + } + + private makeCompoundId(idFields: FieldInfo[], item: any) { + return idFields.map((idf) => item[idf.name]).join(idDivider); } private includeRelationshipIds(model: string, args: any, mode: 'select' | 'include') { @@ -1198,7 +1232,7 @@ class RequestHandler extends APIHandlerBase { return; } for (const [relation, relationInfo] of Object.entries(typeInfo.relationships)) { - args[mode] = { ...args[mode], [relation]: { select: { [relationInfo.idField]: true } } }; + args[mode] = { ...args[mode], [relation]: { select: this.makeIdSelect(relationInfo.idFields) } }; } } @@ -1425,7 +1459,10 @@ class RequestHandler extends APIHandlerBase { if (!relationType) { return { sort: undefined, error: this.makeUnsupportedModelError(fieldInfo.type) }; } - curr[fieldInfo.name] = { [relationType.idField]: dir }; + curr[fieldInfo.name] = relationType.idFields.reduce((acc: any, idField: FieldInfo) => { + acc[idField.name] = dir; + return acc; + }, {}); } else { // regular field curr[fieldInfo.name] = dir; @@ -1509,11 +1546,11 @@ class RequestHandler extends APIHandlerBase { const values = value.split(',').filter((i) => i); const filterValue = values.length > 1 - ? { OR: values.map((v) => this.makeIdFilter(info.idField, info.idFieldType, v)) } - : this.makeIdFilter(info.idField, info.idFieldType, value); + ? { OR: values.map((v) => this.makeIdFilter(info.idFields, v)) } + : this.makeIdFilter(info.idFields, value); return { some: filterValue }; } else { - return { is: this.makeIdFilter(info.idField, info.idFieldType, value) }; + return { is: this.makeIdFilter(info.idFields, value) }; } } else { const coerced = this.coerce(fieldInfo.type, value); diff --git a/packages/server/tests/api/rest.test.ts b/packages/server/tests/api/rest.test.ts index a587678c3..f0ba24dfe 100644 --- a/packages/server/tests/api/rest.test.ts +++ b/packages/server/tests/api/rest.test.ts @@ -5,7 +5,7 @@ import { CrudFailureReason, type ModelMeta } from '@zenstackhq/runtime'; import { loadSchema, run } from '@zenstackhq/testtools'; import { Decimal } from 'decimal.js'; import SuperJSON from 'superjson'; -import makeHandler from '../../src/api/rest'; +import makeHandler, { idDivider } from '../../src/api/rest'; describe('REST server tests', () => { let prisma: any; @@ -26,6 +26,7 @@ describe('REST server tests', () => { updatedAt DateTime @updatedAt email String @unique @email posts Post[] + likes PostLike[] profile Profile? } @@ -47,6 +48,7 @@ describe('REST server tests', () => { publishedAt DateTime? viewCount Int @default(0) comments Comment[] + likes PostLike[] setting Setting? } @@ -63,6 +65,15 @@ describe('REST server tests', () => { 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]) + @@id([postId, userId]) + } `; beforeAll(async () => { @@ -125,6 +136,7 @@ describe('REST server tests', () => { path: '/user', prisma, }); + expect(r.status).toBe(200); expect(r.body).toMatchObject({ data: [], @@ -291,6 +303,35 @@ describe('REST server tests', () => { }); }); + it('fetches a related resource with a compound ID', async () => { + await prisma.user.create({ + data: { + myId: 'user1', + email: 'user1@abc.com', + posts: { + create: { id: 1, title: 'Post1' }, + }, + }, + }); + await prisma.postLike.create({ + data: { postId: 1, userId: 'user1', superLike: true }, + }); + + const r = await handler({ + method: 'get', + path: '/post/1/relationships/likes', + prisma, + }); + + expect(r.status).toBe(200); + expect(r.body).toMatchObject({ + links: { + self: 'http://localhost/api/post/1/relationships/likes', + }, + data: [{ type: 'postLike', id: `1${idDivider}user1` }], + }); + }); + it('fetch a relationship', async () => { // Create a user first await prisma.user.create({ @@ -1283,6 +1324,56 @@ describe('REST server tests', () => { next: null, }); }); + + describe('compound id', () => { + beforeEach(async () => { + await prisma.user.create({ + data: { myId: 'user1', email: 'user1@abc.com', posts: { create: { title: 'Post1' } } }, + }); + await prisma.user.create({ + data: { myId: 'user2', email: 'user2@abc.com' }, + }); + await prisma.postLike.create({ + data: { userId: 'user2', postId: 1, superLike: false }, + }); + }); + + it('get all', async () => { + const r = await handler({ + method: 'get', + path: '/postLike', + prisma, + }); + + expect(r.status).toBe(200); + expect(r.body).toMatchObject({ + data: [ + { + type: 'postLike', + id: `1${idDivider}user2`, + attributes: { userId: 'user2', postId: 1, superLike: false }, + }, + ], + }); + }); + + it('get single', async () => { + const r = await handler({ + method: 'get', + path: `/postLike/1${idDivider}user2`, // Order of ids is same as in the model @@id + prisma, + }); + + expect(r.status).toBe(200); + expect(r.body).toMatchObject({ + data: { + type: 'postLike', + id: `1${idDivider}user2`, + attributes: { userId: 'user2', postId: 1, superLike: false }, + }, + }); + }); + }); }); describe('POST', () => { @@ -1546,6 +1637,50 @@ describe('REST server tests', () => { expect(r.status).toBe(404); }); + + it('create relation with compound id', async () => { + await prisma.user.create({ data: { myId: 'user1', email: 'user1@abc.com' } }); + await prisma.post.create({ data: { id: 1, title: 'Post1' } }); + + const r = await handler({ + method: 'post', + path: '/postLike', + query: {}, + requestBody: { + data: { + type: 'postLike', + id: `1${idDivider}user1`, + attributes: { userId: 'user1', postId: 1, superLike: false }, + }, + }, + prisma, + }); + + expect(r.status).toBe(201); + }); + + it('compound id create single', async () => { + await prisma.user.create({ data: { myId: 'user1', email: 'user1@abc.com' } }); + await prisma.post.create({ + data: { id: 1, title: 'Post1' }, + }); + + const r = await handler({ + method: 'post', + path: '/postLike', + query: {}, + requestBody: { + data: { + type: 'postLike', + id: `1${idDivider}user1`, + attributes: { userId: 'user1', postId: 1, superLike: false }, + }, + }, + prisma, + }); + + expect(r.status).toBe(201); + }); }); describe('PUT', () => { @@ -1684,6 +1819,27 @@ describe('REST server tests', () => { expect(r.body.errors[0].code).toBe('invalid-payload'); }); + it('update item with compound id', async () => { + await prisma.user.create({ data: { myId: 'user1', email: 'user1@abc.com' } }); + await prisma.post.create({ data: { id: 1, title: 'Post1' } }); + await prisma.postLike.create({ data: { userId: 'user1', postId: 1, superLike: false } }); + + const r = await handler({ + method: 'put', + path: `/postLike/1${idDivider}user1`, + query: {}, + requestBody: { + data: { + type: 'postLike', + attributes: { superLike: true }, + }, + }, + prisma, + }); + + expect(r.status).toBe(200); + }); + it('update a single relation', async () => { await prisma.user.create({ data: { myId: 'user1', email: 'user1@abc.com' } }); await prisma.post.create({ @@ -1767,6 +1923,24 @@ describe('REST server tests', () => { }); }); + it('update a collection of relations with compound id', async () => { + await prisma.user.create({ data: { myId: 'user1', email: 'user1@abc.com' } }); + await prisma.post.create({ data: { id: 1, title: 'Post1' } }); + await prisma.postLike.create({ data: { userId: 'user1', postId: 1, superLike: false } }); + + const r = await handler({ + method: 'patch', + path: '/post/1/relationships/likes', + query: {}, + requestBody: { + data: [{ type: 'postLike', id: `1${idDivider}user1`, attributes: { superLike: true } }], + }, + prisma, + }); + + expect(r.status).toBe(200); + }); + it('update a collection of relations to empty', async () => { await prisma.user.create({ data: { myId: 'user1', email: 'user1@abc.com', posts: { create: { id: 1, title: 'Post1' } } }, @@ -1845,6 +2019,21 @@ describe('REST server tests', () => { expect(r.body).toBeUndefined(); }); + it('deletes an item with compound id', async () => { + await prisma.user.create({ + data: { myId: 'user1', email: 'user1@abc.com', posts: { create: { id: 1, title: 'Post1' } } }, + }); + await prisma.postLike.create({ data: { userId: 'user1', postId: 1, superLike: false } }); + + const r = await handler({ + method: 'delete', + path: `/postLike/1${idDivider}user1`, + prisma, + }); + expect(r.status).toBe(204); + expect(r.body).toBeUndefined(); + }); + it('returns 404 if the user does not exist', async () => { const r = await handler({ method: 'delete',