Skip to content
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
63 changes: 60 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,24 @@ Start your API server with I/O schema validation and custom middlewares in minut
coming soon
```

Add the following options to your `tsconfig.json` file in order to make it work as expected:

```json
{
"compilerOptions": {
"noImplicitAny": true,
"strictNullChecks": true
}
}
```

# Tests

```sh
yarn test
```

# Usage
# Basic usage

Full example in `./example`. You can clone the repo and run `yarn start` to check it out in action.

Expand All @@ -40,7 +51,7 @@ const config: ConfigType = {
};
```

## Create endpoints factory
## Create an endpoints factory

```typescript
export const endpointsFactory = new EndpointsFactory();
Expand Down Expand Up @@ -69,7 +80,7 @@ export const getUserEndpoint = endpointsFactory
});
```

You can also add middleware using `.addMiddleware()` method befor `.build()`.
You can also add the middleware using `.addMiddleware()` method before `.build()`.
All inputs and outputs are validated.

## Setup routing
Expand Down Expand Up @@ -123,3 +134,49 @@ export const authMiddleware = createMiddleware({
}
});
```

## Custom server

You can instantiate your own express app and connect your endpoints the following way:

```typescript
const config: ConfigType = {...};
const logger = createLogger(config);
const routing = {...};

initRouting({app, logger, config, routing});
```

# Known issues

# Excessive of endpoint's output

Unfortunately Typescript does not perform [excess proprety check](https://www.typescriptlang.org/docs/handbook/interfaces.html#excess-property-checks) for objects resolved in `Promise`, so there is no error during development of endpoint's output.

```typescript
endpointsFactory.build({
methods, input,
output: z.object({
anything: z.number()
}),
handler: async () => ({
anything: 123,
excessive: 'something' // no type error
})
});
```

You can achieve this check by assigning the output schema to a constant and reusing it in additional definition of handler's return type:

```typescript
const output = z.object({
anything: z.number()
});
endpointsFactory.build({
methods, input, output,
handler: async (): Promise<z.infer<typeof handlerOutput>> => ({
anything: 123,
excessive: 'something' // error TS2322, ok!
})
});
```
2 changes: 1 addition & 1 deletion example/middlewares.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ export const authMiddleware = createMiddleware({

export const methodProviderMiddleware = createMiddleware({
input: z.object({}).nonstrict(),
middleware: ({request}) => Promise.resolve({
middleware: async ({request}) => ({
method: request.method.toLowerCase() as Method,
})
});
10 changes: 5 additions & 5 deletions example/v1/get-user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,21 +18,21 @@ export const getUserEndpoint = endpointsFactory
status: z.nativeEnum(Status),
name: z.string(),
}),
handler: ({input: {id}, options: {method}, logger}) => {
handler: async ({input: {id}, options: {method}, logger}) => {
logger.debug(`Requested id: ${id}, method ${method}`);
const name = 'John Doe';
if (id < 10) {
return Promise.resolve({
return {
status: Status.OK,
name
});
};
}
if (id > 100) {
throw createHttpError(404, 'User not found');
}
return Promise.resolve({
return {
status: Status.Warning,
name
});
};
}
});
10 changes: 5 additions & 5 deletions example/v1/set-user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,20 +15,20 @@ export const setUserEndpoint = keyAndTokenAuthenticatedEndpointsFactory.build({
output: z.object({
status: z.nativeEnum(Status),
}),
handler: ({input: {id, name, key}, options: {token}, logger}) => {
handler: async ({input: {id, name, key}, options: {token}, logger}) => {
logger.debug(`id, key and token: ${id}, ${key}, ${token}`);
if (id < 10) {
return Promise.resolve({
return {
status: Status.Updated,
name
});
};
}
if (id > 100) {
throw createHttpError(404, 'User not found');
}
return Promise.resolve({
return {
status: Status.Delayed,
name
});
};
}
});
12 changes: 8 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
{
"name": "express-zod-api",
"version": "0.0.1",
"version": "0.1.0",
"description": "Express ZOD API",
"license": "MIT",
"scripts": {
"start": "ts-node example/index.ts",
"cleanup": "rm -rf ./dist",
"build": "yarn cleanup && tsc",
"test": "yarn jest --verbose",
"test": "yarn test:unit && yarn test:system",
"test:unit": "yarn jest --verbose ./tests/unit",
"test:system": "yarn jest --verbose ./tests/system",
"lint": "yarn eslint ./src ./example ./tests",
"precommit": "yarn lint && yarn test",
"install_hooks": "husky install"
Expand All @@ -24,13 +26,15 @@
"@types/express": "^4.17.11",
"@types/http-errors": "^1.8.0",
"@types/jest": "^26.0.20",
"@types/node-fetch": "^2.5.8",
"@types/triple-beam": "^1.3.2",
"jest": "^26.6.3",
"ts-jest": "^26.5.1",
"@typescript-eslint/eslint-plugin": "^4.15.1",
"@typescript-eslint/parser": "^4.15.1",
"eslint": "^7.20.0",
"husky": "^5.1.1",
"jest": "^26.6.3",
"node-fetch": "^2.6.1",
"ts-jest": "^26.5.1",
"ts-node": "^9.1.1",
"typescript": "^4.1.5"
},
Expand Down
38 changes: 30 additions & 8 deletions src/endpoint.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import {Logger} from 'winston';
import {ZodError} from 'zod';
import * as z from 'zod';
import {ConfigType} from './config-type';
import {combineEndpointAndMiddlewareInputSchemas, JoinUnshaped, ObjectSchema, Unshape} from './helpers';
import {combineEndpointAndMiddlewareInputSchemas, Merge, ObjectSchema} from './helpers';
import {Request, Response} from 'express';
import {MiddlewareDefinition} from './middleware';
import {defaultResultHandler, ResultHandler} from './result-handler';
Expand Down Expand Up @@ -30,19 +31,19 @@ export abstract class AbstractEndpoint {
export type Method = 'get' | 'post' | 'put' | 'delete' | 'patch';

/** mIN, OPT - from Middlewares */
export class Endpoint<IN extends z.ZodRawShape, OUT extends z.ZodRawShape, mIN, OPT> extends AbstractEndpoint {
export class Endpoint<IN extends ObjectSchema, OUT extends ObjectSchema, mIN, OPT> extends AbstractEndpoint {
protected middlewares: MiddlewareDefinition<any, any, any>[] = [];
protected inputSchema: ObjectSchema<IN & mIN>; // combined with middlewares input
protected outputSchema: ObjectSchema<OUT>;
protected handler: Handler<JoinUnshaped<IN, mIN>, Unshape<OUT>, OPT>
protected inputSchema: Merge<IN, mIN>; // combined with middlewares input
protected outputSchema: OUT;
protected handler: Handler<z.infer<Merge<IN, mIN>>, z.infer<OUT>, OPT>
protected resultHandler: ResultHandler | null;

constructor({methods, middlewares, inputSchema, outputSchema, handler, resultHandler}: {
methods: Method[];
middlewares: MiddlewareDefinition<any, any, any>[],
inputSchema: ObjectSchema<IN>,
outputSchema: ObjectSchema<OUT>,
handler: Handler<JoinUnshaped<IN, mIN>, Unshape<OUT>, OPT>
inputSchema: IN,
outputSchema: OUT,
handler: Handler<z.infer<Merge<IN, mIN>>, z.infer<OUT>, OPT>
resultHandler: ResultHandler | null
}) {
super();
Expand Down Expand Up @@ -99,6 +100,27 @@ export class Endpoint<IN extends z.ZodRawShape, OUT extends z.ZodRawShape, mIN,
}
input = this.inputSchema.parse(input); // final input types transformations for handler
output = await this.handler({input, options, logger});
try {
output = this.outputSchema.parse(output);
} catch (e) {
if (e instanceof ZodError) {
// noinspection ExceptionCaughtLocallyJS
throw new ZodError([
{
message: 'Invalid format',
code: 'custom',
path: ['output'],
},
...e.issues.map((issue) => ({
...issue,
path: issue.path.length === 0 ? ['output'] : issue.path
}))
]);
} else {
// noinspection ExceptionCaughtLocallyJS
throw e;
}
}
} catch (e) {
error = e;
}
Expand Down
14 changes: 7 additions & 7 deletions src/endpoints-factory.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import * as z from 'zod';
import {Endpoint, Handler, Method} from './endpoint';
import {JoinUnshaped, ObjectSchema, Unshape} from './helpers';
import {FlatObject, Merge, ObjectSchema} from './helpers';
import {MiddlewareDefinition} from './middleware';
import {ResultHandler} from './result-handler';

Expand All @@ -24,18 +24,18 @@ export class EndpointsFactory<mIN, mOUT> {
);
}

public addMiddleware<IN extends z.ZodRawShape, OUT>(definition: MiddlewareDefinition<IN, mOUT, OUT>) {
return new EndpointsFactory<mIN & IN, mOUT & OUT>(
public addMiddleware<IN extends ObjectSchema, OUT extends FlatObject>(definition: MiddlewareDefinition<IN, mOUT, OUT>) {
return new EndpointsFactory<Merge<IN, mIN>, mOUT & OUT>(
this.middlewares.concat(definition),
this.resultHandler
);
}

public build<IN extends z.ZodRawShape, OUT extends z.ZodRawShape>({methods, input, output, handler}: {
public build<IN extends ObjectSchema, OUT extends ObjectSchema>({methods, input, output, handler}: {
methods: Method[],
input: ObjectSchema<IN>,
output: ObjectSchema<OUT>,
handler: Handler<JoinUnshaped<IN, mIN>, Unshape<OUT>, mOUT>
input: IN,
output: OUT,
handler: Handler<z.infer<Merge<IN, mIN>>, z.infer<OUT>, mOUT>
}) {
return new Endpoint<IN, OUT, mIN, mOUT>({
methods, handler,
Expand Down
25 changes: 16 additions & 9 deletions src/helpers.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,26 @@
import * as z from 'zod';
import {AnyZodObject} from 'zod/lib/cjs/types/object';
import {MiddlewareDefinition} from './middleware';

export type ObjectSchema<T extends z.ZodRawShape> = z.ZodObject<T, 'passthrough' | 'strict' | 'strip'>;
export type Unshape<T> = T extends z.ZodRawShape ? z.infer<ObjectSchema<T>> : T;
export type JoinUnshaped<A, B> = Unshape<A> & Unshape<B>;
export type FlatObject = Record<string, any>;
export type ObjectSchema = AnyZodObject;

export function combineEndpointAndMiddlewareInputSchemas<IN extends z.ZodRawShape, mIN>(
input: ObjectSchema<IN>,
export type Merge<A extends ObjectSchema, B extends ObjectSchema | any> = z.ZodObject<
// eslint-disable-next-line @typescript-eslint/ban-types
A['_shape'] & (B extends ObjectSchema ? B['_shape'] : {}),
A['_unknownKeys'],
A['_catchall']
>;

export function combineEndpointAndMiddlewareInputSchemas<IN extends ObjectSchema, mIN>(
input: IN,
middlewares: MiddlewareDefinition<any, any, any>[]
): ObjectSchema<IN & mIN> {
): Merge<IN, mIN> {
if (middlewares.length === 0) {
return input as any as ObjectSchema<IN & mIN>;
return input as any as Merge<IN, mIN>;
}
return middlewares
.map((middleware) => middleware.input)
.reduce((carry, schema) => carry.merge(schema))
.merge(input) as ObjectSchema<IN & mIN>;
.reduce((carry: ObjectSchema, schema) => carry.merge(schema))
.merge(input) as Merge<IN, mIN>;
}
2 changes: 1 addition & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
export { ConfigType } from './config-type';
export { AbstractEndpoint, Method } from './endpoint';
export { EndpointsFactory } from './endpoints-factory';
export { ObjectSchema, Unshape } from './helpers';
export { ObjectSchema, FlatObject } from './helpers';
export { createLogger } from './logger';
export { createMiddleware } from './middleware';
export { ResultHandler } from './result-handler';
Expand Down
10 changes: 5 additions & 5 deletions src/middleware.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import {Request, Response} from 'express';
import {Logger} from 'winston';
import * as z from 'zod';
import {ObjectSchema, Unshape} from './helpers';
import {FlatObject, ObjectSchema} from './helpers';

interface MiddlewareParams<IN, OPT> {
input: IN;
Expand All @@ -13,11 +13,11 @@ interface MiddlewareParams<IN, OPT> {

type Middleware<IN, OPT, OUT> = (params: MiddlewareParams<IN, OPT>) => Promise<OUT>;

export interface MiddlewareDefinition<IN extends z.ZodRawShape, OPT, OUT> {
input: ObjectSchema<IN>;
middleware: Middleware<Unshape<IN>, OPT, OUT>;
export interface MiddlewareDefinition<IN extends ObjectSchema, OPT, OUT extends FlatObject> {
input: IN;
middleware: Middleware<z.infer<IN>, OPT, OUT>;
}

export const createMiddleware = <IN extends z.ZodRawShape, OPT, OUT>(
export const createMiddleware = <IN extends ObjectSchema, OPT, OUT extends FlatObject>(
definition: MiddlewareDefinition<IN, OPT, OUT>
) => definition;
Loading