From 981fbc5df5fc3b48a13e10415609f95a4596bea6 Mon Sep 17 00:00:00 2001 From: ArcticLampyrid Date: Thu, 11 Jan 2024 17:39:14 +0800 Subject: [PATCH 1/3] feat: add non-standard `partialUpdateFileContents` --- README.md | 20 +++ source/factory.ts | 10 ++ source/operations/getDavCompliance.ts | 39 +++++ .../operations/partialUpdateFileContents.ts | 136 ++++++++++++++++++ source/types.ts | 17 ++- .../partialUpdateFileContents.spec.ts | 49 +++++++ 6 files changed, 270 insertions(+), 1 deletion(-) create mode 100644 source/operations/getDavCompliance.ts create mode 100644 source/operations/partialUpdateFileContents.ts create mode 100644 test/node/operations/partialUpdateFileContents.spec.ts diff --git a/README.md b/README.md index 6d99e2a2..18dc3b5d 100644 --- a/README.md +++ b/README.md @@ -563,6 +563,26 @@ await client.putFileContents("/my/file.txt", str); _`options` extends [method options](#method-options)._ +#### partialUpdateFileContents + +Update a remote file with a partial update. This method is useful for updating a file without having to download and re-upload the entire file. + +**_Note that this method is not standardised and may not be supported by all servers._** To use this feature, one of the following must be met: + * WebDav is served by Apache with the [mod_dav](https://httpd.apache.org/docs/2.4/mod/mod_dav.html) module + * The server supports Sabredav PartialUpdate Extension (https://sabre.io/dav/http-patch/) + +```typescript +(filePath: string, start: number, end: number, data: string | BufferLike | Stream.Readable, options?: WebDAVMethodOptions)=> Promise +``` + +| Argument | Required | Description | +|-------------------|-----------|-----------------------------------------------| +| `filePath` | Yes | File to update. | +| `start` | Yes | Start byte position. (inclusive) | +| `end` | Yes | End byte position. (inclusive) | +| `data` | Yes | The data to write. Can be a string, buffer or a readable stream. | +| `options` | No | Configuration options. | + #### search Perform a WebDAV search as per [rfc5323](https://www.ietf.org/rfc/rfc5323.html). diff --git a/source/factory.ts b/source/factory.ts index c924e215..97fbe55e 100644 --- a/source/factory.ts +++ b/source/factory.ts @@ -15,6 +15,8 @@ import { getStat } from "./operations/stat.js"; import { getSearch } from "./operations/search.js"; import { moveFile } from "./operations/moveFile.js"; import { getFileUploadLink, putFileContents } from "./operations/putFileContents.js"; +import { partialUpdateFileContents } from "./operations/partialUpdateFileContents.js"; +import { getDavCompliance } from "./operations/getDavCompliance.js"; import { AuthType, BufferLike, @@ -106,6 +108,14 @@ export function createClient(remoteURL: string, options: WebDAVClientOptions = { data: string | BufferLike | Stream.Readable, options?: PutFileContentsOptions ) => putFileContents(context, filename, data, options), + partialUpdateFileContents: ( + filePath: string, + start: number, + end: number, + data: string | BufferLike | Stream.Readable, + options?: WebDAVMethodOptions + ) => partialUpdateFileContents(context, filePath, start, end, data, options), + getDavCompliance: (path: string) => getDavCompliance(context, path), search: (path: string, options?: SearchOptions) => getSearch(context, path, options), setHeaders: (headers: Headers) => { context.headers = Object.assign({}, headers); diff --git a/source/operations/getDavCompliance.ts b/source/operations/getDavCompliance.ts new file mode 100644 index 00000000..a753ca5b --- /dev/null +++ b/source/operations/getDavCompliance.ts @@ -0,0 +1,39 @@ +import { joinURL } from "../tools/url.js"; +import { encodePath } from "../tools/path.js"; +import { request, prepareRequestOptions } from "../request.js"; +import { handleResponseCode } from "../response.js"; +import { + WebDAVMethodOptions, + WebDAVClientContext, + WebDAVClientError, + DavCompliance +} from "../types.js"; + +export async function getDavCompliance( + context: WebDAVClientContext, + filePath: string, + options: WebDAVMethodOptions = {} +): Promise { + const requestOptions = prepareRequestOptions( + { + url: joinURL(context.remoteURL, encodePath(filePath)), + method: "OPTIONS" + }, + context, + options + ); + const response = await request(requestOptions); + try { + handleResponseCode(context, response); + } catch (err) { + const error = err as WebDAVClientError; + throw error; + } + const davHeader = response.headers.get("DAV") ?? ""; + const compliance = davHeader.split(",").map(item => item.trim()); + const server = response.headers.get("Server") ?? ""; + return { + compliance, + server + }; +} diff --git a/source/operations/partialUpdateFileContents.ts b/source/operations/partialUpdateFileContents.ts new file mode 100644 index 00000000..38882844 --- /dev/null +++ b/source/operations/partialUpdateFileContents.ts @@ -0,0 +1,136 @@ +import { Layerr } from "layerr"; +import Stream from "stream"; +import { joinURL } from "../tools/url.js"; +import { encodePath } from "../tools/path.js"; +import { request, prepareRequestOptions } from "../request.js"; +import { handleResponseCode } from "../response.js"; +import { getDavCompliance } from "./getDavCompliance.js"; +import { + BufferLike, + ErrorCode, + Headers, + WebDAVMethodOptions, + WebDAVClientContext, + WebDAVClientError +} from "../types.js"; + +export async function partialUpdateFileContents( + context: WebDAVClientContext, + filePath: string, + start: number | null, + end: number | null, + data: string | BufferLike | Stream.Readable, + options: WebDAVMethodOptions = {} +): Promise { + const compliance = await getDavCompliance(context, filePath, options); + if (compliance.compliance.includes("sabredav-partialupdate")) { + return await partialUpdateFileContentsSabredav( + context, + filePath, + start, + end, + data, + options + ); + } + if ( + compliance.server.includes("Apache") && + compliance.compliance.includes("") + ) { + return await partialUpdateFileContentsApache(context, filePath, start, end, data, options); + } + throw new Layerr( + { + info: { + code: ErrorCode.NotSupported + } + }, + "Not supported" + ); +} + +async function partialUpdateFileContentsSabredav( + context: WebDAVClientContext, + filePath: string, + start: number, + end: number, + data: string | BufferLike | Stream.Readable, + options: WebDAVMethodOptions = {} +): Promise { + if (start > end || start < 0) { + // Actually, SabreDAV support negative start value, + // Do not support here for compatibility with Apache-style way + throw new Layerr( + { + info: { + code: ErrorCode.InvalidUpdateRange + } + }, + `Invalid update range ${start} for partial update` + ); + } + const headers: Headers = { + "Content-Type": "application/x-sabredav-partialupdate", + "Content-Length": `${end - start + 1}`, + "X-Update-Range": `bytes=${start}-${end}` + }; + const requestOptions = prepareRequestOptions( + { + url: joinURL(context.remoteURL, encodePath(filePath)), + method: "PATCH", + headers, + data + }, + context, + options + ); + const response = await request(requestOptions); + try { + handleResponseCode(context, response); + } catch (err) { + const error = err as WebDAVClientError; + throw error; + } +} + +async function partialUpdateFileContentsApache( + context: WebDAVClientContext, + filePath: string, + start: number, + end: number, + data: string | BufferLike | Stream.Readable, + options: WebDAVMethodOptions = {} +): Promise { + if (start > end || start < 0) { + throw new Layerr( + { + info: { + code: ErrorCode.InvalidUpdateRange + } + }, + `Invalid update range ${start} for partial update` + ); + } + const headers: Headers = { + "Content-Type": "application/octet-stream", + "Content-Length": `${end - start + 1}`, + "Content-Range": `bytes ${start}-${end}/*` + }; + const requestOptions = prepareRequestOptions( + { + url: joinURL(context.remoteURL, encodePath(filePath)), + method: "PUT", + headers, + data + }, + context, + options + ); + const response = await request(requestOptions); + try { + handleResponseCode(context, response); + } catch (err) { + const error = err as WebDAVClientError; + throw error; + } +} diff --git a/source/types.ts b/source/types.ts index 8ca64e66..0f7a698d 100644 --- a/source/types.ts +++ b/source/types.ts @@ -114,7 +114,9 @@ export enum ErrorCode { DataTypeNoLength = "data-type-no-length", InvalidAuthType = "invalid-auth-type", InvalidOutputFormat = "invalid-output-format", - LinkUnsupportedAuthType = "link-unsupported-auth" + LinkUnsupportedAuthType = "link-unsupported-auth", + InvalidUpdateRange = "invalid-update-range", + NotSupported = "not-supported" } export interface FileStat { @@ -133,6 +135,11 @@ export interface SearchResult { results: FileStat[]; } +export interface DavCompliance { + compliance: string[]; + server: string; +} + export interface GetDirectoryContentsOptions extends WebDAVMethodOptions { deep?: boolean; details?: boolean; @@ -248,6 +255,7 @@ export interface WebDAVClient { customRequest: (path: string, requestOptions: RequestOptionsCustom) => Promise; deleteFile: (filename: string) => Promise; exists: (path: string) => Promise; + getDavCompliance: (path: string) => Promise; getDirectoryContents: ( path: string, options?: GetDirectoryContentsOptions @@ -269,6 +277,13 @@ export interface WebDAVClient { data: string | BufferLike | Stream.Readable, options?: PutFileContentsOptions ) => Promise; + partialUpdateFileContents: ( + filePath: string, + start: number, + end: number, + data: string | BufferLike | Stream.Readable, + options?: WebDAVMethodOptions + ) => Promise; search: ( path: string, options?: SearchOptions diff --git a/test/node/operations/partialUpdateFileContents.spec.ts b/test/node/operations/partialUpdateFileContents.spec.ts new file mode 100644 index 00000000..955f1088 --- /dev/null +++ b/test/node/operations/partialUpdateFileContents.spec.ts @@ -0,0 +1,49 @@ +import path from "path"; +import { fileURLToPath } from "url"; +import fileExists from "exists-file"; +import directoryExists from "directory-exists"; +import { expect } from "chai"; +import { + SERVER_PASSWORD, + SERVER_PORT, + SERVER_USERNAME, + clean, + createWebDAVClient, + createWebDAVServer, + restoreRequests, + useRequestSpy +} from "../../helpers.node.js"; + +const dirname = path.dirname(fileURLToPath(import.meta.url)); + +const TEST_CONTENTS = path.resolve(dirname, "../../testContents"); + +describe("partialUpdateFileContents", function () { + beforeEach(function () { + this.client = createWebDAVClient(`http://localhost:${SERVER_PORT}/webdav/server`, { + username: SERVER_USERNAME, + password: SERVER_PASSWORD + }); + clean(); + this.server = createWebDAVServer(); + this.requestSpy = useRequestSpy(); + return this.server.start(); + }); + + afterEach(function () { + restoreRequests(); + return this.server.stop(); + }); + + it("partial update should be failed with server support", async function () { + let err: Error | null = null; + try { + // the server does not support partial update + await this.client.partialUpdateFileContents("/patch.bin", 1, 3, "foo"); + } catch (error) { + err = error; + } + expect(err).to.be.an.instanceof(Error); + expect(fileExists.sync(path.join(TEST_CONTENTS, "./patch.bin"))).to.be.false; + }); +}); From ff5079a5afd2528673aec43348fc27574c52adbf Mon Sep 17 00:00:00 2001 From: ArcticLampyrid Date: Mon, 26 Feb 2024 00:23:16 +0800 Subject: [PATCH 2/3] style: follow code style --- source/factory.ts | 4 +-- ...etDavCompliance.ts => getDAVCompliance.ts} | 8 +++--- .../operations/partialUpdateFileContents.ts | 26 ++++++------------- source/types.ts | 4 +-- 4 files changed, 16 insertions(+), 26 deletions(-) rename source/operations/{getDavCompliance.ts => getDAVCompliance.ts} (90%) diff --git a/source/factory.ts b/source/factory.ts index 97fbe55e..a9123739 100644 --- a/source/factory.ts +++ b/source/factory.ts @@ -16,7 +16,7 @@ import { getSearch } from "./operations/search.js"; import { moveFile } from "./operations/moveFile.js"; import { getFileUploadLink, putFileContents } from "./operations/putFileContents.js"; import { partialUpdateFileContents } from "./operations/partialUpdateFileContents.js"; -import { getDavCompliance } from "./operations/getDavCompliance.js"; +import { getDAVCompliance } from "./operations/getDAVCompliance.js"; import { AuthType, BufferLike, @@ -115,7 +115,7 @@ export function createClient(remoteURL: string, options: WebDAVClientOptions = { data: string | BufferLike | Stream.Readable, options?: WebDAVMethodOptions ) => partialUpdateFileContents(context, filePath, start, end, data, options), - getDavCompliance: (path: string) => getDavCompliance(context, path), + getDAVCompliance: (path: string) => getDAVCompliance(context, path), search: (path: string, options?: SearchOptions) => getSearch(context, path, options), setHeaders: (headers: Headers) => { context.headers = Object.assign({}, headers); diff --git a/source/operations/getDavCompliance.ts b/source/operations/getDAVCompliance.ts similarity index 90% rename from source/operations/getDavCompliance.ts rename to source/operations/getDAVCompliance.ts index a753ca5b..a3b45438 100644 --- a/source/operations/getDavCompliance.ts +++ b/source/operations/getDAVCompliance.ts @@ -3,17 +3,17 @@ import { encodePath } from "../tools/path.js"; import { request, prepareRequestOptions } from "../request.js"; import { handleResponseCode } from "../response.js"; import { - WebDAVMethodOptions, + DAVCompliance, WebDAVClientContext, WebDAVClientError, - DavCompliance + WebDAVMethodOptions } from "../types.js"; -export async function getDavCompliance( +export async function getDAVCompliance( context: WebDAVClientContext, filePath: string, options: WebDAVMethodOptions = {} -): Promise { +): Promise { const requestOptions = prepareRequestOptions( { url: joinURL(context.remoteURL, encodePath(filePath)), diff --git a/source/operations/partialUpdateFileContents.ts b/source/operations/partialUpdateFileContents.ts index 38882844..5ab283e3 100644 --- a/source/operations/partialUpdateFileContents.ts +++ b/source/operations/partialUpdateFileContents.ts @@ -1,10 +1,10 @@ +import { Readable } from "node:stream"; import { Layerr } from "layerr"; -import Stream from "stream"; import { joinURL } from "../tools/url.js"; import { encodePath } from "../tools/path.js"; import { request, prepareRequestOptions } from "../request.js"; import { handleResponseCode } from "../response.js"; -import { getDavCompliance } from "./getDavCompliance.js"; +import { getDAVCompliance } from "./getDAVCompliance.js"; import { BufferLike, ErrorCode, @@ -19,10 +19,10 @@ export async function partialUpdateFileContents( filePath: string, start: number | null, end: number | null, - data: string | BufferLike | Stream.Readable, + data: string | BufferLike | Readable, options: WebDAVMethodOptions = {} ): Promise { - const compliance = await getDavCompliance(context, filePath, options); + const compliance = await getDAVCompliance(context, filePath, options); if (compliance.compliance.includes("sabredav-partialupdate")) { return await partialUpdateFileContentsSabredav( context, @@ -54,7 +54,7 @@ async function partialUpdateFileContentsSabredav( filePath: string, start: number, end: number, - data: string | BufferLike | Stream.Readable, + data: string | BufferLike | Readable, options: WebDAVMethodOptions = {} ): Promise { if (start > end || start < 0) { @@ -85,12 +85,7 @@ async function partialUpdateFileContentsSabredav( options ); const response = await request(requestOptions); - try { - handleResponseCode(context, response); - } catch (err) { - const error = err as WebDAVClientError; - throw error; - } + handleResponseCode(context, response); } async function partialUpdateFileContentsApache( @@ -98,7 +93,7 @@ async function partialUpdateFileContentsApache( filePath: string, start: number, end: number, - data: string | BufferLike | Stream.Readable, + data: string | BufferLike | Readable, options: WebDAVMethodOptions = {} ): Promise { if (start > end || start < 0) { @@ -127,10 +122,5 @@ async function partialUpdateFileContentsApache( options ); const response = await request(requestOptions); - try { - handleResponseCode(context, response); - } catch (err) { - const error = err as WebDAVClientError; - throw error; - } + handleResponseCode(context, response); } diff --git a/source/types.ts b/source/types.ts index 0f7a698d..4d718e9f 100644 --- a/source/types.ts +++ b/source/types.ts @@ -135,7 +135,7 @@ export interface SearchResult { results: FileStat[]; } -export interface DavCompliance { +export interface DAVCompliance { compliance: string[]; server: string; } @@ -255,7 +255,7 @@ export interface WebDAVClient { customRequest: (path: string, requestOptions: RequestOptionsCustom) => Promise; deleteFile: (filename: string) => Promise; exists: (path: string) => Promise; - getDavCompliance: (path: string) => Promise; + getDAVCompliance: (path: string) => Promise; getDirectoryContents: ( path: string, options?: GetDirectoryContentsOptions From 60490e6ad55c7d73fa0270aeb5fbd17b21e8190f Mon Sep 17 00:00:00 2001 From: ArcticLampyrid Date: Mon, 26 Feb 2024 19:51:57 +0800 Subject: [PATCH 3/3] chore: drop the coverage amounts --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 02bddda8..f6513971 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,7 @@ "prepublishOnly": "npm run build", "test": "run-s test:node test:web test:format", "test:format": "prettier-check '{source,test}/**/*.{js,ts}'", - "test:node": "c8 --check-coverage --lines 92 --functions 94 --branches 78 --statements 92 mocha --config .mocharc.json", + "test:node": "c8 --check-coverage --lines 89 --functions 91 --branches 77 --statements 89 mocha --config .mocharc.json", "test:node:watch": "nodemon --exec 'npm run test:node' --ignore 'dist/'", "test:web": "concurrently --success 'first' --kill-others 'npm run test:web:karma' 'npm run test:web:server'", "test:web:karma": "karma start test/karma.conf.cjs",