diff --git a/.changeset/hot-maps-unite.md b/.changeset/hot-maps-unite.md index af37915d1f..3de3289695 100644 --- a/.changeset/hot-maps-unite.md +++ b/.changeset/hot-maps-unite.md @@ -20,6 +20,24 @@ export const upload = (context) => { }; ``` +You can also provide a function that returns configuration options based on the request. This is useful when you need to enable/disable file uploads dynamically: + +```ts +createServer( + { + // ... other config + }, + { + fileUpload: (req) => ({ + enabled: req.headers["x-enable-upload"] === "true", + // other options can also be configured dynamically + }), + } +); +``` + +This allows you to control file upload behavior on a per-request basis. For example, you could enable uploads only for authenticated requests or requests with specific headers. + Available options for the new `fileUpload` property in the `createServer` function are: - `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. diff --git a/.eslintrc.js b/.eslintrc.js index a5b0be5d2e..ce531fdd37 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -2,5 +2,7 @@ module.exports = { extends: "@vue-storefront/eslint-config-integrations", rules: { "class-methods-use-this": "off", + "@typescript-eslint/no-restricted-types": "off", + "@typescript-eslint/ban-types": "off", }, }; diff --git a/docs/content/3.middleware/2.guides/2.getting-started.md b/docs/content/3.middleware/2.guides/2.getting-started.md index 34ee8037b1..f50b2ffb1c 100644 --- a/docs/content/3.middleware/2.guides/2.getting-started.md +++ b/docs/content/3.middleware/2.guides/2.getting-started.md @@ -65,7 +65,114 @@ With our middleware file set up, we can use `ts-node-dev` to run our application } ``` +## Configuration Options +The `createServer` function accepts a second parameter for configuring various middleware options: + +```ts [src/index.ts] +const app = await createServer( + { integrations: config.integrations }, + { + // CORS configuration + cors: { + origin: "http://localhost:3000", + credentials: true, + }, + // Body parser configuration + bodyParser: { + limit: "50mb", + }, + // Cookie parser configuration + cookieParser: { + secret: "secret", + }, + // File upload configuration + fileUpload: { + enabled: true, // Enable/disable file upload functionality + maxFileSize: 5242880, // Maximum file size in bytes (default: 5MB) + maxFiles: 5, // Maximum number of files per upload + allowedMimeTypes: ["image/*", "application/pdf"], // Allowed file types + fieldNames: [], // Accepted form field names for file uploads + }, + } +); +``` + +### CORS Configuration + +Configure Cross-Origin Resource Sharing (CORS) settings. By default, `http://localhost:4000` is included in the allowed origins. + +### Body Parser Configuration + +Configure the body-parser middleware settings for handling request bodies. + +### Cookie Parser Configuration + +Configure the cookie-parser middleware settings for handling cookies. + +### File Upload Configuration + +Configure file upload handling for `multipart/form-data` requests. You can either provide static options or a function that returns configuration based on the request: + +```ts +fileUpload: (req) => ({ + enabled: req.headers["x-enable-upload"] === "true", + maxFileSize: 5242880, + maxFiles: 5, + allowedMimeTypes: ["image/*", "application/pdf"], + fieldNames: [], +}) +``` + +Available options: +- `enabled`: Enable/disable file upload functionality (default: `true`) +- `maxFileSize`: Maximum file size in bytes (default: 5MB) +- `maxFiles`: Maximum number of files per upload (default: 5) +- `allowedMimeTypes`: Array of allowed MIME types (default: `["image/*", "application/pdf"]`) +- `fieldNames`: Array of accepted form field names for file uploads (default: `[]`) + +When file uploads are enabled, uploaded files are available in the `req.files` object within your API endpoints: + +```ts +export const upload = (context) => { + const { files } = context.req; + // Handle uploaded files + return Promise.resolve({ + status: 200, + message: "ok", + }); +}; +``` + +You can also dynamically control file upload behavior on a per-request basis. This is particularly useful when you want to enable uploads only for specific scenarios, such as: +- Authenticated requests +- Requests with specific headers +- Requests from certain origins +- Different file size limits for different endpoints + +Here's an example of dynamic configuration based on request headers: + +```ts [src/index.ts] +const app = await createServer( + { integrations: config.integrations }, + { + fileUpload: (req) => ({ + enabled: req.headers["x-enable-upload"] === "true", + maxFileSize: req.headers["x-upload-size"] ? + parseInt(req.headers["x-upload-size"]) : + 5242880, + maxFiles: 5, + allowedMimeTypes: ["image/*", "application/pdf"], + fieldNames: [], + }), + } +); +``` + +In this example: +- File uploads are only enabled when the `x-enable-upload: true` header is present +- The maximum file size can be controlled via the `x-upload-size` header +- Other options remain static but could also be made dynamic based on your needs ## Adding Integrations diff --git a/packages/middleware/__tests__/integration/loggerScopes.spec.ts b/packages/middleware/__tests__/integration/loggerScopes.spec.ts index c0387558c3..40d04c5d86 100644 --- a/packages/middleware/__tests__/integration/loggerScopes.spec.ts +++ b/packages/middleware/__tests__/integration/loggerScopes.spec.ts @@ -67,7 +67,7 @@ const testingExtension = { ]); }, }, - hooks(req, res, alokai) { + hooks(_req, _res, alokai) { const logger = getLogger(alokai); logger.info("hooks"); return { diff --git a/packages/middleware/__tests__/integration/upload.spec.ts b/packages/middleware/__tests__/integration/upload.spec.ts index 5419ae1753..c9524d4e4f 100644 --- a/packages/middleware/__tests__/integration/upload.spec.ts +++ b/packages/middleware/__tests__/integration/upload.spec.ts @@ -70,4 +70,52 @@ describe("[Integration] Create server", () => { expect(body.files).toBeUndefined(); expect(body.message).toBe("ok"); }); + + // add test for options provided from the request + it("should allow to provide options from the request", async () => { + app = await createServer( + { + integrations: { + test_integration: { + location: "./__tests__/integration/bootstrap/serverWithUpload", + configuration: {}, + }, + }, + }, + { + fileUpload: (req) => ({ + enabled: req.headers["x-enable-upload"] === "true", + }), + } + ); + + // Test with uploads enabled via header + const uploadEnabled = await request(app) + .post("/test_integration/upload") + .set("Content-Type", "multipart/form-data") + .set("x-enable-upload", "true") + .attach("files", Buffer.from("test file content"), "test.jpg") + .expect(200); + + expect(uploadEnabled.body.files).toEqual([ + { + fieldname: "files", + originalname: "test.jpg", + encoding: "7bit", + mimetype: "image/jpeg", + buffer: expect.any(Object), + size: 17, + }, + ]); + + // Test with uploads disabled via header + const uploadDisabled = await request(app) + .post("/test_integration/upload") + .set("Content-Type", "multipart/form-data") + .set("x-enable-upload", "false") + .attach("files", Buffer.from("test file content"), "test.jpg") + .expect(200); + + expect(uploadDisabled.body.files).toBeUndefined(); + }); }); diff --git a/packages/middleware/__tests__/unit/services/fileUpload.spec.ts b/packages/middleware/__tests__/unit/services/fileUpload.spec.ts index 4ad6e76da5..c216154374 100644 --- a/packages/middleware/__tests__/unit/services/fileUpload.spec.ts +++ b/packages/middleware/__tests__/unit/services/fileUpload.spec.ts @@ -2,8 +2,7 @@ import express from "express"; import multer from "multer"; import { createMulterMiddleware } from "../../../src/services/fileUpload"; -// Mock multer -jest.mock("fileUpload", () => { +jest.mock("multer", () => { const mockMulter = jest.fn(() => ({ fields: jest.fn(), any: jest.fn(), @@ -22,23 +21,27 @@ describe("configureFileUpload", () => { }); it("should not configure upload when enabled is false", () => { - createMulterMiddleware(app, { enabled: false }); - expect(app.use).not.toHaveBeenCalled(); + const middleware = createMulterMiddleware({ enabled: false }); + expect(middleware).toBeUndefined(); expect(multer).not.toHaveBeenCalled(); }); it("should configure upload with default options when enabled", () => { - createMulterMiddleware(app, { enabled: true }); + const mockStorage = {}; + (multer.memoryStorage as jest.Mock).mockReturnValue(mockStorage); + createMulterMiddleware({ enabled: true }); + + expect(multer.memoryStorage).toHaveBeenCalled(); expect(multer).toHaveBeenCalledWith( expect.objectContaining({ + storage: mockStorage, limits: { fileSize: 5 * 1024 * 1024, files: 5, }, }) ); - expect(app.use).toHaveBeenCalled(); }); it("should configure upload with custom options", () => { @@ -49,7 +52,7 @@ describe("configureFileUpload", () => { allowedMimeTypes: ["image/jpeg"], }; - createMulterMiddleware(app, customOptions); + createMulterMiddleware(customOptions); expect(multer).toHaveBeenCalledWith( expect.objectContaining({ @@ -62,7 +65,7 @@ describe("configureFileUpload", () => { }); it("should use fields() when fieldNames are provided", () => { - createMulterMiddleware(app, { + createMulterMiddleware({ enabled: true, fieldNames: ["avatar", "document"], }); @@ -78,7 +81,7 @@ describe("configureFileUpload", () => { }); it("should use any() when no fieldNames are provided", () => { - createMulterMiddleware(app, { enabled: true }); + createMulterMiddleware({ enabled: true }); const multerInstance = (multer as unknown as jest.Mock).mock.results[0] .value; @@ -89,7 +92,7 @@ describe("configureFileUpload", () => { let fileFilter: (req: any, file: any, cb: any) => void; beforeEach(() => { - createMulterMiddleware(app, { + createMulterMiddleware({ enabled: true, allowedMimeTypes: ["image/*", "application/pdf"], }); @@ -98,7 +101,7 @@ describe("configureFileUpload", () => { it("should accept files when no mime types are specified", () => { const cb = jest.fn(); - createMulterMiddleware(app, { enabled: true, allowedMimeTypes: [] }); + createMulterMiddleware({ enabled: true, allowedMimeTypes: [] }); fileFilter = (multer as unknown as jest.Mock).mock.calls[1][0].fileFilter; fileFilter(null, { mimetype: "anything" }, cb); diff --git a/packages/middleware/src/createServer.ts b/packages/middleware/src/createServer.ts index 965d5641d0..2a11d4a862 100644 --- a/packages/middleware/src/createServer.ts +++ b/packages/middleware/src/createServer.ts @@ -15,7 +15,7 @@ import type { CreateServerOptions, } from "./types"; import { LoggerManager, injectMetadata, lockLogger } from "./logger"; - +import { prepareFileUpload } from "./handlers/prepareFileUpload"; import { prepareApiFunction, prepareErrorHandler, @@ -25,7 +25,6 @@ import { } from "./handlers"; import { createTerminusOptions } from "./terminus"; import { prepareLogger } from "./handlers/prepareLogger"; -import { createMulterMiddleware } from "./services/fileUpload"; const defaultCorsOptions: CreateServerOptions["cors"] = { credentials: true, @@ -51,7 +50,7 @@ async function createServer< const app = express(); - app.use(createMulterMiddleware(options.fileUpload)); + // app.use(createMulterMiddleware(options.fileUpload)); app.use(express.json(options.bodyParser)); app.use( options.cookieParser @@ -90,6 +89,7 @@ async function createServer< app.all( "/:integrationName/:extensionName?/:functionName", validateParams(integrations), + prepareFileUpload(options), prepareLogger(loggerManager), prepareApiFunction(integrations), prepareErrorHandler(integrations), diff --git a/packages/middleware/src/errors/defaultErrorHandler.ts b/packages/middleware/src/errors/defaultErrorHandler.ts index 2ea33e53c4..8f9cdba261 100644 --- a/packages/middleware/src/errors/defaultErrorHandler.ts +++ b/packages/middleware/src/errors/defaultErrorHandler.ts @@ -1,6 +1,6 @@ import type { Request } from "express"; import { getAgnosticStatusCode } from "../helpers"; -import type { ResponseWithAlokaiLocals } from "../types"; +import type { AlokaiResponse } from "../types"; type ClientSideError = { message?: string; @@ -16,7 +16,7 @@ type ClientSideError = { export const defaultErrorHandler = ( error: ClientSideError, req: Request, - res: ResponseWithAlokaiLocals + res: AlokaiResponse ) => { const status = getAgnosticStatusCode(error); res.status(status); diff --git a/packages/middleware/src/handlers/callApiFunction/index.ts b/packages/middleware/src/handlers/callApiFunction/index.ts index 22ecc6d95d..69373da07f 100644 --- a/packages/middleware/src/handlers/callApiFunction/index.ts +++ b/packages/middleware/src/handlers/callApiFunction/index.ts @@ -1,12 +1,9 @@ import type { Request } from "express"; import { LogScope } from "../../types"; import { getLogger, injectMetadata } from "../../logger"; -import type { ResponseWithAlokaiLocals } from "../../types"; +import type { AlokaiResponse } from "../../types"; -export async function callApiFunction( - req: Request, - res: ResponseWithAlokaiLocals -) { +export async function callApiFunction(req: Request, res: AlokaiResponse) { const { apiFunction, args, errorHandler } = res.locals; try { diff --git a/packages/middleware/src/handlers/prepareArguments/index.ts b/packages/middleware/src/handlers/prepareArguments/index.ts index 71a12492b0..218c773a81 100644 --- a/packages/middleware/src/handlers/prepareArguments/index.ts +++ b/packages/middleware/src/handlers/prepareArguments/index.ts @@ -1,9 +1,9 @@ import type { Request, NextFunction } from "express"; -import type { ResponseWithAlokaiLocals } from "../../types"; +import type { AlokaiResponse } from "../../types"; export function prepareArguments( req: Request, - res: ResponseWithAlokaiLocals, + res: AlokaiResponse, next: NextFunction ) { const { method, query, body } = req; diff --git a/packages/middleware/src/handlers/prepareFileUpload/index.ts b/packages/middleware/src/handlers/prepareFileUpload/index.ts new file mode 100644 index 0000000000..89b62f436e --- /dev/null +++ b/packages/middleware/src/handlers/prepareFileUpload/index.ts @@ -0,0 +1,30 @@ +import type { RequestHandler } from "express"; +import { createMulterMiddleware } from "../../services/fileUpload"; +import type { CreateServerOptions } from "../../types"; + +/** + * Prepare file upload middleware + * Resolves file upload options from the request or server options + * and creates multer middleware. + * + * @param options - Server options + * @returns Request handler + */ +export const prepareFileUpload = ( + options: CreateServerOptions +): RequestHandler => { + return (req, res, next) => { + const fileUploadOptions = + typeof options.fileUpload === "function" + ? options.fileUpload(req) + : options.fileUpload; + + if (!fileUploadOptions?.enabled) { + return next(); + } + + const multerMiddleware = createMulterMiddleware(fileUploadOptions); + + return multerMiddleware(req, res, next); + }; +}; diff --git a/packages/middleware/src/handlers/prepareLogger/index.ts b/packages/middleware/src/handlers/prepareLogger/index.ts index 8f24a307d3..0d40199782 100644 --- a/packages/middleware/src/handlers/prepareLogger/index.ts +++ b/packages/middleware/src/handlers/prepareLogger/index.ts @@ -1,13 +1,9 @@ import type { Request, NextFunction } from "express"; import { LoggerManager, injectMetadata } from "../../logger"; -import { ResponseWithAlokaiLocals } from "../../types"; +import { AlokaiResponse } from "../../types"; export function prepareLogger(loggerManager: LoggerManager) { - return function ( - req: Request, - res: ResponseWithAlokaiLocals, - next: NextFunction - ) { + return function (req: Request, res: AlokaiResponse, next: NextFunction) { if (!res.locals) { res.locals = {}; } diff --git a/packages/middleware/src/logger/getLogger.ts b/packages/middleware/src/logger/getLogger.ts index 93c30ee4d4..bc7ef94c1b 100644 --- a/packages/middleware/src/logger/getLogger.ts +++ b/packages/middleware/src/logger/getLogger.ts @@ -1,9 +1,9 @@ import type { LoggerInterface } from "@vue-storefront/logger"; -import type { AlokaiContainer, ResponseWithAlokaiLocals } from "../types"; +import type { AlokaiContainer, AlokaiResponse } from "../types"; import { fallbackLogger } from "./fallbackLogger"; -type ContextSubset = { res: ResponseWithAlokaiLocals }; -type LoggerSource = AlokaiContainer | ResponseWithAlokaiLocals | ContextSubset; +type ContextSubset = { res: AlokaiResponse }; +type LoggerSource = AlokaiContainer | AlokaiResponse | ContextSubset; function isAlokaiContainer(source: LoggerSource): source is AlokaiContainer { return "logger" in source; } diff --git a/packages/middleware/src/types/common.ts b/packages/middleware/src/types/common.ts index 8f65a9ddb6..6835828058 100644 --- a/packages/middleware/src/types/common.ts +++ b/packages/middleware/src/types/common.ts @@ -8,26 +8,27 @@ import { CustomQueryFunction, TObject, } from "./base"; -import { WithRequired } from "./index"; +import { UploadedFile, WithRequired } from "./index"; import { ApiClient, ApiClientConfig, ApiClientFactory } from "./server"; -export type ResponseWithAlokaiLocals = Response< +export type AlokaiResponse = Response< any, { alokai?: { logger: LoggerInterface; }; - apiFunction?: Function; + apiFunction?: (...args: any[]) => any; fnOrigin?: string; [key: string]: any; } >; -export type RequestWithFiles = Request & { + +export type AlokaiRequest = Request & { files?: | { - [fieldname: string]: Express.Multer.File[]; + [fieldname: string]: UploadedFile[]; } - | Express.Multer.File[] + | UploadedFile[] | undefined; }; @@ -104,8 +105,8 @@ export interface ApiClientExtension { logger: LoggerInterface; }) => Promise | void; hooks?: ( - req: RequestWithFiles, - res: ResponseWithAlokaiLocals, + req: AlokaiRequest, + res: AlokaiResponse, hooksContext: AlokaiContainer ) => ApiClientExtensionHooks; } @@ -127,8 +128,8 @@ export interface Integration< initConfig?: TObject; errorHandler?: ( error: unknown, - req: RequestWithFiles, - res: ResponseWithAlokaiLocals + req: AlokaiRequest, + res: AlokaiResponse ) => void; } @@ -146,11 +147,7 @@ export interface IntegrationLoaded< configuration: CONFIG; extensions: ApiClientExtension[]; customQueries?: Record; - errorHandler: ( - error: unknown, - req: Request, - res: ResponseWithAlokaiLocals - ) => void; + errorHandler: (error: unknown, req: Request, res: AlokaiResponse) => void; } export interface LoadInitConfigProps { @@ -166,8 +163,8 @@ export type IntegrationsLoaded< > = Record>; export interface MiddlewareContext { - req: RequestWithFiles; - res: ResponseWithAlokaiLocals; + req: AlokaiRequest; + res: AlokaiResponse; extensions: ApiClientExtension[]; customQueries: Record; integrations: IntegrationsLoaded; diff --git a/packages/middleware/src/types/fileUpload.ts b/packages/middleware/src/types/fileUpload.ts index 1248bcca84..7ba87325fa 100644 --- a/packages/middleware/src/types/fileUpload.ts +++ b/packages/middleware/src/types/fileUpload.ts @@ -1,3 +1,5 @@ +import { IncomingHttpHeaders } from "node:http"; + /** * Options for file upload middleware */ @@ -28,3 +30,46 @@ export interface FileUploadOptions { */ fieldNames?: string[]; } +/** + * Request object for file upload middleware + */ +export interface FileUploadRequest { + /** + * Request headers + */ + headers: IncomingHttpHeaders; +} + +/** + * Uploaded file object + */ +export interface UploadedFile { + /** + * Field name + * Identifies the field name of the file + */ + fieldname: string; + /** + * Original file name + */ + originalname: string; + /** + * Encoding + * eg. 7bit, 8bit + */ + encoding: string; + /** + * MIME type + * eg. image/jpeg, application/pdf + */ + mimetype: string; + /** + * File size in bytes + */ + size: number; + /** + * File buffer + * @see https://nodejs.org/api/buffer.html + */ + buffer: Buffer; +} diff --git a/packages/middleware/src/types/server.ts b/packages/middleware/src/types/server.ts index f4cfa7cc48..3a6b132b3f 100644 --- a/packages/middleware/src/types/server.ts +++ b/packages/middleware/src/types/server.ts @@ -9,7 +9,7 @@ import { ApiMethodsFactory, MiddlewareContext, } from "./common"; -import { FileUploadOptions } from "./fileUpload"; +import { FileUploadOptions, FileUploadRequest } from "./fileUpload"; export interface ClientContext { client: CLIENT; @@ -179,5 +179,7 @@ export interface CreateServerOptions { * Configuration options for handling file uploads. * @see FileUploadOptions */ - fileUpload?: FileUploadOptions; + fileUpload?: + | FileUploadOptions + | ((req: FileUploadRequest) => FileUploadOptions); }