Skip to content

Commit

Permalink
Detecting z.upload() usage automatically (#204)
Browse files Browse the repository at this point in the history
* Introducing hasUpload() helper.

* hasUpload() helper tests.

* Updating Readme and inline documentation.

* Changelog: future version 3.2.0.

* Readme: Fixing typo in content type of Uploads section.

* Readme: updating Uploads section.

* Removing type from example endpoint.
  • Loading branch information
RobinTail authored Nov 27, 2021
1 parent 2b81a27 commit f9668e2
Show file tree
Hide file tree
Showing 8 changed files with 86 additions and 13 deletions.
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,14 @@

## Version 3

### v3.2.0

- Feature #204. Detecting usage of `z.upload()` within Endpoint's input schema automatically.
- There is no longer need to specify `type: "upload"` for `endpointsFactory.build({...})`.
- In case you are using `z.upload()` in endpoint's input schema, inputs will be parsed by the
`multipart/form-data` parser.
- The optional parameter `type?: "json" | "upload"` of `build({...})` is deprecated.

### v3.1.2

- Fixed issue #202, originally reported in PR #201.
Expand Down
10 changes: 3 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -443,8 +443,8 @@ const fileStreamingEndpointsFactory = new EndpointsFactory(

## File uploads

You can switch the `Endpoint` to handle requests with the `multipart/formdata` content type instead of JSON.
Together with a corresponding configuration option, this makes it possible to handle file uploads.
You can switch the `Endpoint` to handle requests with the `multipart/form-data` content type instead of JSON by using
`z.upload()` schema. Together with a corresponding configuration option, this makes it possible to handle file uploads.
Here is a simplified example:

```typescript
Expand All @@ -459,19 +459,15 @@ const config = createConfig({

const fileUploadEndpoint = defaultEndpointsFactory.build({
method: "post",
type: "upload", // <- required
input: z.object({
avatar: z.upload(),
avatar: z.upload(), // <--
}),
output: z.object({
/* ... */
}),
handler: async ({ input: { avatar } }) => {
// avatar: {name, mv(), mimetype, data, size, etc}
// avatar.truncated is true on failure
return {
/* ... */
};
},
});
```
Expand Down
1 change: 0 additions & 1 deletion example/endpoints/upload-avatar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import crypto from "crypto";

export const uploadAvatarEndpoint = defaultEndpointsFactory.build({
method: "post",
type: "upload",
input: z
.object({
avatar: z
Expand Down
34 changes: 34 additions & 0 deletions src/common-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { copyMeta, getMeta } from "./metadata";
import { Method } from "./method";
import { MiddlewareDefinition } from "./middleware";
import { mimeMultipart } from "./mime";
import { ZodUpload } from "./upload-schema";

export type FlatObject = Record<string, any>;

Expand Down Expand Up @@ -227,6 +228,39 @@ export function getRoutePathParams(path: string): string[] {
return match.map((param) => param.slice(1));
}

export function hasUpload(schema: z.ZodTypeAny): boolean {
if (schema instanceof ZodUpload) {
return true;
}
const reduceBool = (arr: boolean[]) =>
arr.reduce((carry, check) => carry || check, false);
if (schema instanceof z.ZodObject) {
return reduceBool(Object.values<z.ZodTypeAny>(schema.shape).map(hasUpload));
}
if (schema instanceof z.ZodUnion) {
return reduceBool(schema._def.options.map(hasUpload));
}
if (schema instanceof z.ZodIntersection) {
return reduceBool([schema._def.left, schema._def.right].map(hasUpload));
}
if (schema instanceof z.ZodOptional || schema instanceof z.ZodNullable) {
return hasUpload(schema.unwrap());
}
if (schema instanceof z.ZodEffects || schema instanceof z.ZodTransformer) {
return hasUpload(schema._def.schema);
}
if (schema instanceof z.ZodRecord) {
return hasUpload(schema._def.valueType);
}
if (schema instanceof z.ZodArray) {
return hasUpload(schema._def.type);
}
if (schema instanceof z.ZodDefault) {
return hasUpload(schema._def.innerType);
}
return false;
}

// obtaining the private helper type from Zod
export type ErrMessage = Exclude<
Parameters<typeof z.ZodString.prototype.email>[0],
Expand Down
2 changes: 1 addition & 1 deletion src/config-type.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ export interface ServerConfig {
// server configuration
listen: number | string; // port or socket
jsonParser?: NextHandleFunction; // custom JSON parser, default: express.json()
upload?: boolean | UploadOptions;
upload?: boolean | UploadOptions; // enable or configure uploads handling
};
}

Expand Down
9 changes: 5 additions & 4 deletions src/endpoints-factory.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { z } from "zod";
import { ApiResponse } from "./api-response";
import { Endpoint, Handler } from "./endpoint";
import { FlatObject, IOSchema, Merge } from "./common-helpers";
import { FlatObject, IOSchema, hasUpload, Merge } from "./common-helpers";
import { Method, MethodsDefinition } from "./method";
import { MiddlewareDefinition } from "./middleware";
import { mimeJson, mimeMultipart } from "./mime";
Expand All @@ -21,7 +21,8 @@ type BuildProps<
output: OUT;
handler: Handler<z.output<Merge<IN, MwIN>>, z.input<OUT>, MwOUT>;
description?: string;
type?: "json" | "upload"; // @todo can we detect the usage of z.upload() within input?
/** @deprecated the factory automatically detects the usage of z.upload() within the input schema */
type?: "json" | "upload"; // @todo remove in v4
} & MethodsDefinition<M>;

export class EndpointsFactory<
Expand Down Expand Up @@ -66,7 +67,7 @@ export class EndpointsFactory<
output,
handler,
description,
type,
type, // @todo remove in v4
...rest
}: BuildProps<IN, OUT, MwIN, MwOUT, M>) {
return new Endpoint<IN, OUT, MwIN, MwOUT, M, POS, NEG>({
Expand All @@ -76,7 +77,7 @@ export class EndpointsFactory<
inputSchema: input,
outputSchema: output,
resultHandler: this.resultHandler,
mimeTypes: type === "upload" ? [mimeMultipart] : [mimeJson],
mimeTypes: hasUpload(input) ? [mimeMultipart] : [mimeJson],
...rest,
});
}
Expand Down
5 changes: 5 additions & 0 deletions tests/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,11 @@ export const serializeSchemaForTest = (
? {
value: schema._def.value,
}
: schema instanceof z.ZodDefault
? {
value: schema._def.innerType,
default: schema._def.defaultValue(),
}
: {}),
};
};
30 changes: 30 additions & 0 deletions tests/unit/common-helpers.spec.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { UploadedFile } from "express-fileupload";
import { expectType } from "tsd";
import {
combinations,
Expand All @@ -8,6 +9,7 @@ import {
getMessageFromError,
getRoutePathParams,
getStatusCodeFromError,
hasUpload,
isLoggerConfig,
OutputMarker,
} from "../../src/common-helpers";
Expand Down Expand Up @@ -697,4 +699,32 @@ describe("Common Helpers", () => {
expect(getRoutePathParams("\n")).toEqual([]);
});
});

