From 85e58410c23eeca34f34204b2a8419d6bb57421d Mon Sep 17 00:00:00 2001 From: Jiyoon Koo Date: Mon, 5 Feb 2024 13:09:03 -0500 Subject: [PATCH] adding `replyTo` field in http request body to submit RFQ (#142) * changing rfqdata fields per spec change. still need to merge schema and test vector change in tbdex repo and update the tbdex submodule * backing out protocol changes * adding replyTo in req body * adding a test to include replyTo field when sending an rfq * changed file names to align with http spec. added valid url check in create exchange handler method, wrote a test against this url check * adding back an xit test case * remove alias * adding a replyTo field check * add test for throwing error if order is sent with replyTo field * removing .onlys from test * adding doc to dev tool method * building requestBody differently for rfq vs other messages. writing a doc for replyTo field in SendMessagesOpts * trust fall exercise with typescript type. removing the paranoid rfq message type check * renaming a variable * renaming * vcjwt is a string --- packages/http-client/src/client.ts | 16 +++++++- packages/http-client/tests/client.spec.ts | 28 +++++++++----- packages/http-server/src/http-server.ts | 4 +- .../{submit-rfq.ts => create-exchange.ts} | 19 ++++++++-- .../http-server/src/request-handlers/index.ts | 2 +- ...it-rfq.spec.ts => create-exchange.spec.ts} | 23 +++++++++++- packages/protocol/src/dev-tools.ts | 37 ++++++++++++++----- 7 files changed, 101 insertions(+), 28 deletions(-) rename packages/http-server/src/request-handlers/{submit-rfq.ts => create-exchange.ts} (83%) rename packages/http-server/tests/{submit-rfq.spec.ts => create-exchange.spec.ts} (67%) diff --git a/packages/http-client/src/client.ts b/packages/http-client/src/client.ts index 9e3f8f05..a27f10e3 100644 --- a/packages/http-client/src/client.ts +++ b/packages/http-client/src/client.ts @@ -65,7 +65,8 @@ export class TbdexHttpClient { * @throws if recipient DID does not have a PFI service entry */ static async sendMessage(opts: SendMessageOptions): Promise { - const { message } = opts + const { message, replyTo } = opts + const jsonMessage: MessageModel = message instanceof Message ? message.toJSON() : message await Message.verify(jsonMessage) @@ -76,10 +77,16 @@ export class TbdexHttpClient { let response: Response try { + let requestBody + if (jsonMessage.metadata.kind == 'rfq') { + requestBody = JSON.stringify({ rfq: jsonMessage, replyTo}) + } else { + requestBody = JSON.stringify(jsonMessage) + } response = await fetch(apiRoute, { method : 'POST', headers : { 'content-type': 'application/json' }, - body : JSON.stringify(jsonMessage) + body : requestBody }) } catch (e) { throw new RequestError({ message: `Failed to send message to ${pfiDid}`, recipientDid: pfiDid, url: apiRoute, cause: e }) @@ -362,6 +369,11 @@ export class TbdexHttpClient { export type SendMessageOptions = { /** the message you want to send */ message: Message | MessageModel + /** + * A string containing a valid URI where new messages from the PFI will be sent. + * This field is only available as an option when sending an RFQ Message. + */ + replyTo?: T extends 'rfq' ? string : never } /** diff --git a/packages/http-client/tests/client.spec.ts b/packages/http-client/tests/client.spec.ts index ad816a40..c6f65287 100644 --- a/packages/http-client/tests/client.spec.ts +++ b/packages/http-client/tests/client.spec.ts @@ -10,7 +10,7 @@ import { RequestTokenVerificationError, RequestTokenSigningError } from '../src/errors/index.js' -import { DevTools, Message } from '@tbdex/protocol' +import { DevTools, Message, Rfq } from '@tbdex/protocol' import * as sinon from 'sinon' import { JwtHeaderParams, JwtPayload, PrivateKeyJwk, Secp256k1 } from '@web5/crypto' import { Convert } from '@web5/common' @@ -31,15 +31,11 @@ sinon.stub(Message, 'verify').resolves('123') describe('client', () => { beforeEach(() => getPfiServiceEndpointStub.resolves('https://localhost:9000')) - describe('sendMessage', () => { + describe('sendMessage', async () => { + let mockRfq: Rfq - let mockRfq: Message<'rfq'> - beforeEach(async () => - { - mockRfq = await DevTools.createRfq({ - sender : await DevTools.createDid(), - receiver : dhtDid - }) + beforeEach(async () => { + mockRfq = await DevTools.createRfq({ sender: dhtDid, receiver: dhtDid }) }) it('throws RequestError if service endpoint url is garbage', async () => { @@ -79,7 +75,19 @@ describe('client', () => { expect(e.url).to.equal(`https://localhost:9000/exchanges/${mockRfq.metadata.exchangeId}/rfq`) } }) - it('should not throw errors if all is well', async () => { + it('should not throw errors if all is well when sending RFQ with replyTo field', async () => { + fetchStub.resolves({ + ok : true, + json : () => Promise.resolve() + } as Response) + + try { + await TbdexHttpClient.sendMessage({message: mockRfq, replyTo: 'https://tbdex.io/callback'}) + } catch (e) { + expect.fail() + } + }) + it('should not throw errors if all is well when sending RFQ without replyTo field', async () => { fetchStub.resolves({ ok : true, json : () => Promise.resolve() diff --git a/packages/http-server/src/http-server.ts b/packages/http-server/src/http-server.ts index b7f0e126..7d4d32ed 100644 --- a/packages/http-server/src/http-server.ts +++ b/packages/http-server/src/http-server.ts @@ -14,7 +14,7 @@ import type { Express } from 'express' import express from 'express' import cors from 'cors' -import { getExchanges, getOfferings, submitOrder, submitClose, submitRfq } from './request-handlers/index.js' +import { getExchanges, getOfferings, submitOrder, submitClose, createExchange } from './request-handlers/index.js' import { jsonBodyParser } from './middleware/index.js' import { fakeExchangesApi, fakeOfferingsApi } from './fakes.js' @@ -125,7 +125,7 @@ export class TbdexHttpServer { listen(port: number | string, callback?: () => void) { const { offeringsApi, exchangesApi, pfiDid } = this - this.api.post('/exchanges/:exchangeId/rfq', submitRfq({ + this.api.post('/exchanges/:exchangeId/rfq', createExchange({ callback: this.callbacks['rfq'], offeringsApi, exchangesApi, })) diff --git a/packages/http-server/src/request-handlers/submit-rfq.ts b/packages/http-server/src/request-handlers/create-exchange.ts similarity index 83% rename from packages/http-server/src/request-handlers/submit-rfq.ts rename to packages/http-server/src/request-handlers/create-exchange.ts index 5934322b..52605d46 100644 --- a/packages/http-server/src/request-handlers/submit-rfq.ts +++ b/packages/http-server/src/request-handlers/create-exchange.ts @@ -5,19 +5,23 @@ import type { ErrorDetail } from '@tbdex/http-client' import { Message } from '@tbdex/protocol' import { CallbackError } from '../callback-error.js' -type SubmitRfqOpts = { +type CreateExchangeOpts = { callback: SubmitCallback<'rfq'> offeringsApi: OfferingsApi exchangesApi: ExchangesApi } -export function submitRfq(options: SubmitRfqOpts): RequestHandler { +export function createExchange(options: CreateExchangeOpts): RequestHandler { const { offeringsApi, exchangesApi, callback } = options return async function (req, res) { let message: Message + if (req.body.replyTo && !isValidUrl(req.body.replyTo)) { + return res.status(400).json({ errors: [{ detail: 'replyTo must be a valid url' }] }) + } + try { - message = await Message.parse(req.body) + message = await Message.parse(req.body.rfq) } catch(e) { const errorResponse: ErrorDetail = { detail: `Parsing of TBDex message failed: ${e.message}` } return res.status(400).json({ errors: [errorResponse] }) @@ -67,3 +71,12 @@ export function submitRfq(options: SubmitRfqOpts): RequestHandler { return res.sendStatus(202) } } + +function isValidUrl(replyToUrl: string) { + try { + new URL(replyToUrl) + return true + } catch (err) { + return false + } +} \ No newline at end of file diff --git a/packages/http-server/src/request-handlers/index.ts b/packages/http-server/src/request-handlers/index.ts index 15092813..7f0d2fb0 100644 --- a/packages/http-server/src/request-handlers/index.ts +++ b/packages/http-server/src/request-handlers/index.ts @@ -2,4 +2,4 @@ export * from './get-exchanges.js' export * from './get-offerings.js' export * from './submit-close.js' export * from './submit-order.js' -export * from './submit-rfq.js' +export * from './create-exchange.js' diff --git a/packages/http-server/tests/submit-rfq.spec.ts b/packages/http-server/tests/create-exchange.spec.ts similarity index 67% rename from packages/http-server/tests/submit-rfq.spec.ts rename to packages/http-server/tests/create-exchange.spec.ts index b5244a16..d64d4d78 100644 --- a/packages/http-server/tests/submit-rfq.spec.ts +++ b/packages/http-server/tests/create-exchange.spec.ts @@ -1,7 +1,7 @@ import type { ErrorDetail } from '@tbdex/http-client' import type { Server } from 'http' -import { TbdexHttpServer } from '../src/main.js' +import { DevTools, TbdexHttpServer } from '../src/main.js' import { expect } from 'chai' let api = new TbdexHttpServer() @@ -48,6 +48,27 @@ describe('POST /exchanges/:exchangeId/rfq', () => { expect(error.detail).to.include('JSON') }) + it('returns a 400 if create exchange request contains a replyTo which is not a valid URL', async () => { + const aliceDid = await DevTools.createDid() + const pfiDid = await DevTools.createDid() + const rfq = await DevTools.createRfq({ sender: aliceDid, receiver: pfiDid }) + await rfq.sign(aliceDid) + + const resp = await fetch('http://localhost:8000/exchanges/123/rfq', { + method : 'POST', + body : JSON.stringify({ rfq: rfq, replyTo: 'foo'}) + }) + + expect(resp.status).to.equal(400) + + const responseBody = await resp.json() as { errors: ErrorDetail[] } + expect(responseBody.errors.length).to.equal(1) + + const [ error ] = responseBody.errors + expect(error.detail).to.exist + expect(error.detail).to.include('replyTo must be a valid url') + }) + xit('returns a 400 if request body is not a valid RFQ') xit('returns a 400 if request body if integrity check fails') xit('returns a 409 if request body if RFQ already exists') diff --git a/packages/protocol/src/dev-tools.ts b/packages/protocol/src/dev-tools.ts index 66aef965..98c6b8db 100644 --- a/packages/protocol/src/dev-tools.ts +++ b/packages/protocol/src/dev-tools.ts @@ -1,25 +1,27 @@ import type { OfferingData, QuoteData, RfqData } from './types.js' import type { PortableDid } from '@web5/dids' + import { DidDhtMethod, DidIonMethod, DidKeyMethod } from '@web5/dids' -import { VerifiableCredential } from '@web5/credentials' import { Offering } from './resource-kinds/index.js' -import { Rfq } from './message-kinds/index.js' +import { Order, Rfq } from './message-kinds/index.js' import { Resource } from './resource.js' +import { Message } from './main.js' +import { VerifiableCredential } from '@web5/credentials' /** * Supported DID Methods * @beta */ -export type DidMethodOptions = 'key' | 'ion' +export type DidMethodOptions = 'key' | 'ion' | 'dht' /** * Options passed to {@link DevTools.createRfq} * @beta */ -export type RfqOptions = { +export type MessageOptions = { /** - * {@link @web5/dids#PortableDid} of the rfq sender. used to generate a random credential that fulfills the vcRequirements + * {@link @web5/dids#PortableDid} of the message sender. When generating RFQ, it is used to generate a random credential that fulfills the vcRequirements * of the offering returned by {@link DevTools.createOffering} */ sender: PortableDid @@ -42,9 +44,9 @@ export class DevTools { if (didMethod === 'key') { return await DidKeyMethod.create() } else if (didMethod === 'ion') { - return await DidIonMethod.create() + return DidIonMethod.create() } else if (didMethod === 'dht') { - return await DidDhtMethod.create() + return DidDhtMethod.create() } else { throw new Error(`${didMethod} method not implemented.`) } @@ -174,7 +176,7 @@ export class DevTools { * * **NOTE**: generates a random credential that fulfills the offering's required claims */ - static async createRfq(opts: RfqOptions) { + static async createRfq(opts: MessageOptions) { const { sender, receiver } = opts const rfqData: RfqData = await DevTools.createRfqData(opts) @@ -185,10 +187,27 @@ export class DevTools { }) } + /** + * Creates and returns an example Order with a generated exchangeId. Useful for testing purposes + * @param opts - options used to create a Message + * @returns Order message + */ + static createOrder(opts: MessageOptions) { + const { sender, receiver } = opts + + return Order.create({ + metadata: { + from : sender.did, + to : receiver?.did ?? 'did:ex:pfi', + exchangeId : Message.generateId('rfq') + } + }) + } + /** * creates an example RfqData. Useful for testing purposes */ - static async createRfqData(opts?: RfqOptions): Promise { + static async createRfqData(opts?: MessageOptions): Promise { let vcJwt: string = '' if (opts?.sender) {