From 3db86d60d26bd681fec68eb11c4c93cb613313ba Mon Sep 17 00:00:00 2001 From: Daniel Buchner Date: Sat, 27 Apr 2024 10:27:10 -0500 Subject: [PATCH] Add /query API endpoint (#125) Co-authored-by: Henry Tsai --- .vscode/launch.json | 1 - src/http-api.ts | 43 +++++++++++++++++++++++++++--- tests/http-api.spec.ts | 59 +++++++++++++++++++++++++++++++++++++++++- 3 files changed, 98 insertions(+), 5 deletions(-) diff --git a/.vscode/launch.json b/.vscode/launch.json index 6ff6553..9c5afef 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -5,7 +5,6 @@ "version": "0.2.0", "configurations": [ { - "runtimeVersion": "18", "type": "node", "request": "launch", "name": "Tests - Node", diff --git a/src/http-api.ts b/src/http-api.ts index 8abfa61..efce968 100644 --- a/src/http-api.ts +++ b/src/http-api.ts @@ -1,4 +1,4 @@ -import { type Dwn, RecordsRead, type RecordsReadReply } from '@tbd54566975/dwn-sdk-js'; +import { type Dwn, RecordsRead, RecordsQuery } from '@tbd54566975/dwn-sdk-js'; import cors from 'cors'; import type { Express, Request, Response } from 'express'; @@ -86,7 +86,8 @@ export class HttpApi { ); } - /* setupRoutes configures the HTTP server's request handlers + /** + * Configures the HTTP server's request handlers. */ #setupRoutes(): void { this.#api.get('/health', (_req, res) => { @@ -107,7 +108,7 @@ export class HttpApi { const record = await RecordsRead.create({ filter: { recordId: req.params.id }, }); - const reply = (await this.dwn.processMessage(req.params.did, record.toJSON())) as RecordsReadReply; + const reply = await this.dwn.processMessage(req.params.did, record.message); if (reply.status.code === 200) { if (reply?.record?.data) { @@ -128,6 +129,42 @@ export class HttpApi { } }); + this.#api.get('/:did/query', async (req, res) => { + + try { + // builds a nested object from flat keys with dot notation which may share the same parent path + // e.g. "filter.protocol=foo&filter.protocolPath=bar" becomes + // { + // filter: { + // protocol: 'foo', + // protocolPath: 'bar' + // } + // } + const recordsQueryOptions = {} as any; + for (const param in req.query) { + const keys = param.split('.'); + const lastKey = keys.pop(); + const lastLevelObject = keys.reduce((obj, key) => obj[key] = obj[key] || {}, recordsQueryOptions) + lastLevelObject[lastKey] = req.query[param]; + } + + const recordsQuery = await RecordsQuery.create({ + filter: recordsQueryOptions.filter, + pagination: recordsQueryOptions.pagination, + dateSort: recordsQueryOptions.dateSort, + }); + + // should always return a 200 status code with a JSON response + const reply = await this.dwn.processMessage(req.params.did, recordsQuery.message); + + res.setHeader('content-type', 'application/json'); + return res.json(reply); + } catch (error) { + // error should only occur when we are unable to create the RecordsQuery message internally, making it a client error + return res.status(400).send(error); + } + }); + this.#api.get('/', (_req, res) => { // return a plain text string res.setHeader('content-type', 'text/plain'); diff --git a/tests/http-api.spec.ts b/tests/http-api.spec.ts index eabd370..ee906a2 100644 --- a/tests/http-api.spec.ts +++ b/tests/http-api.spec.ts @@ -3,12 +3,13 @@ import sinon from 'sinon'; import { Cid, DataStream, + DwnErrorCode, RecordsQuery, RecordsRead, TestDataGenerator, Time, } from '@tbd54566975/dwn-sdk-js'; -import type { Dwn, Persona } from '@tbd54566975/dwn-sdk-js'; +import type { Dwn, DwnError, Persona, RecordsQueryReply } from '@tbd54566975/dwn-sdk-js'; import { expect } from 'chai'; import type { Server } from 'http'; @@ -529,6 +530,62 @@ describe('http api', function () { }); }); + describe('/:did/query', function () { + it('returns record data if record is published', async function () { + const filePath = './fixtures/test.jpeg'; + const { + cid: expectedCid, + size, + stream, + } = await getFileAsReadStream(filePath); + + const { recordsWrite } = await createRecordsWriteMessage(alice, { + dataCid: expectedCid, + dataSize: size, + published: true, + }); + + const requestId = uuidv4(); + const dwnRequest = createJsonRpcRequest(requestId, 'dwn.processMessage', { + message: recordsWrite.toJSON(), + target: alice.did, + }); + + const response = await fetch('http://localhost:3000', { + method: 'POST', + headers: { + 'dwn-request': JSON.stringify(dwnRequest), + }, + body: stream, + }); + + expect(response.status).to.equal(200); + + const body = (await response.json()) as JsonRpcResponse; + expect(body.id).to.equal(requestId); + expect(body.error).to.not.exist; + + const { reply } = body.result; + expect(reply.status.code).to.equal(202); + + const { entries } = await fetch( + `http://localhost:3000/${alice.did}/query?filter.recordId=${recordsWrite.message.recordId}&other.random.param=unused-value`, + ).then(response => response.json()) as RecordsQueryReply; + + expect(entries?.length).to.equal(1); + }); + + it('should return 400 if user provide invalid query', async function () { + const response = await fetch( + `http://localhost:3000/${alice.did}/query?filter=invalid-filter`, + ); + expect(response.status).to.equal(400); + + const responseBody = await response.json() as DwnError; + expect(responseBody.code).to.equal(DwnErrorCode.SchemaValidatorAdditionalPropertyNotAllowed); + }); + }); + describe('/info', function () { it('verify /info has some of the fields it is supposed to have', async function () { const resp = await fetch(`http://localhost:3000/info`);