Repository: perry-mitchell/webdav-client (ts)

Issue:
Support for search method
Support for search method

https://www.ietf.org/rfc/rfc5323.html#section-2

Pull request description:
feat: Implement `SEARCH` according to `rfc5323`
* Fixes #205 

This implements the WebDAV search request.
One real life example would be if you want to query recently changed files from Nextcloud, then this can be used like this:

```js
const query = `<?xml version="1.0" encoding="UTF-8"?>
<d:searchrequest xmlns:d="DAV:" xmlns:ns="https://github.com/icewind1991/SearchDAV/ns">
	<d:basicsearch>
		<d:select>
			<d:prop>
			<d:getcontentlength />
			<d:getcontenttype />
			<d:getetag />
			<d:getlastmodified />
			</d:prop>
		</d:select>
		<d:from>
			<d:scope>
				<d:href>/files/YOUR_USER_ID/</d:href>
				<d:depth>infinity</d:depth>
			</d:scope>
		</d:from>
		<d:where>
			<d:gt>
				<d:prop>
					<d:getlastmodified/>
				</d:prop>
				<d:literal>${Math.round(Date.now() / 1000 - 24 * 60 * 60)}</d:literal>
			</d:gt>
		</d:where>
		<d:limit>
			<d:nresults>100</d:nresults>
			<ns:firstresult>0</ns:firstresult>
		</d:limit>
	</d:basicsearch>
</d:searchrequest>`;

// client with base path `SERVER/remote.php/dav`
client.search('/', { data: query })
```

