From b0de9c331f832ba0b5c6c89658ad7b45ca7ba37f Mon Sep 17 00:00:00 2001 From: Thomas Sunde Nielsen Date: Thu, 17 Oct 2024 14:59:02 +0200 Subject: [PATCH 1/8] Make idDivider configurable --- packages/server/src/api/rest/index.ts | 20 +++++++++++++------- packages/server/tests/api/rest.test.ts | 6 ++++-- 2 files changed, 17 insertions(+), 9 deletions(-) diff --git a/packages/server/src/api/rest/index.ts b/packages/server/src/api/rest/index.ts index 32367df44..19550c74a 100644 --- a/packages/server/src/api/rest/index.ts +++ b/packages/server/src/api/rest/index.ts @@ -33,8 +33,6 @@ const urlPatterns = { relationship: new UrlPattern('/:type/:id/relationships/:relationship'), }; -export const idDivider = '_'; - /** * Request handler options */ @@ -52,6 +50,12 @@ export type Options = { * Defaults to 100. Set to Infinity to disable pagination. */ pageSize?: number; + + /** + * The divider used to separate compound ID fields in the URL. + * Defaults to '_'. + */ + idDivider?: string; }; type RelationshipInfo = { @@ -209,9 +213,11 @@ class RequestHandler extends APIHandlerBase { // all known types and their metadata private typeMap: Record; + public idDivider; constructor(private readonly options: Options) { super(); + this.idDivider = options.idDivider ?? '_'; } async handleRequest({ @@ -1110,7 +1116,7 @@ class RequestHandler extends APIHandlerBase { if (ids.length === 0) { return undefined; } else { - return data[ids.map((id) => id.name).join(idDivider)]; + return data[ids.map((id) => id.name).join(this.idDivider)]; } } @@ -1211,10 +1217,10 @@ class RequestHandler extends APIHandlerBase { return { [idFields[0].name]: this.coerce(idFields[0].type, resourceId) }; } else { return { - [idFields.map((idf) => idf.name).join(idDivider)]: idFields.reduce( + [idFields.map((idf) => idf.name).join(this.idDivider)]: idFields.reduce( (acc, curr, idx) => ({ ...acc, - [curr.name]: this.coerce(curr.type, resourceId.split(idDivider)[idx]), + [curr.name]: this.coerce(curr.type, resourceId.split(this.idDivider)[idx]), }), {} ), @@ -1230,11 +1236,11 @@ class RequestHandler extends APIHandlerBase { } private makeIdKey(idFields: FieldInfo[]) { - return idFields.map((idf) => idf.name).join(idDivider); + return idFields.map((idf) => idf.name).join(this.idDivider); } private makeCompoundId(idFields: FieldInfo[], item: any) { - return idFields.map((idf) => item[idf.name]).join(idDivider); + return idFields.map((idf) => item[idf.name]).join(this.idDivider); } private includeRelationshipIds(model: string, args: any, mode: 'select' | 'include') { diff --git a/packages/server/tests/api/rest.test.ts b/packages/server/tests/api/rest.test.ts index fa7d0cfb8..d6700c81e 100644 --- a/packages/server/tests/api/rest.test.ts +++ b/packages/server/tests/api/rest.test.ts @@ -5,7 +5,9 @@ import { CrudFailureReason, type ModelMeta } from '@zenstackhq/runtime'; import { loadSchema, run } from '@zenstackhq/testtools'; import { Decimal } from 'decimal.js'; import SuperJSON from 'superjson'; -import makeHandler, { idDivider } from '../../src/api/rest'; +import makeHandler from '../../src/api/rest'; + +const idDivider = '_'; describe('REST server tests', () => { let prisma: any; @@ -83,7 +85,7 @@ describe('REST server tests', () => { zodSchemas = params.zodSchemas; modelMeta = params.modelMeta; - const _handler = makeHandler({ endpoint: 'http://localhost/api', pageSize: 5 }); + const _handler = makeHandler({ endpoint: 'http://localhost/api', pageSize: 5, idDivider }); handler = (args) => _handler({ ...args, zodSchemas, modelMeta, url: new URL(`http://localhost/${args.path}`) }); }); From e127e0498c9e19bd06a38599b5b88064bc464faf Mon Sep 17 00:00:00 2001 From: Thomas Sunde Nielsen Date: Thu, 17 Oct 2024 15:24:18 +0200 Subject: [PATCH 2/8] Make REST API ID divider configurable --- packages/server/src/api/rest/index.ts | 48 +++++++------ packages/server/tests/api/rest.test.ts | 95 +++++++++++++++++++++++++- pnpm-lock.yaml | 12 ++-- 3 files changed, 127 insertions(+), 28 deletions(-) diff --git a/packages/server/src/api/rest/index.ts b/packages/server/src/api/rest/index.ts index 19550c74a..8ce1165aa 100644 --- a/packages/server/src/api/rest/index.ts +++ b/packages/server/src/api/rest/index.ts @@ -22,17 +22,6 @@ import { LoggerConfig, Response } from '../../types'; import { APIHandlerBase, RequestContext } from '../base'; import { logWarning, registerCustomSerializers } from '../utils'; -const urlPatterns = { - // collection operations - collection: new UrlPattern('/:type'), - // single resource operations - single: new UrlPattern('/:type/:id'), - // related entity fetching - fetchRelationship: new UrlPattern('/:type/:id/:relationship'), - // relationship operations - relationship: new UrlPattern('/:type/:id/relationships/:relationship'), -}; - /** * Request handler options */ @@ -215,9 +204,26 @@ class RequestHandler extends APIHandlerBase { private typeMap: Record; public idDivider; + private urlPatterns; + constructor(private readonly options: Options) { super(); this.idDivider = options.idDivider ?? '_'; + this.urlPatterns = this.buildUrlPatterns(this.idDivider); + } + + buildUrlPatterns(idDivider: string) { + const options = { segmentNameCharset: `a-zA-Z0-9-_~ %${idDivider}` }; + return { + // collection operations + collection: new UrlPattern('/:type', options), + // single resource operations + single: new UrlPattern('/:type/:id', options), + // related entity fetching + fetchRelationship: new UrlPattern('/:type/:id/:relationship', options), + // relationship operations + relationship: new UrlPattern('/:type/:id/relationships/:relationship', options), + }; } async handleRequest({ @@ -251,19 +257,19 @@ class RequestHandler extends APIHandlerBase { try { switch (method) { case 'GET': { - let match = urlPatterns.single.match(path); + let match = this.urlPatterns.single.match(path); if (match) { // single resource read return await this.processSingleRead(prisma, match.type, match.id, query); } - match = urlPatterns.fetchRelationship.match(path); + match = this.urlPatterns.fetchRelationship.match(path); if (match) { // fetch related resource(s) return await this.processFetchRelated(prisma, match.type, match.id, match.relationship, query); } - match = urlPatterns.relationship.match(path); + match = this.urlPatterns.relationship.match(path); if (match) { // read relationship return await this.processReadRelationship( @@ -275,7 +281,7 @@ class RequestHandler extends APIHandlerBase { ); } - match = urlPatterns.collection.match(path); + match = this.urlPatterns.collection.match(path); if (match) { // collection read return await this.processCollectionRead(prisma, match.type, query); @@ -289,13 +295,13 @@ class RequestHandler extends APIHandlerBase { return this.makeError('invalidPayload'); } - let match = urlPatterns.collection.match(path); + let match = this.urlPatterns.collection.match(path); if (match) { // resource creation return await this.processCreate(prisma, match.type, query, requestBody, modelMeta, zodSchemas); } - match = urlPatterns.relationship.match(path); + match = this.urlPatterns.relationship.match(path); if (match) { // relationship creation (collection relationship only) return await this.processRelationshipCRUD( @@ -319,7 +325,7 @@ class RequestHandler extends APIHandlerBase { return this.makeError('invalidPayload'); } - let match = urlPatterns.single.match(path); + let match = this.urlPatterns.single.match(path); if (match) { // resource update return await this.processUpdate( @@ -333,7 +339,7 @@ class RequestHandler extends APIHandlerBase { ); } - match = urlPatterns.relationship.match(path); + match = this.urlPatterns.relationship.match(path); if (match) { // relationship update return await this.processRelationshipCRUD( @@ -351,13 +357,13 @@ class RequestHandler extends APIHandlerBase { } case 'DELETE': { - let match = urlPatterns.single.match(path); + let match = this.urlPatterns.single.match(path); if (match) { // resource deletion return await this.processDelete(prisma, match.type, match.id); } - match = urlPatterns.relationship.match(path); + match = this.urlPatterns.relationship.match(path); if (match) { // relationship deletion (collection relationship only) return await this.processRelationshipCRUD( diff --git a/packages/server/tests/api/rest.test.ts b/packages/server/tests/api/rest.test.ts index d6700c81e..7e2cf139c 100644 --- a/packages/server/tests/api/rest.test.ts +++ b/packages/server/tests/api/rest.test.ts @@ -85,7 +85,7 @@ describe('REST server tests', () => { zodSchemas = params.zodSchemas; modelMeta = params.modelMeta; - const _handler = makeHandler({ endpoint: 'http://localhost/api', pageSize: 5, idDivider }); + const _handler = makeHandler({ endpoint: 'http://localhost/api', pageSize: 5 }); handler = (args) => _handler({ ...args, zodSchemas, modelMeta, url: new URL(`http://localhost/${args.path}`) }); }); @@ -2521,4 +2521,97 @@ describe('REST server tests', () => { expect(Buffer.isBuffer(included.attributes.bytes)).toBeTruthy(); }); }); + + describe('REST server tests - compound id with custom separator', () => { + const schema = ` + enum Role { + COMMON_USER + ADMIN_USER + } + + model User { + email String + role Role + + @@id([email, role]) + } + `; + const idDivider = ':'; + + beforeAll(async () => { + const params = await loadSchema(schema); + + prisma = params.enhanceRaw(params.prisma, params); + zodSchemas = params.zodSchemas; + modelMeta = params.modelMeta; + + const _handler = makeHandler({ endpoint: 'http://localhost/api', pageSize: 5, idDivider }); + handler = (args) => + _handler({ ...args, zodSchemas, modelMeta, url: new URL(`http://localhost/${args.path}`) }); + }); + + it('POST', async () => { + const r = await handler({ + method: 'post', + path: '/user', + query: {}, + requestBody: { + data: { + type: 'user', + attributes: { email: 'user1@abc.com', role: 'COMMON_USER' }, + }, + }, + prisma, + }); + + expect(r.status).toBe(201); + }); + + it('GET', async () => { + await prisma.user.create({ + data: { email: 'user1@abc.com', role: 'COMMON_USER' }, + }); + + const r = await handler({ + method: 'get', + path: '/user', + query: {}, + prisma, + }); + + expect(r.status).toBe(200); + expect(r.body.data).toHaveLength(1); + }); + + it('GET single', async () => { + await prisma.user.create({ + data: { email: 'user1@abc.com', role: 'COMMON_USER' }, + }); + + const r = await handler({ + method: 'get', + path: '/user/user1@abc.om:COMMON_USER', + query: {}, + prisma, + }); + + expect(r.status).toBe(200); + expect(r.body.data.attributes.email).toBe('user1@abc.com'); + }); + + it('PUT', async () => { + await prisma.user.create({ + data: { email: 'user1@abc.com', role: 'COMMON_USER' }, + }); + + const r = await handler({ + method: 'put', + path: '/user/user1@abc.om:COMMON_USER', + query: {}, + prisma, + }); + + expect(r.status).toBe(200); + }); + }); }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a0da7754f..6771d2899 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -706,7 +706,7 @@ importers: version: 10.3.9(@nestjs/common@10.3.9(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.3.9) '@nestjs/testing': specifier: ^10.3.7 - version: 10.3.9(@nestjs/common@10.3.9(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.3.9(@nestjs/common@10.3.9(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.3.9)(encoding@0.1.13)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.3.9(@nestjs/common@10.3.9(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.3.9)) + version: 10.3.9(@nestjs/common@10.3.9(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.3.9)(@nestjs/platform-express@10.3.9) '@sveltejs/kit': specifier: 1.21.0 version: 1.21.0(svelte@4.2.18)(vite@5.3.2(@types/node@20.14.9)(terser@5.31.1)) @@ -3986,7 +3986,7 @@ packages: engines: {node: '>= 14'} concat-map@0.0.1: - resolution: {integrity: sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=} + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} concat-stream@1.6.2: resolution: {integrity: sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==} @@ -4424,7 +4424,7 @@ packages: resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} ee-first@1.1.1: - resolution: {integrity: sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0=} + resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} electron-to-chromium@1.4.814: resolution: {integrity: sha512-GVulpHjFu1Y9ZvikvbArHmAhZXtm3wHlpjTMcXNGKl4IQ4jMQjlnz8yMQYYqdLHKi/jEL2+CBC2akWVCoIGUdw==} @@ -6100,7 +6100,7 @@ packages: resolution: {integrity: sha512-/sKlQJCBYVY9Ers9hqzKou4H6V5UWc/M59TH2dvkt+84itfnq7uFOMLpOiOS4ujvHP4etln18fmIxA5R5fll0g==} media-typer@0.3.0: - resolution: {integrity: sha1-hxDXrwqmJvj/+hzgAWhUUmMlV0g=} + resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==} engines: {node: '>= 0.6'} merge-descriptors@1.0.1: @@ -8260,7 +8260,7 @@ packages: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} utils-merge@1.0.1: - resolution: {integrity: sha1-n5VxD1CiZ5R7LMwSR0HBAoQn5xM=} + resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==} engines: {node: '>= 0.4.0'} uuid@10.0.0: @@ -10080,7 +10080,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@nestjs/testing@10.3.9(@nestjs/common@10.3.9(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.3.9(@nestjs/common@10.3.9(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.3.9)(encoding@0.1.13)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.3.9(@nestjs/common@10.3.9(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.3.9))': + '@nestjs/testing@10.3.9(@nestjs/common@10.3.9(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.3.9)(@nestjs/platform-express@10.3.9)': dependencies: '@nestjs/common': 10.3.9(reflect-metadata@0.2.2)(rxjs@7.8.1) '@nestjs/core': 10.3.9(@nestjs/common@10.3.9(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.3.9)(encoding@0.1.13)(reflect-metadata@0.2.2)(rxjs@7.8.1) From 7c5940b47aab967fec172b6691a0e128ac0a6790 Mon Sep 17 00:00:00 2001 From: ymc9 <104139426+ymc9@users.noreply.github.com> Date: Thu, 17 Oct 2024 11:53:27 -0700 Subject: [PATCH 3/8] a few fixes: - Update test to use postgres db - Changes url pattern option from `segmentNameCharset` to `segmentValueCharset` - Made sure ID key sent to Prisma is always joined with '_' instead of user-configured divider. I've renamed methods for clarity. --- packages/server/src/api/rest/index.ts | 46 +++++++++++++++----------- packages/server/tests/api/rest.test.ts | 24 +++++++++++--- 2 files changed, 45 insertions(+), 25 deletions(-) diff --git a/packages/server/src/api/rest/index.ts b/packages/server/src/api/rest/index.ts index 8ce1165aa..a9a39a42d 100644 --- a/packages/server/src/api/rest/index.ts +++ b/packages/server/src/api/rest/index.ts @@ -213,7 +213,7 @@ class RequestHandler extends APIHandlerBase { } buildUrlPatterns(idDivider: string) { - const options = { segmentNameCharset: `a-zA-Z0-9-_~ %${idDivider}` }; + const options = { segmentValueCharset: `a-zA-Z0-9-_~ %${idDivider}` }; return { // collection operations collection: new UrlPattern('/:type', options), @@ -403,7 +403,7 @@ class RequestHandler extends APIHandlerBase { return this.makeUnsupportedModelError(type); } - const args: any = { where: this.makeIdFilter(typeInfo.idFields, resourceId) }; + const args: any = { where: this.makePrismaIdFilter(typeInfo.idFields, resourceId) }; // include IDs of relation fields so that they can be serialized this.includeRelationshipIds(type, args, 'include'); @@ -468,7 +468,7 @@ class RequestHandler extends APIHandlerBase { select = select ?? { [relationship]: true }; const args: any = { - where: this.makeIdFilter(typeInfo.idFields, resourceId), + where: this.makePrismaIdFilter(typeInfo.idFields, resourceId), select, }; @@ -526,7 +526,7 @@ class RequestHandler extends APIHandlerBase { } const args: any = { - where: this.makeIdFilter(typeInfo.idFields, resourceId), + where: this.makePrismaIdFilter(typeInfo.idFields, resourceId), select: this.makeIdSelect(typeInfo.idFields), }; @@ -765,7 +765,7 @@ class RequestHandler extends APIHandlerBase { if (relationInfo.isCollection) { createPayload.data[key] = { connect: enumerate(data.data).map((item: any) => ({ - [this.makeIdKey(relationInfo.idFields)]: item.id, + [this.makePrismaIdKey(relationInfo.idFields)]: item.id, })), }; } else { @@ -774,7 +774,7 @@ class RequestHandler extends APIHandlerBase { } createPayload.data[key] = { connect: { - [this.makeIdKey(relationInfo.idFields)]: data.data.id, + [this.makePrismaIdKey(relationInfo.idFields)]: data.data.id, }, }; } @@ -782,7 +782,7 @@ class RequestHandler extends APIHandlerBase { // make sure ID fields are included for result serialization createPayload.include = { ...createPayload.include, - [key]: { select: { [this.makeIdKey(relationInfo.idFields)]: true } }, + [key]: { select: { [this.makePrismaIdKey(relationInfo.idFields)]: true } }, }; } } @@ -819,7 +819,7 @@ class RequestHandler extends APIHandlerBase { } const updateArgs: any = { - where: this.makeIdFilter(typeInfo.idFields, resourceId), + where: this.makePrismaIdFilter(typeInfo.idFields, resourceId), select: { ...typeInfo.idFields.reduce((acc, field) => ({ ...acc, [field.name]: true }), {}), [relationship]: { select: this.makeIdSelect(relationInfo.idFields) }, @@ -854,7 +854,7 @@ class RequestHandler extends APIHandlerBase { updateArgs.data = { [relationship]: { connect: { - [this.makeIdKey(relationInfo.idFields)]: parsed.data.data.id, + [this.makePrismaIdKey(relationInfo.idFields)]: parsed.data.data.id, }, }, }; @@ -878,7 +878,7 @@ class RequestHandler extends APIHandlerBase { updateArgs.data = { [relationship]: { [relationVerb]: enumerate(parsed.data.data).map((item: any) => - this.makeIdFilter(relationInfo.idFields, item.id) + this.makePrismaIdFilter(relationInfo.idFields, item.id) ), }, }; @@ -919,7 +919,7 @@ class RequestHandler extends APIHandlerBase { } const updatePayload: any = { - where: this.makeIdFilter(typeInfo.idFields, resourceId), + where: this.makePrismaIdFilter(typeInfo.idFields, resourceId), data: { ...attributes }, }; @@ -938,7 +938,7 @@ class RequestHandler extends APIHandlerBase { if (relationInfo.isCollection) { updatePayload.data[key] = { set: enumerate(data.data).map((item: any) => ({ - [this.makeIdKey(relationInfo.idFields)]: item.id, + [this.makePrismaIdKey(relationInfo.idFields)]: item.id, })), }; } else { @@ -947,13 +947,13 @@ class RequestHandler extends APIHandlerBase { } updatePayload.data[key] = { set: { - [this.makeIdKey(relationInfo.idFields)]: data.data.id, + [this.makePrismaIdKey(relationInfo.idFields)]: data.data.id, }, }; } updatePayload.include = { ...updatePayload.include, - [key]: { select: { [this.makeIdKey(relationInfo.idFields)]: true } }, + [key]: { select: { [this.makePrismaIdKey(relationInfo.idFields)]: true } }, }; } } @@ -972,7 +972,7 @@ class RequestHandler extends APIHandlerBase { } await prisma[type].delete({ - where: this.makeIdFilter(typeInfo.idFields, resourceId), + where: this.makePrismaIdFilter(typeInfo.idFields, resourceId), }); return { status: 204, @@ -1218,12 +1218,13 @@ class RequestHandler extends APIHandlerBase { return r.toString(); } - private makeIdFilter(idFields: FieldInfo[], resourceId: string) { + private makePrismaIdFilter(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(this.idDivider)]: idFields.reduce( + // TODO: support `@@id` with custom name + [idFields.map((idf) => idf.name).join('_')]: idFields.reduce( (acc, curr, idx) => ({ ...acc, [curr.name]: this.coerce(curr.type, resourceId.split(this.idDivider)[idx]), @@ -1245,6 +1246,11 @@ class RequestHandler extends APIHandlerBase { return idFields.map((idf) => idf.name).join(this.idDivider); } + private makePrismaIdKey(idFields: FieldInfo[]) { + // TODO: support `@@id` with custom name + return idFields.map((idf) => idf.name).join('_'); + } + private makeCompoundId(idFields: FieldInfo[], item: any) { return idFields.map((idf) => item[idf.name]).join(this.idDivider); } @@ -1569,11 +1575,11 @@ class RequestHandler extends APIHandlerBase { const values = value.split(',').filter((i) => i); const filterValue = values.length > 1 - ? { OR: values.map((v) => this.makeIdFilter(info.idFields, v)) } - : this.makeIdFilter(info.idFields, value); + ? { OR: values.map((v) => this.makePrismaIdFilter(info.idFields, v)) } + : this.makePrismaIdFilter(info.idFields, value); return { some: filterValue }; } else { - return { is: this.makeIdFilter(info.idFields, value) }; + return { is: this.makePrismaIdFilter(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 7e2cf139c..6c4496764 100644 --- a/packages/server/tests/api/rest.test.ts +++ b/packages/server/tests/api/rest.test.ts @@ -2,7 +2,7 @@ /// import { CrudFailureReason, type ModelMeta } from '@zenstackhq/runtime'; -import { loadSchema, run } from '@zenstackhq/testtools'; +import { createPostgresDb, dropPostgresDb, loadSchema, run } from '@zenstackhq/testtools'; import { Decimal } from 'decimal.js'; import SuperJSON from 'superjson'; import makeHandler from '../../src/api/rest'; @@ -2537,11 +2537,15 @@ describe('REST server tests', () => { } `; const idDivider = ':'; + const dbName = 'restful-compound-id-custom-separator'; beforeAll(async () => { - const params = await loadSchema(schema); + const params = await loadSchema(schema, { + provider: 'postgresql', + dbUrl: await createPostgresDb(dbName), + }); - prisma = params.enhanceRaw(params.prisma, params); + prisma = params.prisma; zodSchemas = params.zodSchemas; modelMeta = params.modelMeta; @@ -2550,6 +2554,10 @@ describe('REST server tests', () => { _handler({ ...args, zodSchemas, modelMeta, url: new URL(`http://localhost/${args.path}`) }); }); + afterAll(async () => { + dropPostgresDb(dbName); + }); + it('POST', async () => { const r = await handler({ method: 'post', @@ -2590,7 +2598,7 @@ describe('REST server tests', () => { const r = await handler({ method: 'get', - path: '/user/user1@abc.om:COMMON_USER', + path: '/user/user1@abc.com:COMMON_USER', query: {}, prisma, }); @@ -2606,8 +2614,14 @@ describe('REST server tests', () => { const r = await handler({ method: 'put', - path: '/user/user1@abc.om:COMMON_USER', + path: '/user/user1@abc.com:COMMON_USER', query: {}, + requestBody: { + data: { + type: 'user', + attributes: { role: 'ADMIN_USER' }, + }, + }, prisma, }); From c002fa247f92b7ccf0c8a151e5f862c648e58d46 Mon Sep 17 00:00:00 2001 From: Thomas Sunde Nielsen Date: Fri, 18 Oct 2024 10:57:19 +0200 Subject: [PATCH 4/8] Expose urlSegmentNameCharSet option --- packages/server/src/api/rest/index.ts | 17 +++++++++++++---- packages/server/tests/api/rest.test.ts | 7 ++++++- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/packages/server/src/api/rest/index.ts b/packages/server/src/api/rest/index.ts index a9a39a42d..6ea0943b4 100644 --- a/packages/server/src/api/rest/index.ts +++ b/packages/server/src/api/rest/index.ts @@ -45,6 +45,12 @@ export type Options = { * Defaults to '_'. */ idDivider?: string; + + /** + * The charset used for URL segment values. Defaults to `a-zA-Z0-9-_~ %`. + * If the idDivider is set to a different value, it should be included in the charset. + */ + urlSegmentNameCharset?: string; }; type RelationshipInfo = { @@ -202,18 +208,21 @@ class RequestHandler extends APIHandlerBase { // all known types and their metadata private typeMap: Record; - public idDivider; + + // divider used to separate compound ID fields + private idDivider; private urlPatterns; constructor(private readonly options: Options) { super(); this.idDivider = options.idDivider ?? '_'; - this.urlPatterns = this.buildUrlPatterns(this.idDivider); + const segmentCharset = options.urlSegmentNameCharset ?? 'a-zA-Z0-9-_~ %'; + this.urlPatterns = this.buildUrlPatterns(this.idDivider, segmentCharset); } - buildUrlPatterns(idDivider: string) { - const options = { segmentValueCharset: `a-zA-Z0-9-_~ %${idDivider}` }; + buildUrlPatterns(idDivider: string, urlSegmentNameCharset: string) { + const options = { segmentValueCharset: urlSegmentNameCharset }; return { // collection operations collection: new UrlPattern('/:type', options), diff --git a/packages/server/tests/api/rest.test.ts b/packages/server/tests/api/rest.test.ts index 6c4496764..d37c5d9bd 100644 --- a/packages/server/tests/api/rest.test.ts +++ b/packages/server/tests/api/rest.test.ts @@ -2549,7 +2549,12 @@ describe('REST server tests', () => { zodSchemas = params.zodSchemas; modelMeta = params.modelMeta; - const _handler = makeHandler({ endpoint: 'http://localhost/api', pageSize: 5, idDivider }); + const _handler = makeHandler({ + endpoint: 'http://localhost/api', + pageSize: 5, + idDivider, + urlSegmentNameCharset: 'a-zA-Z0-9-_~ %@.', + }); handler = (args) => _handler({ ...args, zodSchemas, modelMeta, url: new URL(`http://localhost/${args.path}`) }); }); From 507431484a05c1a481354d5f1bff9df22e0a859a Mon Sep 17 00:00:00 2001 From: Thomas Sunde Nielsen Date: Fri, 18 Oct 2024 12:53:38 +0200 Subject: [PATCH 5/8] Fixes based on coderabbit review --- packages/server/src/api/rest/index.ts | 10 ++++++---- packages/server/tests/api/rest.test.ts | 6 ++++-- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/packages/server/src/api/rest/index.ts b/packages/server/src/api/rest/index.ts index 6ea0943b4..cde614968 100644 --- a/packages/server/src/api/rest/index.ts +++ b/packages/server/src/api/rest/index.ts @@ -92,6 +92,8 @@ const FilterOperations = [ type FilterOperationType = (typeof FilterOperations)[number] | undefined; +const prismaIdDivider = '_'; + registerCustomSerializers(); /** @@ -216,7 +218,7 @@ class RequestHandler extends APIHandlerBase { constructor(private readonly options: Options) { super(); - this.idDivider = options.idDivider ?? '_'; + this.idDivider = options.idDivider ?? prismaIdDivider; const segmentCharset = options.urlSegmentNameCharset ?? 'a-zA-Z0-9-_~ %'; this.urlPatterns = this.buildUrlPatterns(this.idDivider, segmentCharset); } @@ -1131,7 +1133,7 @@ class RequestHandler extends APIHandlerBase { if (ids.length === 0) { return undefined; } else { - return data[ids.map((id) => id.name).join(this.idDivider)]; + return data[this.makeIdKey(ids)]; } } @@ -1233,7 +1235,7 @@ class RequestHandler extends APIHandlerBase { } else { return { // TODO: support `@@id` with custom name - [idFields.map((idf) => idf.name).join('_')]: idFields.reduce( + [idFields.map((idf) => idf.name).join(prismaIdDivider)]: idFields.reduce( (acc, curr, idx) => ({ ...acc, [curr.name]: this.coerce(curr.type, resourceId.split(this.idDivider)[idx]), @@ -1257,7 +1259,7 @@ class RequestHandler extends APIHandlerBase { private makePrismaIdKey(idFields: FieldInfo[]) { // TODO: support `@@id` with custom name - return idFields.map((idf) => idf.name).join('_'); + return idFields.map((idf) => idf.name).join(prismaIdDivider); } private makeCompoundId(idFields: FieldInfo[], item: any) { diff --git a/packages/server/tests/api/rest.test.ts b/packages/server/tests/api/rest.test.ts index d37c5d9bd..fb9257610 100644 --- a/packages/server/tests/api/rest.test.ts +++ b/packages/server/tests/api/rest.test.ts @@ -2532,6 +2532,7 @@ describe('REST server tests', () => { model User { email String role Role + enabled Boolean @default(true) @@id([email, role]) } @@ -2553,7 +2554,7 @@ describe('REST server tests', () => { endpoint: 'http://localhost/api', pageSize: 5, idDivider, - urlSegmentNameCharset: 'a-zA-Z0-9-_~ %@.', + urlSegmentNameCharset: 'a-zA-Z0-9-_~ %@.:', }); handler = (args) => _handler({ ...args, zodSchemas, modelMeta, url: new URL(`http://localhost/${args.path}`) }); @@ -2624,13 +2625,14 @@ describe('REST server tests', () => { requestBody: { data: { type: 'user', - attributes: { role: 'ADMIN_USER' }, + attributes: { enabled: false }, }, }, prisma, }); expect(r.status).toBe(200); + expect(r.body.data.attributes.enabled).toBe(false); }); }); }); From 0154a61f26817f549e87f61b1bebe85defef9816 Mon Sep 17 00:00:00 2001 From: ymc9 <104139426+ymc9@users.noreply.github.com> Date: Fri, 18 Oct 2024 20:27:40 -0700 Subject: [PATCH 6/8] chore: rename option --- packages/server/src/api/rest/index.ts | 9 +++++---- packages/server/tests/api/rest.test.ts | 2 +- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/packages/server/src/api/rest/index.ts b/packages/server/src/api/rest/index.ts index cde614968..4bf2dcfe3 100644 --- a/packages/server/src/api/rest/index.ts +++ b/packages/server/src/api/rest/index.ts @@ -47,10 +47,11 @@ export type Options = { idDivider?: string; /** - * The charset used for URL segment values. Defaults to `a-zA-Z0-9-_~ %`. - * If the idDivider is set to a different value, it should be included in the charset. + * The charset used for URL segment values. Defaults to `a-zA-Z0-9-_~ %`. You can change it if your entity's ID values + * allow different characters. Specifically, if your models use compound IDs and the idDivider is set to a different value, + * it should be included in the charset. */ - urlSegmentNameCharset?: string; + urlSegmentCharset?: string; }; type RelationshipInfo = { @@ -219,7 +220,7 @@ class RequestHandler extends APIHandlerBase { constructor(private readonly options: Options) { super(); this.idDivider = options.idDivider ?? prismaIdDivider; - const segmentCharset = options.urlSegmentNameCharset ?? 'a-zA-Z0-9-_~ %'; + const segmentCharset = options.urlSegmentCharset ?? 'a-zA-Z0-9-_~ %'; this.urlPatterns = this.buildUrlPatterns(this.idDivider, segmentCharset); } diff --git a/packages/server/tests/api/rest.test.ts b/packages/server/tests/api/rest.test.ts index fb9257610..847052d23 100644 --- a/packages/server/tests/api/rest.test.ts +++ b/packages/server/tests/api/rest.test.ts @@ -2554,7 +2554,7 @@ describe('REST server tests', () => { endpoint: 'http://localhost/api', pageSize: 5, idDivider, - urlSegmentNameCharset: 'a-zA-Z0-9-_~ %@.:', + urlSegmentCharset: 'a-zA-Z0-9-_~ %@.:', }); handler = (args) => _handler({ ...args, zodSchemas, modelMeta, url: new URL(`http://localhost/${args.path}`) }); From 2f192be35d0174017bba38294bb13fba3fa95b20 Mon Sep 17 00:00:00 2001 From: ymc9 <104139426+ymc9@users.noreply.github.com> Date: Fri, 18 Oct 2024 20:34:30 -0700 Subject: [PATCH 7/8] fix: add missing await in test --- packages/server/tests/api/rest.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/server/tests/api/rest.test.ts b/packages/server/tests/api/rest.test.ts index 847052d23..8fb071adb 100644 --- a/packages/server/tests/api/rest.test.ts +++ b/packages/server/tests/api/rest.test.ts @@ -2561,7 +2561,7 @@ describe('REST server tests', () => { }); afterAll(async () => { - dropPostgresDb(dbName); + await dropPostgresDb(dbName); }); it('POST', async () => { From 4fa960f0dab207a710cf7f9f46ea2c02b80a12e6 Mon Sep 17 00:00:00 2001 From: ymc9 <104139426+ymc9@users.noreply.github.com> Date: Fri, 18 Oct 2024 20:51:45 -0700 Subject: [PATCH 8/8] fix test --- packages/server/tests/api/rest.test.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/server/tests/api/rest.test.ts b/packages/server/tests/api/rest.test.ts index 8fb071adb..640dcbebe 100644 --- a/packages/server/tests/api/rest.test.ts +++ b/packages/server/tests/api/rest.test.ts @@ -2561,6 +2561,9 @@ describe('REST server tests', () => { }); afterAll(async () => { + if (prisma) { + await prisma.$disconnect(); + } await dropPostgresDb(dbName); });