Skip to content

Commit

Permalink
Add large file upload
Browse files Browse the repository at this point in the history
  • Loading branch information
rkodev committed Feb 11, 2025
1 parent 2bb7470 commit e1f72ed
Show file tree
Hide file tree
Showing 12 changed files with 295 additions and 2 deletions.
2 changes: 1 addition & 1 deletion src/authentication/AzureIdentityAccessTokenProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ export class GraphAzureIdentityAccessTokenProvider extends AzureIdentityAccessTo
public constructor(
credentials: TokenCredential,
scopes?: string[],
options?: GetTokenOptions | undefined,
options?: GetTokenOptions,
allowedHosts?: Set<string>,
observabilityOptions?: ObservabilityOptions,
isCaeEnabled?: boolean,
Expand Down
2 changes: 1 addition & 1 deletion src/authentication/AzureIdentityAuthenticationProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ export class GraphAzureIdentityAuthenticationProvider extends AzureIdentityAuthe
public constructor(
credentials: TokenCredential,
scopes?: string[],
options?: GetTokenOptions | undefined,
options?: GetTokenOptions,
allowedHosts?: Set<string>,
observabilityOptions?: ObservabilityOptions,
isCaeEnabled?: boolean,
Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,6 @@ export * from "./adapter/index.js";
export * from "./http/index.js";
export * from "./middleware/index.js";
export * from "./authentication/index.js";
export * from "./task/index.js";
export * from "./utils/Constants.js";
export * from "./utils/Version.js";
3 changes: 3 additions & 0 deletions src/task/IProgress.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export interface IProgress {
report(progress: number): void;
}
150 changes: 150 additions & 0 deletions src/task/LargeFileUploadTask.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
import { Parsable, RequestAdapter } from "@microsoft/kiota-abstractions";
import { UploadSession } from "./UploadSession";
import { UploadSliceRequestBuilder } from "./UploadSliceRequestBuilder";
import { UploadResult } from "./UploadResult";
import { IProgress } from "./IProgress";

export interface ILargeFileUploadTask<T extends Parsable> {
Upload(progressEventHandler: IProgress): Promise<UploadResult<T>>;

Resume(progressEventHandler: IProgress): Promise<UploadResult<T>>;

RefreshUploadStatus(): Promise<void>;

UpdateSession(): Promise<UploadSession>;

DeleteSession(): Promise<UploadSession>;

Cancel(): Promise<void>;
}

const DefaultSliceSize = 320 * 1024;

export class LargeFileUploadTask<T extends Parsable> implements ILargeFileUploadTask<T> {
rangesRemaining: number[][] = [];
Session: UploadSession;

constructor(
readonly uploadSession: Parsable,
readonly uploadStream: ReadableStream<Uint8Array>,
readonly maxSliceSize = -1,
readonly requestAdapter: RequestAdapter,
) {
if (!uploadStream?.locked) {
throw new Error("Please provide stream value");
}
if (requestAdapter === undefined) {
throw new Error("Request adapter is a required parameter");
}
if (maxSliceSize <= 0) {
this.maxSliceSize = DefaultSliceSize;
}

this.Session = this.extractSessionInfo(uploadSession);
this.rangesRemaining = this.GetRangesRemaining(this.Session);
}

public async Upload(progress?: IProgress, maxTries = 3): Promise<UploadResult<T>> {
let uploadTries = 0;
while (uploadTries < maxTries) {
const sliceRequests = this.GetUploadSliceRequests();
for (const request of sliceRequests) {
const uploadResult = await request.UploadSlice(this.uploadStream);
progress?.report(request.rangeEnd);
if (uploadResult?.UploadSucceeded()) {
return uploadResult;
}
}

await this.UpdateSession();
uploadTries++;

if (uploadTries < maxTries) {
// Exponential backoff
await this.sleep(2000 * (uploadTries + 1));
}
}

throw new Error("Max retries reached");
}

public Resume(_?: IProgress): Promise<UploadResult<T>> {
throw new Error("Method not implemented.");
}

public RefreshUploadStatus(): Promise<void> {
throw new Error("Method not implemented.");
}

public UpdateSession(): Promise<UploadSession> {
throw new Error("Method not implemented.");
}

public DeleteSession(): Promise<UploadSession> {
throw new Error("Method not implemented.");
}

public Cancel(): Promise<void> {
throw new Error("Method not implemented.");
}

private extractSessionInfo(parsable: Parsable): UploadSession {
const uploadSession: UploadSession = {
expirationDateTime: null,
nextExpectedRanges: null,
odataType: null,
uploadUrl: null,
};

if ("expirationDateTime" in parsable) uploadSession.expirationDateTime = parsable.expirationDateTime as Date | null;
if ("nextExpectedRanges" in parsable)
uploadSession.nextExpectedRanges = parsable.nextExpectedRanges as string[] | null;
if ("odataType" in parsable) uploadSession.odataType = parsable.odataType as string | null;
if ("uploadUrl" in parsable) uploadSession.uploadUrl = parsable.uploadUrl as string | null;

return uploadSession;
}

private sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}

private GetUploadSliceRequests(): UploadSliceRequestBuilder<T>[] {
const uploadSlices: UploadSliceRequestBuilder<T>[] = [];
const rangesRemaining = this.rangesRemaining;
const session = this.Session;
rangesRemaining.forEach(range => {
let currentRangeBegin = range[0];
while (currentRangeBegin <= range[1]) {
const nextSliceSize = this.nextSliceSize(currentRangeBegin, range[1]);
const uploadRequest = new UploadSliceRequestBuilder<T>(
this.requestAdapter,
session.uploadUrl!,
currentRangeBegin,
currentRangeBegin + nextSliceSize - 1,
range[1] + 1,
);
uploadSlices.push(uploadRequest);
currentRangeBegin += nextSliceSize;
}
});
return uploadSlices;
}

private nextSliceSize(currentRangeBegin: number, currentRangeEnd: number): number {
const sizeBasedOnRange = currentRangeEnd - currentRangeBegin + 1;
return sizeBasedOnRange > this.maxSliceSize ? this.maxSliceSize : sizeBasedOnRange;
}

private GetRangesRemaining(uploadSession: UploadSession): number[][] {
// nextExpectedRanges: https://dev.onedrive.com/items/upload_large_files.htm
// Sample: ["12345-55232","77829-99375"]
// Also, second number in range can be blank, which means 'until the end'
const ranges: number[][] = [];
uploadSession.nextExpectedRanges?.forEach(rangeString => {
const rangeArray = rangeString.split("-");
ranges.push([parseInt(rangeArray[0], 10), parseInt(rangeArray[1], 10)]);
});
return ranges;
}
}
20 changes: 20 additions & 0 deletions src/task/UploadResponseHandler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { ErrorMappings, ResponseHandler } from "@microsoft/kiota-abstractions";

export class UploadResponseHandler implements ResponseHandler {
handleResponse<NativeResponseType, ModelType>(
response: NativeResponseType,
_: ErrorMappings | undefined,
): Promise<ModelType | undefined> {
if (response instanceof Response) {
if (response.ok) {
if (response.body != null) {
const body = response.body as unknown as ModelType;
return Promise.resolve(body);
} else {
return Promise.resolve(undefined);
}
}
}
return Promise.resolve(undefined);
}
}
6 changes: 6 additions & 0 deletions src/task/UploadResponseHandlerOption.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { ResponseHandler, ResponseHandlerOption } from "@microsoft/kiota-abstractions";
import { UploadResponseHandler } from "./UploadResponseHandler";

export class UploadResponseHandlerOption extends ResponseHandlerOption {
public responseHandler?: ResponseHandler = new UploadResponseHandler();
}
41 changes: 41 additions & 0 deletions src/task/UploadResult.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { Parsable, ParseNode } from "@microsoft/kiota-abstractions";
import { UploadSession } from "./UploadSession";
import { ILargeFileUploadTask } from "./LargeFileUploadTask";

export class UploadResult<T extends Parsable> {
uploadSession?: UploadSession;
uploadTask?: ILargeFileUploadTask<T>;
itemResponse?: T;
location?: string;

UploadSucceeded() {
return this.itemResponse !== undefined || this.location !== undefined;
}
}

// eslint-disable-next-line prefer-arrow/prefer-arrow-functions
export function createUploadResult(
_: ParseNode | undefined,
): (instance?: Parsable) => Record<string, (node: ParseNode) => void> {
return deserializeIntoUploadResult;
}

// eslint-disable-next-line prefer-arrow/prefer-arrow-functions
export function deserializeIntoUploadResult<T extends Parsable>(
uploadResult: Partial<UploadResult<T>> | undefined = {},
): Record<string, (node: ParseNode) => void> {
return {
uploadSession: _ => {
uploadResult.uploadSession = undefined;
},
uploadTask: _ => {
uploadResult.uploadSession = undefined;
},
itemResponse: _ => {
uploadResult.uploadSession = undefined;
},
location: _ => {
uploadResult.uploadSession = undefined;
},
};
}
6 changes: 6 additions & 0 deletions src/task/UploadSession.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export interface UploadSession {
expirationDateTime?: Date | null;
nextExpectedRanges?: string[] | null;
odataType?: string | null;
uploadUrl?: string | null;
}
65 changes: 65 additions & 0 deletions src/task/UploadSliceRequestBuilder.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { Parsable, RequestAdapter, RequestInformation } from "@microsoft/kiota-abstractions";
import { createUploadResult, UploadResult } from "./UploadResult";
import { Headers } from "@microsoft/kiota-abstractions/dist/es/src/headers";
import { HttpMethod } from "@microsoft/kiota-abstractions/dist/es/src/httpMethod";
import { HeadersInspectionOptions } from "@microsoft/kiota-http-fetchlibrary";
import { UploadResponseHandlerOption } from "./UploadResponseHandlerOption";

const binaryContentType = "application/octet-stream";

export class UploadSliceRequestBuilder<T extends Parsable> {
constructor(
readonly requestAdapter: RequestAdapter,
readonly sessionUrl: string,
readonly rangeBegin: number,
readonly rangeEnd: number,
readonly totalSessionLength: number,
) {}

public async UploadSlice(stream: ReadableStream<Uint8Array>): Promise<UploadResult<T> | undefined> {
const data = await this.readSection(stream, this.rangeBegin, this.rangeEnd);
const requestInformation = this.createPutRequestInformation(data);

const responseHandler = new UploadResponseHandlerOption();

const headerOptions = new HeadersInspectionOptions({ inspectResponseHeaders: true });
requestInformation.addRequestOptions([headerOptions, responseHandler]);

return this.requestAdapter.send<UploadResult<T>>(requestInformation, createUploadResult, undefined);
}

private async readSection(stream: ReadableStream<Uint8Array>, start: number, end: number): Promise<ArrayBuffer> {
const reader = stream.getReader();
let bytesRead = 0;
const chunks: Uint8Array[] = [];

while (bytesRead < end - start + 1) {
const { done, value } = await reader.read();
if (done) break;
chunks.push(value);
bytesRead += value.length;
}

const result = new Uint8Array(bytesRead);
let offset = 0;
for (const chunk of chunks) {
result.set(chunk, offset);
offset += chunk.length;
}

return result.buffer;
}

private createPutRequestInformation(content: ArrayBuffer): RequestInformation {
const header = new Headers();
header.set("Content-Range", new Set([`bytes ${this.rangeBegin}-${this.rangeEnd - 1}/${this.totalSessionLength}`]));
header.set("Content-Length", new Set([`${this.rangeEnd - this.rangeBegin}`]));

const request = new RequestInformation();
request.headers = header;
request.urlTemplate = this.sessionUrl;
request.httpMethod = HttpMethod.PUT;
request.setStreamContent(content, binaryContentType);
return request;
}
}
1 change: 1 addition & 0 deletions src/task/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./LargeFileUploadTask";
Empty file.

0 comments on commit e1f72ed

Please sign in to comment.