mirror of
https://github.com/zenstackhq/zenstack
synced 2026-05-24 10:08:55 +00:00
feat: RESTful style server API handler (#405)
Co-authored-by: Jawad <jawad.stouli@gmail.com>
This commit is contained in:
parent
86c57fd35b
commit
f07ccdded0
20 changed files with 5861 additions and 2275 deletions
|
|
@ -168,7 +168,8 @@ async function handleRequest(
|
|||
message: err.message,
|
||||
});
|
||||
} else {
|
||||
logError(options, undefined, (err as Error).message);
|
||||
const _err = err as Error;
|
||||
logError(options, undefined, _err.message + (_err.stack ? '\n' + _err.stack : ''));
|
||||
res.status(500).send({
|
||||
message: (err as Error).message,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2,3 +2,4 @@ export * from './omit';
|
|||
export * from './password';
|
||||
export * from './policy';
|
||||
export * from './preset';
|
||||
export * from './utils';
|
||||
|
|
|
|||
|
|
@ -18,7 +18,13 @@ 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, prismaClientKnownRequestError, prismaClientUnknownRequestError } from '../utils';
|
||||
import {
|
||||
enumerate,
|
||||
getIdFields,
|
||||
getModelFields,
|
||||
prismaClientKnownRequestError,
|
||||
prismaClientUnknownRequestError,
|
||||
} from '../utils';
|
||||
import { Logger } from './logger';
|
||||
|
||||
/**
|
||||
|
|
@ -858,15 +864,7 @@ export class PolicyUtil {
|
|||
* Gets "id" field for a given model.
|
||||
*/
|
||||
getIdFields(model: string) {
|
||||
const fields = this.modelMeta.fields[lowerCaseFirst(model)];
|
||||
if (!fields) {
|
||||
throw this.unknownError(`Unable to load fields for ${model}`);
|
||||
}
|
||||
const result = Object.values(fields).filter((f) => f.isId);
|
||||
if (result.length === 0) {
|
||||
throw this.unknownError(`model ${model} does not have an id field`);
|
||||
}
|
||||
return result;
|
||||
return getIdFields(this.modelMeta, model);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
/* eslint-disable @typescript-eslint/no-var-requires */
|
||||
|
||||
import { AUXILIARY_FIELDS } from '@zenstackhq/sdk';
|
||||
import { lowerCaseFirst } from 'lower-case-first';
|
||||
import path from 'path';
|
||||
import * as util from 'util';
|
||||
import { DbClientContract } from '../types';
|
||||
import { ModelMeta } from './types';
|
||||
|
||||
/**
|
||||
* Wraps a value into array if it's not already one
|
||||
|
|
@ -19,6 +21,21 @@ export function getModelFields(data: object) {
|
|||
return data ? Object.keys(data).filter((f) => !AUXILIARY_FIELDS.includes(f)) : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets id fields for the given model.
|
||||
*/
|
||||
export function getIdFields(modelMeta: ModelMeta, model: string) {
|
||||
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) {
|
||||
throw new Error(`model ${model} does not have an id field`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Array or scalar
|
||||
*/
|
||||
|
|
@ -35,6 +52,9 @@ export function enumerate<T>(x: Enumerable<T>) {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats an object for pretty printing.
|
||||
*/
|
||||
export function formatObject(value: unknown) {
|
||||
return util.formatWithOptions({ depth: 10 }, value);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,14 +27,19 @@
|
|||
"@zenstackhq/runtime": "workspace:*",
|
||||
"@zenstackhq/sdk": "workspace:*",
|
||||
"change-case": "^4.1.2",
|
||||
"lower-case-first": "^2.0.2",
|
||||
"tiny-invariant": "^1.3.1",
|
||||
"ts-japi": "^1.8.0",
|
||||
"upper-case-first": "^2.0.2",
|
||||
"url-pattern": "^1.0.3",
|
||||
"zod": "3.21.1",
|
||||
"zod-validation-error": "^0.2.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/body-parser": "^1.19.2",
|
||||
"@types/express": "^4.17.17",
|
||||
"@types/jest": "^29.5.0",
|
||||
"@types/lower-case-first": "^1.0.1",
|
||||
"@types/supertest": "^2.0.12",
|
||||
"@types/upper-case-first": "^1.1.2",
|
||||
"@zenstackhq/testtools": "workspace:*",
|
||||
|
|
|
|||
1506
packages/server/src/api/rest/index.ts
Normal file
1506
packages/server/src/api/rest/index.ts
Normal file
File diff suppressed because it is too large
Load diff
186
packages/server/src/api/rpc/index.ts
Normal file
186
packages/server/src/api/rpc/index.ts
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
import {
|
||||
DbOperations,
|
||||
isPrismaClientKnownRequestError,
|
||||
isPrismaClientUnknownRequestError,
|
||||
isPrismaClientValidationError,
|
||||
} from '@zenstackhq/runtime';
|
||||
import { ModelZodSchema } from '@zenstackhq/runtime/zod';
|
||||
import { LoggerConfig, RequestContext, Response } from '../types';
|
||||
import { logError, stripAuxFields, zodValidate } from '../utils';
|
||||
|
||||
/**
|
||||
* Request handler options
|
||||
*/
|
||||
export type Options = {
|
||||
/**
|
||||
* Logging configuration. Set to `null` to disable logging.
|
||||
* If unset or set to `undefined`, log will be output to console.
|
||||
*/
|
||||
logger?: LoggerConfig | null;
|
||||
|
||||
/**
|
||||
* Zod schemas for validating create and update payloads. By default
|
||||
* loaded from the standard output location of the `@zenstackhq/zod`
|
||||
* plugin. You can pass it in explicitly if you configured the plugin
|
||||
* to output to a different location.
|
||||
*/
|
||||
zodSchemas?: ModelZodSchema;
|
||||
};
|
||||
|
||||
/**
|
||||
* Prisma RPC style API request handler that mirrors the Prisma Client API
|
||||
*/
|
||||
class RequestHandler {
|
||||
constructor(private readonly options: Options = {}) {}
|
||||
|
||||
async handleRequest({ prisma, method, path, query, requestBody }: RequestContext): Promise<Response> {
|
||||
const parts = path.split('/').filter((p) => !!p);
|
||||
const op = parts.pop();
|
||||
const model = parts.pop();
|
||||
|
||||
if (parts.length !== 0 || !op || !model) {
|
||||
return { status: 400, body: { message: 'invalid request path' } };
|
||||
}
|
||||
|
||||
method = method.toUpperCase();
|
||||
const dbOp = op as keyof DbOperations;
|
||||
let args: unknown;
|
||||
let resCode = 200;
|
||||
|
||||
switch (dbOp) {
|
||||
case 'create':
|
||||
case 'createMany':
|
||||
case 'upsert':
|
||||
if (method !== 'POST') {
|
||||
return { status: 400, body: { message: 'invalid request method, only POST is supported' } };
|
||||
}
|
||||
if (!requestBody) {
|
||||
return { status: 400, body: { message: 'missing request body' } };
|
||||
}
|
||||
|
||||
args = requestBody;
|
||||
|
||||
// TODO: upsert's status code should be conditional
|
||||
resCode = 201;
|
||||
break;
|
||||
|
||||
case 'findFirst':
|
||||
case 'findUnique':
|
||||
case 'findMany':
|
||||
case 'aggregate':
|
||||
case 'groupBy':
|
||||
case 'count':
|
||||
if (method !== 'GET') {
|
||||
return { status: 400, body: { message: 'invalid request method, only GET is supported' } };
|
||||
}
|
||||
try {
|
||||
args = query?.q ? this.unmarshal(query.q as string) : {};
|
||||
} catch {
|
||||
return { status: 400, body: { message: 'query param must contain valid JSON' } };
|
||||
}
|
||||
break;
|
||||
|
||||
case 'update':
|
||||
case 'updateMany':
|
||||
if (method !== 'PUT' && method !== 'PATCH') {
|
||||
return {
|
||||
status: 400,
|
||||
body: { message: 'invalid request method, only PUT AND PATCH are supported' },
|
||||
};
|
||||
}
|
||||
if (!requestBody) {
|
||||
return { status: 400, body: { message: 'missing request body' } };
|
||||
}
|
||||
|
||||
args = requestBody;
|
||||
break;
|
||||
|
||||
case 'delete':
|
||||
case 'deleteMany':
|
||||
if (method !== 'DELETE') {
|
||||
return { status: 400, body: { message: 'invalid request method, only DELETE is supported' } };
|
||||
}
|
||||
try {
|
||||
args = query?.q ? this.unmarshal(query.q as string) : {};
|
||||
} catch {
|
||||
return { status: 400, body: { message: 'query param must contain valid JSON' } };
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
return { status: 400, body: { message: 'invalid operation: ' + op } };
|
||||
}
|
||||
|
||||
if (this.options.zodSchemas) {
|
||||
const { data, error } = zodValidate(this.options.zodSchemas, model, dbOp, args);
|
||||
if (error) {
|
||||
return { status: 400, body: { message: error } };
|
||||
} else {
|
||||
args = data;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
if (!prisma[model]) {
|
||||
return { status: 400, body: { message: `unknown model name: ${model}` } };
|
||||
}
|
||||
const result = await prisma[model][dbOp](args);
|
||||
stripAuxFields(result);
|
||||
return { status: resCode, body: result };
|
||||
} catch (err) {
|
||||
if (isPrismaClientKnownRequestError(err)) {
|
||||
logError(this.options.logger, err.code, err.message);
|
||||
if (err.code === 'P2004') {
|
||||
// rejected by policy
|
||||
return {
|
||||
status: 403,
|
||||
body: {
|
||||
prisma: true,
|
||||
rejectedByPolicy: true,
|
||||
code: err.code,
|
||||
message: err.message,
|
||||
reason: err.meta?.reason,
|
||||
},
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
status: 400,
|
||||
body: {
|
||||
prisma: true,
|
||||
code: err.code,
|
||||
message: err.message,
|
||||
reason: err.meta?.reason,
|
||||
},
|
||||
};
|
||||
}
|
||||
} else if (isPrismaClientUnknownRequestError(err) || isPrismaClientValidationError(err)) {
|
||||
logError(this.options.logger, err.message);
|
||||
return {
|
||||
status: 400,
|
||||
body: {
|
||||
prisma: true,
|
||||
message: err.message,
|
||||
},
|
||||
};
|
||||
} else {
|
||||
const _err = err as Error;
|
||||
logError(this.options.logger, _err.message + (_err.stack ? '\n' + _err.stack : ''));
|
||||
return {
|
||||
status: 400,
|
||||
body: {
|
||||
message: (err as Error).message,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private unmarshal(value: string) {
|
||||
return JSON.parse(value);
|
||||
}
|
||||
}
|
||||
|
||||
export default function makeHandler(options?: Options) {
|
||||
const handler = new RequestHandler(options);
|
||||
return handler.handleRequest.bind(handler);
|
||||
}
|
||||
48
packages/server/src/api/types.ts
Normal file
48
packages/server/src/api/types.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import { DbClientContract } from '@zenstackhq/runtime';
|
||||
|
||||
type LoggerMethod = (message: string, code?: string) => void;
|
||||
|
||||
/**
|
||||
* Logger config.
|
||||
*/
|
||||
export type LoggerConfig = {
|
||||
debug?: LoggerMethod;
|
||||
info?: LoggerMethod;
|
||||
warn?: LoggerMethod;
|
||||
error?: LoggerMethod;
|
||||
};
|
||||
|
||||
/**
|
||||
* Options for initializing an API endpoint request handler.
|
||||
* @see requestHandler
|
||||
*/
|
||||
export type RequestHandlerOptions = {
|
||||
/**
|
||||
* Logger configuration. By default log to console. Set to null to turn off logging.
|
||||
*/
|
||||
logger?: LoggerConfig | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* API request context
|
||||
*/
|
||||
export type RequestContext = {
|
||||
prisma: DbClientContract;
|
||||
method: string;
|
||||
path: string;
|
||||
query?: Record<string, string | string[]>;
|
||||
requestBody?: unknown;
|
||||
};
|
||||
|
||||
/**
|
||||
* API response
|
||||
*/
|
||||
export type Response = {
|
||||
status: number;
|
||||
body: unknown;
|
||||
};
|
||||
|
||||
/**
|
||||
* API request handler function
|
||||
*/
|
||||
export type HandleRequestFn = (req: RequestContext) => Promise<Response>;
|
||||
69
packages/server/src/api/utils.ts
Normal file
69
packages/server/src/api/utils.ts
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
import { DbOperations } from '@zenstackhq/runtime';
|
||||
import type { ModelZodSchema } from '@zenstackhq/runtime/zod';
|
||||
import { upperCaseFirst } from 'upper-case-first';
|
||||
import { fromZodError } from 'zod-validation-error';
|
||||
import { AUXILIARY_FIELDS } from '@zenstackhq/sdk';
|
||||
import { LoggerConfig } from './types';
|
||||
|
||||
export function getZodSchema(zodSchemas: ModelZodSchema, model: string, operation: keyof DbOperations) {
|
||||
if (zodSchemas[model]) {
|
||||
return zodSchemas[model][operation];
|
||||
} else if (zodSchemas[upperCaseFirst(model)]) {
|
||||
return zodSchemas[upperCaseFirst(model)][operation];
|
||||
} else {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function zodValidate(
|
||||
zodSchemas: ModelZodSchema | undefined,
|
||||
model: string,
|
||||
operation: keyof DbOperations,
|
||||
args: unknown
|
||||
) {
|
||||
const zodSchema = zodSchemas && getZodSchema(zodSchemas, model, operation);
|
||||
if (zodSchema) {
|
||||
const parseResult = zodSchema.safeParse(args);
|
||||
if (parseResult.success) {
|
||||
return { data: parseResult.data, error: undefined };
|
||||
} else {
|
||||
return { data: undefined, error: fromZodError(parseResult.error).message };
|
||||
}
|
||||
} else {
|
||||
return { data: args, error: undefined };
|
||||
}
|
||||
}
|
||||
|
||||
export function logError(logger: LoggerConfig | undefined | null, message: string, code?: string) {
|
||||
if (logger === undefined) {
|
||||
console.error(`@zenstackhq/server: error ${code ? '[' + code + ']' : ''}, ${message}`);
|
||||
} else if (logger?.error) {
|
||||
logger.error(message, code);
|
||||
}
|
||||
}
|
||||
|
||||
export function logWarning(logger: LoggerConfig | undefined | null, message: string, code?: string) {
|
||||
if (logger === undefined) {
|
||||
console.warn(`@zenstackhq/server: error ${code ? '[' + code + ']' : ''}, ${message}`);
|
||||
} else if (logger?.warn) {
|
||||
logger.warn(message, code);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively strip auxiliary fields from the given data.
|
||||
*/
|
||||
export function stripAuxFields(data: unknown) {
|
||||
if (Array.isArray(data)) {
|
||||
return data.forEach(stripAuxFields);
|
||||
} else if (data && typeof data === 'object') {
|
||||
for (const [key, value] of Object.entries(data)) {
|
||||
if (AUXILIARY_FIELDS.includes(key)) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
delete (data as any)[key];
|
||||
} else {
|
||||
stripAuxFields(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -2,7 +2,8 @@
|
|||
import { DbClientContract } from '@zenstackhq/runtime';
|
||||
import { getModelZodSchemas, ModelZodSchema } from '@zenstackhq/runtime/zod';
|
||||
import type { Handler, Request, Response } from 'express';
|
||||
import { handleRequest, LoggerConfig } from '../openapi';
|
||||
import RPCAPIHandler from '../api/rpc';
|
||||
import { HandleRequestFn, LoggerConfig } from '../api/types';
|
||||
|
||||
/**
|
||||
* Express middleware options
|
||||
|
|
@ -23,33 +24,38 @@ export interface MiddlewareOptions {
|
|||
* (need to enable `@core/zod` plugin in schema.zmodel) or omit to disable input validation.
|
||||
*/
|
||||
zodSchemas?: ModelZodSchema | boolean;
|
||||
|
||||
/**
|
||||
* Api request handler function
|
||||
*/
|
||||
handler?: HandleRequestFn;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an Express middleware for handling CRUD requests.
|
||||
*/
|
||||
const factory = (options: MiddlewareOptions): Handler => {
|
||||
let schemas: ModelZodSchema | undefined;
|
||||
let zodSchemas: ModelZodSchema | undefined;
|
||||
if (typeof options.zodSchemas === 'object') {
|
||||
schemas = options.zodSchemas;
|
||||
zodSchemas = options.zodSchemas;
|
||||
} else if (options.zodSchemas === true) {
|
||||
schemas = getModelZodSchemas();
|
||||
zodSchemas = getModelZodSchemas();
|
||||
}
|
||||
|
||||
const requestHandler = options.handler || RPCAPIHandler({ logger: options.logger, zodSchemas });
|
||||
|
||||
return async (request, response) => {
|
||||
const prisma = (await options.getPrisma(request, response)) as DbClientContract;
|
||||
if (!prisma) {
|
||||
throw new Error('unable to get prisma from request context');
|
||||
}
|
||||
|
||||
const r = await handleRequest({
|
||||
const r = await requestHandler({
|
||||
method: request.method,
|
||||
path: request.path,
|
||||
query: request.query as Record<string, string | string[]>,
|
||||
query: request.query as Record<string, string>,
|
||||
requestBody: request.body,
|
||||
prisma,
|
||||
logger: options.logger,
|
||||
zodSchemas: schemas,
|
||||
});
|
||||
|
||||
response.status(r.status).json(r.body);
|
||||
|
|
|
|||
|
|
@ -3,7 +3,8 @@ import { DbClientContract } from '@zenstackhq/runtime';
|
|||
import { getModelZodSchemas, ModelZodSchema } from '@zenstackhq/runtime/zod';
|
||||
import { FastifyPluginCallback, FastifyReply, FastifyRequest } from 'fastify';
|
||||
import fp from 'fastify-plugin';
|
||||
import { handleRequest, LoggerConfig } from '../openapi';
|
||||
import RPCApiHandler from '../api/rpc';
|
||||
import { HandleRequestFn, LoggerConfig } from '../api/types';
|
||||
|
||||
/**
|
||||
* Fastify plugin options
|
||||
|
|
@ -29,6 +30,11 @@ export interface PluginOptions {
|
|||
* (need to enable `@core/zod` plugin in schema.zmodel) or omit to disable input validation.
|
||||
*/
|
||||
zodSchemas?: ModelZodSchema | boolean;
|
||||
|
||||
/**
|
||||
* Api request handler function
|
||||
*/
|
||||
api: HandleRequestFn;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -50,6 +56,8 @@ const pluginHandler: FastifyPluginCallback<PluginOptions> = (fastify, options, d
|
|||
schemas = getModelZodSchemas();
|
||||
}
|
||||
|
||||
const requestHanler = options.api ?? RPCApiHandler({ logger: options.logger, zodSchemas: schemas });
|
||||
|
||||
fastify.all(`${prefix}/*`, async (request, reply) => {
|
||||
const prisma = (await options.getPrisma(request, reply)) as DbClientContract;
|
||||
if (!prisma) {
|
||||
|
|
@ -57,14 +65,12 @@ const pluginHandler: FastifyPluginCallback<PluginOptions> = (fastify, options, d
|
|||
}
|
||||
const query = request.query as Record<string, string>;
|
||||
|
||||
const response = await handleRequest({
|
||||
const response = await requestHanler({
|
||||
method: request.method,
|
||||
path: (request.params as any)['*'],
|
||||
query,
|
||||
requestBody: request.body,
|
||||
prisma,
|
||||
logger: options.logger,
|
||||
zodSchemas: schemas,
|
||||
});
|
||||
|
||||
reply.status(response.status).send(response.body);
|
||||
|
|
|
|||
|
|
@ -1,246 +0,0 @@
|
|||
import {
|
||||
DbClientContract,
|
||||
DbOperations,
|
||||
isPrismaClientKnownRequestError,
|
||||
isPrismaClientUnknownRequestError,
|
||||
isPrismaClientValidationError,
|
||||
} from '@zenstackhq/runtime';
|
||||
import type { ModelZodSchema } from '@zenstackhq/runtime/zod';
|
||||
import { upperCaseFirst } from 'upper-case-first';
|
||||
import { fromZodError } from 'zod-validation-error';
|
||||
import { stripAuxFields } from './utils';
|
||||
|
||||
type LoggerMethod = (message: string, code?: 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
|
||||
*/
|
||||
export type RequestHandlerOptions = {
|
||||
/**
|
||||
* Logger configuration. By default log to console. Set to null to turn off logging.
|
||||
*/
|
||||
logger?: LoggerConfig | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* OpenApi request context.
|
||||
*/
|
||||
export type RequestContext = {
|
||||
method: string;
|
||||
path: string;
|
||||
query?: Record<string, string | string[]>;
|
||||
requestBody?: unknown;
|
||||
prisma: DbClientContract;
|
||||
logger?: LoggerConfig;
|
||||
zodSchemas?: ModelZodSchema;
|
||||
};
|
||||
|
||||
/**
|
||||
* OpenApi response.
|
||||
*/
|
||||
export type Response = {
|
||||
status: number;
|
||||
body: unknown;
|
||||
};
|
||||
|
||||
function getZodSchema(zodSchemas: ModelZodSchema, model: string, operation: keyof DbOperations) {
|
||||
if (zodSchemas[model]) {
|
||||
return zodSchemas[model][operation];
|
||||
} else if (zodSchemas[upperCaseFirst(model)]) {
|
||||
return zodSchemas[upperCaseFirst(model)][operation];
|
||||
} else {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function zodValidate(
|
||||
zodSchemas: ModelZodSchema | undefined,
|
||||
model: string,
|
||||
operation: keyof DbOperations,
|
||||
args: unknown
|
||||
) {
|
||||
const zodSchema = zodSchemas && getZodSchema(zodSchemas, model, operation);
|
||||
if (zodSchema) {
|
||||
const parseResult = zodSchema.safeParse(args);
|
||||
if (parseResult.success) {
|
||||
return { data: parseResult.data, error: undefined };
|
||||
} else {
|
||||
return { data: undefined, error: fromZodError(parseResult.error).message };
|
||||
}
|
||||
} else {
|
||||
return { data: args, error: undefined };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles OpenApi requests
|
||||
*/
|
||||
export async function handleRequest({
|
||||
method,
|
||||
path,
|
||||
query,
|
||||
requestBody,
|
||||
prisma,
|
||||
logger,
|
||||
zodSchemas,
|
||||
}: RequestContext): Promise<Response> {
|
||||
const parts = path.split('/').filter((p) => !!p);
|
||||
const op = parts.pop();
|
||||
const model = parts.pop();
|
||||
|
||||
if (parts.length !== 0 || !op || !model) {
|
||||
return { status: 400, body: { message: 'invalid request path' } };
|
||||
}
|
||||
|
||||
method = method.toUpperCase();
|
||||
const dbOp = op as keyof DbOperations;
|
||||
let args: unknown;
|
||||
let resCode = 200;
|
||||
|
||||
switch (dbOp) {
|
||||
case 'create':
|
||||
case 'createMany':
|
||||
case 'upsert':
|
||||
if (method !== 'POST') {
|
||||
return { status: 400, body: { message: 'invalid request method, only POST is supported' } };
|
||||
}
|
||||
if (!requestBody) {
|
||||
return { status: 400, body: { message: 'missing request body' } };
|
||||
}
|
||||
|
||||
args = requestBody;
|
||||
|
||||
// TODO: upsert's status code should be conditional
|
||||
resCode = 201;
|
||||
break;
|
||||
|
||||
case 'findFirst':
|
||||
case 'findUnique':
|
||||
case 'findMany':
|
||||
case 'aggregate':
|
||||
case 'groupBy':
|
||||
case 'count':
|
||||
if (method !== 'GET') {
|
||||
return { status: 400, body: { message: 'invalid request method, only GET is supported' } };
|
||||
}
|
||||
try {
|
||||
args = query?.q ? unmarshal(query.q as string) : {};
|
||||
} catch {
|
||||
return { status: 400, body: { message: 'query param must contain valid JSON' } };
|
||||
}
|
||||
break;
|
||||
|
||||
case 'update':
|
||||
case 'updateMany':
|
||||
if (method !== 'PUT' && method !== 'PATCH') {
|
||||
return { status: 400, body: { message: 'invalid request method, only PUT AND PATCH are supported' } };
|
||||
}
|
||||
if (!requestBody) {
|
||||
return { status: 400, body: { message: 'missing request body' } };
|
||||
}
|
||||
|
||||
args = requestBody;
|
||||
break;
|
||||
|
||||
case 'delete':
|
||||
case 'deleteMany':
|
||||
if (method !== 'DELETE') {
|
||||
return { status: 400, body: { message: 'invalid request method, only DELETE is supported' } };
|
||||
}
|
||||
try {
|
||||
args = query?.q ? unmarshal(query.q as string) : {};
|
||||
} catch {
|
||||
return { status: 400, body: { message: 'query param must contain valid JSON' } };
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
return { status: 400, body: { message: 'invalid operation: ' + op } };
|
||||
}
|
||||
|
||||
if (zodSchemas) {
|
||||
const { data, error } = zodValidate(zodSchemas, model, dbOp, args);
|
||||
if (error) {
|
||||
return { status: 400, body: { message: error } };
|
||||
} else {
|
||||
args = data;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
if (!prisma[model]) {
|
||||
return { status: 400, body: { message: `unknown model name: ${model}` } };
|
||||
}
|
||||
const result = await prisma[model][dbOp](args);
|
||||
stripAuxFields(result);
|
||||
return { status: resCode, body: result };
|
||||
} catch (err) {
|
||||
if (isPrismaClientKnownRequestError(err)) {
|
||||
logError(logger, err.code, err.message);
|
||||
if (err.code === 'P2004') {
|
||||
// rejected by policy
|
||||
return {
|
||||
status: 403,
|
||||
body: {
|
||||
prisma: true,
|
||||
rejectedByPolicy: true,
|
||||
code: err.code,
|
||||
message: err.message,
|
||||
reason: err.meta?.reason,
|
||||
},
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
status: 400,
|
||||
body: {
|
||||
prisma: true,
|
||||
code: err.code,
|
||||
message: err.message,
|
||||
reason: err.meta?.reason,
|
||||
},
|
||||
};
|
||||
}
|
||||
} else if (isPrismaClientUnknownRequestError(err) || isPrismaClientValidationError(err)) {
|
||||
logError(logger, err.message);
|
||||
return {
|
||||
status: 400,
|
||||
body: {
|
||||
prisma: true,
|
||||
message: err.message,
|
||||
},
|
||||
};
|
||||
} else {
|
||||
const _err = err as Error;
|
||||
logError(logger, _err.message + (_err.stack ? '\n' + _err.stack : ''));
|
||||
return {
|
||||
status: 400,
|
||||
body: {
|
||||
message: (err as Error).message,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function unmarshal(value: string) {
|
||||
return JSON.parse(value);
|
||||
}
|
||||
|
||||
function logError(logger: LoggerConfig | undefined | null, message: string, code?: string) {
|
||||
if (logger === undefined) {
|
||||
console.error(`@zenstackhq/server: error ${code ? '[' + code + ']' : ''}, ${message}`);
|
||||
} else if (logger?.error) {
|
||||
logger.error(message, code);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
import { AUXILIARY_FIELDS } from '@zenstackhq/sdk';
|
||||
|
||||
/**
|
||||
* Recursively strip auxiliary fields from the given data.
|
||||
*/
|
||||
export function stripAuxFields(data: unknown) {
|
||||
if (Array.isArray(data)) {
|
||||
return data.forEach(stripAuxFields);
|
||||
} else if (data && typeof data === 'object') {
|
||||
for (const [key, value] of Object.entries(data)) {
|
||||
if (AUXILIARY_FIELDS.includes(key)) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
delete (data as any)[key];
|
||||
} else {
|
||||
stripAuxFields(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
56
packages/server/tests/api/rest/express-adapter.test.ts
Normal file
56
packages/server/tests/api/rest/express-adapter.test.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
/// <reference types="@types/jest" />
|
||||
|
||||
import { loadSchema } from '@zenstackhq/testtools';
|
||||
import bodyParser from 'body-parser';
|
||||
import express from 'express';
|
||||
import request from 'supertest';
|
||||
import { ZenStackMiddleware } from '../../../src/express';
|
||||
import { makeUrl, schema } from '../../utils';
|
||||
import RESTAPIHandler from '../../../src/api/rest';
|
||||
|
||||
describe('Express adapter tests', () => {
|
||||
it('run middleware', async () => {
|
||||
const { prisma, zodSchemas, modelMeta } = await loadSchema(schema);
|
||||
|
||||
const app = express();
|
||||
app.use(bodyParser.json());
|
||||
app.use(
|
||||
'/api',
|
||||
ZenStackMiddleware({
|
||||
getPrisma: () => prisma,
|
||||
zodSchemas,
|
||||
handler: RESTAPIHandler({ endpoint: 'http://localhost/api', modelMeta }),
|
||||
})
|
||||
);
|
||||
|
||||
let r = await request(app).get(makeUrl('/api/post/1'));
|
||||
expect(r.status).toBe(404);
|
||||
|
||||
r = await request(app)
|
||||
.post('/api/user')
|
||||
.send({
|
||||
data: {
|
||||
type: 'user',
|
||||
attributes: {
|
||||
id: 'user1',
|
||||
email: 'user1@abc.com',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(r.status).toBe(201);
|
||||
expect(r.body).toMatchObject({
|
||||
jsonapi: { version: '1.0' },
|
||||
data: { type: 'user', id: 'user1', attributes: { email: 'user1@abc.com' } },
|
||||
});
|
||||
|
||||
r = await request(app)
|
||||
.put('/api/user/user1')
|
||||
.send({ data: { type: 'user', attributes: { email: 'user1@def.com' } } });
|
||||
expect(r.status).toBe(200);
|
||||
expect(r.body.data.attributes.email).toBe('user1@def.com');
|
||||
|
||||
r = await request(app).delete(makeUrl('/api/user/user1', { where: { id: 'user1' } }));
|
||||
expect(r.status).toBe(204);
|
||||
});
|
||||
});
|
||||
1664
packages/server/tests/api/rest/rest.test.ts
Normal file
1664
packages/server/tests/api/rest/rest.test.ts
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -4,8 +4,8 @@ import { loadSchema } from '@zenstackhq/testtools';
|
|||
import bodyParser from 'body-parser';
|
||||
import express from 'express';
|
||||
import request from 'supertest';
|
||||
import { ZenStackMiddleware } from '../src/express';
|
||||
import { makeUrl, schema } from './utils';
|
||||
import { ZenStackMiddleware } from '../../../src/express';
|
||||
import { makeUrl, schema } from '../../utils';
|
||||
|
||||
describe('Express adapter tests', () => {
|
||||
it('run plugin', async () => {
|
||||
|
|
@ -2,8 +2,9 @@
|
|||
|
||||
import { loadSchema } from '@zenstackhq/testtools';
|
||||
import fastify from 'fastify';
|
||||
import { ZenStackFastifyPlugin } from '../src/fastify';
|
||||
import { makeUrl, schema } from './utils';
|
||||
import { ZenStackFastifyPlugin } from '../../../src/fastify';
|
||||
import { makeUrl, schema } from '../../utils';
|
||||
import Prisma from '../../../src/api/rpc';
|
||||
|
||||
describe('Fastify adapter tests', () => {
|
||||
it('run plugin', async () => {
|
||||
|
|
@ -14,6 +15,7 @@ describe('Fastify adapter tests', () => {
|
|||
prefix: '/api',
|
||||
getPrisma: () => prisma,
|
||||
zodSchemas,
|
||||
api: Prisma(),
|
||||
});
|
||||
|
||||
let r = await app.inject({
|
||||
|
|
@ -121,6 +123,7 @@ describe('Fastify adapter tests', () => {
|
|||
prefix: '/api',
|
||||
getPrisma: () => prisma,
|
||||
zodSchemas,
|
||||
api: Prisma(),
|
||||
});
|
||||
|
||||
let r = await app.inject({
|
||||
|
|
@ -2,18 +2,26 @@
|
|||
/// <reference types="@types/jest" />
|
||||
|
||||
import { loadSchema } from '@zenstackhq/testtools';
|
||||
import { handleRequest } from '../src/openapi';
|
||||
import { schema } from './utils';
|
||||
import RPCAPIHandler from '../../../src/api/rpc';
|
||||
import { schema } from '../../utils';
|
||||
|
||||
describe('OpenAPI server tests', () => {
|
||||
let prisma: any;
|
||||
let zodSchemas: any;
|
||||
|
||||
beforeAll(async () => {
|
||||
const params = await loadSchema(schema);
|
||||
prisma = params.prisma;
|
||||
zodSchemas = params.zodSchemas;
|
||||
});
|
||||
|
||||
it('crud', async () => {
|
||||
const { prisma, zodSchemas } = await loadSchema(schema);
|
||||
const handleRequest = RPCAPIHandler();
|
||||
|
||||
let r = await handleRequest({
|
||||
method: 'get',
|
||||
path: '/post/findMany',
|
||||
prisma,
|
||||
zodSchemas,
|
||||
});
|
||||
expect(r.status).toBe(200);
|
||||
expect(r.body).toHaveLength(0);
|
||||
|
|
@ -36,7 +44,6 @@ describe('OpenAPI server tests', () => {
|
|||
},
|
||||
},
|
||||
prisma,
|
||||
zodSchemas,
|
||||
});
|
||||
expect(r.status).toBe(201);
|
||||
expect(r.body).toEqual(
|
||||
|
|
@ -58,7 +65,6 @@ describe('OpenAPI server tests', () => {
|
|||
method: 'get',
|
||||
path: '/post/findMany',
|
||||
prisma,
|
||||
zodSchemas,
|
||||
});
|
||||
expect(r.status).toBe(200);
|
||||
expect(r.body).toHaveLength(2);
|
||||
|
|
@ -68,7 +74,6 @@ describe('OpenAPI server tests', () => {
|
|||
path: '/post/findMany',
|
||||
query: { q: JSON.stringify({ where: { viewCount: { gt: 1 } } }) },
|
||||
prisma,
|
||||
zodSchemas,
|
||||
});
|
||||
expect(r.status).toBe(200);
|
||||
expect(r.body).toHaveLength(1);
|
||||
|
|
@ -78,7 +83,6 @@ describe('OpenAPI server tests', () => {
|
|||
path: '/user/update',
|
||||
requestBody: { where: { id: 'user1' }, data: { email: 'user1@def.com' } },
|
||||
prisma,
|
||||
zodSchemas,
|
||||
});
|
||||
expect(r.status).toBe(200);
|
||||
expect((r.body as any).email).toBe('user1@def.com');
|
||||
|
|
@ -88,7 +92,6 @@ describe('OpenAPI server tests', () => {
|
|||
path: '/post/count',
|
||||
query: { q: JSON.stringify({ where: { viewCount: { gt: 1 } } }) },
|
||||
prisma,
|
||||
zodSchemas,
|
||||
});
|
||||
expect(r.status).toBe(200);
|
||||
expect(r.body).toBe(1);
|
||||
|
|
@ -98,7 +101,6 @@ describe('OpenAPI server tests', () => {
|
|||
path: '/post/aggregate',
|
||||
query: { q: JSON.stringify({ _sum: { viewCount: true } }) },
|
||||
prisma,
|
||||
zodSchemas,
|
||||
});
|
||||
expect(r.status).toBe(200);
|
||||
expect((r.body as any)._sum.viewCount).toBe(3);
|
||||
|
|
@ -108,7 +110,6 @@ describe('OpenAPI server tests', () => {
|
|||
path: '/post/groupBy',
|
||||
query: { q: JSON.stringify({ by: ['published'], _sum: { viewCount: true } }) },
|
||||
prisma,
|
||||
zodSchemas,
|
||||
});
|
||||
expect(r.status).toBe(200);
|
||||
expect(r.body).toEqual(
|
||||
|
|
@ -123,14 +124,13 @@ describe('OpenAPI server tests', () => {
|
|||
path: '/user/deleteMany',
|
||||
query: { q: JSON.stringify({ where: { id: 'user1' } }) },
|
||||
prisma,
|
||||
zodSchemas,
|
||||
});
|
||||
expect(r.status).toBe(200);
|
||||
expect((r.body as any).count).toBe(1);
|
||||
});
|
||||
|
||||
it('validation error', async () => {
|
||||
const { prisma, zodSchemas } = await loadSchema(schema);
|
||||
let handleRequest = RPCAPIHandler();
|
||||
|
||||
// without validation
|
||||
let r = await handleRequest({
|
||||
|
|
@ -141,12 +141,13 @@ describe('OpenAPI server tests', () => {
|
|||
expect(r.status).toBe(400);
|
||||
expect((r.body as any).message).toContain('Argument where is missing');
|
||||
|
||||
handleRequest = RPCAPIHandler({ zodSchemas });
|
||||
|
||||
// with validation
|
||||
r = await handleRequest({
|
||||
method: 'get',
|
||||
path: '/post/findUnique',
|
||||
prisma,
|
||||
zodSchemas,
|
||||
});
|
||||
expect(r.status).toBe(400);
|
||||
expect((r.body as any).message).toContain('Validation error');
|
||||
|
|
@ -157,7 +158,6 @@ describe('OpenAPI server tests', () => {
|
|||
path: '/post/create',
|
||||
requestBody: { data: {} },
|
||||
prisma,
|
||||
zodSchemas,
|
||||
});
|
||||
expect(r.status).toBe(400);
|
||||
expect((r.body as any).message).toContain('Validation error');
|
||||
|
|
@ -165,7 +165,7 @@ describe('OpenAPI server tests', () => {
|
|||
});
|
||||
|
||||
it('invalid path or args', async () => {
|
||||
const { prisma } = await loadSchema(schema);
|
||||
const handleRequest = RPCAPIHandler();
|
||||
|
||||
let r = await handleRequest({
|
||||
method: 'get',
|
||||
|
|
@ -103,8 +103,6 @@ export async function loadSchema(
|
|||
throw new Error('Could not find workspace root');
|
||||
}
|
||||
|
||||
console.log('Workspace root:', root);
|
||||
|
||||
const pkgContent = fs.readFileSync(path.join(__dirname, 'package.template.json'), { encoding: 'utf-8' });
|
||||
fs.writeFileSync(path.join(projectRoot, 'package.json'), pkgContent.replaceAll('<root>', root));
|
||||
|
||||
|
|
|
|||
4214
pnpm-lock.yaml
4214
pnpm-lock.yaml
File diff suppressed because it is too large
Load diff
Loading…
Reference in a new issue