Skip to content
Merged
135 changes: 128 additions & 7 deletions packages/server/src/api/rest/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -716,6 +716,14 @@ class RequestHandler extends APIHandlerBase {
body = SuperJSON.deserialize({ json: body, meta: body.meta.serialization });
}

let operation: 'create' | 'update' | 'upsert' = mode;
let matchFields = [];

if (body.meta?.operation === 'upsert' && body.meta?.matchFields.length) {
operation = 'upsert';
matchFields = body.meta.matchFields;
}

const parsed = this.createUpdatePayloadSchema.parse(body);
const attributes: any = parsed.data.attributes;

Expand All @@ -740,7 +748,7 @@ class RequestHandler extends APIHandlerBase {
}
}

return { attributes, relationships: parsed.data.relationships };
return { attributes, relationships: parsed.data.relationships, operation, matchFields };
}

private async processCreate(
Expand All @@ -756,12 +764,111 @@ class RequestHandler extends APIHandlerBase {
return this.makeUnsupportedModelError(type);
}

const { error, attributes, relationships } = this.processRequestBody(type, requestBody, zodSchemas, 'create');
const { error, attributes, relationships, operation, matchFields } = this.processRequestBody(
type,
requestBody,
zodSchemas,
'create'
);

if (error) {
return error;
}

let entity: any;

if (operation === 'upsert') {
entity = await this.runUpsert(typeInfo, type, prisma, modelMeta, attributes, relationships, matchFields);
} else if (operation === 'create') {
entity = await this.runCreate(typeInfo, type, prisma, attributes, relationships);
} else {
return this.makeError('invalidPayload');
}

if (entity.status) {
return entity;
}

return {
status: 201,
body: await this.serializeItems(type, entity),
};
}

private async runUpsert(
typeInfo: ModelInfo,
type: string,
prisma: DbClientContract,
modelMeta: ModelMeta,
attributes: any,
relationships: any,
matchFields: any[]
) {
const uniqueFields = Object.values(modelMeta.models[type].uniqueConstraints || {}).map((uf) => uf.fields);

if (
!uniqueFields.some((uniqueCombination) => uniqueCombination.every((field) => matchFields.includes(field)))
) {
return this.makeError('invalidPayload', 'Match fields must be unique fields', 400);
}

const upsertPayload: any = {};
upsertPayload.where = this.makeUpsertWhere(matchFields, attributes, typeInfo);

upsertPayload.create = { ...attributes };
upsertPayload.update = {
...Object.fromEntries(Object.entries(attributes).filter((e) => !matchFields.includes(e[0]))),
};

if (relationships) {
for (const [key, data] of Object.entries<any>(relationships)) {
if (!data?.data) {
return this.makeError('invalidRelationData');
}

const relationInfo = typeInfo.relationships[key];
if (!relationInfo) {
return this.makeUnsupportedRelationshipError(type, key, 400);
}

if (relationInfo.isCollection) {
upsertPayload.create[key] = {
connect: enumerate(data.data).map((item: any) =>
this.makeIdConnect(relationInfo.idFields, item.id)
),
};
upsertPayload.update[key] = {
connect: enumerate(data.data).map((item: any) =>
this.makeIdConnect(relationInfo.idFields, item.id)
),
};
} else {
if (typeof data.data !== 'object') {
return this.makeError('invalidRelationData');
}
upsertPayload.create[key] = {
connect: this.makeIdConnect(relationInfo.idFields, data.data.id),
};
upsertPayload.update[key] = {
connect: this.makeIdConnect(relationInfo.idFields, data.data.id),
};
}
}
}

// include IDs of relation fields so that they can be serialized.
this.includeRelationshipIds(type, upsertPayload, 'include');

return prisma[type].upsert(upsertPayload);
}

