@balena/pinejs 20.1.0-build-add-large-file-uploads-interfaces-e645db6b429537d3cb2e40da3167bcfcc6b31b37-1 → 20.1.0-build-large-file-uploads-2-b83400458570d926f7a6abddeb7a149e89f52abe-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.
@@ -11,9 +11,16 @@ import {
11
11
  odataNameToSqlName,
12
12
  sqlNameToODataName,
13
13
  } from '@balena/odata-to-abstract-sql';
14
+ import type { ConfigLoader } from '../server-glue/module.js';
14
15
  import { errors, permissions } from '../server-glue/module.js';
15
16
  import type { WebResourceType as WebResource } from '@balena/sbvr-types';
16
17
  import { TypedError } from 'typed-error';
18
+ import type WebresourceModel from './webresource.js';
19
+ import { importSBVR } from '../server-glue/sbvr-loader.js';
20
+ import {
21
+ isMultipartUploadAvailable,
22
+ multipartUploadHooks,
23
+ } from './multipartUpload.js';
17
24
 
18
25
  export * from './handlers/index.js';
19
26
 
@@ -56,17 +63,23 @@ export interface CommitMultipartUploadPayload {
56
63
  providerCommitData?: Record<string, any>;
57
64
  }
58
65
 
66
+ export interface CancelMultipartUploadPayload {
67
+ fileKey: string;
68
+ uploadId: string;
69
+ }
70
+
59
71
  export interface WebResourceHandler {
60
72
  handleFile: (resource: IncomingFile) => Promise<UploadResponse>;
61
73
  removeFile: (fileReference: string) => Promise<void>;
62
74
  onPreRespond: (webResource: WebResource) => Promise<WebResource>;
63
- beginMultipartUpload?: (
64
- fieldName: string,
65
- payload: BeginMultipartUploadPayload,
66
- ) => Promise<BeginMultipartUploadHandlerResponse>;
67
- commitMultipartUpload?: (
68
- commitInfo: CommitMultipartUploadPayload,
69
- ) => Promise<WebResource>;
75
+ multipartUpload?: {
76
+ begin: (
77
+ fieldName: string,
78
+ payload: BeginMultipartUploadPayload,
79
+ ) => Promise<BeginMultipartUploadHandlerResponse>;
80
+ commit: (commitInfo: CommitMultipartUploadPayload) => Promise<WebResource>;
81
+ cancel: (cancelInfo: CancelMultipartUploadPayload) => Promise<void>;
82
+ };
70
83
  }
71
84
 
72
85
  export class WebResourceError extends TypedError {}
