@travetto/web-upload 6.0.0-rc.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,58 @@
1
+ <!-- This file was generated by @travetto/doc and should not be modified directly -->
2
+ <!-- Please modify https://github.com/travetto/travetto/tree/main/module/web-upload/DOC.tsx and execute "npx trv doc" to rebuild -->
3
+ # Web Upload Support
4
+
5
+ ## Provides integration between the travetto asset and web module.
6
+
7
+ **Install: @travetto/web-upload**
8
+ ```bash
9
+ npm install @travetto/web-upload
10
+
11
+ # or
12
+
13
+ yarn add @travetto/web-upload
14
+ ```
15
+
16
+ This module provides a clean and direct mechanism for processing uploads, built upon [@fastify/busboy](https://github.com/fastify/busboy). The module also provides some best practices with respect to temporary file management.
17
+
18
+ Once the files are uploaded, they are exposed via [Endpoint](https://github.com/travetto/travetto/tree/main/module/web/src/decorator/endpoint.ts#L14) parameters using the [@Upload](https://github.com/travetto/travetto/tree/main/module/web-upload/src/decorator.ts#L20) decorator. This decorator requires the related field type to be a standard [Buffer](https://nodejs.org/api/buffer.html#class-file) object, or a [FileMap](https://github.com/travetto/travetto/tree/main/module/web-upload/src/types.ts#L4).
19
+
20
+ A simple example:
21
+
22
+ **Code: Web controller with upload support**
23
+ ```typescript
24
+ import { Controller, Post, Get } from '@travetto/web';
25
+ import { FileMap, Upload } from '@travetto/web-upload';
26
+
27
+ @Controller('/simple')
28
+ export class Simple {
29
+
30
+ @Get('/age')
31
+ getAge() {
32
+ return { age: 50 };
33
+ }
34
+
35
+ @Post('/age')
36
+ getPage() {
37
+ return { age: 20 };
38
+ }
39
+
40
+ /**
41
+ * @param file A file to upload
42
+ */
43
+ @Post('/file')
44
+ loadFile(@Upload() upload: File) {
45
+ return upload;
46
+ }
47
+
48
+ /**
49
+ * @param uploads A map of files that were uploaded
50
+ */
51
+ @Post('/files')
52
+ async loadFiles(@Upload() uploads: FileMap) {
53
+ for (const [, upload] of Object.entries(uploads)) {
54
+ return upload;
55
+ }
56
+ }
57
+ }
58
+ ```
package/__index__.ts ADDED
@@ -0,0 +1,5 @@
1
+ import type { } from './src/trv.d.ts';
2
+ export * from './src/decorator.ts';
3
+ export * from './src/config.ts';
4
+ export * from './src/util.ts';
5
+ export * from './src/types.ts';
package/package.json ADDED
@@ -0,0 +1,49 @@
1
+ {
2
+ "name": "@travetto/web-upload",
3
+ "version": "6.0.0-rc.2",
4
+ "description": "Provides integration between the travetto asset and web module.",
5
+ "keywords": [
6
+ "web",
7
+ "travetto",
8
+ "typescript",
9
+ "upload"
10
+ ],
11
+ "homepage": "https://travetto.io",
12
+ "license": "MIT",
13
+ "author": {
14
+ "email": "travetto.framework@gmail.com",
15
+ "name": "Travetto Framework"
16
+ },
17
+ "files": [
18
+ "__index__.ts",
19
+ "src",
20
+ "support"
21
+ ],
22
+ "main": "__index__.ts",
23
+ "repository": {
24
+ "url": "git+https://github.com/travetto/travetto.git",
25
+ "directory": "module/web-upload"
26
+ },
27
+ "dependencies": {
28
+ "@fastify/busboy": "^3.1.1",
29
+ "@travetto/config": "^6.0.0-rc.2",
30
+ "@travetto/web": "^6.0.0-rc.2",
31
+ "file-type": "^20.4.1",
32
+ "mime": "^4.0.7"
33
+ },
34
+ "peerDependencies": {
35
+ "@travetto/test": "^6.0.0-rc.2"
36
+ },
37
+ "peerDependenciesMeta": {
38
+ "@travetto/test": {
39
+ "optional": true
40
+ }
41
+ },
42
+ "travetto": {
43
+ "displayName": "Web Upload Support"
44
+ },
45
+ "private": false,
46
+ "publishConfig": {
47
+ "access": "public"
48
+ }
49
+ }
package/src/config.ts ADDED
@@ -0,0 +1,29 @@
1
+ import { Config } from '@travetto/config';
2
+ import { Ignore } from '@travetto/schema';
3
+
4
+ /**
5
+ * Config for uploading within @travetto/web
6
+ */
7
+ @Config('web.upload')
8
+ export class WebUploadConfig {
9
+ applies = false;
10
+
11
+ /**
12
+ * Max file size in bytes
13
+ */
14
+ maxSize = 10 * 1024 * 1024;
15
+ /**
16
+ * List of types to allow/exclude
17
+ */
18
+ types: string[] = [];
19
+ /**
20
+ * Cleanup temporary files after request finishes
21
+ */
22
+ cleanupFiles: boolean = true;
23
+
24
+ @Ignore()
25
+ uploads?: Record<string, Partial<WebUploadConfig>>;
26
+
27
+ @Ignore()
28
+ matcher: (contentType: string) => boolean;
29
+ }
@@ -0,0 +1,68 @@
1
+ import { AppError, toConcrete, ClassInstance } from '@travetto/runtime';
2
+ import { ControllerRegistry, EndpointParamConfig, Param } from '@travetto/web';
3
+ import { SchemaRegistry } from '@travetto/schema';
4
+
5
+ import { WebUploadInterceptor } from './interceptor.ts';
6
+ import { WebUploadConfig } from './config.ts';
7
+ import { FileMap } from './types.ts';
8
+ import { WebUploadUtil } from './util.ts';
9
+
10
+ type UploadConfig = Partial<Pick<WebUploadConfig, 'types' | 'maxSize' | 'cleanupFiles'>>;
11
+
12
+ const FileMapContract = toConcrete<FileMap>();
13
+
14
+ /**
15
+ * Allows for supporting uploads
16
+ *
17
+ * @augments `@travetto/web-upload:Upload`
18
+ * @augments `@travetto/web:Param`
19
+ */
20
+ export function Upload(
21
+ param: string | Partial<EndpointParamConfig> & UploadConfig = {},
22
+ ): (inst: ClassInstance, prop: string, idx: number) => void {
23
+
24
+ if (typeof param === 'string') {
25
+ param = { name: param };
26
+ }
27
+
28
+ const finalConf = { ...param };
29
+
30
+ return (inst: ClassInstance, prop: string, idx: number): void => {
31
+ // Register field
32
+ ControllerRegistry.registerEndpointInterceptorConfig(
33
+ inst.constructor, inst[prop], WebUploadInterceptor,
34
+ {
35
+ applies: true,
36
+ maxSize: finalConf.maxSize,
37
+ types: finalConf.types,
38
+ cleanupFiles: finalConf.cleanupFiles,
39
+ uploads: {
40
+ [finalConf.name ?? prop]: {
41
+ maxSize: finalConf.maxSize,
42
+ types: finalConf.types,
43
+ cleanupFiles: finalConf.cleanupFiles
44
+ }
45
+ }
46
+ }
47
+ );
48
+
49
+ return Param('body', {
50
+ ...finalConf,
51
+ extract: (request, config) => {
52
+ const field = SchemaRegistry.getMethodSchema(inst.constructor, prop)[idx];
53
+
54
+ if (!field) {
55
+ throw new AppError(`Unknown field type, ensure you are using ${Blob.name}, ${File.name} or ${FileMapContract.name}`);
56
+ }
57
+
58
+ if (!(field.type === Blob || field.type === File || field.type === FileMapContract)) {
59
+ throw new AppError(`Cannot use upload decorator with ${field.type.name}, but only an ${Blob.name}, ${File.name} or ${FileMapContract.name}`);
60
+ }
61
+
62
+ const isMap = field.type === FileMapContract;
63
+ const map = WebUploadUtil.getRequestUploads(request);
64
+ return isMap ? map : map[config.name!];
65
+ }
66
+ })(inst, prop, idx);
67
+ };
68
+ }
@@ -0,0 +1,54 @@
1
+ import { Inject, Injectable } from '@travetto/di';
2
+ import {
3
+ BodyParseInterceptor, WebInterceptor, WebInterceptorCategory, WebChainedContext,
4
+ WebResponse, DecompressInterceptor, WebInterceptorContext
5
+ } from '@travetto/web';
6
+
7
+ import { WebUploadConfig } from './config.ts';
8
+ import { WebUploadUtil } from './util.ts';
9
+ import { FileMap } from './types.ts';
10
+
11
+ @Injectable()
12
+ export class WebUploadInterceptor implements WebInterceptor<WebUploadConfig> {
13
+
14
+ category: WebInterceptorCategory = 'request';
15
+ runsBefore = [BodyParseInterceptor];
16
+ dependsOn = [DecompressInterceptor];
17
+
18
+ @Inject()
19
+ config: WebUploadConfig;
20
+
21
+ /**
22
+ * Produces final config object
23
+ */
24
+ finalizeConfig({ config: base }: WebInterceptorContext<WebUploadConfig>, inputs: Partial<WebUploadConfig>[]): WebUploadConfig {
25
+ base.uploads ??= {};
26
+ // Override the uploads object with all the data from the inputs
27
+ for (const [k, cfg] of inputs.flatMap(el => Object.entries(el.uploads ?? {}))) {
28
+ Object.assign(base.uploads[k] ??= {}, cfg);
29
+ }
30
+ return base;
31
+ }
32
+
33
+ applies({ config }: WebInterceptorContext<WebUploadConfig>): boolean {
34
+ return config.applies;
35
+ }
36
+
37
+ async filter({ request, config, next }: WebChainedContext<WebUploadConfig>): Promise<WebResponse> {
38
+ const uploads: FileMap = {};
39
+
40
+ try {
41
+ for await (const item of WebUploadUtil.getUploads(request, config)) {
42
+ uploads[item.field] = await WebUploadUtil.toFile(item, config.uploads?.[item.field] ?? config);
43
+ }
44
+
45
+ WebUploadUtil.setRequestUploads(request, uploads);
46
+
47
+ return await next();
48
+ } finally {
49
+ for (const [field, item] of Object.entries(uploads)) {
50
+ await WebUploadUtil.finishUpload(item, config.uploads?.[field] ?? config);
51
+ }
52
+ }
53
+ }
54
+ }
package/src/trv.d.ts ADDED
@@ -0,0 +1,7 @@
1
+ import { FileMap } from './types.ts';
2
+
3
+ declare module '@travetto/web' {
4
+ interface WebRequestInternal {
5
+ uploads?: FileMap
6
+ }
7
+ }
package/src/types.ts ADDED
@@ -0,0 +1,4 @@
1
+ /**
2
+ * @concrete
3
+ */
4
+ export interface FileMap extends Record<string, File> { }
package/src/util.ts ADDED
@@ -0,0 +1,191 @@
1
+ import { createReadStream, createWriteStream } from 'node:fs';
2
+ import path from 'node:path';
3
+ import os from 'node:os';
4
+ import fs from 'node:fs/promises';
5
+ import { pipeline } from 'node:stream/promises';
6
+ import { Readable, Transform } from 'node:stream';
7
+
8
+ import busboy from '@fastify/busboy';
9
+
10
+ import { WebRequest, MimeUtil, WebBodyUtil } from '@travetto/web';
11
+ import { AsyncQueue, AppError, castTo, Util, BinaryUtil } from '@travetto/runtime';
12
+
13
+ import { WebUploadConfig } from './config.ts';
14
+ import { FileMap } from './types.ts';
15
+
16
+ const MULTIPART = new Set(['application/x-www-form-urlencoded', 'multipart/form-data']);
17
+
18
+ type UploadItem = { stream: Readable, filename?: string, field: string };
19
+ type FileType = { ext: string, mime: string };
20
+ const RawFileSymbol = Symbol();
21
+ const WebUploadSymbol = Symbol();
22
+
23
+ /**
24
+ * Web upload utilities
25
+ */
26
+ export class WebUploadUtil {
27
+
28
+ /**
29
+ * Write limiter
30
+ * @returns
31
+ */
32
+ static limitWrite(maxSize: number, field?: string): Transform {
33
+ let read = 0;
34
+ return new Transform({
35
+ transform(chunk, encoding, callback): void {
36
+ read += (Buffer.isBuffer(chunk) || typeof chunk === 'string') ? chunk.length : (chunk instanceof Uint8Array ? chunk.byteLength : 0);
37
+ if (read > maxSize) {
38
+ callback(new AppError('File size exceeded', { category: 'data', details: { read, size: maxSize, field } }));
39
+ } else {
40
+ callback(null, chunk);
41
+ }
42
+ },
43
+ });
44
+ }
45
+
46
+ /**
47
+ * Get uploaded file path location
48
+ */
49
+ static getUploadLocation(file: File): string {
50
+ return castTo<{ [RawFileSymbol]: string }>(file)[RawFileSymbol];
51
+ }
52
+
53
+ /**
54
+ * Get all the uploads, separating multipart from direct
55
+ */
56
+ static async* getUploads(request: WebRequest, config: Partial<WebUploadConfig>): AsyncIterable<UploadItem> {
57
+ if (!WebBodyUtil.isRaw(request.body)) {
58
+ throw new AppError('No input stream provided for upload', { category: 'data' });
59
+ }
60
+
61
+ const bodyStream = Buffer.isBuffer(request.body) ? Readable.from(request.body) : request.body;
62
+ request.body = undefined;
63
+
64
+ if (MULTIPART.has(request.headers.getContentType()?.full!)) {
65
+ const fileMaxes = Object.values(config.uploads ?? {}).map(x => x.maxSize).filter(x => x !== undefined);
66
+ const largestMax = fileMaxes.length ? Math.max(...fileMaxes) : config.maxSize;
67
+ const itr = new AsyncQueue<UploadItem>();
68
+
69
+ // Upload
70
+ bodyStream.pipe(busboy({
71
+ headers: {
72
+ 'content-type': request.headers.get('Content-Type')!,
73
+ 'content-disposition': request.headers.get('Content-Disposition')!,
74
+ 'content-length': request.headers.get('Content-Length')!,
75
+ 'content-range': request.headers.get('Content-Range')!,
76
+ 'content-encoding': request.headers.get('Content-Encoding')!,
77
+ 'content-transfer-encoding': request.headers.get('Content-Transfer-Encoding')!,
78
+ },
79
+ limits: { fileSize: largestMax }
80
+ })
81
+ .on('file', (field, stream, filename) => itr.add({ stream, filename, field }))
82
+ .on('limit', field => itr.throw(new AppError(`File size exceeded for ${field}`, { category: 'data' })))
83
+ .on('finish', () => itr.close())
84
+ .on('error', (err) => itr.throw(err instanceof Error ? err : new Error(`${err}`))));
85
+
86
+ yield* itr;
87
+ } else {
88
+ yield { stream: bodyStream, filename: request.headers.getFilename(), field: 'file' };
89
+ }
90
+ }
91
+
92
+ /**
93
+ * Convert an UploadItem to a File
94
+ */
95
+ static async toFile({ stream, filename, field }: UploadItem, config: Partial<WebUploadConfig>): Promise<File> {
96
+ const uniqueDir = path.resolve(os.tmpdir(), `file_${Date.now()}_${Util.uuid(5)}`);
97
+ await fs.mkdir(uniqueDir, { recursive: true });
98
+
99
+ filename = filename ? path.basename(filename) : `unknown_${Date.now()}`;
100
+
101
+ const location = path.resolve(uniqueDir, filename);
102
+ const remove = (): Promise<void> => fs.rm(location).catch(() => { });
103
+ const mimeCheck = config.matcher ??= MimeUtil.matcher(config.types);
104
+
105
+ try {
106
+ const target = createWriteStream(location);
107
+
108
+ await (config.maxSize ?
109
+ pipeline(stream, this.limitWrite(config.maxSize, field), target) :
110
+ pipeline(stream, target));
111
+
112
+ const detected = await this.getFileType(location);
113
+
114
+ if (!mimeCheck(detected.mime)) {
115
+ throw new AppError(`Content type not allowed: ${detected.mime}`, { category: 'data' });
116
+ }
117
+
118
+ if (!path.extname(filename)) {
119
+ filename = `${filename}.${detected.ext}`;
120
+ }
121
+
122
+ const file = BinaryUtil.readableBlob(() => createReadStream(location), {
123
+ contentType: detected.mime,
124
+ filename,
125
+ hash: await BinaryUtil.hashInput(createReadStream(location)),
126
+ size: (await fs.stat(location)).size,
127
+ });
128
+
129
+ Object.assign(file, { [RawFileSymbol]: location });
130
+
131
+ return file;
132
+ } catch (err) {
133
+ await remove();
134
+ throw err;
135
+ }
136
+ }
137
+
138
+ /**
139
+ * Get file type
140
+ */
141
+ static async getFileType(input: string | Readable): Promise<FileType> {
142
+ const { FileTypeParser } = await import('file-type');
143
+ const { fromStream } = await import('strtok3');
144
+
145
+ const parser = new FileTypeParser();
146
+ let tok: ReturnType<typeof fromStream> | undefined;
147
+ let matched: FileType | undefined;
148
+
149
+ try {
150
+ tok = await fromStream(typeof input === 'string' ? createReadStream(input) : input);
151
+ matched = await parser.fromTokenizer(tok);
152
+ } finally {
153
+ await tok?.close();
154
+ }
155
+
156
+ if (!matched && typeof input === 'string') {
157
+ const { Mime } = (await import('mime'));
158
+ const otherTypes = (await import('mime/types/other.js')).default;
159
+ const standardTypes = (await import('mime/types/standard.js')).default;
160
+ const checker = new Mime(standardTypes, otherTypes);
161
+ const mime = checker.getType(input);
162
+ if (mime) {
163
+ return { ext: checker.getExtension(mime)!, mime };
164
+ }
165
+ }
166
+ return matched ?? { ext: 'bin', mime: 'application/octet-stream' };
167
+ }
168
+
169
+ /**
170
+ * Finish upload
171
+ */
172
+ static async finishUpload(upload: File, config: Partial<WebUploadConfig>): Promise<void> {
173
+ if (config.cleanupFiles !== false) {
174
+ await fs.rm(this.getUploadLocation(upload), { force: true });
175
+ }
176
+ }
177
+
178
+ /**
179
+ * Get Uploads
180
+ */
181
+ static getRequestUploads(request: WebRequest & { [WebUploadSymbol]?: FileMap }): FileMap {
182
+ return request[WebUploadSymbol] ?? {};
183
+ }
184
+
185
+ /**
186
+ * Set Uploads
187
+ */
188
+ static setRequestUploads(request: WebRequest & { [WebUploadSymbol]?: FileMap }, uploads: FileMap): void {
189
+ request[WebUploadSymbol] ??= uploads;
190
+ }
191
+ }
@@ -0,0 +1,2 @@
1
+ ---
2
+ yml: text
File without changes
File without changes
Binary file
Binary file
Binary file
Binary file
Binary file
@@ -0,0 +1,181 @@
1
+ import assert from 'node:assert';
2
+
3
+ import { BinaryUtil, castTo } from '@travetto/runtime';
4
+ import { Controller, Post } from '@travetto/web';
5
+ import { BeforeAll, Suite, Test, TestFixtures } from '@travetto/test';
6
+ import { RootRegistry } from '@travetto/registry';
7
+
8
+ import { BaseWebSuite } from '@travetto/web/support/test/suite/base.ts';
9
+
10
+ import { Upload } from '../../src/decorator.ts';
11
+ import { FileMap } from '../../src/types.ts';
12
+
13
+ const bHash = (blob: Blob) => BinaryUtil.getBlobMeta(blob)?.hash;
14
+
15
+ @Controller('/test/upload')
16
+ class TestUploadController {
17
+
18
+ @Post('/all')
19
+ async uploadAll(@Upload() uploads: FileMap): Promise<{ hash?: string } | undefined> {
20
+ for (const [, blob] of Object.entries(uploads)) {
21
+ return { hash: bHash(blob) };
22
+ }
23
+ }
24
+
25
+ @Post('/')
26
+ async upload(@Upload() file: File) {
27
+ return { hash: bHash(file) };
28
+ }
29
+
30
+ @Post('/all-named')
31
+ async uploads(@Upload('file1') file1: Blob, @Upload('file2') file2: Blob) {
32
+ return { hash1: bHash(file1), hash2: bHash(file2) };
33
+ }
34
+
35
+ @Post('/all-named-custom')
36
+ async uploadVariousLimits(@Upload({ name: 'file1', types: ['!image/png'] }) file1: Blob, @Upload('file2') file2: Blob) {
37
+ return { hash1: bHash(file1), hash2: bHash(file2) };
38
+ }
39
+
40
+ @Post('/all-named-size')
41
+ async uploadVariousSizeLimits(@Upload({ name: 'file1', maxSize: 100 }) file1: File, @Upload({ name: 'file2', maxSize: 8000 }) file2: File) {
42
+ return { hash1: bHash(file1), hash2: bHash(file2) };
43
+ }
44
+ }
45
+
46
+ @Suite()
47
+ export abstract class WebUploadServerSuite extends BaseWebSuite {
48
+
49
+ fixture: TestFixtures;
50
+
51
+ async getUploads(...files: { name: string, resource: string, type?: string }[]): Promise<FormData> {
52
+ const data = new FormData();
53
+ await Promise.all(files.map(async ({ name, type, resource }) => {
54
+ const file = await this.fixture.readFile(resource);
55
+ if (type) {
56
+ Object.defineProperty(file, 'type', { get: () => type });
57
+ }
58
+ data.append(name, file);
59
+ }));
60
+ return data;
61
+ }
62
+
63
+ @BeforeAll()
64
+ async init() {
65
+ this.fixture = new TestFixtures(['@travetto/asset']);
66
+ await RootRegistry.init();
67
+ }
68
+
69
+ @Test()
70
+ async testUploadAll() {
71
+ const uploads = await this.getUploads({ name: 'random', resource: 'logo.png', type: 'image/png' });
72
+ const response = await this.request<{ hash: string }>({ body: uploads, context: { httpMethod: 'POST', path: '/test/upload/all' } });
73
+
74
+ const file = await this.fixture.readStream('/logo.png');
75
+ assert(response.body?.hash === await BinaryUtil.hashInput(file));
76
+ }
77
+
78
+ @Test()
79
+ async testUploadDirect() {
80
+ const uploads = await this.getUploads({ name: 'file', resource: 'logo.png', type: 'image/png' });
81
+ const sent = castTo<Blob>(uploads.get('file'));
82
+ const response = await this.request<{ hash: string }>({ context: { httpMethod: 'POST', path: '/test/upload' }, body: sent });
83
+
84
+ const file = await this.fixture.readStream('/logo.png');
85
+ assert(response.body?.hash === await BinaryUtil.hashInput(file));
86
+ }
87
+
88
+ @Test()
89
+ async testUpload() {
90
+ const uploads = await this.getUploads({ name: 'file', resource: 'logo.png', type: 'image/png' });
91
+ const response = await this.request<{ hash: string }>({ body: uploads, context: { httpMethod: 'POST', path: '/test/upload' } });
92
+
93
+ const file = await this.fixture.readStream('/logo.png');
94
+ assert(response.body?.hash === await BinaryUtil.hashInput(file));
95
+ }
96
+
97
+ @Test()
98
+ async testMultiUpload() {
99
+ const uploads = await this.getUploads(
100
+ { name: 'file1', resource: 'logo.png', type: 'image/png' },
101
+ { name: 'file2', resource: 'logo.png', type: 'image/png' }
102
+ );
103
+ const response = await this.request<{ hash1: string, hash2: string }>({
104
+ body: uploads,
105
+ context: {
106
+ httpMethod: 'POST', path: '/test/upload/all-named'
107
+ }
108
+ });
109
+ const file = await this.fixture.readStream('/logo.png');
110
+ const hash = await BinaryUtil.hashInput(file);
111
+
112
+ assert(response.body?.hash1 === hash);
113
+ assert(response.body?.hash2 === hash);
114
+ }
115
+
116
+ @Test()
117
+ async testMultiUploadCustom() {
118
+ const uploadBad = await this.getUploads(
119
+ { name: 'file1', resource: 'logo.png', type: 'image/png' },
120
+ { name: 'file2', resource: 'logo.png', type: 'image/png' }
121
+ );
122
+
123
+ const badResponse = await this.request<{ hash1: string, hash2: string }>({
124
+ body: uploadBad,
125
+ context: { httpMethod: 'POST', path: '/test/upload/all-named-custom' },
126
+ }, false);
127
+ assert(badResponse.context.httpStatusCode === 400);
128
+
129
+ const uploads = await this.getUploads(
130
+ { name: 'file1', resource: 'logo.gif', type: 'image/gif' },
131
+ { name: 'file2', resource: 'logo.png', type: 'image/png' }
132
+ );
133
+ const response = await this.request<{ hash1: string, hash2: string }>({
134
+ body: uploads,
135
+ context: { httpMethod: 'POST', path: '/test/upload/all-named-custom' },
136
+ }, false);
137
+ assert(response.context.httpStatusCode === 200);
138
+
139
+ const file1 = await this.fixture.readStream('/logo.gif');
140
+ const hash1 = await BinaryUtil.hashInput(file1);
141
+
142
+ const file2 = await this.fixture.readStream('/logo.png');
143
+ const hash2 = await BinaryUtil.hashInput(file2);
144
+
145
+ assert(response.body?.hash1 === hash1);
146
+ assert(response.body?.hash2 === hash2);
147
+ }
148
+
149
+ @Test()
150
+ async testMultiUploadSize() {
151
+ const uploadBad = await this.getUploads(
152
+ { name: 'file1', resource: 'logo.png', type: 'image/png' },
153
+ { name: 'file2', resource: 'logo.png', type: 'image/png' }
154
+ );
155
+
156
+ const badResponse = await this.request<{ hash1: string, hash2: string }>({
157
+ body: uploadBad,
158
+ context: { httpMethod: 'POST', path: '/test/upload/all-named-size' },
159
+ }, false);
160
+ assert(badResponse.context.httpStatusCode === 400);
161
+
162
+ const uploads = await this.getUploads(
163
+ { name: 'file1', resource: 'asset.yml', type: 'text/plain' },
164
+ { name: 'file2', resource: 'logo.png', type: 'image/png' }
165
+ );
166
+ const response = await this.request<{ hash1: string, hash2: string }>({
167
+ body: uploads,
168
+ context: { httpMethod: 'POST', path: '/test/upload/all-named-size' },
169
+ }, false);
170
+ assert(response.context.httpStatusCode === 200);
171
+
172
+ const file1 = await this.fixture.readStream('/asset.yml');
173
+ const hash1 = await BinaryUtil.hashInput(file1);
174
+
175
+ const file2 = await this.fixture.readStream('/logo.png');
176
+ const hash2 = await BinaryUtil.hashInput(file2);
177
+
178
+ assert(response.body?.hash1 === hash1);
179
+ assert(response.body?.hash2 === hash2);
180
+ }
181
+ }