Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: express.js adapter #271

Merged
merged 1 commit into from
Mar 15, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 27 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,12 +104,37 @@ The following diagram gives a high-level overview of how it works.
## Features

- Access control and data validation rules right inside your Prisma schema
- Auto-generated RESTful API and client library
- Auto-generated OpenAPI (RESTful) specifications, services, and client libraries
- End-to-end type safety
- Extensible: custom attributes, functions, and a plugin system
- Framework agnostic
- A framework-agnostic core with framework-specific adapters
- Uncompromised performance

### Plugins

- Prisma schema generator
- Zod schema generator
- React hooks generator
- OpenAPI specification generator
- [tRPC](https://trpc.io) router generator
- 🙋🏻 [Request for a plugin](https://go.zenstack.dev/chat)

### Framework adapters

- [Next.js](https://zenstack.dev/docs/reference/server-adapters/next)
- [Fastify](https://zenstack.dev/docs/reference/server-adapters/fastify)
- [ExpressJS](https://zenstack.dev/docs/reference/server-adapters/express)
- Nuxt.js (Future)
- SvelteKit (Future)
- 🙋🏻 [Request for an adapter](https://go.zenstack.dev/chat)

### Prisma schema extensions

- [Custom attributes and functions](https://zenstack.dev/docs/reference/zmodel-language#custom-attributes-and-functions)
- Multi-file schema (coming soon)
- String-typed JSON field (coming soon)
- 🙋🏻 [Request for an extension](https://go.zenstack.dev/chat)

## Examples

Check out the [Collaborative Todo App](https://zenstack-todo.vercel.app/) for a running example. You can find the source code below:
Expand Down
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.73",
"version": "1.0.0-alpha.74",
"description": "",
"scripts": {
"build": "pnpm -r build",
Expand Down
2 changes: 1 addition & 1 deletion 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.73",
"version": "1.0.0-alpha.74",
"displayName": "ZenStack modeling language compiler",
"description": "ZenStack modeling language compiler",
"homepage": "https://zenstack.dev",
Expand Down
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.73",
"version": "1.0.0-alpha.74",
"displayName": "ZenStack Next.js integration",
"description": "ZenStack Next.js integration",
"homepage": "https://zenstack.dev",
Expand Down
2 changes: 1 addition & 1 deletion packages/plugins/openapi/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@zenstackhq/openapi",
"displayName": "ZenStack Plugin and Runtime for OpenAPI",
"version": "1.0.0-alpha.73",
"version": "1.0.0-alpha.74",
"description": "ZenStack plugin and runtime supporting OpenAPI",
"main": "index.js",
"repository": {
Expand Down
2 changes: 1 addition & 1 deletion 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.73",
"version": "1.0.0-alpha.74",
"description": "ZenStack plugin and runtime for ReactJS",
"main": "index.js",
"repository": {
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.73",
"version": "1.0.0-alpha.74",
"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.73",
"version": "1.0.0-alpha.74",
"description": "Runtime of ZenStack for both client-side and server-side environments.",
"repository": {
"type": "git",
Expand Down
4 changes: 3 additions & 1 deletion packages/runtime/src/zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ export function getModelZodSchemas(): ModelZodSchema {
// eslint-disable-next-line @typescript-eslint/no-var-requires
return require('.zenstack/zod').default;
} catch {
throw new Error('Model meta cannot be loaded');
throw new Error(
'Zod schemas cannot be loaded. Please make sure "@core/zod" plugin is enabled in schema.zmodel.'
);
}
}
2 changes: 1 addition & 1 deletion packages/schema/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.73",
"version": "1.0.0-alpha.74",
"author": {
"name": "ZenStack Team"
},
Expand Down
2 changes: 1 addition & 1 deletion packages/schema/src/cli/plugin-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ export class PluginRunner {
}> = [];

const pluginDecls = context.schema.declarations.filter((d): d is Plugin => isPlugin(d));
const prereqPlugins = ['@core/prisma', '@core/model-meta', '@core/access-policy', '@core/zod'];
const prereqPlugins = ['@core/prisma', '@core/model-meta', '@core/access-policy'];
const allPluginProviders = prereqPlugins.concat(
pluginDecls
.map((p) => this.getPluginProvider(p))
Expand Down
2 changes: 1 addition & 1 deletion packages/schema/src/res/stdlib.zmodel
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,7 @@ attribute @map(_ name: String) @@@prisma
attribute @@map(_ name: String) @@@prisma

/*
* Exclude a field from the Prisma Client (for example, a model that you do not want Prisma users to update).
* Exclude a field from the Prisma Client (for example, a field that you do not want Prisma users to update).
*/
attribute @ignore() @@@prisma

Expand Down
2 changes: 1 addition & 1 deletion packages/sdk/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@zenstackhq/sdk",
"version": "1.0.0-alpha.73",
"version": "1.0.0-alpha.74",
"description": "ZenStack plugin development SDK",
"main": "index.js",
"scripts": {
Expand Down
8 changes: 7 additions & 1 deletion packages/server/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@zenstackhq/server",
"version": "1.0.0-alpha.73",
"version": "1.0.0-alpha.74",
"displayName": "ZenStack Server-side Adapters",
"description": "ZenStack server-side adapters",
"homepage": "https://zenstack.dev",
Expand Down Expand Up @@ -31,13 +31,19 @@
"zod-validation-error": "^0.2.1"
},
"devDependencies": {
"@types/body-parser": "^1.19.2",
"@types/express": "^4.17.17",
"@types/jest": "^29.4.0",
"@types/supertest": "^2.0.12",
"@zenstackhq/testtools": "workspace:*",
"body-parser": "^1.20.2",
"copyfiles": "^2.4.1",
"express": "^4.18.2",
"fastify": "^4.14.1",
"fastify-plugin": "^4.5.0",
"jest": "^29.4.3",
"rimraf": "^3.0.2",
"supertest": "^6.3.3",
"ts-jest": "^29.0.5",
"typescript": "^4.9.4"
}
Expand Down
1 change: 1 addition & 0 deletions packages/server/src/express/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default as ZenStackMiddleware } from './middleware';
58 changes: 58 additions & 0 deletions packages/server/src/express/middleware.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { DbClientContract } from '@zenstackhq/runtime';
import { getModelZodSchemas, ModelZodSchema } from '@zenstackhq/runtime/zod';
import type { Handler, Request, Response } from 'express';
import { handleRequest, LoggerConfig } from '../openapi';

/**
* Express middleware options
*/
export interface MiddlewareOptions {
/**
* Callback for getting a PrismaClient for the given request
*/
getPrisma: (req: Request, res: Response) => unknown | Promise<unknown>;

/**
* Logger settings
*/
logger?: LoggerConfig;

/**
* Zod schemas for validating request input. Pass `true` to load from standard location (need to enable `@core/zod` plugin in schema.zmodel).
*/
zodSchemas?: ModelZodSchema | boolean;
}

/**
* Creates an Express middleware for handling CRUD requests.
*/
const factory = (options: MiddlewareOptions): Handler => {
let schemas: ModelZodSchema | undefined;
if (typeof options.zodSchemas === 'object') {
schemas = options.zodSchemas;
} else if (options.zodSchemas === true) {
schemas = getModelZodSchemas();
}

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({
method: request.method,
path: request.path,
query: request.query as Record<string, string | string[]>,
requestBody: request.body,
prisma,
logger: options.logger,
zodSchemas: schemas,
});

response.status(r.status).json(r.body);
};
};

export default factory;
20 changes: 15 additions & 5 deletions packages/server/src/fastify/plugin.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { DbClientContract } from '@zenstackhq/runtime';
import { ModelZodSchema } from '@zenstackhq/runtime/zod';
import { getModelZodSchemas, ModelZodSchema } from '@zenstackhq/runtime/zod';
import { FastifyPluginCallback, FastifyReply, FastifyRequest } from 'fastify';
import fp from 'fastify-plugin';
import { handleRequest, LoggerConfig } from '../openapi';
Expand All @@ -15,7 +15,7 @@ export interface PluginOptions {
prefix: string;

/**
* Callback for gettign a PrismaClient for the given request
* Callback for getting a PrismaClient for the given request
*/
getPrisma: (request: FastifyRequest, reply: FastifyReply) => unknown | Promise<unknown>;

Expand All @@ -25,11 +25,14 @@ export interface PluginOptions {
logger?: LoggerConfig;

/**
* Path to the generated zod schemas
* Zod schemas for validating request input. Pass `true` to load from standard location (need to enable `@core/zod` plugin in schema.zmodel).
*/
zodSchemas?: ModelZodSchema;
zodSchemas?: ModelZodSchema | boolean;
}

/**
* Fastify plugin for handling CRUD requests.
*/
const pluginHandler: FastifyPluginCallback<PluginOptions> = (fastify, options, done) => {
const prefix = options.prefix ?? '';

Expand All @@ -39,6 +42,13 @@ const pluginHandler: FastifyPluginCallback<PluginOptions> = (fastify, options, d
options.logger?.info?.(`ZenStackPlugin installing routes at prefix: ${prefix}`);
}

let schemas: ModelZodSchema | undefined;
if (typeof options.zodSchemas === 'object') {
schemas = options.zodSchemas;
} else if (options.zodSchemas === true) {
schemas = getModelZodSchemas();
}

fastify.all(`${prefix}/*`, async (request, reply) => {
const prisma = (await options.getPrisma(request, reply)) as DbClientContract;
if (!prisma) {
Expand All @@ -53,7 +63,7 @@ const pluginHandler: FastifyPluginCallback<PluginOptions> = (fastify, options, d
requestBody: request.body,
prisma,
logger: options.logger,
zodSchemas: options.zodSchemas,
zodSchemas: schemas,
});

reply.status(response.status).send(response.body);
Expand Down
21 changes: 10 additions & 11 deletions packages/server/src/openapi/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import {
isPrismaClientUnknownRequestError,
isPrismaClientValidationError,
} from '@zenstackhq/runtime';
import { getModelZodSchemas, ModelZodSchema } from '@zenstackhq/runtime/zod';
import type { ModelZodSchema } from '@zenstackhq/runtime/zod';
import { capitalCase } from 'change-case';
import invariant from 'tiny-invariant';
import { fromZodError } from 'zod-validation-error';
Expand Down Expand Up @@ -55,10 +55,7 @@ export type Response = {
body: unknown;
};

function getZodSchema(zodSchemas: ModelZodSchema | undefined, model: string, operation: keyof DbOperations) {
if (!zodSchemas) {
zodSchemas = getModelZodSchemas();
}
function getZodSchema(zodSchemas: ModelZodSchema, model: string, operation: keyof DbOperations) {
if (zodSchemas[model]) {
return zodSchemas[model][operation];
} else if (zodSchemas[capitalCase(model)]) {
Expand All @@ -74,7 +71,7 @@ function zodValidate(
operation: keyof DbOperations,
args: unknown
) {
const zodSchema = getZodSchema(zodSchemas, model, operation);
const zodSchema = zodSchemas && getZodSchema(zodSchemas, model, operation);
if (zodSchema) {
const parseResult = zodSchema.safeParse(args);
if (parseResult.success) {
Expand Down Expand Up @@ -168,11 +165,13 @@ export async function handleRequest({
return { status: 400, body: { message: 'invalid operation: ' + op } };
}

const { data, error } = zodValidate(zodSchemas, model, dbOp, args);
if (error) {
return { status: 400, body: { message: error } };
} else {
args = data;
if (zodSchemas) {
const { data, error } = zodValidate(zodSchemas, model, dbOp, args);
if (error) {
return { status: 400, body: { message: error } };
} else {
args = data;
}
}

try {
Expand Down
Loading