private async runCreate(
typeInfo: ModelInfo,
type: string,
prisma: DbClientContract,
attributes: any,
relationships: any
) {
const createPayload: any = { data: { ...attributes } };

// turn relationship payload into Prisma connect objects
Expand Down Expand Up @@ -802,11 +909,7 @@ class RequestHandler extends APIHandlerBase {
// include IDs of relation fields so that they can be serialized.
this.includeRelationshipIds(type, createPayload, 'include');

const entity = await prisma[type].create(createPayload);
return {
status: 201,
body: await this.serializeItems(type, entity),
};
return prisma[type].create(createPayload);
}

private async processRelationshipCRUD(
Expand Down Expand Up @@ -1296,6 +1399,24 @@ class RequestHandler extends APIHandlerBase {
return idFields.map((idf) => item[idf.name]).join(this.idDivider);
}

private makeUpsertWhere(matchFields: any[], attributes: any, typeInfo: ModelInfo) {
const where = matchFields.reduce((acc: any, field: string) => {
acc[field] = attributes[field] ?? null;
return acc;
}, {});

if (
typeInfo.idFields.length > 1 &&
matchFields.some((mf) => typeInfo.idFields.map((idf) => idf.name).includes(mf))
) {
return {
[this.makePrismaIdKey(typeInfo.idFields)]: where,
};
}

return where;
}

private includeRelationshipIds(model: string, args: any, mode: 'select' | 'include') {
const typeInfo = this.typeMap[model];
if (!typeInfo) {
Expand Down
135 changes: 135 additions & 0 deletions packages/server/tests/api/rest.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { createPostgresDb, dropPostgresDb, loadSchema, run } from '@zenstackhq/t
import { Decimal } from 'decimal.js';
import SuperJSON from 'superjson';
import makeHandler from '../../src/api/rest';
import { query } from 'express';

const idDivider = '_';

Expand Down Expand Up @@ -1800,6 +1801,140 @@ describe('REST server tests', () => {

expect(r.status).toBe(201);
});

it('upsert a new entity', async () => {
const r = await handler({
method: 'post',
path: '/user',
query: {},
requestBody: {
data: {
type: 'user',
attributes: { myId: 'user1', email: '[email protected]' },
},
meta: {
operation: 'upsert',
matchFields: ['myId'],
},
},
prisma,
});

expect(r.status).toBe(201);
expect(r.body).toMatchObject({
jsonapi: { version: '1.1' },
data: {
type: 'user',
id: 'user1',
attributes: { email: '[email protected]' },
relationships: {
posts: {
links: {
self: 'http://localhost/api/user/user1/relationships/posts',
related: 'http://localhost/api/user/user1/posts',
},
data: [],
},
},
},
});
});

it('upsert an existing entity', async () => {
await prisma.user.create({
data: { myId: 'user1', email: '[email protected]' },
});

const r = await handler({
method: 'post',
path: '/user',
query: {},
requestBody: {
data: {
type: 'user',
attributes: { myId: 'user1', email: '[email protected]' },
},
meta: {
operation: 'upsert',
matchFields: ['myId'],
},
},
prisma,
});

expect(r.status).toBe(201);
expect(r.body).toMatchObject({
jsonapi: { version: '1.1' },
data: {
type: 'user',
id: 'user1',
attributes: { email: '[email protected]' },
},
});
});

it('upsert fails if matchFields are not unique', async () => {
await prisma.user.create({
data: { myId: 'user1', email: '[email protected]' },
});

const r = await handler({
method: 'post',
path: '/profile',
query: {},
requestBody: {
data: {
type: 'profile',
attributes: { gender: 'male' },
relationships: {
user: {
data: { type: 'user', id: 'user1' },
},
},
},
meta: {
operation: 'upsert',
matchFields: ['gender'],
},
},
prisma,
});

expect(r.status).toBe(400);
expect(r.body).toMatchObject({
errors: [
{
status: 400,
code: 'invalid-payload',
},
],
});
});

it('upsert works with compound id', async () => {
await prisma.user.create({ data: { myId: 'user1', email: '[email protected]' } });
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 },
},
meta: {
operation: 'upsert',
matchFields: ['userId', 'postId'],
},
},
prisma,
});

expect(r.status).toBe(201);
});
});

describe('PUT', () => {
Expand Down
Loading