Patch:
diff --git a/README.md b/README.md
index 28fb8de..198f678 100644
--- a/README.md
+++ b/README.md
@@ -554,6 +554,34 @@ await client.putFileContents("/my/file.txt", str);
 
 _`options` extends [method options](#method-options)._
 
+#### search
+
+Perform a WebDAV search as per [rfc5323](https://www.ietf.org/rfc/rfc5323.html).
+
+```typescript
+const searchRequest = `
+<?xml version="1.0" encoding="UTF-8"?>
+<d:searchrequest xmlns:d="DAV:" xmlns:f="http://example.com/foo">
+    <f:natural-language-query>
+    Find files changed last week
+    </f:natural-language-query>
+</d:searchrequest>
+`
+const result: SearchResult = await client.search("/some-collection", { data: searchRequest });
+```
+
+```typescript
+(path: string, options?: SearchOptions) => Promise<SearchResult | ResponseDataDetailed<SearchResult>>
+```
+
+| Argument          | Required  | Description                                   |
+|-------------------|-----------|-----------------------------------------------|
+| `path`            | Yes       | Remote path to which executes the search.     |
+| `options`         | No        | Configuration options.                        |
+| `options.details` | No        | Return detailed results (headers etc.). Defaults to `false`. |
+
+_`options` extends [method options](#method-options)._
+
 #### stat
 
 Get a file or directory stat object. Returns an [item stat](#item-stats).
@@ -655,7 +683,7 @@ Properties:
 
 #### Detailed responses
 
-Requests that return results, such as `getDirectoryContents`, `getFileContents`, `getQuota` and `stat`, can be configured to return more detailed information, such as response headers. Pass `{ details: true }` to their options argument to receive an object like the following:
+Requests that return results, such as `getDirectoryContents`, `getFileContents`, `getQuota`, `search` and `stat`, can be configured to return more detailed information, such as response headers. Pass `{ details: true }` to their options argument to receive an object like the following:
 
 | Property     | Type            | Description                            |
 |--------------|-----------------|----------------------------------------|
diff --git a/source/factory.ts b/source/factory.ts
index 2f58d10..c924e21 100644
--- a/source/factory.ts
+++ b/source/factory.ts
@@ -12,6 +12,7 @@ import { getFileContents, getFileDownloadLink } from "./operations/getFileConten
 import { lock, unlock } from "./operations/lock.js";
 import { getQuota } from "./operations/getQuota.js";
 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 {
@@ -27,6 +28,7 @@ import {
     LockOptions,
     PutFileContentsOptions,
     RequestOptionsCustom,
+    SearchOptions,
     StatOptions,
     WebDAVClient,
     WebDAVClientContext,
@@ -104,6 +106,7 @@ export function createClient(remoteURL: string, options: WebDAVClientOptions = {
             data: string | BufferLike | Stream.Readable,
             options?: PutFileContentsOptions
         ) => putFileContents(context, filename, data, options),
+        search: (path: string, options?: SearchOptions) => getSearch(context, path, options),
         setHeaders: (headers: Headers) => {
             context.headers = Object.assign({}, headers);
         },
diff --git a/source/operations/search.ts b/source/operations/search.ts
new file mode 100644
index 0000000..061484d
--- /dev/null
+++ b/source/operations/search.ts
@@ -0,0 +1,38 @@
+import { parseSearch, parseXML } from "../tools/dav.js";
+import { joinURL } from "../tools/url.js";
+import { encodePath } from "../tools/path.js";
+import { request, prepareRequestOptions } from "../request.js";
+import { handleResponseCode, processResponsePayload } from "../response.js";
+import {
+    SearchResult,
+    ResponseDataDetailed,
+    SearchOptions,
+    WebDAVClientContext
+} from "../types.js";
+
+export async function getSearch(
+    context: WebDAVClientContext,
+    searchArbiter: string,
+    options: SearchOptions = {}
+): Promise<SearchResult | ResponseDataDetailed<SearchResult>> {
+    const { details: isDetailed = false } = options;
+    const requestOptions = prepareRequestOptions(
+        {
+            url: joinURL(context.remoteURL, encodePath(searchArbiter)),
+            method: "SEARCH",
+            headers: {
+                Accept: "text/plain,application/xml",
+                // Ensure a Content-Type header is set was this is required by e.g. sabre/dav
+                "Content-Type": context.headers["Content-Type"] || "application/xml; charset=utf-8"
+            }
+        },
+        context,
+        options
+    );
+    const response = await request(requestOptions);
+    handleResponseCode(context, response);
+    const responseText = await response.text();
+    const responseData = await parseXML(responseText);
+    const results = parseSearch(responseData, searchArbiter, isDetailed);
+    return processResponsePayload(response, results, isDetailed);
+}
diff --git a/source/tools/dav.ts b/source/tools/dav.ts
index 38621dc..242ca04 100644
--- a/source/tools/dav.ts
+++ b/source/tools/dav.ts
@@ -2,14 +2,16 @@ import path from "path-posix";
 import { XMLParser } from "fast-xml-parser";
 import nestedProp from "nested-property";
 import { decodeHTMLEntities } from "./encode.js";
-import { normalisePath } from "./path.js";
+import { encodePath, normalisePath } from "./path.js";
 import {
     DAVResult,
+    DAVResultPropstatResponse,
     DAVResultRaw,
     DAVResultResponse,
     DAVResultResponseProps,
     DiskQuotaAvailable,
     FileStat,
+    SearchResult,
     WebDAVClientError
 } from "../types.js";
 
@@ -49,12 +51,21 @@ function getPropertyOfType(
 
 function normaliseResponse(response: any): DAVResultResponse {
     const output = Object.assign({}, response);
-    nestedProp.set(output, "propstat", getPropertyOfType(output, "propstat", PropertyType.Object));
-    nestedProp.set(
-        output,
-        "propstat.prop",
-        getPropertyOfType(output, "propstat.prop", PropertyType.Object)
-    );
+    // Only either status OR propstat is allowed
+    if (output.status) {
+        nestedProp.set(output, "status", getPropertyOfType(output, "status", PropertyType.Object));
+    } else {
+        nestedProp.set(
+            output,
+            "propstat",
+            getPropertyOfType(output, "propstat", PropertyType.Object)
+        );
+        nestedProp.set(
+            output,
+            "propstat.prop",
+            getPropertyOfType(output, "propstat.prop", PropertyType.Object)
+        );
+    }
     return output;
 }
 
@@ -149,9 +160,12 @@ export function parseStat(
     filename: string,
     isDetailed: boolean = false
 ): FileStat {
-    let responseItem: DAVResultResponse = null;
+    let responseItem: DAVResultPropstatResponse = null;
     try {
-        responseItem = result.multistatus.response[0];
+        // should be a propstat response, if not the if below will throw an error
+        if ((result.multistatus.response[0] as DAVResultPropstatResponse).propstat) {
+            responseItem = result.multistatus.response[0] as DAVResultPropstatResponse;
+        }
     } catch (e) {
         /* ignore */
     }
@@ -177,6 +191,37 @@ export function parseStat(
     return prepareFileFromProps(props, filePath, isDetailed);
 }
 
+/**
+ * Parse a DAV result for a search request
+ *
+ * @param result The resulting DAV response
+ * @param searchArbiter The collection path that was searched
+ * @param isDetailed Whether or not the raw props of the resource should be returned
+ */
+export function parseSearch(result: DAVResult, searchArbiter: string, isDetailed: boolean) {
+    const response: SearchResult = {
+        truncated: false,
+        results: []
+    };
+
+    response.truncated = result.multistatus.response.some(v => {
+        return (
+            (v.status || v.propstat?.status).split(" ", 3)?.[1] === "507" &&
+            v.href.replace(/\/$/, "").endsWith(encodePath(searchArbiter).replace(/\/$/, ""))
+        );
+    });
+
+    result.multistatus.response.forEach(result => {
+        if (result.propstat === undefined) {
+            return;
+        }
+        const filename = result.href.split("/").map(decodeURIComponent).join("/");
+        response.results.push(prepareFileFromProps(result.propstat.prop, filename, isDetailed));
+    });
+
+    return response;
+}
+
 /**
  * Translate a disk quota indicator to a recognised
  *  value (includes "unlimited" and "unknown")
diff --git a/source/types.ts b/source/types.ts
index 781a3ab..06b14b1 100644
--- a/source/types.ts
+++ b/source/types.ts
@@ -32,14 +32,34 @@ export interface CreateWriteStreamOptions extends WebDAVMethodOptions {
     overwrite?: boolean;
 }
 
-export interface DAVResultResponse {
+/** <propstat> as per http://www.webdav.org/specs/rfc2518.html#rfc.section.12.9.1.1 */
+interface DAVPropStat {
+    prop: DAVResultResponseProps;
+    status: string;
+    responsedescription?: string;
+}
+
+/**
+ * DAV response can either be (href, propstat, responsedescription?) or (href, status, responsedescription?)
+ * @see http://www.webdav.org/specs/rfc2518.html#rfc.section.12.9.1
+ */
+interface DAVResultBaseResponse {
     href: string;
-    propstat: {
-        prop: DAVResultResponseProps;
-        status: string;
-    };
+    responsedescription?: string;
+}
+
+export interface DAVResultPropstatResponse extends DAVResultBaseResponse {
+    propstat: DAVPropStat;
 }
 
+export interface DAVResultStatusResponse extends DAVResultBaseResponse {
+    status: string;
+}
+
+export type DAVResultResponse = DAVResultBaseResponse &
+    Partial<DAVResultPropstatResponse> &
+    Partial<DAVResultStatusResponse>;
+
 export interface DAVResultResponseProps {
     displayname: string;
     resourcetype: {
@@ -51,6 +71,8 @@ export interface DAVResultResponseProps {
     getcontenttype?: string;
     "quota-available-bytes"?: any;
     "quota-used-bytes"?: string;
+
+    [additionalProp: string]: unknown;
 }
 
 export interface DAVResult {
@@ -106,6 +128,11 @@ export interface FileStat {
     props?: DAVResultResponseProps;
 }
 
+export interface SearchResult {
+    truncated: boolean;
+    results: FileStat[];
+}
+
 export interface GetDirectoryContentsOptions extends WebDAVMethodOptions {
     deep?: boolean;
     details?: boolean;
@@ -201,6 +228,10 @@ export interface StatOptions extends WebDAVMethodOptions {
     details?: boolean;
 }
 
+export interface SearchOptions extends WebDAVMethodOptions {
+    details?: boolean;
+}
+
 export type UploadProgress = ProgressEvent;
 
 export type UploadProgressCallback = ProgressEventCallback;
@@ -238,6 +269,10 @@ export interface WebDAVClient {
         data: string | BufferLike | Stream.Readable,
         options?: PutFileContentsOptions
     ) => Promise<boolean>;
+    search: (
+        path: string,
+        options?: SearchOptions
+    ) => Promise<SearchResult | ResponseDataDetailed<SearchResult>>;
     setHeaders: (headers: Headers) => void;
     stat: (
         path: string,


Test patch:
diff --git a/test/node/operations/search.spec.ts b/test/node/operations/search.spec.ts
new file mode 100644
index 0000000..3db7ab4
--- /dev/null
+++ b/test/node/operations/search.spec.ts
@@ -0,0 +1,95 @@
+import fs from "fs";
+import path from "path";
+import { fileURLToPath } from "url";
+import { expect } from "chai";
+import {
+    SERVER_PASSWORD,
+    SERVER_PORT,
+    SERVER_USERNAME,
+    clean,
+    createWebDAVClient,
+    createWebDAVServer,
+    restoreRequests,
+    returnFakeResponse,
+    useRequestSpy
+} from "../../helpers.node.js";
+import { ResponseDataDetailed, SearchResult, WebDAVClient } from "../../../source/types.js";
+
+const dirname = path.dirname(fileURLToPath(import.meta.url));
+
+function useTruncatedSearchResults() {
+    returnFakeResponse(
+        fs.readFileSync(path.resolve(dirname, "../../responses/search-truncated.xml"), "utf8")
+    );
+}
+
+function useFullSearchResults() {
+    returnFakeResponse(
+        fs.readFileSync(path.resolve(dirname, "../../responses/search-full-success.xml"), "utf8")
+    );
+}
+
+const searchRequest = `<?xml version="1.0" encoding="UTF-8"?>
+<d:searchrequest xmlns:d="DAV:" xmlns:f="http://example.com/foo">
+    <f:natural-language-query>
+    Find files changed 2023-08-03
+    </f:natural-language-query>
+</d:searchrequest>
+`;
+
+describe("search", function () {
+    let client: WebDAVClient;
+    beforeEach(function () {
+        // fake client, not actually used when mocking responses
+        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("returns full search response", function () {
+        useFullSearchResults();
+        return client.search("/", { data: searchRequest }).then(function (result) {
+            expect(result).to.be.an("object");
+            expect(result).to.have.property("truncated", false);
+            expect(result).to.have.property("results");
+            expect((result as SearchResult).results.length).to.equal(2);
+            expect((result as SearchResult).results[0].basename).to.equal("first-file.md");
+            expect((result as SearchResult).results[1].basename).to.equal("second file.txt");
+        });
+    });
+
+    it("returns full detailed search response", function () {
+        useFullSearchResults();
+        return client.search("/", { data: searchRequest, details: true }).then(function (result) {
+            expect(result).to.be.an("object");
+            result = (result as ResponseDataDetailed<SearchResult>).data;
+            expect(result).to.be.an("object");
+            expect(result).to.have.property("truncated", false);
+            expect(result).to.have.property("results");
+            expect(result.results.length).to.equal(2);
+            expect(result.results[0].basename).to.equal("first-file.md");
+            expect(result.results[0].props.getcontenttype).to.equal("text/markdown");
+        });
+    });
+
+    it("returns truncated search response", function () {
+        useTruncatedSearchResults();
+        return client.search("/", { data: searchRequest }).then(function (result) {
+            expect(result).to.be.an("object");
+            expect(result).to.have.property("truncated", true);
+            expect(result).to.have.property("results");
+            expect((result as SearchResult).results.length).to.equal(1);
+            expect((result as SearchResult).results[0].basename).to.equal("first-file.md");
+        });
+    });
+});
diff --git a/test/responses/search-full-success.xml b/test/responses/search-full-success.xml
new file mode 100644
index 0000000..e530f26
--- /dev/null
+++ b/test/responses/search-full-success.xml
@@ -0,0 +1,30 @@
+<?xml version="1.0"?>
+<d:multistatus
+	xmlns:d="DAV:">
+	<d:response>
+		<d:href>/first-file.md</d:href>
+		<d:propstat>
+			<d:prop>
+				<d:getcontentlength>0</d:getcontentlength>
+				<d:getcontenttype>text/markdown</d:getcontenttype>
+				<d:getetag>&quot;54d5b8eac2d78c7824925f2fa008bc8d&quot;</d:getetag>
+				<d:getlastmodified>Thu, 03 Aug 2023 21:19:11 GMT</d:getlastmodified>
+				<d:resourcetype/>
+			</d:prop>
+			<d:status>HTTP/1.1 200 OK</d:status>
+		</d:propstat>
+	</d:response>
+	<d:response>
+		<d:href>/second%20file.txt</d:href>
+		<d:propstat>
+			<d:prop>
+				<d:getcontentlength>164</d:getcontentlength>
+				<d:getcontenttype>text/plain</d:getcontenttype>
+				<d:getetag>&quot;a604cf383444aab743c71f12b81de338&quot;</d:getetag>
+				<d:getlastmodified>Thu, 03 Aug 2023 16:12:57 GMT</d:getlastmodified>
+				<d:resourcetype/>
+			</d:prop>
+			<d:status>HTTP/1.1 200 OK</d:status>
+		</d:propstat>
+	</d:response>
+</d:multistatus>
diff --git a/test/responses/search-truncated.xml b/test/responses/search-truncated.xml
new file mode 100644
index 0000000..2ea6d59
--- /dev/null
+++ b/test/responses/search-truncated.xml
@@ -0,0 +1,30 @@
+<?xml version="1.0"?>
+<d:multistatus
+	xmlns:d="DAV:">
+	<d:response>
+		<d:href>/first-file.md</d:href>
+		<d:propstat>
+			<d:prop>
+				<d:getcontentlength>0</d:getcontentlength>
+				<d:getcontenttype>text/markdown</d:getcontenttype>
+				<d:getetag>&quot;54d5b8eac2d78c7824925f2fa008bc8d&quot;</d:getetag>
+				<d:getlastmodified>Thu, 03 Aug 2023 21:19:11 GMT</d:getlastmodified>
+				<d:resourcetype/>
+			</d:prop>
+			<d:status>HTTP/1.1 200 OK</d:status>
+		</d:propstat>
+		<d:propstat>
+			<d:prop>
+				<d:quota-available-bytes/>
+				<nc:is-encrypted/>
+				<oc:owner-display-name/>
+				<oc:owner-id/>
+			</d:prop>
+			<d:status>HTTP/1.1 404 Not Found</d:status>
+		</d:propstat>
+	</d:response>
+	<d:response>
+		<d:href>/</d:href>
+		<d:status>HTTP/1.1 507 Insufficient Storage</d:status>
+	</d:response>
+</d:multistatus>