diff --git a/package.json b/package.json index 03060457c..f5e233bee 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "zenstack-monorepo", - "version": "1.0.0-alpha.121", + "version": "1.0.0-alpha.122", "description": "", "scripts": { "build": "pnpm -r build", diff --git a/packages/language/package.json b/packages/language/package.json index 433c222ab..6e118a593 100644 --- a/packages/language/package.json +++ b/packages/language/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/language", - "version": "1.0.0-alpha.121", + "version": "1.0.0-alpha.122", "displayName": "ZenStack modeling language compiler", "description": "ZenStack modeling language compiler", "homepage": "https://zenstack.dev", diff --git a/packages/next/package.json b/packages/next/package.json index 6baa34de1..c850c6b1c 100644 --- a/packages/next/package.json +++ b/packages/next/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/next", - "version": "1.0.0-alpha.121", + "version": "1.0.0-alpha.122", "displayName": "ZenStack Next.js integration", "description": "ZenStack Next.js integration", "homepage": "https://zenstack.dev", diff --git a/packages/plugins/openapi/package.json b/packages/plugins/openapi/package.json index ff6043903..4dc0960fd 100644 --- a/packages/plugins/openapi/package.json +++ b/packages/plugins/openapi/package.json @@ -1,7 +1,7 @@ { "name": "@zenstackhq/openapi", "displayName": "ZenStack Plugin and Runtime for OpenAPI", - "version": "1.0.0-alpha.121", + "version": "1.0.0-alpha.122", "description": "ZenStack plugin and runtime supporting OpenAPI", "main": "index.js", "repository": { diff --git a/packages/plugins/openapi/src/rest-generator.ts b/packages/plugins/openapi/src/rest-generator.ts index 8b56e3e22..5a15f5681 100644 --- a/packages/plugins/openapi/src/rest-generator.ts +++ b/packages/plugins/openapi/src/rest-generator.ts @@ -533,6 +533,7 @@ export class RESTfulOpenAPIGenerator extends OpenAPIGeneratorBase { _jsonapi: { type: 'object', description: 'An object describing the server’s implementation', + required: ['version'], properties: { version: { type: 'string' }, meta: this.ref('_meta'), @@ -548,32 +549,33 @@ export class RESTfulOpenAPIGenerator extends OpenAPIGeneratorBase { description: 'Identifier for a resource', required: ['type', 'id'], properties: { - type: { type: 'string' }, - id: { type: 'string' }, + type: { type: 'string', description: 'Resource type' }, + id: { type: 'string', description: 'Resource id' }, }, }, _resource: this.allOf(this.ref('_resourceIdentifier'), { type: 'object', description: 'A resource with attributes and relationships', properties: { - attributes: { type: 'object' }, - relationships: { type: 'object' }, + attributes: { type: 'object', description: 'Resource attributes' }, + relationships: { type: 'object', description: 'Resource relationships' }, }, }), _links: { type: 'object', required: ['self'], description: 'Links related to the resource', - properties: { self: { type: 'string' } }, + properties: { self: { type: 'string', description: 'Link for refetching the curent results' } }, }, _pagination: { type: 'object', description: 'Pagination information', + required: ['first', 'last', 'prev', 'next'], properties: { - first: this.nullable({ type: 'string' }), - last: this.nullable({ type: 'string' }), - prev: this.nullable({ type: 'string' }), - next: this.nullable({ type: 'string' }), + first: this.nullable({ type: 'string', description: 'Link to the first page' }), + last: this.nullable({ type: 'string', description: 'Link to the last page' }), + prev: this.nullable({ type: 'string', description: 'Link to the previous page' }), + next: this.nullable({ type: 'string', description: 'Link to the next page' }), }, }, _errors: { @@ -581,11 +583,16 @@ export class RESTfulOpenAPIGenerator extends OpenAPIGeneratorBase { description: 'An array of error objects', items: { type: 'object', + required: ['status', 'code'], properties: { - status: { type: 'string' }, - code: { type: 'string' }, - title: { type: 'string' }, - detail: { type: 'string' }, + status: { type: 'string', description: 'HTTP status' }, + code: { type: 'string', description: 'Error code' }, + prismaCode: { + type: 'string', + description: 'Prisma error code if the error is thrown by Prisma', + }, + title: { type: 'string', description: 'Error title' }, + detail: { type: 'string', description: 'Error detail' }, }, }, }, @@ -603,8 +610,11 @@ export class RESTfulOpenAPIGenerator extends OpenAPIGeneratorBase { required: ['self', 'related'], description: 'Links related to a relationship', properties: { - self: { type: 'string' }, - related: { type: 'string' }, + self: { type: 'string', description: 'Link for fetching this relationship' }, + related: { + type: 'string', + description: 'Link for fetching the resource represented by this relationship', + }, }, }, _toOneRelationship: { diff --git a/packages/plugins/openapi/tests/baseline/rest.baseline.yaml b/packages/plugins/openapi/tests/baseline/rest.baseline.yaml index 884a9fe50..1c5542024 100644 --- a/packages/plugins/openapi/tests/baseline/rest.baseline.yaml +++ b/packages/plugins/openapi/tests/baseline/rest.baseline.yaml @@ -1296,6 +1296,8 @@ components: _jsonapi: type: object description: An object describing the server’s implementation + required: + - version properties: version: type: string @@ -1314,8 +1316,10 @@ components: properties: type: type: string + description: Resource type id: type: string + description: Resource id _resource: allOf: - $ref: '#/components/schemas/_resourceIdentifier' @@ -1324,8 +1328,10 @@ components: properties: attributes: type: object + description: Resource attributes relationships: type: object + description: Resource relationships _links: type: object required: @@ -1334,40 +1340,60 @@ components: properties: self: type: string + description: Link for refetching the curent results _pagination: type: object description: Pagination information + required: + - first + - last + - prev + - next properties: first: oneOf: - type: string + description: Link to the first page - type: 'null' last: oneOf: - type: string + description: Link to the last page - type: 'null' prev: oneOf: - type: string + description: Link to the previous page - type: 'null' next: oneOf: - type: string + description: Link to the next page - type: 'null' _errors: type: array description: An array of error objects items: type: object + required: + - status + - code properties: status: type: string + description: HTTP status code: type: string + description: Error code + prismaCode: + type: string + description: Prisma error code if the error is thrown by Prisma title: type: string + description: Error title detail: type: string + description: Error detail _errorResponse: type: object required: @@ -1387,8 +1413,10 @@ components: properties: self: type: string + description: Link for fetching this relationship related: type: string + description: Link for fetching the resource represented by this relationship _toOneRelationship: type: object description: A to-one relationship diff --git a/packages/plugins/react/package.json b/packages/plugins/react/package.json index 57108414d..8a921beb5 100644 --- a/packages/plugins/react/package.json +++ b/packages/plugins/react/package.json @@ -1,7 +1,7 @@ { "name": "@zenstackhq/react", "displayName": "ZenStack plugin and runtime for ReactJS", - "version": "1.0.0-alpha.121", + "version": "1.0.0-alpha.122", "description": "ZenStack plugin and runtime for ReactJS", "main": "index.js", "repository": { diff --git a/packages/plugins/swr/package.json b/packages/plugins/swr/package.json index 03e9c32d7..abfcc5d3b 100644 --- a/packages/plugins/swr/package.json +++ b/packages/plugins/swr/package.json @@ -1,7 +1,7 @@ { "name": "@zenstackhq/swr", "displayName": "ZenStack plugin for generating SWR hooks", - "version": "1.0.0-alpha.121", + "version": "1.0.0-alpha.122", "description": "ZenStack plugin for generating SWR hooks", "main": "index.js", "repository": { diff --git a/packages/plugins/tanstack-query/package.json b/packages/plugins/tanstack-query/package.json index 48e70a4ef..fa9453689 100644 --- a/packages/plugins/tanstack-query/package.json +++ b/packages/plugins/tanstack-query/package.json @@ -1,7 +1,7 @@ { "name": "@zenstackhq/tanstack-query", "displayName": "ZenStack plugin for generating tanstack-query hooks", - "version": "1.0.0-alpha.121", + "version": "1.0.0-alpha.122", "description": "ZenStack plugin for generating tanstack-query hooks", "main": "index.js", "repository": { diff --git a/packages/plugins/trpc/package.json b/packages/plugins/trpc/package.json index 28e1976b5..ff2feec7c 100644 --- a/packages/plugins/trpc/package.json +++ b/packages/plugins/trpc/package.json @@ -1,7 +1,7 @@ { "name": "@zenstackhq/trpc", "displayName": "ZenStack plugin for tRPC", - "version": "1.0.0-alpha.121", + "version": "1.0.0-alpha.122", "description": "ZenStack plugin for tRPC", "main": "index.js", "repository": { diff --git a/packages/runtime/package.json b/packages/runtime/package.json index b8c8f60e7..511139a15 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -1,7 +1,7 @@ { "name": "@zenstackhq/runtime", "displayName": "ZenStack Runtime Library", - "version": "1.0.0-alpha.121", + "version": "1.0.0-alpha.122", "description": "Runtime of ZenStack for both client-side and server-side environments.", "repository": { "type": "git", diff --git a/packages/runtime/src/enhancements/policy/policy-utils.ts b/packages/runtime/src/enhancements/policy/policy-utils.ts index 2392f9cce..9f056f94f 100644 --- a/packages/runtime/src/enhancements/policy/policy-utils.ts +++ b/packages/runtime/src/enhancements/policy/policy-utils.ts @@ -864,7 +864,7 @@ export class PolicyUtil { * Gets "id" field for a given model. */ getIdFields(model: string) { - return getIdFields(this.modelMeta, model); + return getIdFields(this.modelMeta, model, true); } /** diff --git a/packages/runtime/src/enhancements/utils.ts b/packages/runtime/src/enhancements/utils.ts index fdf38f3ff..11d4a0801 100644 --- a/packages/runtime/src/enhancements/utils.ts +++ b/packages/runtime/src/enhancements/utils.ts @@ -24,13 +24,13 @@ export function getModelFields(data: object) { /** * Gets id fields for the given model. */ -export function getIdFields(modelMeta: ModelMeta, model: string) { +export function getIdFields(modelMeta: ModelMeta, model: string, throwIfNotFound = false) { const fields = modelMeta.fields[lowerCaseFirst(model)]; if (!fields) { throw new Error(`Unable to load fields for ${model}`); } const result = Object.values(fields).filter((f) => f.isId); - if (result.length === 0) { + if (result.length === 0 && throwIfNotFound) { throw new Error(`model ${model} does not have an id field`); } return result; diff --git a/packages/schema/package.json b/packages/schema/package.json index 43cc2e09c..5fadda400 100644 --- a/packages/schema/package.json +++ b/packages/schema/package.json @@ -3,7 +3,7 @@ "publisher": "zenstack", "displayName": "ZenStack Language Tools", "description": "A toolkit for building secure CRUD apps with Next.js + Typescript", - "version": "1.0.0-alpha.121", + "version": "1.0.0-alpha.122", "author": { "name": "ZenStack Team" }, diff --git a/packages/schema/src/plugins/prisma/schema-generator.ts b/packages/schema/src/plugins/prisma/schema-generator.ts index 6b276d31e..d2558bda0 100644 --- a/packages/schema/src/plugins/prisma/schema-generator.ts +++ b/packages/schema/src/plugins/prisma/schema-generator.ts @@ -22,6 +22,7 @@ import { } from '@zenstackhq/language/ast'; import { analyzePolicies, + getDataModels, getLiteral, getLiteralArray, GUARD_FIELD_NAME, @@ -219,9 +220,7 @@ export default class PrismaSchemaGenerator { this.generateModelField(model, field); } - const { allowAll, denyAll, hasFieldValidation } = analyzePolicies(decl); - - if ((!allowAll && !denyAll) || hasFieldValidation) { + if (this.shouldGenerateAuxFields(decl)) { // generate auxiliary fields for policy check // add an "zenstack_guard" field for dealing with boolean conditions @@ -274,6 +273,40 @@ export default class PrismaSchemaGenerator { decl.comments.forEach((c) => model.addComment(c)); } + private shouldGenerateAuxFields(decl: DataModel) { + const { allowAll, denyAll, hasFieldValidation } = analyzePolicies(decl); + + if (!allowAll && !denyAll) { + // has policy conditions + return true; + } + + if (hasFieldValidation) { + return true; + } + + // check if the model is related by other models, if so + // aux fields are needed for nested queries + const root = decl.$container; + for (const model of getDataModels(root)) { + if (model === decl) { + continue; + } + for (const field of model.fields) { + if (field.type.reference?.ref === decl) { + // found a relation with policies + const otherPolicies = analyzePolicies(model); + if ((!otherPolicies.allowAll && !otherPolicies.denyAll) || otherPolicies.hasFieldValidation) { + // the relating side has policies + return true; + } + } + } + } + + return false; + } + private isPrismaAttribute(attr: DataModelAttribute | DataModelFieldAttribute) { if (!attr.decl.ref) { return false; diff --git a/packages/schema/tests/generator/prisma-generator.test.ts b/packages/schema/tests/generator/prisma-generator.test.ts index 12a8b59da..466003fa1 100644 --- a/packages/schema/tests/generator/prisma-generator.test.ts +++ b/packages/schema/tests/generator/prisma-generator.test.ts @@ -372,4 +372,124 @@ describe('Prisma generator test', () => { expect(content).toBe(expected); }); + + it('no aux fields without policy', async () => { + const model = await loadModel(` + datasource db { + provider = 'postgresql' + url = env('URL') + } + + model Post { + id Int @id() + title String + } + `); + + const { name } = tmp.fileSync({ postfix: '.prisma' }); + await new PrismaSchemaGenerator().generate(model, { + name: 'Prisma', + provider: '@core/prisma', + schemaPath: 'schema.zmodel', + output: name, + format: true, + }); + + const content = fs.readFileSync(name, 'utf-8'); + expect(content).not.toContain('zenstack_guard'); + expect(content).not.toContain('zenstack_transaction'); + }); + + it('aux fields generated due to policies', async () => { + const model = await loadModel(` + datasource db { + provider = 'postgresql' + url = env('URL') + } + + model Post { + id Int @id() + title String @length(1, 32) + @@allow('read', title == "foo") + } + `); + + const { name } = tmp.fileSync({ postfix: '.prisma' }); + await new PrismaSchemaGenerator().generate(model, { + name: 'Prisma', + provider: '@core/prisma', + schemaPath: 'schema.zmodel', + output: name, + format: true, + }); + + const content = fs.readFileSync(name, 'utf-8'); + expect(content).toContain('zenstack_guard'); + expect(content).toContain('zenstack_transaction'); + }); + + it('aux fields generated due to field validation', async () => { + const model = await loadModel(` + datasource db { + provider = 'postgresql' + url = env('URL') + } + + model Post { + id Int @id() + title String @length(1, 32) + } + `); + + const { name } = tmp.fileSync({ postfix: '.prisma' }); + await new PrismaSchemaGenerator().generate(model, { + name: 'Prisma', + provider: '@core/prisma', + schemaPath: 'schema.zmodel', + output: name, + format: true, + }); + + const content = fs.readFileSync(name, 'utf-8'); + expect(content).toContain('zenstack_guard'); + expect(content).toContain('zenstack_transaction'); + }); + + it('aux fields generated due to relationship', async () => { + const model = await loadModel(` + datasource db { + provider = 'postgresql' + url = env('URL') + } + + model User { + id Int @id() + age Int + posts Post[] + @@allow('all', age > 18) + } + + model Post { + id Int @id() + title String + author User @relation(fields: [authorId], references: [id]) + authorId Int + } + `); + + const { name } = tmp.fileSync({ postfix: '.prisma' }); + await new PrismaSchemaGenerator().generate(model, { + name: 'Prisma', + provider: '@core/prisma', + schemaPath: 'schema.zmodel', + output: name, + format: true, + }); + + const content = fs.readFileSync(name, 'utf-8'); + const dmmf = await getDMMF({ datamodel: content }); + const post = dmmf.datamodel?.models?.find((m) => m.name === 'Post'); + expect(post?.fields.map((f) => f.name)).toContain('zenstack_guard'); + expect(post?.fields.map((f) => f.name)).toContain('zenstack_transaction'); + }); }); diff --git a/packages/sdk/package.json b/packages/sdk/package.json index ce0c7d421..5407cda13 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/sdk", - "version": "1.0.0-alpha.121", + "version": "1.0.0-alpha.122", "description": "ZenStack plugin development SDK", "main": "index.js", "scripts": { diff --git a/packages/server/package.json b/packages/server/package.json index 53a89579a..baa448f10 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/server", - "version": "1.0.0-alpha.121", + "version": "1.0.0-alpha.122", "displayName": "ZenStack Server-side Adapters", "description": "ZenStack server-side adapters", "homepage": "https://zenstack.dev", diff --git a/packages/server/src/api/rest/index.ts b/packages/server/src/api/rest/index.ts index c91d91df4..d4417ae27 100644 --- a/packages/server/src/api/rest/index.ts +++ b/packages/server/src/api/rest/index.ts @@ -1529,7 +1529,15 @@ class RequestHandler { return { status: 400, body: { - errors: [{ status: 400, code: 'prisma-error', title: 'Prisma error', detail: err.message }], + errors: [ + { + status: 400, + code: 'prisma-error', + prismaCode: err.code, + title: 'Prisma error', + detail: err.message, + }, + ], }, }; } diff --git a/packages/server/tests/api/rest.test.ts b/packages/server/tests/api/rest.test.ts index 583dcd2a6..7e7ddd566 100644 --- a/packages/server/tests/api/rest.test.ts +++ b/packages/server/tests/api/rest.test.ts @@ -12,52 +12,52 @@ let zodSchemas: any; let modelMeta: ModelMeta; let handler: (any: any) => Promise; -export const schema = ` -model User { - myId String @id @default(cuid()) - createdAt DateTime @default (now()) - updatedAt DateTime @updatedAt - email String @unique - posts Post[] - profile Profile? -} - -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 - author User? @relation(fields: [authorId], references: [myId]) - authorId String? - published Boolean @default(false) - viewCount Int @default(0) - comments Comment[] - 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 -} -`; - describe('REST server tests - regular prisma', () => { + const schema = ` + model User { + myId String @id @default(cuid()) + createdAt DateTime @default (now()) + updatedAt DateTime @updatedAt + email String @unique + posts Post[] + profile Profile? + } + + 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 + author User? @relation(fields: [authorId], references: [myId]) + authorId String? + published Boolean @default(false) + viewCount Int @default(0) + comments Comment[] + 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 + } + `; + beforeAll(async () => { const params = await loadSchema(schema); @@ -1835,19 +1835,19 @@ describe('REST server tests - regular prisma', () => { }); }); -export const schemaWithPolicy = ` -model Foo { - id Int @id - value Int - - @@allow('create,read', true) - @@allow('update', value > 0) -} -`; - describe('REST server tests - enhanced prisma', () => { + const schema = ` + model Foo { + id Int @id + value Int + + @@allow('create,read', true) + @@allow('update', value > 0) + } + `; + beforeAll(async () => { - const params = await loadSchema(schemaWithPolicy); + const params = await loadSchema(schema); prisma = withPolicy(params.prisma, undefined, params.policy, params.modelMeta); zodSchemas = params.zodSchemas; @@ -1886,3 +1886,121 @@ describe('REST server tests - enhanced prisma', () => { expect(r.status).toBe(403); }); }); + +describe('REST server tests - NextAuth project regression', () => { + const schema = ` + model Post { + id String @id @default(cuid()) + title String + content String + + // full access for all + @@allow('all', true) + } + + model Account { + id String @id @default(cuid()) + userId String + type String + provider String + providerAccountId String + refresh_token String? + access_token String? + expires_at Int? + token_type String? + scope String? + id_token String? + session_state String? + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@unique([provider, providerAccountId]) + } + + model Session { + id String @id @default(cuid()) + sessionToken String @unique + userId String + expires DateTime + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + } + + model User { + id String @id @default(cuid()) + name String? + email String @email @unique + password String @password @omit + emailVerified DateTime? + image String? + accounts Account[] + sessions Session[] + + @@allow('create,read', true) + @@allow('delete,update', auth() != null) + } + + model VerificationToken { + identifier String + token String @unique + expires DateTime + + @@unique([identifier, token]) + } + `; + + beforeAll(async () => { + const params = await loadSchema(schema); + + 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('crud test', async () => { + let r = await handler({ + method: 'get', + path: '/user', + prisma, + }); + expect(r.status).toBe(200); + expect((r.body as any).data).toHaveLength(0); + + r = await handler({ + method: 'post', + path: '/user', + query: {}, + requestBody: { + data: { type: 'user', attributes: { email: 'user1@abc.com', password: '1234' } }, + }, + prisma, + }); + expect(r.status).toBe(201); + + r = await handler({ + method: 'get', + path: '/user', + prisma, + }); + expect(r.status).toBe(200); + expect((r.body as any).data).toHaveLength(1); + + r = await handler({ + method: 'post', + path: '/user', + query: {}, + requestBody: { + data: { type: 'user', attributes: { email: 'user1@abc.com', password: '1234' } }, + }, + prisma, + }); + expect(r.status).toBe(400); + expect((r.body as any).errors[0].prismaCode).toBe('P2002'); + }); +}); diff --git a/packages/testtools/package.json b/packages/testtools/package.json index 0f9167d5f..04d2ea308 100644 --- a/packages/testtools/package.json +++ b/packages/testtools/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/testtools", - "version": "1.0.0-alpha.121", + "version": "1.0.0-alpha.122", "description": "ZenStack Test Tools", "main": "index.js", "publishConfig": { diff --git a/tests/integration/test-run/package-lock.json b/tests/integration/test-run/package-lock.json index 9d400afe0..94130fed8 100644 --- a/tests/integration/test-run/package-lock.json +++ b/tests/integration/test-run/package-lock.json @@ -126,7 +126,7 @@ }, "../../../packages/runtime/dist": { "name": "@zenstackhq/runtime", - "version": "1.0.0-alpha.121", + "version": "1.0.0-alpha.122", "license": "MIT", "dependencies": { "@paralleldrive/cuid2": "^2.2.0", @@ -160,7 +160,7 @@ }, "../../../packages/schema/dist": { "name": "zenstack", - "version": "1.0.0-alpha.121", + "version": "1.0.0-alpha.122", "hasInstallScript": true, "license": "MIT", "dependencies": {