@@ -96,30 +109,27 @@ export const setupWebresourceHandler = (handler: WebResourceHandler): void => {
96
109
  export const getWebresourceHandler = (): WebResourceHandler | undefined => {
97
110
  return configuredWebResourceHandler;
98
111
  };
112
+ const notValidUpload = () => Promise.resolve(false);
99
113
 
100
- const isFileInValidPath = async (
101
- fieldname: string,
114
+ const getRequestUploadValidator = async (
102
115
  req: Express.Request,
103
- ): Promise<boolean> => {
116
+ odataRequest: uriParser.ParsedODataRequest,
117
+ ): Promise<(fieldName: string) => Promise<boolean>> => {
104
118
  if (req.method !== 'POST' && req.method !== 'PATCH') {
105
- return false;
119
+ return notValidUpload;
106
120
  }
107
121
 
108
122
  const apiRoot = getApiRoot(req);
109
123
  if (apiRoot == null) {
110
- return false;
124
+ return notValidUpload;
111
125
  }
112
126
  const model = getModel(apiRoot);
113
- const odataRequest = uriParser.parseOData({
114
- url: req.url,
115
- method: req.method,
116
- });
117
127
  const sqlResourceName = sbvrUtils.resolveSynonym(odataRequest);
118
128
 
119
129
  const table = model.abstractSql.tables[sqlResourceName];
120
130
 
121
131
  if (table == null) {
122
- return false;
132
+ return notValidUpload;
123
133
  }
124
134
 
125
135
  const permission = req.method === 'POST' ? 'create' : 'update';
@@ -134,20 +144,22 @@ const isFileInValidPath = async (
134
144
  );
135
145
 
136
146
  if (!hasPermissions) {
137
- return false;
147
+ return notValidUpload;
138
148
  }
139
149
 
140
- const dbFieldName = odataNameToSqlName(fieldname);
141
- return table.fields.some(
142
- (field) =>
143
- field.fieldName === dbFieldName && field.dataType === 'WebResource',
144
- );
150
+ return async (fieldname: string) => {
151
+ const dbFieldName = odataNameToSqlName(fieldname);
152
+ return table.fields.some(
153
+ (field) =>
154
+ field.fieldName === dbFieldName && field.dataType === 'WebResource',
155
+ );
156
+ };
145
157
  };
146
158
 
147
159
  export const getUploaderMiddlware = (
148
160
  handler: WebResourceHandler,
149
161
  ): Express.RequestHandler => {
150
- return (req, res, next) => {
162
+ return async (req, res, next) => {
151
163
  if (!req.is('multipart')) {
152
164
  next();
153
165
  return;
@@ -158,6 +170,20 @@ export const getUploaderMiddlware = (
158
170
  const bb = busboy({ headers: req.headers });
159
171
  let isAborting = false;
160
172
 
173
+ const parsedOdataRequest = uriParser.parseOData({
174
+ url: req.url,
175
+ method: req.method,
176
+ });
177
+ const webResourcesFieldNames = getWebResourceFields(
178
+ parsedOdataRequest,
179
+ false,
180
+ );
181
+
182
+ const isValidUpload = await getRequestUploadValidator(
183
+ req,
184
+ parsedOdataRequest,
185
+ );
186
+
161
187
  const finishFileUpload = () => {
162
188
  req.unpipe(bb);
163
189
  req.on('readable', req.read.bind(req));
@@ -185,7 +211,7 @@ export const getUploaderMiddlware = (
185
211
  completeUploads.push(
186
212
  (async () => {
187
213
  try {
188
- if (!(await isFileInValidPath(fieldname, req))) {
214
+ if (!(await isValidUpload(fieldname))) {
189
215
  filestream.resume();
190
216
  return;
191
217
  }
@@ -207,7 +233,10 @@ export const getUploaderMiddlware = (
207
233
  uploadedFilePaths.push(result.filename);
208
234
  } catch (err: any) {
209
235
  filestream.resume();
210
- throw err;
236
+ bb.emit(
237
+ 'error',
238
+ new errors.BadRequestError(err.message ?? 'Error uploading file'),
239
+ );
211
240
  }
212
241
  })(),
213
242
  );
@@ -217,6 +246,14 @@ export const getUploaderMiddlware = (
217
246
  // This receives the form fields and transforms them into a standard JSON body
218
247
  // This is a similar behavior as previous multer library did
219
248
  bb.on('field', (name, val) => {
249
+ if (webResourcesFieldNames.includes(name)) {
250
+ isAborting = true;
251
+ bb.emit(
252
+ 'error',
253
+ new errors.BadRequestError('WebResource field must be a blob.'),
254
+ );
255
+ return;
256
+ }
220
257
  req.body[name] = val;
221
258
  });
222
259
 
@@ -242,7 +279,7 @@ export const getUploaderMiddlware = (
242
279
  }
243
280
  });
244
281
 
245
- bb.on('error', async (err) => {
282
+ bb.on('error', async (err: Error) => {
246
283
  finishFileUpload();
247
284
  await clearFiles();
248
285
 
@@ -254,15 +291,17 @@ export const getUploaderMiddlware = (
254
291
  );
255
292
  }
256
293
 
257
- getLogger(getApiRoot(req)).error('Error uploading file', err);
258
- next(err);
294
+ if (!sbvrUtils.handleHttpErrors(req, res, err)) {
295
+ getLogger(getApiRoot(req)).error('Error uploading file', err);
296
+ next(err);
297
+ }
259
298
  });
260
299
  req.pipe(bb);
261
300
  };
262
301
  };
263
302
 
264
- const getWebResourceFields = (
265
- request: uriParser.ODataRequest,
303
+ export const getWebResourceFields = (
304
+ request: uriParser.ParsedODataRequest,
266
305
  useTranslations = true,
267
306
  ): string[] => {
268
307
  // Translations will use modifyFields(translated) rather than fields(original) so we need to
@@ -294,6 +333,9 @@ const throwIfWebresourceNotInMultipart = (
294
333
  { req, request }: HookArgs,
295
334
  ) => {
296
335
  if (
336
+ request.custom.isAction !== 'beginUpload' &&
337
+ request.custom.isAction !== 'commitUpload' &&
338
+ request.custom.isAction !== 'cancelUpload' &&
297
339
  !req.is?.('multipart') &&
298
340
  webResourceFields.some((field) => request.values[field] != null)
299
341
  ) {
@@ -490,4 +532,37 @@ export const setupUploadHooks = (
490
532
  resourceName,
491
533
  getCreateWebResourceHooks(handler),
492
534
  );
535
+
536
+ if (isMultipartUploadAvailable(handler)) {
537
+ sbvrUtils.addPureHook(
538
+ 'POST',
539
+ apiRoot,
540
+ resourceName,
541
+ multipartUploadHooks(handler),
542
+ );
543
+ }
544
+ };
545
+
546
+ const initSql = `
547
+ CREATE INDEX IF NOT EXISTS idx_multipart_upload_uuid ON "multipart upload" (uuid);
548
+ CREATE INDEX IF NOT EXISTS idx_multipart_upload_status ON "multipart upload" (status);
549
+ `;
550
+
551
+ const modelText = await importSBVR('./webresource.sbvr', import.meta);
552
+
553
+ declare module '../sbvr-api/sbvr-utils.js' {
554
+ export interface API {
555
+ webresource: PinejsClient<WebresourceModel>;
556
+ }
557
+ }
558
+
559
+ export const config: ConfigLoader.Config = {
560
+ models: [
561
+ {
562
+ modelName: 'webresource',
563
+ apiRoot: 'webresource',
564
+ modelText,
565
+ initSql,
566
+ },
567
+ ],
493
568
  };
@@ -0,0 +1,371 @@
1
+ import type { WebResourceType as WebResource } from '@balena/sbvr-types';
2
+ import { randomUUID } from 'node:crypto';
3
+ import type { AnyObject } from 'pinejs-client-core';
4
+ import type {
5
+ BeginMultipartUploadPayload,
6
+ UploadPart,
7
+ WebResourceHandler,
8
+ } from './index.js';
9
+ import { getWebResourceFields } from './index.js';
10
+ import type { PinejsClient } from '../sbvr-api/sbvr-utils.js';
11
+ import { api } from '../sbvr-api/sbvr-utils.js';
12
+ import type { ODataRequest } from '../sbvr-api/uri-parser.js';
13
+ import { errors, sbvrUtils } from '../server-glue/module.js';
14
+ import { webResource as webResourceEnv } from '../config-loader/env.js';
15
+ import * as permissions from '../sbvr-api/permissions.js';
16
+
17
+ type BeginUploadDbCheck = BeginMultipartUploadPayload & WebResource;
18
+
19
+ export interface PendingUpload extends BeginMultipartUploadPayload {
20
+ fieldName: string;
21
+ fileKey: string;
22
+ uploadId: string;
23
+ }
24
+
25
+ export interface BeginUploadResponse {
26
+ [fieldName: string]: {
27
+ uuid: string;
28
+ uploadParts: UploadPart[];
29
+ };
30
+ }
31
+
32
+ // Is WebResourceHandler but with beginMultipartUpload and commitMultipartUpload as not optional
33
+ type MultipartUploadHandler = WebResourceHandler &
34
+ Required<Pick<WebResourceHandler, 'multipartUpload'>>;
35
+
36
+ const MB = 1024 * 1024;
37
+
38
+ export const isMultipartUploadAvailable = (
39
+ webResourceHandler: WebResourceHandler,
40
+ ): webResourceHandler is MultipartUploadHandler => {
41
+ return (
42
+ webResourceEnv.multipartUploadEnabled &&
43
+ webResourceHandler.multipartUpload != null
44
+ );
45
+ };
46
+
47
+ export const multipartUploadHooks = (
48
+ webResourceHandler: MultipartUploadHandler,
49
+ ): sbvrUtils.Hooks => {
50
+ return {
51
+ POSTPARSE: async ({ req, request, tx, api: applicationApi }) => {
52
+ if (request.odataQuery.property?.resource === 'beginUpload') {
53
+ const uploadParams = await validateBeginUpload(request, applicationApi);
54
+
55
+ // This transaction is necessary because beginUpload requests
56
+ // will rollback the transaction (in order to first validate)
57
+ // The metadata requested. If we don't pass any transaction
58
+ // It will use the default transaction handler which will error out
59
+ // on any rollback.
60
+ tx = await sbvrUtils.db.transaction();
61
+ req.tx = tx;
62
+ request.tx = tx;
63
+
64
+ request.method = 'PATCH';
65
+ request.values = uploadParams;
66
+ request.odataQuery.resource = request.resourceName;
67
+ delete request.odataQuery.property;
68
+ request.custom.isAction = 'beginUpload';
69
+ } else if (request.odataQuery.property?.resource === 'commitUpload') {
70
+ const commitPayload = await validateCommitUpload(
71
+ request,
72
+ applicationApi,
73
+ );
74
+
75
+ const webresource = await webResourceHandler.multipartUpload.commit({
76
+ fileKey: commitPayload.metadata.fileKey,
77
+ uploadId: commitPayload.metadata.uploadId,
78
+ filename: commitPayload.metadata.filename,
79
+ providerCommitData: commitPayload.providerCommitData,
80
+ });
81
+
82
+ await api.webresource.patch({
83
+ resource: 'multipart_upload',
84
+ body: {
85
+ status: 'completed',
86
+ },
87
+ options: {
88
+ $filter: {
89
+ uuid: commitPayload.uuid,
90
+ },
91
+ },
92
+ passthrough: {
93
+ tx: tx,
94
+ req: permissions.root,
95
+ },
96
+ });
97
+
98
+ request.method = 'PATCH';
99
+ request.values = {
100
+ [commitPayload.metadata.fieldName]: webresource,
101
+ };
102
+ request.odataQuery.resource = request.resourceName;
103
+ delete request.odataQuery.property;
104
+ request.custom.isAction = 'commitUpload';
105
+ request.custom.commitUploadPayload = webresource;
106
+ } else if (request.odataQuery.property?.resource === 'cancelUpload') {
107
+ const { uuid, fileKey, uploadId } = await validateCancelPayload(
108
+ request,
109
+ applicationApi,
110
+ );
111
+
112
+ await webResourceHandler.multipartUpload.cancel({ fileKey, uploadId });
113
+
114
+ await api.webresource.patch({
115
+ resource: 'multipart_upload',
116
+ body: {
117
+ status: 'cancelled',
118
+ },
119
+ options: {
120
+ $filter: { uuid },
121
+ },
122
+ passthrough: {
123
+ tx: tx,
124
+ req: permissions.root,
125
+ },
126
+ });
127
+
128
+ request.method = 'GET';
129
+ request.odataQuery.resource = request.resourceName;
130
+ delete request.odataQuery.property;
131
+ request.custom.isAction = 'cancelUpload';
132
+ }
133
+ },
134
+ PRERESPOND: async ({ req, request, response, tx }) => {
135
+ if (request.custom.isAction === 'beginUpload') {
136
+ // In the case where the transaction has failed because it had invalid payload
137
+ // such as breaking a db constraint, this hook wouldn't have been called
138
+ // and would rather throw with the rule it failed to validate
139
+ // We rollback here as the patch was just a way to validate the upload payload
140
+ await tx.rollback();
141
+
142
+ response.statusCode = 200;
143
+ response.body = await beginUpload({
144
+ webResourceHandler,
145
+ odataRequest: request,
146
+ actorId: req.user?.actor,
147
+ });
148
+ } else if (request.custom.isAction === 'commitUpload') {
149
+ response.body = await webResourceHandler.onPreRespond(
150
+ request.custom.commitUploadPayload,
151
+ );
152
+ } else if (request.custom.isAction === 'cancelUpload') {
153
+ response.statusCode = 204;
154
+ delete response.body;
155
+ }
156
+ },
157
+ };
158
+ };
159
+
160
+ const beginUpload = async ({
161
+ webResourceHandler,
162
+ odataRequest,
163
+ actorId,
164
+ }: {
165
+ webResourceHandler: MultipartUploadHandler;
166
+ odataRequest: ODataRequest;
167
+ actorId?: number;
168
+ }): Promise<BeginUploadResponse> => {
169
+ const payload = odataRequest.values as {
170
+ [x: string]: BeginMultipartUploadPayload;
171
+ };
172
+ const fieldName = Object.keys(payload)[0];
173
+ const metadata = payload[fieldName];
174
+ const { fileKey, uploadId, uploadParts } =
175
+ await webResourceHandler.multipartUpload.begin(fieldName, metadata);
176
+ const uuid = randomUUID();
177
+
178
+ return await sbvrUtils.db.transaction(async (tx) => {
179
+ try {
180
+ await api.webresource.post({
181
+ resource: 'multipart_upload',
182
+ body: {
183
+ uuid,
184
+ resource_name: odataRequest.resourceName,
185
+ field_name: fieldName,
186
+ resource_id: odataRequest.affectedIds?.[0],
187
+ upload_id: uploadId,
188
+ file_key: fileKey,
189
+ status: 'pending',
190
+ filename: metadata.filename,
191
+ content_type: metadata.content_type,
192
+ size: metadata.size,
193
+ chunk_size: metadata.chunk_size,
194
+ expiry_date: Date.now() + 7 * 24 * 60 * 60 * 1000, // 7 days in ms
195
+ is_created_by__actor: actorId,
196
+ },
197
+ passthrough: {
198
+ req: permissions.root,
199
+ tx,
200
+ },
201
+ });
202
+ return { [fieldName]: { uuid, uploadParts } };
203
+ } catch (err) {
204
+ console.error('failed to start multipart upload', err);
205
+ throw new errors.BadRequestError('Failed to start multipart upload');
206
+ }
207
+ });
208
+ };
209
+
210
+ const validateBeginUpload = async (
211
+ request: ODataRequest,
212
+ applicationApi: PinejsClient,
213
+ ) => {
214
+ if (request.odataQuery.key == null) {
215
+ throw new errors.BadRequestError();
216
+ }
217
+
218
+ await applicationApi.request({
219
+ method: 'POST',
220
+ url: request.url.substring(1).replace('beginUpload', 'canAccess'),
221
+ body: { method: 'PATCH' },
222
+ });
223
+
224
+ const fieldNames = Object.keys(request.values);
225
+ if (fieldNames.length !== 1) {
226
+ throw new errors.BadRequestError(
227
+ 'You can only get upload url for one field at a time',
228
+ );
229
+ }
230
+
231
+ const [fieldName] = fieldNames;
232
+ const webResourceFields = getWebResourceFields(request, false);
233
+ if (!webResourceFields.includes(fieldName)) {
234
+ throw new errors.BadRequestError(
235
+ `You must provide a valid webresource field from: ${JSON.stringify(webResourceFields)}`,
236
+ );
237
+ }
238
+
239
+ const beginUploadPayload = parseBeginUploadPayload(request.values[fieldName]);
240
+ if (beginUploadPayload == null) {
241
+ throw new errors.BadRequestError('Invalid file metadata');
242
+ }
243
+
244
+ const uploadMetadataCheck: BeginUploadDbCheck = {
245
+ ...beginUploadPayload,
246
+ // This is "probe" request. We don't actually store anything on the application table yet
247
+ // We just avoid creating the application record if it would fail anyway for some other db constraint
248
+ href: 'metadata_check_probe',
249
+ };
250
+
251
+ return { [fieldName]: uploadMetadataCheck };
252
+ };
253
+
254
+ const parseBeginUploadPayload = (
255
+ payload: AnyObject,
256
+ ): BeginMultipartUploadPayload | null => {
257
+ if (typeof payload !== 'object') {
258
+ return null;
259
+ }
260
+
261
+ let { filename, content_type, size, chunk_size } = payload;
262
+ if (
263
+ typeof filename !== 'string' ||
264
+ typeof content_type !== 'string' ||
265
+ typeof size !== 'number' ||
266
+ (chunk_size != null && typeof chunk_size !== 'number') ||
267
+ (chunk_size != null && chunk_size < 5 * MB)
268
+ ) {
269
+ return null;
270
+ }
271
+
272
+ if (chunk_size == null) {
273
+ chunk_size = 5 * MB;
274
+ }
275
+ return { filename, content_type, size, chunk_size };
276
+ };
277
+
278
+ const validateCommitUpload = async (
279
+ request: ODataRequest,
280
+ applicationApi: PinejsClient,
281
+ ) => {
282
+ if (request.odataQuery.key == null) {
283
+ throw new errors.BadRequestError();
284
+ }
285
+
286
+ await applicationApi.request({
287
+ method: 'POST',
288
+ url: request.url.substring(1).replace('commitUpload', 'canAccess'),
289
+ body: { method: 'PATCH' },
290
+ });
291
+
292
+ const { uuid, providerCommitData } = request.values;
293
+ if (typeof uuid !== 'string') {
294
+ throw new errors.BadRequestError('Invalid uuid type');
295
+ }
296
+
297
+ const [multipartUpload] = await api.webresource.get({
298
+ resource: 'multipart_upload',
299
+ options: {
300
+ $select: ['id', 'file_key', 'upload_id', 'field_name', 'filename'],
301
+ $filter: {
302
+ uuid,
303
+ status: 'pending',
304
+ expiry_date: { $gt: { $now: {} } },
305
+ },
306
+ },
307
+ passthrough: {
308
+ tx: request.tx,
309
+ req: permissions.rootRead,
310
+ },
311
+ });
312
+
313
+ if (multipartUpload == null) {
314
+ throw new errors.BadRequestError(`Invalid upload for uuid ${uuid}`);
315
+ }
316
+
317
+ const metadata = {
318
+ fileKey: multipartUpload.file_key,
319
+ uploadId: multipartUpload.upload_id,
320
+ filename: multipartUpload.filename,
321
+ fieldName: multipartUpload.field_name,
322
+ };
323
+
324
+ return { uuid, providerCommitData, metadata };
325
+ };
326
+
327
+ const validateCancelPayload = async (
328
+ request: ODataRequest,
329
+ applicationApi: PinejsClient,
330
+ ) => {
331
+ if (request.odataQuery.key == null) {
332
+ throw new errors.BadRequestError();
333
+ }
334
+
335
+ await applicationApi.request({
336
+ method: 'POST',
337
+ url: request.url.substring(1).replace('cancelUpload', 'canAccess'),
338
+ body: { method: 'PATCH' },
339
+ });
340
+
341
+ const { uuid } = request.values;
342
+ if (typeof uuid !== 'string') {
343
+ throw new errors.BadRequestError('Invalid uuid type');
344
+ }
345
+
346
+ const [multipartUpload] = await api.webresource.get({
347
+ resource: 'multipart_upload',
348
+ options: {
349
+ $select: ['id', 'file_key', 'upload_id'],
350
+ $filter: {
351
+ uuid,
352
+ status: 'pending',
353
+ expiry_date: { $gt: { $now: {} } },
354
+ },
355
+ },
356
+ passthrough: {
357
+ tx: request.tx,
358
+ req: permissions.rootRead,
359
+ },
360
+ });
361
+
362
+ if (multipartUpload == null) {
363
+ throw new errors.BadRequestError(`Invalid upload for uuid ${uuid}`);
364
+ }
365
+
366
+ return {
367
+ uuid,
368
+ fileKey: multipartUpload.file_key,
369
+ uploadId: multipartUpload.upload_id,
370
+ };
371
+ };
@@ -0,0 +1,60 @@
1
+ Vocabulary: webresource
2
+
3
+ Term: actor
4
+ Concept Type: Integer (Type)
5
+ Term: expiry date
6
+ Concept Type: Date Time (Type)
7
+ Term: uuid
8
+ Concept Type: Short Text (Type)
9
+ Term: resource name
10
+ Concept Type: Short Text (Type)
11
+ Term: field name
12
+ Concept Type: Short Text (Type)
13
+ Term: resource id
14
+ Concept Type: Integer (Type)
15
+ Term: upload id
16
+ Concept Type: Short Text (Type)
17
+ Term: file key
18
+ Concept Type: Short Text (Type)
19
+ Term: status
20
+ Concept Type: Short Text (Type)
21
+ Term: filename
22
+ Concept Type: Short Text (Type)
23
+ Term: content type
24
+ Concept Type: Short Text (Type)
25
+ Term: size
26
+ Concept Type: Integer (Type)
27
+ Term: chunk size
28
+ Concept Type: Integer (Type)
29
+ Term: valid until date
30
+ Concept Type: Date Time (Type)
31
+
32
+ Term: multipart upload
33
+ Fact type: multipart upload has uuid
34
+ Necessity: each multipart upload has exactly one uuid
35
+ Necessity: each uuid is of exactly one multipart upload
36
+ Fact type: multipart upload has resource name
37
+ Necessity: each multipart upload has exactly one resource name
38
+ Fact type: multipart upload has field name
39
+ Necessity: each multipart upload has exactly one field name
40
+ Fact type: multipart upload has resource id
41
+ Necessity: each multipart upload has exactly one resource id
42
+ Fact type: multipart upload has upload id
43
+ Necessity: each multipart upload has exactly one upload id
44
+ Fact type: multipart upload has file key
45
+ Necessity: each multipart upload has exactly one file key
46
+ Fact type: multipart upload has status
47
+ Necessity: each multipart upload has exactly one status
48
+ Definition: "pending" or "completed" or "cancelled"
49
+ Fact type: multipart upload has filename
50
+ Necessity: each multipart upload has exactly one filename
51
+ Fact type: multipart upload has content type
52
+ Necessity: each multipart upload has exactly one content type
53
+ Fact type: multipart upload has size
54
+ Necessity: each multipart upload has exactly one size
55
+ Fact type: multipart upload has chunk size
56
+ Necessity: each multipart upload has exactly one chunk size
57
+ Fact type: multipart upload has expiry date
58
+ Necessity: each multipart upload has exactly one expiry date
59
+ Fact type: multipart upload is created by actor
60
+ Necessity: each multipart upload is created by at most one actor