diff --git a/abstract-sdk.d.ts b/abstract-sdk.d.ts index 1bbd6909..28aec275 100644 --- a/abstract-sdk.d.ts +++ b/abstract-sdk.d.ts @@ -237,7 +237,7 @@ interface Descriptors extends Endpoint { interface Files extends Endpoint { info(descriptor: FileDescriptor, requestOptions: RequestOptions): Promise; list(descriptor: BranchCommitDescriptor, requestOptions: RequestOptions): Promise; - raw(descriptor: FileDescriptor, options?: RawOptions): Promise; + raw(descriptor: FileDescriptor, options?: RawProgressOptions): Promise; } interface Layers extends Endpoint { @@ -1851,11 +1851,18 @@ type RequestConfig = { cli?: () => T }; +type ProgressCallback = (receivedBytes: number, totalBytes: number) => void; + type ApiRequestOptions = { customHostname?: string, - raw?: boolean + raw?: boolean, + onProgress?: ProgressCallback }; type RequestOptions = { transportMode?: ("api" | "cli")[] -}; \ No newline at end of file +}; + +type RawProgressOptions = RawOptions & { + onProgress?: ProgressCallback +}; diff --git a/docs/abstract-api.md b/docs/abstract-api.md index 2303ed60..58c7fcd8 100644 --- a/docs/abstract-api.md +++ b/docs/abstract-api.md @@ -133,7 +133,7 @@ abstract.assets.commit({ }); ``` -### Retrieve an asset file +### Export an asset file ![API][api-icon] @@ -794,11 +794,11 @@ abstract.files.info({ }); ``` -### Retrieve a Sketch file +### Export a file -![CLI][cli-icon] +![CLI][cli-icon] ![API][api-icon] -`files.raw(FileDescriptor, RawOptions): Promise` +`files.raw(FileDescriptor, RawProgressOptions): Promise` Retrieve a Sketch file from Abstract based on its file ID and save it to disk. Files will be saved to the current working directory by default, but a custom `filename` option can be used to customize this location. @@ -811,14 +811,38 @@ abstract.files.raw({ }); ``` -You can also load the file info at any commit on the branch… +The resulting `ArrayBuffer` can be also be used with node `fs` APIs directly. For example, it's possible to write the file to disk manually after post-processing it: ```js -abstract.files.info({ +const arrayBuffer = await abstract.files.raw({ projectId: "616daa90-1736-11e8-b8b0-8d1fec7aef78", branchId: "master", - fileId: "51DE7CD1-ECDC-473C-B30E-62AE913743B7", - sha: "fb7e9b50da6c330fc43ffb369616f0cd1fa92cc2" + fileId: "51DE7CD1-ECDC-473C-B30E-62AE913743B7" + sha: "latest" +}, { + disableWrite: true +}); + +processedBuffer = postProcess(arrayBuffer); + +fs.writeFile("file.sketch", Buffer.from(processedBuffer), (err) => { + if (err) throw err; + console.log("File written!"); +}); +``` + +It's also possible to get insight into the underlying progress of the file export. + +```js +abstract.files.raw({ + projectId: "616daa90-1736-11e8-b8b0-8d1fec7aef78", + branchId: "master", + fileId: "51DE7CD1-ECDC-473C-B30E-62AE913743B7" + sha: "latest" +}, { + onProgress: (receivedBytes: number, totalBytes: number) => { + console.log(`${receivedBytes * 100 / totalBytes}% complete`); + } }); ``` @@ -1636,6 +1660,16 @@ Options objects that can be passed to different SDK endpoints. } ``` +### RawProgressOptions +```js +{ + transportMode?: ("api" | "cli")[], + disableWrite?: boolean, + filename?: string, + onProgress?: (receivedBytes: number, totalBytes: number) => void; +} +``` + ## Descriptors Reference for the parameters required to load resources with the Abstract SDK. diff --git a/src/endpoints/Endpoint.js b/src/endpoints/Endpoint.js index 2b44f644..f918ee37 100644 --- a/src/endpoints/Endpoint.js +++ b/src/endpoints/Endpoint.js @@ -1,5 +1,6 @@ /* @flow */ /* global fetch */ +import { Readable } from "stream"; import "cross-fetch/polyfill"; import { spawn } from "child_process"; import uuid from "uuid/v4"; @@ -80,7 +81,7 @@ export default class Endpoint { fetchOptions: Object = {}, apiOptions: ApiRequestOptions = {} ) { - const { customHostname, raw } = apiOptions; + const { customHostname, raw, onProgress } = apiOptions; const hostname = customHostname || (await this.options.apiUrl); fetchOptions.body = fetchOptions.body && JSON.stringify(fetchOptions.body); @@ -96,6 +97,36 @@ export default class Endpoint { return (undefined: any); } + if (onProgress) { + const totalSize = Number(response.headers.get("Content-Length")); + let receivedSize = 0; + const body = await response.body; + // Node environments using cross-fetch polyfill + /* istanbul ignore else */ + if (body instanceof Readable) { + body.on("readable", () => { + let chunk; + while ((chunk = body.read())) { + receivedSize += chunk.length; + onProgress(receivedSize, totalSize); + } + }); + // Browser environments using native fetch + } else if (body) { + const reader = body.getReader(); + while (true) { + const { done, value } = await reader.read(); + if (value) { + receivedSize += value.length; + onProgress(receivedSize, totalSize); + } + if (done) { + break; + } + } + } + } + // prettier-ignore const apiValue: any = await (raw ? response.arrayBuffer() : response.json()); const logValue = raw ? apiValue.toString() : apiValue; diff --git a/src/endpoints/Files.js b/src/endpoints/Files.js index a71899b1..afcf55ae 100644 --- a/src/endpoints/Files.js +++ b/src/endpoints/Files.js @@ -4,7 +4,7 @@ import type { BranchCommitDescriptor, File, FileDescriptor, - RawOptions, + RawProgressOptions, RequestOptions } from "../types"; import { FileExportError, NotFoundError } from "../errors"; @@ -77,8 +77,8 @@ export default class Files extends Endpoint { }); } - async raw(descriptor: FileDescriptor, options: RawOptions = {}) { - const { disableWrite, filename, ...requestOptions } = options; + async raw(descriptor: FileDescriptor, options: RawProgressOptions = {}) { + const { disableWrite, filename, onProgress, ...requestOptions } = options; const latestDescriptor = await this.client.descriptors.getLatestDescriptor( descriptor ); @@ -119,7 +119,8 @@ export default class Files extends Endpoint { }, { customHostname: fileUrl, - raw: true + raw: true, + onProgress } ); diff --git a/src/types.js b/src/types.js index a5667efc..e177eb9b 100644 --- a/src/types.js +++ b/src/types.js @@ -88,9 +88,15 @@ export type ErrorMap = { [mode: string]: Error }; +export type ProgressCallback = ( + receivedBytes: number, + totalBytes: number +) => void; + export type ApiRequestOptions = { customHostname?: string, - raw?: boolean + raw?: boolean, + onProgress?: ProgressCallback }; export type RequestOptions = { @@ -115,6 +121,11 @@ export type RawOptions = { filename?: string }; +export type RawProgressOptions = { + ...RawOptions, + onProgress?: ProgressCallback +}; + export type AccessToken = ?string | ShareDescriptor; export type AccessTokenOption = | AccessToken // TODO: Deprecate? diff --git a/src/util/testing.js b/src/util/testing.js index 55b07661..e8ee473f 100644 --- a/src/util/testing.js +++ b/src/util/testing.js @@ -80,11 +80,12 @@ export function mockPreviewAPI( (nock("http://previewurl"): any)[method](url).reply(code, response); } -export function mockAssetAPI( +export function mockObjectAPI( url: string, response: Object, code: number = 200, method: string = "get" ) { - (nock("http://objecturl"): any)[method](url).reply(code, response); + const base = (nock("http://objecturl"): any).replyContentLength(); + base[method](url).reply(code, response); } diff --git a/tests/endpoints/Assets.test.js b/tests/endpoints/Assets.test.js index 5fb0f576..215dea45 100644 --- a/tests/endpoints/Assets.test.js +++ b/tests/endpoints/Assets.test.js @@ -1,5 +1,5 @@ // @flow -import { mockAPI, mockAssetAPI, API_CLIENT } from "../../src/util/testing"; +import { mockAPI, mockObjectAPI, API_CLIENT } from "../../src/util/testing"; describe("assets", () => { describe("info", () => { @@ -35,7 +35,7 @@ describe("assets", () => { url: "https://objects.goabstract.com/foo" }); - mockAssetAPI("/foo", { + mockObjectAPI("/foo", { id: "asset-id" }); @@ -62,7 +62,7 @@ describe("assets", () => { url: "https://objects.goabstract.com/foo" }); - mockAssetAPI("/foo", { + mockObjectAPI("/foo", { id: "asset-id" }); diff --git a/tests/endpoints/Files.test.js b/tests/endpoints/Files.test.js index 16b161c9..5dc5e210 100644 --- a/tests/endpoints/Files.test.js +++ b/tests/endpoints/Files.test.js @@ -1,6 +1,6 @@ // @flow import { - mockAssetAPI, + mockObjectAPI, mockAPI, mockCLI, API_CLIENT, @@ -132,7 +132,7 @@ describe("files", () => { global.setTimeout = globalSetTimeout; }); - test("api - node", async () => { + test("api - node with progress", async () => { mockAPI("/projects/project-id/branches/branch-id/files", { files: [ { @@ -163,7 +163,7 @@ describe("files", () => { "post" ); - mockAssetAPI("/file", { + mockObjectAPI("/file", { id: "file-id" }); @@ -175,7 +175,11 @@ describe("files", () => { sha: "sha" }, { - disableWrite: true + disableWrite: true, + onProgress: (received, total) => { + expect(received).toBe(16); + expect(total).toBe(16); + } } );