describe("hasUpload()", () => {
test("should return true for z.upload()", () => {
expect(hasUpload(z.upload())).toBeTruthy();
});
test("should return true for wrapped z.upload()", () => {
expect(hasUpload(z.object({ test: z.upload() }))).toBeTruthy();
expect(hasUpload(z.upload().or(z.boolean()))).toBeTruthy();
expect(
hasUpload(
z.object({ test: z.boolean() }).and(z.object({ test2: z.upload() }))
)
).toBeTruthy();
expect(hasUpload(z.optional(z.upload()))).toBeTruthy();
expect(hasUpload(z.upload().nullable())).toBeTruthy();
expect(hasUpload(z.upload().default({} as UploadedFile))).toBeTruthy();
expect(hasUpload(z.record(z.upload()))).toBeTruthy();
expect(hasUpload(z.upload().refine(() => true))).toBeTruthy();
expect(hasUpload(z.array(z.upload()))).toBeTruthy();
});
test("should return false in other cases", () => {
expect(hasUpload(z.object({}))).toBeFalsy();
expect(hasUpload(z.any())).toBeFalsy();
expect(hasUpload(z.literal("test"))).toBeFalsy();
expect(hasUpload(z.boolean().and(z.literal(true)))).toBeFalsy();
expect(hasUpload(z.number().or(z.string()))).toBeFalsy();
});
});
});

0 comments on commit f9668e2

Please sign in to comment.