Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

adding replyTo field in http request body to submit RFQ #142

Merged
merged 17 commits into from
Feb 5, 2024
Merged
Show file tree
Hide file tree
Changes from 13 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 14 additions & 2 deletions packages/http-client/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,8 @@ export class TbdexHttpClient {
* @throws if recipient DID does not have a PFI service entry
*/
static async sendMessage<T extends MessageKind>(opts: SendMessageOptions<T>): Promise<void> {
const { message } = opts
const { message, replyTo } = opts
jiyoonie9 marked this conversation as resolved.
Show resolved Hide resolved

const jsonMessage: MessageModel<T> = message instanceof Message ? message.toJSON() : message

await Message.verify(jsonMessage)
Expand All @@ -39,10 +40,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 })
Expand Down Expand Up @@ -277,6 +284,11 @@ export class TbdexHttpClient {
export type SendMessageOptions<T extends MessageKind> = {
/** the message you want to send */
message: Message<T> | MessageModel<T>
/**
* 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
diehuxx marked this conversation as resolved.
Show resolved Hide resolved
}

/**
Expand Down
26 changes: 19 additions & 7 deletions packages/http-client/tests/client.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,18 +21,18 @@ describe('client', () => {
beforeEach(() => getPfiServiceEndpointStub.resolves('https://localhost:9000'))

describe('sendMessage', async () => {
let mockMessage: Rfq
let mockRfqMessage: Rfq

beforeEach(async () => {
mockMessage = await DevTools.createRfq({ sender: dhtDid, receiver: dhtDid })
mockRfqMessage = await DevTools.createRfq({ sender: dhtDid, receiver: dhtDid })
})

it('throws RequestError if service endpoint url is garbage', async () => {
getPfiServiceEndpointStub.resolves('garbage')
fetchStub.rejects({message: 'Failed to fetch on URL'})

try {
await TbdexHttpClient.sendMessage({message: mockMessage})
await TbdexHttpClient.sendMessage({message: mockRfqMessage})
expect.fail()
} catch(e) {
expect(e.name).to.equal('RequestError')
Expand All @@ -53,25 +53,37 @@ describe('client', () => {
} as Response)

try {
await TbdexHttpClient.sendMessage({message: mockMessage})
await TbdexHttpClient.sendMessage({message: mockRfqMessage})
expect.fail()
} catch(e) {
expect(e.name).to.equal('ResponseError')
expect(e).to.be.instanceof(ResponseError)
expect(e.statusCode).to.exist
expect(e.details).to.exist
expect(e.recipientDid).to.equal(dhtDid.did)
expect(e.url).to.equal(`https://localhost:9000/exchanges/${mockMessage.metadata.exchangeId}/rfq`)
expect(e.url).to.equal(`https://localhost:9000/exchanges/${mockRfqMessage.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: mockMessage})
await TbdexHttpClient.sendMessage({message: mockRfqMessage, 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()
} as Response)

try {
await TbdexHttpClient.sendMessage({message: mockRfqMessage})
} catch (e) {
expect.fail()
}
Expand Down
4 changes: 2 additions & 2 deletions packages/http-server/src/http-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -117,7 +117,7 @@ export class TbdexHttpServer {
listen(port: number | string, callback?: () => void) {
const { offeringsApi, exchangesApi } = this

this.api.post('/exchanges/:exchangeId/rfq', submitRfq({
this.api.post('/exchanges/:exchangeId/rfq', createExchange({
callback: this.callbacks['rfq'], offeringsApi, exchangesApi,
}))

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<MessageKind>

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] })
Expand Down Expand Up @@ -67,3 +71,12 @@ export function submitRfq(options: SubmitRfqOpts): RequestHandler {
return res.sendStatus(202)
}
}

function isValidUrl(urlStr: string) {
try {
new URL(urlStr)
return true
} catch (err) {
return false
}
}
2 changes: 1 addition & 1 deletion packages/http-server/src/request-handlers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Original file line number Diff line number Diff line change
@@ -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()
Expand Down Expand Up @@ -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 () => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

would be cool to include a test that returns a 202 if all is well

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm working on a slight refactor of http-server which include heeeella tests. I can add that test in my upcoming PR if it's helpful for getting this one through the door.

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')
Expand Down
34 changes: 27 additions & 7 deletions packages/protocol/src/dev-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,28 +2,29 @@
import type { OfferingData, QuoteData, RfqData } from './types.js'
import type { PortableDid } from '@web5/dids'

import { DidIonMethod, DidKeyMethod } from '@web5/dids'
import { DidDhtMethod, DidIonMethod, DidKeyMethod } from '@web5/dids'
import { utils as vcUtils } from '@web5/credentials'
import { Offering } from './resource-kinds/index.js'
import { Convert } from '@web5/common'
import { Crypto } from './crypto.js'
import { Jose } from '@web5/crypto'
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'

/**
* 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
Expand Down Expand Up @@ -72,6 +73,8 @@ export class DevTools {
return DidKeyMethod.create()
} else if (didMethod === 'ion') {
return DidIonMethod.create()
} else if (didMethod === 'dht') {
return DidDhtMethod.create()
} else {
throw new Error(`${didMethod} method not implemented.`)
}
Expand Down Expand Up @@ -203,7 +206,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)
Expand All @@ -214,10 +217,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<RfqData> {
static async createRfqData(opts?: MessageOptions): Promise<RfqData> {
let credential: any = ''

if (opts?.sender) {
Expand Down
4 changes: 2 additions & 2 deletions packages/protocol/src/message-kinds/rfq.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,8 +96,8 @@ export class Rfq extends Message<'rfq'> {
/**
* Validate the Rfq's payin/payout method against an Offering's allow payin/payout methods
*
* @param rfqPaymentMethod The Rfq's selected payin/payout method being validated
* @param allowedPaymentMethods The Offering's allowed payin/payout methods
* @param rfqPaymentMethod - The Rfq's selected payin/payout method being validated
* @param allowedPaymentMethods - The Offering's allowed payin/payout methods
*
* @throws if {@link Rfq.payinMethod} property `kind` cannot be validated against the provided offering's payinMethod kinds
* @throws if {@link Rfq.payinMethod} property `paymentDetails` cannot be validated against the provided offering's payinMethod requiredPaymentDetails
Expand Down