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

Ref: Async peers instead of changing config type #1321

Merged
merged 11 commits into from
Nov 20, 2023
27 changes: 14 additions & 13 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,31 +6,32 @@

- **Breaking changes**:
- `express-fileupload` and `compression` become optional peer dependencies;
- Methods `createConfig` and `testEndpoint()` are changed (read the migration guide below).
- Method `createServer()` becomes async;
- Method `testEndpoint()` requires an additional argument;
- Read the migration guide below.
- Features:
- Supporting both `jest` and `vitest` frameworks for `testEndpoint()`.
- How to migrate while maintaining previous functionality and behavior:
- If you have `upload` option enabled in your config:
- Install `express-fileupload` and `@types/express-fileupload` packages;
- Replace `upload` property with `uploader: fileUpload({ abortOnLimit: false, parseNested: true })` in config.
- If you have `compression` option enabled in your config:
- Install `compression` and `@types/compression` packages;
- Replace `compression` property with `compressor: compression()` in config.
- If you're using the entities returned from `createServer()` method:
- Add `await` before calling it: `const {...} = await createServer(...)`.
- If you can not use `await` (on the top level of CommonJS):
- Wrap your code with async IIFE: `(async () => { ... })()`, which will allow you to use `await`;
- Or use `.then()` syntax of `Promise`.
- If you're using `testEndpoint()` method:
- Specify either `mockFn: jest.fn` or `mockFn: vi.fn` within its object argument.

```typescript
import compression from "compression";
import fileUpload from "express-fileupload";
import { createConfig, testEndpoint } from "express-zod-api";
import { createServer, testEndpoint } from "express-zod-api";

const config = createConfig({
server: {
// The following two options are required to operate normally:
uploader: fileUpload({ abortOnLimit: false, parseNested: true }),
compressor: compression(),
},
});
// This async IIFE wrapper is only needed when using await on the top level CJS
(async () => {
// await is only needed when you're using the returns of createServer()
const { app, httpServer } = await createServer(config, routing);
})();

const { responseMock } = testEndpoint({
endpoint,
Expand Down
24 changes: 11 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -483,7 +483,12 @@ const config = createConfig({
// ... cors, logger, etc
});

const { app, httpServer, httpsServer, logger } = createServer(config, routing);
// 'await' is only needed if you're going to use the returned entities.
// For CJS in that case you can wrap you code with (async () => { ... })()
const { app, httpServer, httpsServer, logger } = await createServer(
config,
routing,
);
```

