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: add upload functionality in the middleware #7323

Merged
merged 4 commits into from
Nov 22, 2024
Merged
Show file tree
Hide file tree
Changes from 2 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: 29 additions & 0 deletions .changeset/hot-maps-unite.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
---
"@vue-storefront/middleware": minor
---

**[ADDED]** Support for file uploads
Now you can upload files to the server with a `multipart/form-data` content type. Files are available in the `req.files` object.

```ts
// Example of an endpoint that handles file uploads
export const upload = (context) => {
// Files are available in the `req.files` object
const { files } = context.req;

// Do something with files

return Promise.resolve({
status: 200,
message: "ok",
});
};
```

Available options are:
jagoral marked this conversation as resolved.
Show resolved Hide resolved

- `enabled`: (boolean) Enable/disable file upload functionality. Default: `true`. If you set it to `false`, the middleware will not initialize file upload functionality and the `req.files` object will be empty. That can be useful if you do not want to handle file uploads in your app and want to avoid unnecessary processing.
- `maxFileSize`: (number) Maximum file size in bytes. Default: `5242880` (5MB)
- `maxFiles`: (number) Maximum number of files that can be uploaded at once. Default: `5`
- `allowedMimeTypes`: (string[]) Array of allowed MIME types. Default: `["image/*", "application/pdf"]`
- `fieldNames`: (string[]) Array of accepted form field names for file uploads. Default: `[]`
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@ export { error } from "./error";
export { throwAxiosError } from "./throwAxiosError";
export { throwError } from "./throwError";
export { getConfig } from "./getConfig";
export { upload } from "./upload";
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
export const upload = (context) => {
const { files } = context.req;

return Promise.resolve({
status: 200,
message: "ok",
error: false,
files,
});
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { apiClientFactory } from "../../../src/apiClientFactory";
import * as api from "./api";
import { AlokaiContainer, getLogger } from "../../../src";

const onCreate = async (
config: Record<string, unknown> = {},
alokai: AlokaiContainer
) => {
const logger = getLogger(alokai);
logger.info("oncreate");
jagoral marked this conversation as resolved.
Show resolved Hide resolved

return {
config,
client: null,
};
};

const { createApiClient } = apiClientFactory({
onCreate,
api,
});

export { createApiClient };
61 changes: 61 additions & 0 deletions packages/middleware/__tests__/integration/upload.spec.ts
jagoral marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { Server } from "http";
import request from "supertest";
import { createServer } from "../../src/index";

describe("[Integration] Create server", () => {
let app: Server;

const getServer = async (isUploadEnabled: boolean = true) => {
return await createServer(
{
integrations: {
test_integration: {
location: "./__tests__/integration/bootstrap/serverWithUpload",
configuration: {},
},
},
},
{
fileUpload: {
enabled: isUploadEnabled,
},
}
);
};

it("should allow to upload files if fileUpload is enabled", async () => {
app = await getServer();

const { status, body } = await request(app)
.post("/test_integration/upload")
.set("Content-Type", "multipart/form-data")
.attach("files", Buffer.from("test file content"), "test.jpg")
.expect(200);

expect(status).toBe(200);
expect(body).toBeDefined();
expect(body.files).toEqual([
{
fieldname: "files",
originalname: "test.jpg",
encoding: "7bit",
mimetype: "image/jpeg",
buffer: expect.any(Object),
size: 17,
},
]);
});

it("should not allow to upload files if fileUpload is disabled", async () => {
app = await getServer(false);

const { status, body } = await request(app)
.post("/test_integration/upload")
.set("Content-Type", "multipart/form-data")
.expect(200);

expect(status).toBe(200);
expect(body).toBeDefined();
expect(body.files).toBeUndefined();
});
});
126 changes: 126 additions & 0 deletions packages/middleware/__tests__/unit/services/fileUpload.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import express from "express";
import multer from "multer";
import { configureFileUpload } from "../../../src/services/fileUpload";

// Mock multer
jest.mock("fileUpload", () => {
const mockMulter = jest.fn(() => ({
fields: jest.fn(),
any: jest.fn(),
})) as jest.Mock & { memoryStorage: jest.Mock };
mockMulter.memoryStorage = jest.fn();
return mockMulter;
});

describe("configureFileUpload", () => {
let app: express.Express;

beforeEach(() => {
app = express();
app.use = jest.fn();
jest.clearAllMocks();
});

it("should not configure upload when enabled is false", () => {
configureFileUpload(app, { enabled: false });
expect(app.use).not.toHaveBeenCalled();
expect(multer).not.toHaveBeenCalled();
});

it("should configure upload with default options when enabled", () => {
configureFileUpload(app, { enabled: true });

expect(multer).toHaveBeenCalledWith(
expect.objectContaining({
limits: {
fileSize: 5 * 1024 * 1024,
files: 5,
},
})
);
expect(app.use).toHaveBeenCalled();
});

it("should configure upload with custom options", () => {
const customOptions = {
enabled: true,
maxFileSize: 10 * 1024 * 1024,
maxFiles: 3,
allowedMimeTypes: ["image/jpeg"],
};

configureFileUpload(app, customOptions);

expect(multer).toHaveBeenCalledWith(
expect.objectContaining({
limits: {
fileSize: 10 * 1024 * 1024,
files: 3,
},
})
);
});

it("should use fields() when fieldNames are provided", () => {
configureFileUpload(app, {
enabled: true,
fieldNames: ["avatar", "document"],
});

const expectedFields = [
{ name: "avatar", maxCount: 1 },
{ name: "document", maxCount: 1 },
];

const multerInstance = (multer as unknown as jest.Mock).mock.results[0]
.value;
expect(multerInstance.fields).toHaveBeenCalledWith(expectedFields);
});

it("should use any() when no fieldNames are provided", () => {
configureFileUpload(app, { enabled: true });

const multerInstance = (multer as unknown as jest.Mock).mock.results[0]
.value;
expect(multerInstance.any).toHaveBeenCalled();
});

describe("fileFilter", () => {
let fileFilter: (req: any, file: any, cb: any) => void;

beforeEach(() => {
configureFileUpload(app, {
enabled: true,
allowedMimeTypes: ["image/*", "application/pdf"],
});
fileFilter = (multer as unknown as jest.Mock).mock.calls[0][0].fileFilter;
});

it("should accept files when no mime types are specified", () => {
const cb = jest.fn();
configureFileUpload(app, { enabled: true, allowedMimeTypes: [] });
fileFilter = (multer as unknown as jest.Mock).mock.calls[1][0].fileFilter;

fileFilter(null, { mimetype: "anything" }, cb);
expect(cb).toHaveBeenCalledWith(null, true);
});

it("should accept files matching exact mime type", () => {
const cb = jest.fn();
fileFilter(null, { mimetype: "application/pdf" }, cb);
expect(cb).toHaveBeenCalledWith(null, true);
});

it("should accept files matching wildcard mime type", () => {
const cb = jest.fn();
fileFilter(null, { mimetype: "image/jpeg" }, cb);
expect(cb).toHaveBeenCalledWith(null, true);
});

it("should reject files with unallowed mime types", () => {
const cb = jest.fn();
fileFilter(null, { mimetype: "text/plain" }, cb);
expect(cb).toHaveBeenCalledWith(null, false);
});
});
});
4 changes: 3 additions & 1 deletion packages/middleware/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,9 @@
"helmet": "^5.1.1",
"@godaddy/terminus": "^4.12.1",
"lodash.merge": "^4.6.2",
"xss": "^1.0.15"
"xss": "^1.0.15",
"multer": "^1.4.5-lts.1",
"@types/multer": "^1.4.7"
bartoszherba marked this conversation as resolved.
Show resolved Hide resolved
},
"devDependencies": {
"@types/body-parser": "^1.19.2",
Expand Down
3 changes: 3 additions & 0 deletions packages/middleware/src/createServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
} from "./handlers";
import { createTerminusOptions } from "./terminus";
import { prepareLogger } from "./handlers/prepareLogger";
import { configureFileUpload } from "./services/fileUpload";

