Skip to content

Commit 08c4c9e

Browse files
authored
feat: Add large file upload (#347)
* Add large file upload
1 parent 00cd02b commit 08c4c9e

File tree

7 files changed

+908
-2
lines changed

7 files changed

+908
-2
lines changed

src/tasks/LargeFileUploadTask.ts

Lines changed: 331 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,331 @@
1+
/**
2+
* -------------------------------------------------------------------------------------------
3+
* Copyright (c) Microsoft Corporation. All Rights Reserved. Licensed under the MIT License.
4+
* See License in the project root for license information.
5+
* -------------------------------------------------------------------------------------------
6+
*/
7+
8+
/**
9+
* @module LargeFileUploadTask
10+
**/
11+
12+
import {
13+
Parsable,
14+
RequestAdapter,
15+
RequestInformation,
16+
ParsableFactory,
17+
ParseNode,
18+
ErrorMappings,
19+
HttpMethod,
20+
} from "@microsoft/kiota-abstractions";
21+
import { UploadSlice } from "./UploadSlice";
22+
import { SeekableStreamReader } from "./SeekableStreamReader";
23+
24+
/**
25+
* @interface
26+
* Signature to represent progress receiver
27+
* @property {number} progress - The progress value (This is the last uploaded byte)
28+
*/
29+
export interface IProgress {
30+
report(progress: number): void;
31+
}
32+
33+
/**
34+
* @interface
35+
* Signature to represent an upload session, i.e the response returned by the server after for a pending upload
36+
*
37+
* @property {Date} expirationDateTime - The expiration time of the session
38+
* @property {string[]} nextExpectedRanges - The next expected ranges
39+
* @property {string} odataType - The type of the object
40+
* @property {string} uploadUrl - The URL to which the file upload needs to be done
41+
*/
42+
export interface UploadSession {
43+
expirationDateTime?: Date | null;
44+
nextExpectedRanges?: string[] | null;
45+
odataType?: string | null;
46+
uploadUrl?: string | null;
47+
}
48+
49+
/**
50+
* @interface
51+
* Signature to represent the result of an upload
52+
*/
53+
export interface UploadResult<T> {
54+
itemResponse?: T | null;
55+
location?: string;
56+
}
57+
58+
/**
59+
* UploadSession ParsableFactory
60+
* Creates a factory function to deserialize the upload session response.
61+
*
62+
* @param {ParseNode} _parseNode - The parse node to deserialize.
63+
* @returns {Function} - A function that takes an instance of Parsable and returns a record of deserialization functions.
64+
*/
65+
export const createUploadSessionFromDiscriminatorValue = (
66+
_parseNode: ParseNode | undefined,
67+
): ((instance?: Parsable) => Record<string, (node: ParseNode) => void>) => {
68+
return deserializeIntoUploadSession;
69+
};
70+
71+
/**
72+
* Deserializes the upload session response body.
73+
*
74+
* @param {Partial<UploadSession>} [uploadSession] - The upload session object to deserialize into.
75+
* @returns {Record<string, (node: ParseNode) => void>} - A record of deserialization functions.
76+
*/
77+
export const deserializeIntoUploadSession = (
78+
uploadSession: Partial<UploadSession> | undefined = {},
79+
): Record<string, (node: ParseNode) => void> => {
80+
return {
81+
expirationDateTime: n => {
82+
uploadSession.expirationDateTime = n.getDateValue();
83+
},
84+
nextExpectedRanges: n => {
85+
uploadSession.nextExpectedRanges = n.getCollectionOfPrimitiveValues();
86+
},
87+
"@odata.type": n => {
88+
uploadSession.odataType = n.getStringValue();
89+
},
90+
uploadUrl: n => {
91+
uploadSession.uploadUrl = n.getStringValue();
92+
},
93+
};
94+
};
95+
96+
/**
97+
* @constant
98+
* A default slice size for a large file
99+
*/
100+
const DefaultSliceSize = 320 * 1024;
101+
102+
/**
103+
* A class representing LargeFileUploadTask
104+
*/
105+
export class LargeFileUploadTask<T extends Parsable> {
106+
/**
107+
* @private
108+
* The ranges to upload
109+
*/
110+
rangesRemaining: number[][] = [];
111+
112+
/**
113+
* @private
114+
* The error mappings
115+
*/
116+
errorMappings: ErrorMappings;
117+
118+
/**
119+
* @private
120+
* The seekable stream reader
121+
*/
122+
seekableStreamReader: SeekableStreamReader;
123+
124+
/**
125+
* @private
126+
* The upload session
127+
*/
128+
Session: UploadSession;
129+
130+
/**
131+
* Constructs a new instance of the LargeFileUploadTask class.
132+
*
133+
* @param {RequestAdapter} requestAdapter - The request adapter to use for making HTTP requests.
134+
* @param {Parsable} uploadSession - The upload session information.
135+
* @param {ReadableStream<Uint8Array>} uploadStream - Returns an instance of an unconsumed new stream to be uploaded.
136+
* @param {number} [maxSliceSize=-1] - The maximum size of each file slice to be uploaded.
137+
* @param {ParsableFactory<T>} parsableFactory - The factory to create parsable objects.
138+
* @param {ErrorMappings} [errorMappings] - error mappings.
139+
*
140+
* @throws {Error} If any of the required parameters are undefined or invalid.
141+
*/
142+
constructor(
143+
private readonly requestAdapter: RequestAdapter,
144+
uploadSession: Parsable,
145+
uploadStream: ReadableStream<Uint8Array>,
146+
private readonly maxSliceSize = -1,
147+
private readonly parsableFactory: ParsableFactory<T>,
148+
errorMappings: ErrorMappings,
149+
) {
150+
if (!uploadSession) {
151+
const error = new Error("Upload session is undefined, Please provide a valid upload session");
152+
error.name = "Invalid Upload Session Error";
153+
throw error;
154+
}
155+
if (!uploadStream) {
156+
const error = new Error("Upload stream is undefined, Please provide a valid upload stream");
157+
error.name = "Invalid Upload Stream Error";
158+
throw error;
159+
}
160+
if (!requestAdapter) {
161+
const error = new Error("Request adapter is undefined, Please provide a valid request adapter");
162+
error.name = "Invalid Request Adapter Error";
163+
throw error;
164+
}
165+
if (!parsableFactory) {
166+
const error = new Error("Parsable factory is undefined, Please provide a valid parsable factory");
167+
error.name = "Invalid Parsable Factory Error";
168+
throw error;
169+
}
170+
if (uploadStream.locked) {
171+
const error = new Error("Upload stream is locked, Please provide a valid upload stream");
172+
error.name = "Invalid Upload Stream Error";
173+
throw error;
174+
}
175+
if (maxSliceSize <= 0) {
176+
this.maxSliceSize = DefaultSliceSize;
177+
}
178+
this.parsableFactory = parsableFactory;
179+
180+
this.seekableStreamReader = new SeekableStreamReader(uploadStream);
181+
182+
this.Session = this.extractSessionInfo(uploadSession);
183+
this.rangesRemaining = this.getRangesRemaining(this.Session);
184+
this.errorMappings = errorMappings;
185+
}
186+
187+
/**
188+
* Uploads the file in a sequential order by slicing the file in terms of ranges.
189+
*
190+
* @param {IProgress} [progress] - Optional progress receiver to report upload progress.
191+
* @returns {Promise<UploadResult<T>>} - The result of the upload.
192+
* @throws {Error} If the upload fails.
193+
*/
194+
public async upload(progress?: IProgress): Promise<UploadResult<T>> {
195+
const uploadUrl = this.Session.uploadUrl;
196+
if (!uploadUrl) {
197+
throw new Error("Upload URL is a required parameter.");
198+
}
199+
for (const range of this.rangesRemaining) {
200+
let currentRangeBegin = range[0];
201+
while (currentRangeBegin <= range[1]) {
202+
const nextSliceSize = this.nextSliceSize(currentRangeBegin, range[1]);
203+
const uploadRequest = new UploadSlice<T>(
204+
this.requestAdapter,
205+
uploadUrl,
206+
currentRangeBegin,
207+
currentRangeBegin + nextSliceSize - 1,
208+
range[1] + 1,
209+
this.parsableFactory,
210+
this.errorMappings,
211+
this.seekableStreamReader,
212+
);
213+
const uploadResult = await uploadRequest.uploadSlice();
214+
progress?.report(uploadRequest.rangeEnd);
215+
const { itemResponse, location } = uploadResult as Partial<UploadResult<T>>;
216+
if (itemResponse || location) {
217+
return uploadResult as UploadResult<T>;
218+
}
219+
currentRangeBegin += nextSliceSize;
220+
}
221+
}
222+
throw new Error("Upload failed");
223+
}
224+
225+
/**
226+
* @public
227+
* Resumes the current upload session
228+
* @param progress
229+
*/
230+
public async resume(progress?: IProgress): Promise<UploadResult<T>> {
231+
await this.updateSession();
232+
return this.upload(progress);
233+
}
234+
235+
/**
236+
* Refreshes the current upload session status by making a GET request to the upload URL.
237+
* Updates the session expiration date, next expected ranges, and remaining ranges based on the response.
238+
*
239+
* @returns {Promise<UploadSession | undefined>} - A promise that resolves to the updated upload session.
240+
* @throws {Error} If the request fails.
241+
*/
242+
public async updateSession(): Promise<UploadSession | undefined> {
243+
const url = this.Session.uploadUrl;
244+
if (!url) {
245+
throw new Error("Upload url is invalid");
246+
}
247+
const requestInformation = new RequestInformation(HttpMethod.GET, url);
248+
const response = await this.requestAdapter.send<UploadSession>(
249+
requestInformation,
250+
createUploadSessionFromDiscriminatorValue,
251+
this.errorMappings,
252+
);
253+
254+
if (response) {
255+
this.Session.expirationDateTime = response.expirationDateTime;
256+
this.Session.nextExpectedRanges = response.nextExpectedRanges;
257+
if (response.uploadUrl) {
258+
this.Session.uploadUrl = response.uploadUrl;
259+
}
260+
this.rangesRemaining = this.getRangesRemaining(this.Session);
261+
}
262+
return response;
263+
}
264+
265+
/**
266+
* Deletes the current upload session.
267+
* Sends a PUT request to the upload URL to cancel the session.
268+
*
269+
* @returns {Promise<void>} A promise that resolves when the session is canceled.
270+
*/
271+
public async deleteSession(): Promise<void> {
272+
const url = this.Session.uploadUrl;
273+
if (!url) {
274+
throw new Error("Upload url is invalid");
275+
}
276+
const requestInformation = new RequestInformation(HttpMethod.DELETE, url);
277+
await this.requestAdapter.sendNoResponseContent(requestInformation, this.errorMappings);
278+
}
279+
280+
/**
281+
* Extracts the upload session information from a parsable object.
282+
*
283+
* @param {Parsable} parsable - The parsable object containing the upload session information.
284+
* @returns {UploadSession} - The extracted upload session information.
285+
*/
286+
private extractSessionInfo(parsable: Parsable): UploadSession {
287+
const { expirationDateTime, nextExpectedRanges, odataType, uploadUrl } = parsable as Partial<UploadSession>;
288+
if (!nextExpectedRanges || !uploadUrl || nextExpectedRanges.length === 0) {
289+
throw new Error("Upload session is invalid");
290+
}
291+
return {
292+
expirationDateTime: expirationDateTime ?? null,
293+
nextExpectedRanges: nextExpectedRanges ?? null,
294+
odataType: odataType ?? null,
295+
uploadUrl: uploadUrl ?? null,
296+
};
297+
}
298+
299+
/**
300+
* Calculates the size of the next slice to be uploaded.
301+
*
302+
* @param {number} currentRangeBegin - The beginning of the current range.
303+
* @param {number} currentRangeEnd - The end of the current range.
304+
* @returns {number} - The size of the next slice.
305+
*/
306+
private nextSliceSize(currentRangeBegin: number, currentRangeEnd: number): number {
307+
const sizeBasedOnRange = currentRangeEnd - currentRangeBegin + 1;
308+
return sizeBasedOnRange > this.maxSliceSize ? this.maxSliceSize : sizeBasedOnRange;
309+
}
310+
311+
/**
312+
* @private
313+
* Parses the upload session response and returns a nested number array of ranges pending upload
314+
* @param uploadSession
315+
*/
316+
private getRangesRemaining(uploadSession: UploadSession): number[][] {
317+
// nextExpectedRanges: https://dev.onedrive.com/items/upload_large_files.htm
318+
// Sample: ["12345-55232","77829-99375"]
319+
// Also, second number in range can be blank, which means 'until the end'
320+
const ranges: number[][] = [];
321+
if (uploadSession.nextExpectedRanges) {
322+
ranges.push(
323+
...uploadSession.nextExpectedRanges.map(rangeString => {
324+
const rangeArray = rangeString.split("-");
325+
return [parseInt(rangeArray[0], 10), parseInt(rangeArray[1], 10)];
326+
}),
327+
);
328+
}
329+
return ranges;
330+
}
331+
}

0 commit comments

Comments
 (0)