Skip to content

Commit

Permalink
fix: incremental bugfixes (zenstackhq#211)
Browse files Browse the repository at this point in the history
  • Loading branch information
ymc9 authored Feb 19, 2023
1 parent 0597a72 commit 8e73b0b
Show file tree
Hide file tree
Showing 18 changed files with 945 additions and 32 deletions.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "zenstack-monorepo",
"version": "1.0.0-alpha.33",
"version": "1.0.0-alpha.41",
"description": "",
"scripts": {
"build": "pnpm -r build",
Expand Down
6 changes: 3 additions & 3 deletions packages/language/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@zenstackhq/language",
"version": "1.0.0-alpha.33",
"version": "1.0.0-alpha.41",
"displayName": "ZenStack modeling language compiler",
"description": "ZenStack modeling language compiler",
"homepage": "https://zenstack.dev",
Expand All @@ -22,11 +22,11 @@
"devDependencies": {
"concurrently": "^7.4.0",
"copyfiles": "^2.4.1",
"langium-cli": "^1.0.0",
"langium-cli": "1.0.0",
"rimraf": "^3.0.2",
"typescript": "^4.9.4"
},
"dependencies": {
"langium": "^1.0.1"
"langium": "1.0.1"
}
}
2 changes: 1 addition & 1 deletion packages/next/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@zenstackhq/next",
"version": "1.0.0-alpha.33",
"version": "1.0.0-alpha.41",
"displayName": "ZenStack Next.js integration",
"description": "ZenStack Next.js integration",
"homepage": "https://zenstack.dev",
Expand Down
28 changes: 28 additions & 0 deletions packages/next/src/request-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,18 @@ import {
import { NextApiRequest, NextApiResponse } from 'next';
import superjson from 'superjson';

type LoggerMethod = (code: string | undefined, message: string) => void;

/**
* Logger config.
*/
export type LoggerConfig = {
debug?: LoggerMethod;
info?: LoggerMethod;
warn?: LoggerMethod;
error?: LoggerMethod;
};

/**
* Options for initializing a Next.js API endpoint request handler.
* @see requestHandler
Expand All @@ -18,6 +30,11 @@ export type RequestHandlerOptions = {
*/
getPrisma: (req: NextApiRequest, res: NextApiResponse) => Promise<unknown> | unknown;

/**
* Logger configuration. By default log to console. Set to null to turn off logging.
*/
logger?: LoggerConfig | null;

/**
* Whether to use superjson for serialization/deserialization. Defaults to true.
*/
Expand Down Expand Up @@ -48,6 +65,14 @@ export function requestHandler(
};
}

function logError(options: RequestHandlerOptions, code: string | undefined, message: string) {
if (options.logger === undefined) {
console.error(`zenstack-next error: ${code ? '[' + code + ']' : ''} ${message}`);
} else if (options.logger?.error) {
options.logger.error(code, message);
}
}

async function handleRequest(
req: NextApiRequest,
res: NextApiResponse,
Expand Down Expand Up @@ -113,6 +138,7 @@ async function handleRequest(
res.status(resCode).send(marshal(result, options.useSuperJson));
} catch (err) {
if (isPrismaClientKnownRequestError(err)) {
logError(options, err.code, err.message);
if (err.code === 'P2004') {
// rejected by policy
res.status(403).send({
Expand All @@ -129,11 +155,13 @@ async function handleRequest(
});
}
} else if (isPrismaClientUnknownRequestError(err) || isPrismaClientValidationError(err)) {
logError(options, undefined, err.message);
res.status(400).send({
prisma: true,
message: err.message,
});
} else {
logError(options, undefined, (err as Error).message);
res.status(500).send({
message: (err as Error).message,
});
Expand Down
4 changes: 2 additions & 2 deletions packages/plugins/react/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@zenstackhq/react",
"displayName": "ZenStack plugin and runtime for ReactJS",
"version": "1.0.0-alpha.33",
"version": "1.0.0-alpha.41",
"description": "ZenStack plugin and runtime for ReactJS",
"main": "index.js",
"repository": {
Expand Down Expand Up @@ -29,7 +29,7 @@
"change-case": "^4.1.2",
"decimal.js": "^10.4.2",
"superjson": "^1.11.0",
"swr": "^1.3.0",
"swr": "^2.0.3",
"ts-morph": "^16.0.0"
},
"devDependencies": {
Expand Down
2 changes: 1 addition & 1 deletion packages/plugins/react/src/react-hooks-generator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -291,7 +291,7 @@ function generateModelHooks(
.addBody()
.addStatements([
wrapReadbackErrorCheck(
`return await request.put<${inputType}, ${returnType}>(\`\${endpoint}/${modelRouteName}/upsert\`, args, mutate);`
`return await request.post<${inputType}, ${returnType}>(\`\${endpoint}/${modelRouteName}/upsert\`, args, mutate);`
),
]);
}
Expand Down
2 changes: 1 addition & 1 deletion packages/plugins/react/src/runtime.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { createContext } from 'react';
import superjson from 'superjson';
import useSWR, { useSWRConfig } from 'swr';
import type { MutatorCallback, MutatorOptions, SWRResponse } from 'swr/dist/types';
import type { MutatorCallback, MutatorOptions, SWRResponse } from 'swr';
import { registerSerializers } from './serialization-utils';

/**
Expand Down
2 changes: 1 addition & 1 deletion packages/plugins/trpc/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@zenstackhq/trpc",
"displayName": "ZenStack plugin for tRPC",
"version": "1.0.0-alpha.33",
"version": "1.0.0-alpha.41",
"description": "ZenStack plugin for tRPC",
"main": "index.js",
"repository": {
Expand Down
2 changes: 1 addition & 1 deletion packages/runtime/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@zenstackhq/runtime",
"displayName": "ZenStack Runtime Library",
"version": "1.0.0-alpha.33",
"version": "1.0.0-alpha.41",
"description": "Runtime of ZenStack for both client-side and server-side environments.",
"repository": {
"type": "git",
Expand Down
6 changes: 3 additions & 3 deletions packages/runtime/src/enhancements/policy/handler.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
/* eslint-disable @typescript-eslint/no-explicit-any */

import { PrismaClientValidationError } from '@prisma/client/runtime';
import { format } from 'util';
import { AuthUser, DbClientContract, PolicyOperationKind } from '../../types';
import { BatchResult, PrismaProxyHandler } from '../proxy';
import { ModelMeta, PolicyDef } from '../types';
import { formatObject } from '../utils';
import { Logger } from './logger';
import { PolicyUtil } from './policy-utils';

Expand Down Expand Up @@ -220,7 +220,7 @@ export class PolicyProxyHandler<DbClient extends DbClientContract> implements Pr
}

// conduct the deletion
this.logger.info(`Conducting delete ${this.model}:\n${format(args)}`);
this.logger.info(`Conducting delete ${this.model}:\n${formatObject(args)}`);
await this.modelClient.delete(args);

if (!readResult) {
Expand All @@ -238,7 +238,7 @@ export class PolicyProxyHandler<DbClient extends DbClientContract> implements Pr
await this.utils.injectAuthGuard(args, this.model, 'delete');

// conduct the deletion
this.logger.info(`Conducting deleteMany ${this.model}:\n${format(args)}`);
this.logger.info(`Conducting deleteMany ${this.model}:\n${formatObject(args)}`);
return this.modelClient.deleteMany(args);
}

Expand Down
113 changes: 105 additions & 8 deletions packages/runtime/src/enhancements/policy/policy-utils.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
/* eslint-disable @typescript-eslint/no-explicit-any */

import { PrismaClientKnownRequestError, PrismaClientUnknownRequestError } from '@prisma/client/runtime';
import { TRANSACTION_FIELD_NAME, AUXILIARY_FIELDS } from '@zenstackhq/sdk';
import { AUXILIARY_FIELDS, TRANSACTION_FIELD_NAME } from '@zenstackhq/sdk';
import { camelCase } from 'change-case';
import cuid from 'cuid';
import deepcopy from 'deepcopy';
import { format } from 'util';
import { fromZodError } from 'zod-validation-error';
import {
AuthUser,
Expand All @@ -19,7 +18,7 @@ import { getVersion } from '../../version';
import { resolveField } from '../model-meta';
import { NestedWriteVisitor, VisitorContext } from '../nested-write-vistor';
import { ModelMeta, PolicyDef, PolicyFunc } from '../types';
import { enumerate, getModelFields } from '../utils';
import { enumerate, formatObject, getModelFields } from '../utils';
import { Logger } from './logger';

/**
Expand All @@ -43,6 +42,7 @@ export class PolicyUtil {
and(...conditions: (boolean | object)[]): any {
if (conditions.includes(false)) {
// always false
// TODO: custom id field
return { id: { in: [] } };
}

Expand Down Expand Up @@ -77,6 +77,17 @@ export class PolicyUtil {
}
}

/**
* Creates a negation of a query condition.
*/
not(condition: object | boolean): any {
if (typeof condition === 'boolean') {
return !condition;
} else {
return { NOT: condition };
}
}

/**
* Gets pregenerated authorization guard object for a given model and operation.
*
Expand Down Expand Up @@ -116,10 +127,96 @@ export class PolicyUtil {
* Injects model auth guard as where clause.
*/
async injectAuthGuard(args: any, model: string, operation: PolicyOperationKind) {
if (args.where) {
// inject into relation fields:
// to-many: some/none/every
// to-one: direct-conditions/is/isNot
await this.injectGuardForFields(model, args.where, operation);
}

const guard = await this.getAuthGuard(model, operation);
args.where = this.and(args.where, guard);
}

async injectGuardForFields(model: string, payload: any, operation: PolicyOperationKind) {
for (const [field, subPayload] of Object.entries<any>(payload)) {
if (!subPayload) {
continue;
}

const fieldInfo = await resolveField(this.modelMeta, model, field);
if (!fieldInfo || !fieldInfo.isDataModel) {
continue;
}

if (fieldInfo.isArray) {
await this.injectGuardForToManyField(fieldInfo, subPayload, operation);
} else {
await this.injectGuardForToOneField(fieldInfo, subPayload, operation);
}
}
}

async injectGuardForToManyField(
fieldInfo: FieldInfo,
payload: { some?: any; every?: any; none?: any },
operation: PolicyOperationKind
) {
const guard = await this.getAuthGuard(fieldInfo.type, operation);
if (payload.some) {
await this.injectGuardForFields(fieldInfo.type, payload.some, operation);
// turn "some" into: { some: { AND: [guard, payload.some] } }
payload.some = this.and(payload.some, guard);
}
if (payload.none) {
await this.injectGuardForFields(fieldInfo.type, payload.none, operation);
// turn none into: { none: { AND: [guard, payload.none] } }
payload.none = this.and(payload.none, guard);
}
if (
payload.every &&
typeof payload.every === 'object' &&
// ignore empty every clause
Object.keys(payload.every).length > 0
) {
await this.injectGuardForFields(fieldInfo.type, payload.every, operation);

// turn "every" into: { none: { AND: [guard, { NOT: payload.every }] } }
if (!payload.none) {
payload.none = {};
}
payload.none = this.and(payload.none, guard, this.not(payload.every));
delete payload.every;
}
}

async injectGuardForToOneField(
fieldInfo: FieldInfo,
payload: { is?: any; isNot?: any } & Record<string, any>,
operation: PolicyOperationKind
) {
const guard = await this.getAuthGuard(fieldInfo.type, operation);
if (payload.is || payload.isNot) {
if (payload.is) {
await this.injectGuardForFields(fieldInfo.type, payload.is, operation);
// turn "is" into: { is: { AND: [ originalIs, guard ] }
payload.is = this.and(payload.is, guard);
}
if (payload.isNot) {
await this.injectGuardForFields(fieldInfo.type, payload.isNot, operation);
// turn "isNot" into: { isNot: { AND: [ originalIsNot, { NOT: guard } ] } }
payload.isNot = this.and(payload.isNot, this.not(guard));
delete payload.isNot;
}
} else {
await this.injectGuardForFields(fieldInfo.type, payload, operation);
// turn direct conditions into: { is: { AND: [ originalConditions, guard ] } }
const combined = this.and(deepcopy(payload), guard);
Object.keys(payload).forEach((key) => delete payload[key]);
payload.is = combined;
}
}

/**
* Read model entities w.r.t the given query args. The result list
* are guaranteed to fully satisfy 'read' policy rules recursively.
Expand All @@ -143,7 +240,7 @@ export class PolicyUtil {
// recursively inject read guard conditions into the query args
await this.injectNestedReadConditions(model, args);

this.logger.info(`Reading with validation for ${model}: ${format(args)}`);
this.logger.info(`Reading with validation for ${model}: ${formatObject(args)}`);
const result: any[] = await this.db[model].findMany(args);

await Promise.all(result.map((item) => this.postProcessForRead(item, model, args, 'read')));
Expand Down Expand Up @@ -192,8 +289,8 @@ export class PolicyUtil {
injectTarget[field] = {};
}
// inject extra condition for to-many relation
const guard = await this.getAuthGuard(fieldInfo.type, 'read');
injectTarget[field].where = this.and(injectTarget.where, guard);

await this.injectAuthGuard(injectTarget[field], fieldInfo.type, 'read');
} else {
// there's no way of injecting condition for to-one relation, so we
// make sure 'id' field is selected and check them against query result
Expand Down Expand Up @@ -414,7 +511,7 @@ export class PolicyUtil {

const idField = this.getIdField(model);
const query = { where: filter, select: { ...preValueSelect, [idField.name]: true } };
this.logger.info(`fetching pre-update entities for ${model}: ${format(query)})}`);
this.logger.info(`fetching pre-update entities for ${model}: ${formatObject(query)})}`);
const entities = await this.db[model].findMany(query);
entities.forEach((entity) => modelEntities?.set(this.getEntityId(model, entity), entity));
}
Expand Down Expand Up @@ -620,7 +717,7 @@ export class PolicyUtil {
}

private async checkPostUpdate(model: string, id: any, db: Record<string, DbOperations>, preValue: any) {
this.logger.info(`Checking post-update policy for ${model}#${id}, preValue: ${format(preValue)}`);
this.logger.info(`Checking post-update policy for ${model}#${id}, preValue: ${formatObject(preValue)}`);

const guard = await this.getAuthGuard(model, 'postUpdate', preValue);

Expand Down
5 changes: 5 additions & 0 deletions packages/runtime/src/enhancements/utils.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { AUXILIARY_FIELDS } from '@zenstackhq/sdk';
import * as util from 'util';

/**
* Wraps a value into array if it's not already one
Expand Down Expand Up @@ -29,3 +30,7 @@ export function enumerate<T>(x: Enumerable<T>) {
return [x];
}
}

export function formatObject(value: unknown) {
return util.formatWithOptions({ depth: 10 }, value);
}
Loading

0 comments on commit 8e73b0b

Please sign in to comment.