Skip to content

Commit

Permalink
Merge pull request #351 from susnux/feat/implement-search
Browse files Browse the repository at this point in the history
feat: Implement `SEARCH` according to `rfc5323`
  • Loading branch information
perry-mitchell committed Aug 30, 2023
2 parents 658d11d + d117e1f commit 9a0a168
Show file tree
Hide file tree
Showing 8 changed files with 319 additions and 15 deletions.
30 changes: 29 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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 |
|--------------|-----------------|----------------------------------------|
Expand Down
3 changes: 3 additions & 0 deletions source/factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -27,6 +28,7 @@ import {
LockOptions,
PutFileContentsOptions,
RequestOptionsCustom,
SearchOptions,
StatOptions,
WebDAVClient,
WebDAVClientContext,
Expand Down Expand Up @@ -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);
},
Expand Down
38 changes: 38 additions & 0 deletions source/operations/search.ts
Original file line number Diff line number Diff line change
@@ -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);
}
63 changes: 54 additions & 9 deletions source/tools/dav.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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 */
}
Expand All @@ -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")
Expand Down
45 changes: 40 additions & 5 deletions source/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand All @@ -51,6 +71,8 @@ export interface DAVResultResponseProps {
getcontenttype?: string;
"quota-available-bytes"?: any;
"quota-used-bytes"?: string;

[additionalProp: string]: unknown;
}

export interface DAVResult {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down
95 changes: 95 additions & 0 deletions test/node/operations/search.spec.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
});

0 comments on commit 9a0a168

Please sign in to comment.