Ensure having `@types/node` package installed. At least you need to specify the port (usually it is 443) or UNIX socket,
Expand All @@ -509,17 +514,15 @@ const config = createConfig({ logger /* ..., */ });
According to [Express.js best practices guide](http://expressjs.com/en/advanced/best-practice-performance.html)
it might be a good idea to enable GZIP compression of your API responses.

Install the following additional packages: `compression` and `@types/compression`, and complete your configuration with
a compressor:
Install the following additional packages: `compression` and `@types/compression`, and enable or configure compression:

```typescript
import compression from "compression";
import { createConfig } from "express-zod-api";

const config = createConfig({
server: {
/** @link https://www.npmjs.com/package/compression#options */
compressor: compression({ threshold: "1kb" }),
compression: { threshold: "1kb" }, // or true
},
});
```
Expand Down Expand Up @@ -712,20 +715,15 @@ const fileStreamingEndpointsFactory = new EndpointsFactory(

## File uploads

Install the following additional packages: `express-fileupload` and `@types/express-fileupload`, and complete your
configuration with a file uploader:
Install the following additional packages: `express-fileupload` and `@types/express-fileupload`, and enable or
configure file uploads:

```typescript
import fileUpload from "express-fileupload";
import { createConfig } from "express-zod-api";

const config = createConfig({
server: {
uploader: fileUpload({
// These options are required to operate normally:
abortOnLimit: false,
parseNested: true,
}),
upload: true, // or options
},
});
```
Expand Down
6 changes: 2 additions & 4 deletions example/config.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,11 @@
import express from "express";
import compression from "compression";
import fileUpload from "express-fileupload";
import { createConfig } from "../src";

export const config = createConfig({
server: {
listen: 8090,
uploader: fileUpload({ abortOnLimit: false, parseNested: true }),
compressor: compression(), // affects sendAvatarEndpoint
upload: true,
compression: true, // affects sendAvatarEndpoint
rawParser: express.raw(), // required for rawAcceptingEndpoint
},
cors: true,
Expand Down
17 changes: 16 additions & 1 deletion src/common-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,11 @@ import { createHash } from "node:crypto";
import { Logger } from "winston";
import { z } from "zod";
import { CommonConfig, InputSource, InputSources } from "./config-type";
import { InputValidationError, OutputValidationError } from "./errors";
import {
InputValidationError,
MissingPeerError,
OutputValidationError,
} from "./errors";
import { ZodFile } from "./file-schema";
import { IOSchema } from "./io-schema";
import { getMeta } from "./metadata";
Expand Down Expand Up @@ -319,3 +323,14 @@ export type ErrMessage = Exclude<
// the copy of the private Zod errorUtil.errToObj
export const errToObj = (message: ErrMessage | undefined) =>
typeof message === "string" ? { message } : message || {};

export const loadPeer = async <T>(
moduleName: string,
moduleExport: string = "default",
): Promise<T> => {
try {
return (await import(moduleName))[moduleExport];
} catch {
throw new MissingPeerError(moduleName);
}
};
37 changes: 27 additions & 10 deletions src/config-type.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import type compression from "compression";
import { Express, Request, RequestHandler } from "express";
import type fileUpload from "express-fileupload";
import { ServerOptions } from "node:https";
import { Logger } from "winston";
import { AbstractEndpoint } from "./endpoint";
Expand Down Expand Up @@ -65,6 +67,23 @@ export interface CommonConfig<TAG extends string = string> {
tags?: TagsConfig<TAG>;
}

type UploadOptions = Pick<
fileUpload.Options,
| "createParentPath"
| "uriDecodeFileNames"
| "safeFileNames"
| "preserveExtension"
| "useTempFiles"
| "tempFileDir"
| "debug"
| "uploadTimeout"
>;

type CompressionOptions = Pick<
compression.CompressionOptions,
"threshold" | "level" | "strategy" | "chunkSize" | "memLevel"
>;

export interface ServerConfig<TAG extends string = string>
extends CommonConfig<TAG> {
/** @desc Server configuration. */
Expand All @@ -78,19 +97,17 @@ export interface ServerConfig<TAG extends string = string>
* */
jsonParser?: RequestHandler;
/**
* @desc Enable and configure file uploads
* @default undefined
* @example import fileUpload from "express-fileupload"
* @example uploader: fileUpload({ abortOnLimit: false, parseNested: true })
* @desc Enable or configure uploads handling.
* @default false
* @requires express-fileupload
* */
uploader?: RequestHandler;
upload?: boolean | UploadOptions;
/**
* @desc Enable and configure compression
* @default undefined
* @example import compression from "compression"
* @example compressor: compression()
* @desc Enable or configure response compression.
* @default false
* @requires compression
*/
compressor?: RequestHandler;
compression?: boolean | CompressionOptions;
/**
* @desc Enables parsing certain request payloads into raw Buffers (application/octet-stream by default)
* @desc When enabled, use ez.raw() as input schema to get input.raw in Endpoint's handler
Expand Down
9 changes: 9 additions & 0 deletions src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,3 +66,12 @@ export class ResultHandlerError extends Error {
this.originalError = originalError || undefined;
}
}

export class MissingPeerError extends Error {
public override name = "MissingPeerError";
constructor(module: string) {
super(
`Missing peer dependency: '${module}'. Please install it to use the feature activated in config.`,
);
}
}
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ export {
RoutingError,
OutputValidationError,
InputValidationError,
MissingPeerError,
} from "./errors";
export { withMeta } from "./metadata";
export { testEndpoint } from "./testing";
Expand Down
30 changes: 24 additions & 6 deletions src/server.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import express, { ErrorRequestHandler, RequestHandler } from "express";
import type compression from "compression";
import type fileUpload from "express-fileupload";
import http from "node:http";
import https from "node:https";
import { Logger } from "winston";
import { AppConfig, CommonConfig, ServerConfig } from "./config-type";
import { ResultHandlerError } from "./errors";
import { makeErrorFromAnything } from "./common-helpers";
import { loadPeer, makeErrorFromAnything } from "./common-helpers";
import { createLogger } from "./logger";
import {
AnyResultHandlerDefinition,
Expand Down Expand Up @@ -74,14 +76,30 @@ export const attachRouting = (config: AppConfig, routing: Routing) => {
return { notFoundHandler, logger };
};

export const createServer = (config: ServerConfig, routing: Routing) => {
export const createServer = async (config: ServerConfig, routing: Routing) => {
const app = express().disable("x-powered-by");
if (config.server.compressor) {
app.use(config.server.compressor);
if (config.server.compression) {
const compressor = await loadPeer<typeof compression>("compression");
app.use(
compressor(
typeof config.server.compression === "object"
? config.server.compression
: undefined,
),
);
}
app.use(config.server.jsonParser || express.json());
if (config.server.uploader) {
app.use(config.server.uploader);
if (config.server.upload) {
const uploader = await loadPeer<typeof fileUpload>("express-fileupload");
app.use(
uploader({
...(typeof config.server.upload === "object"
? config.server.upload
: {}),
abortOnLimit: false,
parseNested: true,
}),
);
}
if (config.server.rawParser) {
app.use(config.server.rawParser);
Expand Down
14 changes: 13 additions & 1 deletion tests/express-mock.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
// @see https://github.com/swc-project/jest/issues/14#issuecomment-970189585

const expressJsonMock = jest.fn();
const compressionMock = jest.fn();
const fileUploadMock = jest.fn();
jest.mock("compression", () => compressionMock);
jest.mock("express-fileupload", () => fileUploadMock);

const staticHandler = jest.fn();
const staticMock = jest.fn(() => staticHandler);
Expand All @@ -22,4 +26,12 @@ appCreatorMock.static = staticMock;

const expressMock = jest.mock("express", () => appCreatorMock);

export { expressMock, appMock, expressJsonMock, staticMock, staticHandler };
export {
compressionMock,
fileUploadMock,
expressMock,
appMock,
expressJsonMock,
staticMock,
staticHandler,
};
37 changes: 19 additions & 18 deletions tests/system/system.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import compression from "compression";
import cors from "cors";
import type { Server } from "node:http";
import { z } from "zod";
Expand All @@ -16,7 +15,7 @@ describe("App", () => {
const port = givePort();
let server: Server;

beforeAll(() => {
beforeAll(async () => {
const routing = {
v1: {
corsed: new EndpointsFactory(defaultResultHandler)
Expand Down Expand Up @@ -136,23 +135,25 @@ describe("App", () => {
},
};
jest.spyOn(global.console, "log").mockImplementation(jest.fn());
server = createServer(
{
server: {
listen: port,
compressor: compression({ threshold: 1 }),
},
cors: false,
startupLogo: true,
logger: {
level: "silent",
color: false,
},
inputSources: {
post: ["query", "body", "files"],
server = (
await createServer(
{
server: {
listen: port,
compression: { threshold: 1 },
},
cors: false,
startupLogo: true,
logger: {
level: "silent",
color: false,
},
inputSources: {
post: ["query", "body", "files"],
},
},
},
routing,
routing,
)
).httpServer;
});

Expand Down
3 changes: 3 additions & 0 deletions tests/unit/__snapshots__/index.spec.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ exports[`Index Entrypoint exports InputValidationError should have certain value

exports[`Index Entrypoint exports Integration should have certain value 1`] = `[Function]`;

exports[`Index Entrypoint exports MissingPeerError should have certain value 1`] = `[Function]`;

exports[`Index Entrypoint exports OutputValidationError should have certain value 1`] = `[Function]`;

exports[`Index Entrypoint exports RoutingError should have certain value 1`] = `[Function]`;
Expand Down Expand Up @@ -101,6 +103,7 @@ exports[`Index Entrypoint exports should have certain entities exposed 1`] = `
"Integration",
"LoggerConfig",
"Method",
"MissingPeerError",
"OutputValidationError",
"Routing",
"RoutingError",
Expand Down
Loading