diff --git a/examples/express-basic.ts b/examples/express-basic.ts index 251a99d1..7b8a7012 100644 --- a/examples/express-basic.ts +++ b/examples/express-basic.ts @@ -19,6 +19,6 @@ const uploads = uploadx({ } }); -app.use('/uploads', uploads); +app.use('/files', uploads); app.listen(PORT, () => console.log('listening on port:', PORT)); diff --git a/examples/express.ts b/examples/express.ts index 5feb9f08..0064611a 100644 --- a/examples/express.ts +++ b/examples/express.ts @@ -32,7 +32,15 @@ app.use( directory: 'upload', expiration: { maxAge: '1h', purgeInterval: '10min' }, userIdentifier: (req: AuthRequest) => (req.user ? `${req.user.id}-${req.user.email}` : ''), - logger + logger, + onError: ({ statusCode, body }) => { + const errors = [{ status: statusCode, title: body?.code, detail: body?.message }]; + return { + statusCode, + headers: { 'Content-Type': 'application/vnd.api+json' }, + body: { errors } + }; + } }), onComplete ); diff --git a/packages/core/src/handlers/base-handler.ts b/packages/core/src/handlers/base-handler.ts index 3e643f7c..63a441e0 100644 --- a/packages/core/src/handlers/base-handler.ts +++ b/packages/core/src/handlers/base-handler.ts @@ -17,6 +17,7 @@ import { fail, getBaseUrl, hash, + HttpErrorBody, IncomingMessageWithBody, isUploadxError, isValidationError, @@ -207,7 +208,7 @@ export abstract class BaseHandler let data: string; if (typeof body !== 'string') { data = JSON.stringify(body); - res.setHeader('Content-Type', 'application/json'); + if (!headers['Content-Type']) res.setHeader('Content-Type', 'application/json'); } else { data = body; } @@ -221,21 +222,15 @@ export abstract class BaseHandler * Send Error to client */ sendError(res: http.ServerResponse, error: Error): void { - const response = isUploadxError(error) + const httpError = isUploadxError(error) ? this._errorResponses[error.uploadxErrorCode] : !isValidationError(error) ? this.storage.normalizeError(error) : error; - const { statusCode = 200, headers, ...rest } = response; - const body = response.body ? response.body : rest; - this.send(res, this.formatErrorResponse({ statusCode, body, headers })); - } - - /** - * Adjusting the error response - */ - formatErrorResponse({ statusCode, body, headers }: UploadxResponse): UploadxResponse { - return { statusCode, body: { error: body }, headers }; + const { statusCode = 200, headers, ...rest } = httpError; + const body = httpError.body ? httpError.body : (rest as HttpErrorBody); + const response = { statusCode, body, headers }; + this.send(res, this.storage.onError(response) || response); } /** diff --git a/packages/core/src/storages/config.ts b/packages/core/src/storages/config.ts index 0e055f28..00841530 100644 --- a/packages/core/src/storages/config.ts +++ b/packages/core/src/storages/config.ts @@ -1,14 +1,17 @@ -import { logger, Logger } from '../utils'; +import { HttpError, logger } from '../utils'; import { File } from './file'; import { BaseStorageOptions } from './storage'; -class ConfigHandler { +export class ConfigHandler { static defaults = { allowMIME: ['*/*'], maxUploadSize: '5TB', filename: ({ id }: File): string => id, useRelativeLocation: false, onComplete: () => null, + onError: ({ statusCode, body, headers }: HttpError) => { + return { statusCode, body: { error: body }, headers }; + }, path: '/files', validation: {}, maxMetadataSize: '4MB', @@ -24,10 +27,4 @@ class ConfigHandler { get(): Required> { return this._config as unknown as Required>; } - - getLogger(): Logger { - return this._config.logger; - } } - -export const configHandler = new ConfigHandler(); diff --git a/packages/core/src/storages/disk-storage.ts b/packages/core/src/storages/disk-storage.ts index 229be1b5..c423bf46 100644 --- a/packages/core/src/storages/disk-storage.ts +++ b/packages/core/src/storages/disk-storage.ts @@ -61,7 +61,7 @@ export class DiskStorage extends BaseStorage { if (config.metaStorage) { this.meta = config.metaStorage; } else { - const metaConfig = { ...config, ...(config.metaStorageConfig || {}) }; + const metaConfig = { ...config, ...(config.metaStorageConfig || {}), logger: this.logger }; this.meta = new LocalMetaStorage(metaConfig); } this.accessCheck().catch(err => { diff --git a/packages/core/src/storages/meta-storage.ts b/packages/core/src/storages/meta-storage.ts index f38965b5..d19c5b2b 100644 --- a/packages/core/src/storages/meta-storage.ts +++ b/packages/core/src/storages/meta-storage.ts @@ -1,5 +1,5 @@ import { FileName } from './file'; -import { configHandler } from './config'; +import { logger, Logger } from '../utils'; /** @experimental */ export interface UploadListEntry { @@ -19,6 +19,7 @@ export interface UploadList { export interface MetaStorageOptions { prefix?: string; suffix?: string; + logger?: Logger; } /** @@ -27,13 +28,14 @@ export interface MetaStorageOptions { export class MetaStorage { prefix = ''; suffix = ''; - logger = configHandler.getLogger(); + logger: Logger; constructor(config?: MetaStorageOptions) { this.prefix = config?.prefix || ''; this.suffix = config?.suffix || METAFILE_EXTNAME; this.prefix && FileName.INVALID_PREFIXES.push(this.prefix); this.suffix && FileName.INVALID_SUFFIXES.push(this.suffix); + this.logger = config?.logger || logger; } /** diff --git a/packages/core/src/storages/storage.ts b/packages/core/src/storages/storage.ts index e37e7572..1666c92c 100644 --- a/packages/core/src/storages/storage.ts +++ b/packages/core/src/storages/storage.ts @@ -1,6 +1,5 @@ import * as bytes from 'bytes'; import * as http from 'http'; - import { Cache, ErrorMap, @@ -8,12 +7,14 @@ import { ERRORS, fail, HttpError, + HttpErrorBody, isEqual, Locker, Logger, LogLevel, toMilliseconds, typeis, + UploadxResponse, Validation, Validator, ValidatorConfig @@ -21,7 +22,7 @@ import { import { File, FileInit, FileName, FilePart, FileQuery, isExpired, updateMetadata } from './file'; import { MetaStorage, UploadList } from './meta-storage'; import { setInterval } from 'timers'; -import { configHandler } from './config'; +import { ConfigHandler } from './config'; export type UserIdentifier = (req: any, res: any) => string; @@ -29,6 +30,8 @@ export type OnComplete = ( file: TFile ) => Promise | TResponseBody; +export type OnError = (error: HttpError) => any; + export type PurgeList = UploadList & { maxAgeMs: number }; export interface ExpirationOptions { @@ -59,6 +62,7 @@ export interface BaseStorageOptions { useRelativeLocation?: boolean; /** Completed callback */ onComplete?: OnComplete; + onError?: OnError; /** Node http base path */ path?: string; /** Upload validation options */ @@ -97,6 +101,7 @@ export const locker = new Locker(1000, LOCK_TIMEOUT); export abstract class BaseStorage { onComplete: (file: TFile) => Promise | any; + onError: (err: HttpError) => UploadxResponse; maxUploadSize: number; maxMetadataSize: number; path: string; @@ -110,9 +115,11 @@ export abstract class BaseStorage { abstract meta: MetaStorage; protected constructor(public config: BaseStorageOptions) { + const configHandler = new ConfigHandler(); const opts = configHandler.set(config); this.path = opts.path; this.onComplete = opts.onComplete; + this.onError = opts.onError; this.namingFunction = opts.filename; this.maxUploadSize = bytes.parse(opts.maxUploadSize); this.maxMetadataSize = bytes.parse(opts.maxMetadataSize); diff --git a/packages/gcs/src/gcs-storage.ts b/packages/gcs/src/gcs-storage.ts index c590d6ee..afd6b7cd 100644 --- a/packages/gcs/src/gcs-storage.ts +++ b/packages/gcs/src/gcs-storage.ts @@ -108,7 +108,7 @@ export class GCStorage extends BaseStorage { if (config.metaStorage) { this.meta = config.metaStorage; } else { - const metaConfig = { ...config, ...(config.metaStorageConfig || {}) }; + const metaConfig = { ...config, ...(config.metaStorageConfig || {}), logger: this.logger }; this.meta = 'directory' in metaConfig ? new LocalMetaStorage(metaConfig) diff --git a/packages/s3/src/s3-storage.ts b/packages/s3/src/s3-storage.ts index 9baf94d2..a71d4d66 100644 --- a/packages/s3/src/s3-storage.ts +++ b/packages/s3/src/s3-storage.ts @@ -106,7 +106,7 @@ export class S3Storage extends BaseStorage { if (config.metaStorage) { this.meta = config.metaStorage; } else { - const metaConfig = { ...config, ...(config.metaStorageConfig || {}) }; + const metaConfig = { ...config, ...(config.metaStorageConfig || {}), logger: this.logger }; this.meta = 'directory' in metaConfig ? new LocalMetaStorage(metaConfig) diff --git a/test/shared/uploader.ts b/test/shared/uploader.ts index 423541f4..54a516b3 100644 --- a/test/shared/uploader.ts +++ b/test/shared/uploader.ts @@ -1,6 +1,7 @@ import { BaseHandler, BaseStorage, + BaseStorageOptions, File, FileInit, FilePart, @@ -14,7 +15,7 @@ export class TestStorage extends BaseStorage { path = '/files'; isReady = true; meta = new MetaStorage(); - constructor(config = {}) { + constructor(config = {} as BaseStorageOptions) { super(config); } diff --git a/test/uploadx.spec.ts b/test/uploadx.spec.ts index 07dad95d..451e03e0 100644 --- a/test/uploadx.spec.ts +++ b/test/uploadx.spec.ts @@ -11,14 +11,23 @@ jest.mock('fs'); describe('::Uploadx', () => { const file1 = { ...metadata }; const file2 = { ...metadata, name: 'testfile2.mp4' }; - let uri1 = ''; - let uri2 = ''; - let start: number; const path1 = '/uploadx'; const path2 = '/uploadx2'; const directory = join(testRoot, 'uploadx'); const opts = { ...storageOptions, directory, maxMetadataSize: 250 }; - const uploadx2 = new Uploadx({ storage: new DiskStorage(opts) }); + const uploadx2 = new Uploadx({ + storage: new DiskStorage({ + ...storageOptions, + onError: ({ statusCode, body }) => { + const errors = [{ status: statusCode, title: body?.code, detail: body?.message }]; + return { + statusCode, + headers: { 'Content-Type': 'application/vnd.api+json' }, + body: { errors } + }; + } + }) + }); app.use(path1, uploadx(opts)); app.use(path2, uploadx2.handle); function exposedHeaders(response: request.Response): string[] { @@ -35,9 +44,9 @@ describe('::Uploadx', () => { .send(file); } - beforeAll(async () => cleanup(directory)); + beforeEach(async () => cleanup(directory)); - afterAll(async () => cleanup(directory)); + afterEach(async () => cleanup(directory)); describe('default options', () => { it('should be defined', () => { @@ -78,9 +87,8 @@ describe('::Uploadx', () => { it('should 201 (x-headers)', async () => { const res = await create(file1).expect(201).expect('x-upload-expires', /.*/); - uri1 = res.header.location as string; expect(exposedHeaders(res)).toEqual(expect.arrayContaining(['location', 'x-upload-expires'])); - expect(uri1).toBeDefined(); + expect(res.headers.location).toBeDefined(); }); it('should 201 (metadata)', async () => { @@ -89,9 +97,8 @@ describe('::Uploadx', () => { .send(file2) .expect(201) .expect('x-upload-expires', /.*/); - uri2 = res.header.location as string; expect(exposedHeaders(res)).toEqual(expect.arrayContaining(['location', 'x-upload-expires'])); - expect(uri2).toBeDefined(); + expect(res.header.location).toBeDefined(); }); it('should 201 (query)', async () => { @@ -106,17 +113,17 @@ describe('::Uploadx', () => { describe('PATCH', () => { it('update metadata', async () => { - uri2 ||= (await create(file2)).header.location; - const res = await request(app).patch(uri2).send({ name: 'newname.mp4' }).expect(200); + const uri = (await create(file2)).header.location as string; + const res = await request(app).patch(uri).send({ name: 'newname.mp4' }).expect(200); expect(res.body.name).toBe('newname.mp4'); }); }); describe('PUT', () => { it('should 200 (simple request)', async () => { - uri2 ||= (await create(file2)).header.location; + const uri = (await create(file2)).header.location as string; const res = await request(app) - .put(uri2) + .put(uri) .set('Digest', `sha=${metadata.sha1}`) .send(testfile.asBuffer) .expect(200); @@ -125,17 +132,16 @@ describe('::Uploadx', () => { }); it('should 200 (chunks)', async () => { - uri1 ||= (await create(file1)).header.location; - + const uri = (await create(file1)).header.location as string; function uploadChunks(): Promise { return new Promise(resolve => { - start = 0; + let start = 0; const readable = testfile.asReadable; // eslint-disable-next-line @typescript-eslint/no-misused-promises readable.on('data', async (chunk: { length: number }) => { readable.pause(); const res = await request(app) - .put(uri1) + .put(uri) .redirects(0) .set('content-type', 'application/octet-stream') .set('content-range', `bytes ${start}-${start + chunk.length - 1}/${metadata.size}`) @@ -224,17 +230,17 @@ describe('::Uploadx', () => { describe('GET', () => { it('should return info array', async () => { - uri1 ||= (await create(file1)).header.location; - uri2 ||= (await create(file2)).header.location; + await create(file1); + await create(file2); const res = await request(app).get(`${path1}`).expect(200); - expect(res.body.items.length).toBeGreaterThan(2); + expect(res.body.items).toHaveLength(2); }); }); describe('DELETE', () => { it('should 204', async () => { - uri2 ||= (await create(file2)).header.location; - await request(app).delete(uri2).expect(204); + const uri = (await create(file2)).header.location as string; + await request(app).delete(uri).expect(204); }); }); @@ -244,6 +250,25 @@ describe('::Uploadx', () => { }); }); + describe('onError', () => { + it('should return custom eeror response', async () => { + const res = await request(app) + .post(path2) + .send({ ...file2, size: 10e10 }); + expect(res.status).toBe(413); + expect(res.body).toMatchObject({ + errors: [ + { + detail: 'Request entity too large', + status: 413, + title: 'RequestEntityTooLarge' + } + ] + }); + expect(res.type).toBe('application/vnd.api+json'); + }); + }); + describe('events', () => { it('should emit `created`', async () => { let event; diff --git a/tsconfig.base.json b/tsconfig.base.json index beb638bf..5499b838 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -4,7 +4,6 @@ "module": "commonjs", "baseUrl": "./", "rootDir": ".", - "outDir": "dist", "composite": true, "target": "es2019", "forceConsistentCasingInFileNames": true,