Skip to content

Commit

Permalink
Merge pull request #362 from ArcticLampyrid/ArcticLampyrid/issue303
Browse files Browse the repository at this point in the history
Add partialUpdateFileContents method
  • Loading branch information
perry-mitchell committed Mar 18, 2024
2 parents cbf2f7a + 60490e6 commit b06f2de
Show file tree
Hide file tree
Showing 7 changed files with 261 additions and 2 deletions.
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>
```

| 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).
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
10 changes: 10 additions & 0 deletions source/factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -108,6 +110,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);
Expand Down
39 changes: 39 additions & 0 deletions source/operations/getDAVCompliance.ts
Original file line number Diff line number Diff line change
@@ -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 {
DAVCompliance,
WebDAVClientContext,
WebDAVClientError,
WebDAVMethodOptions
} from "../types.js";

export async function getDAVCompliance(
context: WebDAVClientContext,
filePath: string,
options: WebDAVMethodOptions = {}
): Promise<DAVCompliance> {
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
};
}
126 changes: 126 additions & 0 deletions source/operations/partialUpdateFileContents.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import { Readable } from "node:stream";
import { Layerr } from "layerr";
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 | Readable,
options: WebDAVMethodOptions = {}
): Promise<void> {
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("<http://apache.org/dav/propset/fs/1>")
) {
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 | Readable,
options: WebDAVMethodOptions = {}
): Promise<void> {
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);
handleResponseCode(context, response);
}

async function partialUpdateFileContentsApache(
context: WebDAVClientContext,
filePath: string,
start: number,
end: number,
data: string | BufferLike | Readable,
options: WebDAVMethodOptions = {}
): Promise<void> {
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);
handleResponseCode(context, response);
}
17 changes: 16 additions & 1 deletion source/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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;
Expand Down Expand Up @@ -265,6 +272,7 @@ export interface WebDAVClient {
customRequest: (path: string, requestOptions: RequestOptionsCustom) => Promise<Response>;
deleteFile: (filename: string) => Promise<void>;
exists: (path: string) => Promise<boolean>;
getDAVCompliance: (path: string) => Promise<DAVCompliance>;
getDirectoryContents: (
path: string,
options?: GetDirectoryContentsOptions
Expand All @@ -290,6 +298,13 @@ export interface WebDAVClient {
data: string | BufferLike | Stream.Readable,
options?: PutFileContentsOptions
) => Promise<boolean>;
partialUpdateFileContents: (
filePath: string,
start: number,
end: number,
data: string | BufferLike | Stream.Readable,
options?: WebDAVMethodOptions
) => Promise<void>;
search: (
path: string,
options?: SearchOptions
Expand Down
49 changes: 49 additions & 0 deletions test/node/operations/partialUpdateFileContents.spec.ts
Original file line number Diff line number Diff line change
@@ -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;
});
});

0 comments on commit b06f2de

Please sign in to comment.