Skip to content

Commit

Permalink
feat: add onCreate, onUpdate, onDelete hooks (#637)
Browse files Browse the repository at this point in the history
* refactor(core): improve metadata updating

* refactor(core): add http response helpers

* feat: add `onCreate` , `onUpdate`, `onDelete`  hooks

* test: add hooks tests

* docs: add reference to hooks
  • Loading branch information
kukhariev authored Oct 9, 2022
1 parent c8c2c62 commit 849cf4c
Show file tree
Hide file tree
Showing 10 changed files with 143 additions and 48 deletions.
10 changes: 7 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
[![npm version][npm-image]][npm-url] [![Build status][gha-image]][gha-url]
[![commits since latest release][comm-image]][comm-url]

Resumable upload middleware for [express](https://github.com/expressjs/express), [fastify](https://github.com/fastify/fastify) and plain node.js.
Resumable upload middleware for [express](https://github.com/expressjs/express)
, [fastify](https://github.com/fastify/fastify) and plain node.js.
Server-side part of [ngx-uploadx](https://github.com/kukhariev/ngx-uploadx)

## ✨ Features
Expand Down Expand Up @@ -82,8 +83,11 @@ Some available options: :
| `useRelativeLocation` | `boolean` | `false` | _Use relative urls_ |
| `filename` | `Function` | | _File naming function_ |
| `userIdentifier` | `UserIdentifier` | | _Get user identity_ |
| `onComplete` | `OnComplete` | | _On upload complete callback_ |
| `onError` | `OnError` | | _Customise error response_ |
| `onCreate` | `OnCreate` | | _Callback that is called when a new upload is created_ |
| `onUpdate` | `OnUpdate` | | _Callback that is called when an upload is updated_ |
| `onComplete` | `OnComplete` | | _Callback that is called when an upload is completed_ |
| `onDelete` | `OnDelete` | | _Callback that is called when an upload is cancelled_ |
| `onError` | `OnError` | | _Customize error response_ |
| `expiration` | `ExpirationOptions` | | _Configuring the cleanup of abandoned and completed uploads_ |
| `logger` | `Logger` | | _Custom logger injection_ |
| `logLevel` | `LogLevel` | `"none"` | _Set built-in logger severity level_ |
Expand Down
7 changes: 2 additions & 5 deletions packages/core/src/handlers/base-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@ import {
fail,
getBaseUrl,
hash,
HttpErrorBody,
IncomingMessageWithBody,
isUploadxError,
isValidationError,
Expand Down Expand Up @@ -231,10 +230,8 @@ export abstract class BaseHandler<TFile extends UploadxFile>
: !isValidationError(error)
? this.storage.normalizeError(error)
: error;
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);
const response = this.storage.onError(httpError);
this.send(res, response);
}

/**
Expand Down
15 changes: 9 additions & 6 deletions packages/core/src/handlers/uploadx.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,17 +31,20 @@ export class Uploadx<TFile extends UploadxFile> extends BaseHandler<TFile> {
file.bytesWritten > 0 && (headers['Range'] = `bytes=0-${file.bytesWritten - 1}`);
setHeaders(res, headers);
if (file.status === 'completed') return file;
const statusCode = file.bytesWritten > 0 ? 200 : 201;
this.send(res, { statusCode });
const response = await this.storage.onCreate(file);
response.statusCode = file.bytesWritten > 0 ? 200 : 201;
this.send(res, response);
return file;
}

async patch(req: http.IncomingMessage, res: http.ServerResponse): Promise<TFile> {
const id = await this.getAndVerifyId(req, res);
const metadata = await this.getMetadata(req);
const file = await this.storage.update({ id }, { metadata, id });
const file = await this.storage.update({ id }, metadata);
const headers = this.buildHeaders(file, { Location: this.buildFileUrl(req, file) });
this.send(res, { body: file.metadata, headers });
setHeaders(res, headers);
const response = await this.storage.onUpdate(file);
this.send(res, response);
return file;
}

Expand All @@ -68,7 +71,6 @@ export class Uploadx<TFile extends UploadxFile> extends BaseHandler<TFile> {
headers['Range'] = `bytes=0-${file.bytesWritten - 1}`;
res.statusMessage = 'Resume Incomplete';
this.send(res, { statusCode: Uploadx.RESUME_STATUS_CODE, headers });

return file;
}

Expand All @@ -78,7 +80,8 @@ export class Uploadx<TFile extends UploadxFile> extends BaseHandler<TFile> {
async delete(req: http.IncomingMessage, res: http.ServerResponse): Promise<TFile> {
const id = await this.getAndVerifyId(req, res);
const [file] = await this.storage.delete({ id });
this.send(res, { statusCode: 204 });
const response = await this.storage.onDelete(file);
this.send(res, { statusCode: 204, ...response });
return file;
}

Expand Down
7 changes: 5 additions & 2 deletions packages/core/src/storages/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,15 @@ import { File } from './file';
import { BaseStorageOptions } from './storage';

export class ConfigHandler {
static defaults = {
static defaults: BaseStorageOptions<File> = {
allowMIME: ['*/*'],
maxUploadSize: '5TB',
filename: ({ id }: File): string => id,
useRelativeLocation: false,
onComplete: () => null,
onComplete: (file: File) => file,
onUpdate: (file: File) => file,
onCreate: () => '',
onDelete: () => '',
onError: ({ statusCode, body, headers }: HttpError) => {
return { statusCode, body: { error: body }, headers };
},
Expand Down
12 changes: 4 additions & 8 deletions packages/core/src/storages/file.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Readable } from 'stream';
import { hash, isRecord, uid } from '../utils';
import { hash, isRecord, extendObject, uid } from '../utils';
import { isAbsolute } from 'path';

export function isExpired(file: File): boolean {
Expand Down Expand Up @@ -103,13 +103,9 @@ export function isMetadata(raw: unknown): raw is Metadata {
return isRecord(raw);
}

export function updateMetadata(file: File, metadata: unknown): void {
if (isMetadata(file.metadata) && isMetadata(metadata)) {
file.metadata = { ...file.metadata, ...metadata };
file.originalName = extractOriginalName(file.metadata) || file.originalName;
} else {
file.metadata = metadata as Metadata;
}
export function updateMetadata<T extends File>(file: T, metadata: Partial<T>): void {
extendObject(file, metadata);
file.originalName = extractOriginalName(file.metadata) || file.originalName;
}

export function getFileStatus(file: File): UploadxEventType {
Expand Down
47 changes: 29 additions & 18 deletions packages/core/src/storages/storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,11 @@ import {
HttpError,
HttpErrorBody,
isEqual,
isRecord,
Locker,
Logger,
LogLevel,
normalizeHookResponse,
normalizeOnErrorResponse,
toMilliseconds,
typeis,
UploadxResponse,
Expand All @@ -27,11 +28,15 @@ import { ConfigHandler } from './config';

export type UserIdentifier = (req: any, res: any) => string;

export type OnComplete<TFile extends File, TResponseBody = any> = (
file: TFile
) => Promise<TResponseBody> | TResponseBody;
export type OnCreate<TFile extends File, TBody = any> = (file: TFile) => Promise<TBody> | TBody;

export type OnError<TResponseBody = HttpErrorBody> = (error: HttpError<TResponseBody>) => any;
export type OnUpdate<TFile extends File, TBody = any> = (file: TFile) => Promise<TBody> | TBody;

export type OnComplete<TFile extends File, TBody = any> = (file: TFile) => Promise<TBody> | TBody;

export type OnDelete<TFile extends File, TBody = any> = (file: TFile) => Promise<TBody> | TBody;

export type OnError<TBody = HttpErrorBody> = (error: HttpError<TBody>) => any;

export type PurgeList = UploadList & { maxAgeMs: number };

Expand Down Expand Up @@ -61,9 +66,15 @@ export interface BaseStorageOptions<T extends File> {
userIdentifier?: UserIdentifier;
/** Force relative URI in Location header */
useRelativeLocation?: boolean;
/** Completed callback */
/** Callback function that is called when a new upload is created */
onCreate?: OnCreate<T>;
/** Callback function that is called when an upload is updated */
onUpdate?: OnUpdate<T>;
/** Callback function that is called when an upload is completed */
onComplete?: OnComplete<T>;
/** Customise error response */
/** Callback function that is called when an upload is cancelled */
onDelete?: OnDelete<T>;
/** Customize error response */
onError?: OnError;
/** Node http base path */
path?: string;
Expand All @@ -75,6 +86,7 @@ export interface BaseStorageOptions<T extends File> {
metaStorage?: MetaStorage<T>;
/**
* Automatic cleaning of abandoned and completed uploads
*
* @example
* ```ts
* app.use(
Expand Down Expand Up @@ -102,7 +114,10 @@ const LOCK_TIMEOUT = 300; // seconds
export const locker = new Locker(1000, LOCK_TIMEOUT);

export abstract class BaseStorage<TFile extends File> {
onCreate: (file: TFile) => Promise<UploadxResponse>;
onUpdate: (file: TFile) => Promise<UploadxResponse>;
onComplete: (file: TFile) => Promise<UploadxResponse>;
onDelete: (file: TFile) => Promise<UploadxResponse>;
onError: (err: HttpError) => UploadxResponse;
maxUploadSize: number;
maxMetadataSize: number;
Expand All @@ -120,15 +135,11 @@ export abstract class BaseStorage<TFile extends File> {
const configHandler = new ConfigHandler();
const opts = configHandler.set(config);
this.path = opts.path;
this.onComplete = async file => {
const response = (await opts.onComplete(file)) as UploadxResponse;
if (isRecord(response)) {
const { statusCode, headers, body, ...rest } = response;
return { statusCode, headers, body: body ?? rest };
}
return { body: response ?? file };
};
this.onError = opts.onError;
this.onCreate = normalizeHookResponse(opts.onCreate);
this.onUpdate = normalizeHookResponse(opts.onUpdate);
this.onComplete = normalizeHookResponse(opts.onComplete);
this.onDelete = normalizeHookResponse(opts.onDelete);
this.onError = normalizeOnErrorResponse(opts.onError);
this.namingFunction = opts.filename;
this.maxUploadSize = bytes.parse(opts.maxUploadSize);
this.maxMetadataSize = bytes.parse(opts.maxMetadataSize);
Expand Down Expand Up @@ -261,10 +272,10 @@ export abstract class BaseStorage<TFile extends File> {
}

/**
* Updates user-defined metadata for an upload
* Set user-provided metadata as key-value pairs
* @experimental
*/
async update({ id }: FileQuery, { metadata }: Partial<File>): Promise<TFile> {
async update({ id }: FileQuery, metadata: Partial<File>): Promise<TFile> {
const file = await this.getMeta(id);
updateMetadata(file, metadata);
await this.saveMeta(file);
Expand Down
27 changes: 26 additions & 1 deletion packages/core/src/utils/http.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import * as http from 'http';
import { getLastOne } from './primitives';
import { HttpError, HttpErrorBody } from './errors';
import { getLastOne, isRecord } from './primitives';

export interface IncomingMessageWithBody<T = any> extends http.IncomingMessage {
body?: T;
Expand Down Expand Up @@ -150,3 +151,27 @@ export function tupleToResponse<T extends ResponseBody>(
const [statusCode, body, headers] = response;
return { statusCode, body, headers };
}

export function normalizeHookResponse<T>(fn: (file: T) => Promise<UploadxResponse>) {
return async (file: T) => {
const response = await fn(file);
if (isRecord(response)) {
const { statusCode, headers, body, ...rest } = response;
return { statusCode, headers, body: body ?? rest };
}
return { body: response ?? '' };
};
}

/*
@internal
*/
export function normalizeOnErrorResponse(fn: (error: HttpError) => UploadxResponse) {
return (error: HttpError) => {
if (isRecord(error)) {
const { statusCode, headers, body, ...rest } = error;
return fn({ statusCode, headers, body: body ?? (rest as HttpErrorBody) });
}
return fn({ body: error ?? 'unknown error', statusCode: 500 });
};
}
15 changes: 15 additions & 0 deletions packages/core/src/utils/primitives.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,21 @@ export function isRecord(x: unknown): x is Record<any, any> {
return x !== null && typeof x === 'object' && !Array.isArray(x);
}

export function extendObject<T extends Record<any, any>>(target: T, ...sources: Partial<T[]>): T {
if (!sources.length) return target;
const source = sources.shift();
if (isRecord(source)) {
for (const key in source) {
if (isRecord(source[key])) {
if (!isRecord(target[key])) Object.assign(target, { [key]: {} });
extendObject(target[key] as any, source[key]);
} else {
Object.assign(target, { [key]: source[key] });
}
}
}
return extendObject(target, ...sources);
}
/**
* Convert a human-readable duration to ms
*/
Expand Down
38 changes: 34 additions & 4 deletions test/uploadx.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ describe('::Uploadx', () => {
const uploadx2 = new Uploadx({
storage: new DiskStorage({
...storageOptions,
onCreate: () => 'created',
onUpdate: () => 'updated',
onComplete: () => 'completed',
onError: ({ statusCode, body }) => {
const errors = [{ status: statusCode, title: body?.code, detail: body?.message }];
Expand Down Expand Up @@ -113,10 +115,23 @@ describe('::Uploadx', () => {
});

describe('PATCH', () => {
it('update metadata', async () => {
it('update metadata and originalName', async () => {
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');
const res = await request(app)
.patch(uri)
.send({ metadata: { name: 'newname.mp4' } })
.expect(200);
expect(res.body.metadata.name).toBe('newname.mp4');
expect(res.body.originalName).toBe('newname.mp4');
});

it('set non metadata', async () => {
const uri = (await create(file2)).header.location as string;
const res = await request(app)
.patch(uri)
.send({ custom: { property: 'updated' } })
.expect(200);
expect(res.body.custom.property).toBe('updated');
});
});

Expand Down Expand Up @@ -251,8 +266,23 @@ describe('::Uploadx', () => {
});
});

describe('onCreate', () => {
it('should return custom response', async () => {
const res = await request(app).post(path2).send(file1);
expect(res.text).toBe('created');
});
});

describe('onUpdate', () => {
it('should return custom response', async () => {
const uri = (await request(app).post(path2).send(file1)).header.location as string;
const res = await request(app).patch(uri).send({ custom: true });
expect(res.text).toBe('updated');
});
});

describe('onComplete', () => {
it('should return custom complete response', async () => {
it('should return custom response', async () => {
const uri = (await request(app).post(path2).send(file1)).header.location as string;
const res = await request(app).put(uri).send(testfile.asBuffer);
expect(res.text).toBe('completed');
Expand Down
13 changes: 12 additions & 1 deletion test/util.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import * as fs from 'fs';
import { vol } from 'memfs';
import { IncomingMessage } from 'http';
import { vol } from 'memfs';
import { join } from 'path';
import * as utils from '../packages/core/src';
import { testRoot } from './shared';
Expand Down Expand Up @@ -188,6 +188,17 @@ describe('utils', () => {
expect(md5Cached('123456')).toBe('e10adc3949ba59abbe56e057f20f883e');
expect(fnvCached('123456')).toBe('9995b6aa');
});

it('extendObject', () => {
expect(utils.extendObject<object>({ a: 1 }, { a: { b: 1 }, c: null })).toEqual({
a: { b: 1 },
c: null
});
expect(utils.extendObject<object>({ a: 1, c: [4] }, { a: { b: 1 }, c: [1, 2, 3] })).toEqual({
a: { b: 1 },
c: [1, 2, 3]
});
});
});

describe('BasicLogger', () => {
Expand Down

0 comments on commit 849cf4c

Please sign in to comment.