const defaultCorsOptions: CreateServerOptions["cors"] = {
credentials: true,
Expand All @@ -50,6 +51,8 @@ async function createServer<

const app = express();

configureFileUpload(app, options.fileUpload);
jagoral marked this conversation as resolved.
Show resolved Hide resolved

app.use(express.json(options.bodyParser));
app.use(
options.cookieParser
Expand Down
51 changes: 51 additions & 0 deletions packages/middleware/src/services/fileUpload.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import multer from "multer";
import type { Express } from "express";
import type { FileUploadOptions } from "../types";

const DEFAULT_OPTIONS: FileUploadOptions = {
enabled: false,
maxFileSize: 5 * 1024 * 1024, // 5MB
maxFiles: 5,
allowedMimeTypes: ["image/*", "application/pdf"],
};

export function configureFileUpload(app: Express, options?: FileUploadOptions) {
const config = { ...DEFAULT_OPTIONS, ...options };

if (!config.enabled) {
return;
}

const storage = multer.memoryStorage();

const upload = multer({
storage,
limits: {
fileSize: config.maxFileSize,
files: config.maxFiles,
},
fileFilter: (_req, file, cb) => {
if (!config.allowedMimeTypes?.length) {
return cb(null, true);
}

const isAllowed = config.allowedMimeTypes.some((type) => {
if (type.endsWith("/*")) {
const mainType = type.split("/")[0];
return file.mimetype.startsWith(`${mainType}/`);
}
return type === file.mimetype;
});

cb(null, isAllowed);
},
});

// If specific field names are provided, use fields()
if (config.fieldNames?.length) {
const fields = config.fieldNames.map((name) => ({ name, maxCount: 1 }));
app.use(upload.fields(fields));
} else {
app.use(upload.any());
}
}
14 changes: 11 additions & 3 deletions packages/middleware/src/types/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,14 @@ export type ResponseWithAlokaiLocals = Response<
[key: string]: any;
}
>;
export type RequestWithFiles = Request & {
lsliwaradioluz marked this conversation as resolved.
Show resolved Hide resolved
files?:
| {
[fieldname: string]: Express.Multer.File[];
}
| Express.Multer.File[]
| undefined;
};

export type ExtensionEndpointHandler = ApiClientMethod & {
_extensionName?: string;
Expand Down Expand Up @@ -96,7 +104,7 @@ export interface ApiClientExtension<API = any, CONTEXT = any, CONFIG = any> {
logger: LoggerInterface;
}) => Promise<void> | void;
hooks?: (
req: Request,
req: RequestWithFiles,
res: ResponseWithAlokaiLocals,
hooksContext: AlokaiContainer
) => ApiClientExtensionHooks;
Expand All @@ -119,7 +127,7 @@ export interface Integration<
initConfig?: TObject;
errorHandler?: (
error: unknown,
req: Request,
req: RequestWithFiles,
res: ResponseWithAlokaiLocals
) => void;
}
Expand Down Expand Up @@ -158,7 +166,7 @@ export type IntegrationsLoaded<
> = Record<string, IntegrationLoaded<CONFIG, API>>;

export interface MiddlewareContext<API extends ApiMethods = any> {
req: Request;
req: RequestWithFiles;
res: ResponseWithAlokaiLocals;
extensions: ApiClientExtension<API>[];
customQueries: Record<string, CustomQueryFunction>;
Expand Down
7 changes: 7 additions & 0 deletions packages/middleware/src/types/fileUpload.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export interface FileUploadOptions {
enabled?: boolean;
bartoszherba marked this conversation as resolved.
Show resolved Hide resolved
maxFileSize?: number; // in bytes
maxFiles?: number;
allowedMimeTypes?: string[];
fieldNames?: string[]; // specific field names to accept
jagoral marked this conversation as resolved.
Show resolved Hide resolved
}
1 change: 1 addition & 0 deletions packages/middleware/src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,3 +39,4 @@ export * from "./base";
export * from "./common";
export * from "./server";
export * from "./config";
export * from "./fileUpload";
Loading
Loading