Skip to content

Commit

Permalink
Add balance resource and todos (#212)
Browse files Browse the repository at this point in the history
* add balance resource and todos

* update validtor to cjs

* add client get balances method

* resolve todos

* add client and vector tests

* bump tbdex submodule

* add todos

* add optional balances endpoint

* update comments

* update test vectors spec

* add authz to balances endpoint

* remove paymentMethodKind enum

* update httpServer opts

* bump spec, update syntax, remove xits

* remove cjs
  • Loading branch information
kirahsapong authored Mar 29, 2024
1 parent fa95997 commit 7edf704
Show file tree
Hide file tree
Showing 18 changed files with 658 additions and 23 deletions.
45 changes: 45 additions & 0 deletions packages/http-client/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { JwtPayload } from '@web5/crypto'
import type { ErrorDetail } from './types.js'
import type { DidDocument, BearerDid } from '@web5/dids'
import {
Balance,
Close,
MessageModel,
Order,
Expand Down Expand Up @@ -164,6 +165,40 @@ export class TbdexHttpClient {
return data
}

/**
* gets balances from the pfi provided
* @param opts - options
* @beta
*/
static async getBalances(opts: GetBalancesOptions): Promise<Balance[]> {
const { pfiDid, did } = opts

const pfiServiceEndpoint = await TbdexHttpClient.getPfiServiceEndpoint(pfiDid)
const apiRoute = `${pfiServiceEndpoint}/balances`
const requestToken = await TbdexHttpClient.generateRequestToken({ requesterDid: did, pfiDid })

let response: Response
try {
response = await fetch(apiRoute, {
headers: {
authorization: `Bearer ${requestToken}`
}
})
} catch (e) {
throw new RequestError({ message: `Failed to get balances from ${pfiDid}`, recipientDid: pfiDid, url: apiRoute, cause: e })
}

if (!response.ok) {
const errorDetails = await response.json() as ErrorDetail[]
throw new ResponseError({ statusCode: response.status, details: errorDetails, recipientDid: pfiDid, url: apiRoute })
}

const responseBody = await response.json() as { data: Balance[] }
const data: Balance[] = responseBody.data

return data
}

/**
* get a specific exchange from the pfi provided
* @param opts - options
Expand Down Expand Up @@ -362,6 +397,16 @@ export type GetOfferingsOptions = {
pfiDid: string
}

/**
* options passed to {@link TbdexHttpClient.getBalances} method
* @beta
*/
export type GetBalancesOptions = {
/** the DID of the PFI from whom you want to get balances */
pfiDid: string
did: BearerDid
}

/**
* options passed to {@link TbdexHttpClient.getExchange} method
* @beta
Expand Down
50 changes: 50 additions & 0 deletions packages/http-client/tests/client.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -336,6 +336,56 @@ describe('client', () => {
})
})

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

try {
await TbdexHttpClient.getBalances({ pfiDid: pfiDid.uri, did: aliceDid })
expect.fail()
} catch(e) {
expect(e.name).to.equal('RequestError')
expect(e).to.be.instanceof(RequestError)
expect(e.message).to.include('Failed to get balances')
expect(e.cause).to.exist
expect(e.cause.message).to.include('URL')
}
})

it('throws ResponseError if response status is not ok', async () => {
fetchStub.resolves({
ok : false,
status : 400,
json : () => Promise.resolve({
detail: 'some error'
})
} as Response)

try {
await TbdexHttpClient.getBalances({ pfiDid: pfiDid.uri, did: aliceDid })
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(pfiDid.uri)
expect(e.url).to.equal('https://localhost:9000/balances')
}
})

it('returns balances array if response is ok', async () => {
fetchStub.resolves({
ok : true,
json : () => Promise.resolve({ data: [] })
} as Response)

const balances = await TbdexHttpClient.getBalances({ pfiDid: pfiDid.uri, did: aliceDid })
expect(balances).to.have.length(0)
})
})

describe('getExchange', () => {
it('throws RequestError if service endpoint url is garbage', async () => {
getPfiServiceEndpointStub.resolves('garbage')
Expand Down
36 changes: 35 additions & 1 deletion packages/http-server/src/http-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@ import type {
SubmitCloseCallback,
GetExchangesCallback,
GetOfferingsCallback,
GetBalancesCallback,
GetExchangeCallback,
BalancesApi,
} from './types.js'

import type { Express, Request, Response } from 'express'
Expand All @@ -20,6 +22,7 @@ import { InMemoryOfferingsApi } from './in-memory-offerings-api.js'
import { InMemoryExchangesApi } from './in-memory-exchanges-api.js'
import { submitMessage } from './request-handlers/submit-message.js'
import { getExchange } from './request-handlers/get-exchange.js'
import { getBalances } from './request-handlers/get-balances.js'

/**
* Maps the requests to their respective callbacks handlers
Expand All @@ -29,18 +32,24 @@ type CallbackMap = {
getExchange?: GetExchangeCallback
getExchanges?: GetExchangesCallback
getOfferings?: GetOfferingsCallback
getBalances?: GetBalancesCallback
createExchange?: CreateExchangeCallback
submitOrder?: SubmitOrderCallback
submitClose?: SubmitCloseCallback
}

/**
* Options for creating a new HttpServer
* @param opts.offeringsApi Optionally accepts an {@link OfferingsApi}. Defaults to an {@link InMemoryOfferingsApi} which supports additional methods.
* @param opts.exchangesApi Optionally accepts an {@link ExchangesApi}. Defaults to an {@link InMemoryExchangesApi} which supports additional methods.
* @param opts.balancesApi Optionally accepts a {@link BalancesApi}. Example: {@link InMemoryBalancesApi} which supports additional methods. Else, leave `undefined` if not supporting the balances endpoint.
* @param opts.pfiDid Required if instantiating the HttpServer with options. Else, defaults to an arbitrary string for example purposes only.
* @beta
*/
type NewHttpServerOptions = {

Check warning on line 49 in packages/http-server/src/http-server.ts

View workflow job for this annotation

GitHub Actions / tbdocs-reporter

extractor: ae-unresolved-link

The `@link` reference could not be resolved: The package "`@tbdex`/http-server" does not have an export "InMemoryOfferingsApi"

Check warning on line 49 in packages/http-server/src/http-server.ts

View workflow job for this annotation

GitHub Actions / tbdocs-reporter

extractor: ae-unresolved-link

The `@link` reference could not be resolved: The package "`@tbdex`/http-server" does not have an export "InMemoryExchangesApi"

Check warning on line 49 in packages/http-server/src/http-server.ts

View workflow job for this annotation

GitHub Actions / tbdocs-reporter

extractor: ae-unresolved-link

The `@link` reference could not be resolved: The package "`@tbdex`/http-server" does not have an export "InMemoryBalancesApi"
offeringsApi?: OfferingsApi
exchangesApi?: ExchangesApi,
balancesApi?: BalancesApi,
pfiDid: string
}

Expand Down Expand Up @@ -69,6 +78,11 @@ export class TbdexHttpServer {
*/
offeringsApi: OfferingsApi

/**
* PFI Balances API
*/
balancesApi?: BalancesApi

/**
* PFI DID
*/
Expand All @@ -79,6 +93,7 @@ export class TbdexHttpServer {

this.exchangesApi = opts?.exchangesApi ?? new InMemoryExchangesApi()
this.offeringsApi = opts?.offeringsApi ?? new InMemoryOfferingsApi()
this.balancesApi = opts?.balancesApi
this.pfiDid = opts?.pfiDid ?? 'did:ex:pfi'

// initialize api here so that consumers can attach custom endpoints
Expand Down Expand Up @@ -145,13 +160,22 @@ export class TbdexHttpServer {
this.callbacks.getOfferings = callback
}

/**
* Set up a callback or overwrite the existing callback for the GetBalances endpoint
* @param callback - A callback to be invoked when a valid request is sent to the
* GetBalances endpoint.
*/
onGetBalances(callback: GetBalancesCallback): void {
this.callbacks.getBalances = callback
}

/**
* Setup the PFI routes and start a express server to listen for incoming requests
* @param port - server port number
* @param callback - to be called when the server is ready
*/
listen(port: number | string, callback?: () => void) {
const { offeringsApi, exchangesApi, pfiDid } = this
const { offeringsApi, exchangesApi, balancesApi, pfiDid } = this

this.api.post('/exchanges', (req: Request, res: Response) =>
createExchange(req, res, {
Expand Down Expand Up @@ -192,6 +216,16 @@ export class TbdexHttpServer {
})
)

if (balancesApi) {
this.api.get('/balances', (req, res) =>
getBalances(req, res, {
callback: this.callbacks['getBalances'],
balancesApi,
pfiDid,
})
)
}

// TODO: support hostname and backlog arguments
return this.api.listen(port, callback)
}
Expand Down
57 changes: 57 additions & 0 deletions packages/http-server/src/in-memory-balances-api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { Balance } from '@tbdex/protocol'
import { BalancesApi } from './types.js'

/**
* An in-memory implementation of {@link BalancesApi} for example and default purposes.
* InMemoryBalancesApi has additional methods {@link InMemoryBalancesApi.addBalance}
* {@link InMemoryBalancesApi.clearRequesterBalances}
* and {@link InMemoryBalancesApi.clearBalances}
*/
export class InMemoryBalancesApi implements BalancesApi {
/** Map from requester DID to list of Balances */
balancesMap: Map<string, Balance[]>

constructor() {
this.balancesMap = new Map<string, Balance[]>()
}

/**
* Add a single balance resource
* @param balance - Balance to be added to the {@link balancesMap}
*/
addBalance(opts: {requesterDid: string, balance: Balance}): void {
let requesterBalances = this.balancesMap.get(opts.requesterDid) ?? []
requesterBalances.push(opts.balance)
this.balancesMap.set(opts.requesterDid, requesterBalances)
}

/**
* Clear existing list of balances for a single requester
*/
clearRequesterBalances(opts: {requesterDid: string}): void {
this.balancesMap.delete(opts.requesterDid)
}

/**
* Clear existing list of balances
*/
clearAllBalances(): void {
this.balancesMap.clear()
}

/**
*
* @returns A list of balances
*/
async getBalances(opts?: { requesterDid: string }): Promise<Balance[]> {
if (opts === undefined || opts.requesterDid === undefined) {
// In production, this should probably return an empty list.
// For example and testing purposes, we return all balances.

return Array.from(this.balancesMap.values()).flatMap(balances => balances)
}

return this.balancesMap.get(opts.requesterDid) ?? []
}

}
44 changes: 44 additions & 0 deletions packages/http-server/src/request-handlers/get-balances.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { Request, Response } from 'express'
import type { GetBalancesCallback, BalancesApi } from '../types.js'
import { TbdexHttpClient } from '@tbdex/http-client'

type GetBalancesOpts = {
callback?: GetBalancesCallback
balancesApi: BalancesApi,
pfiDid: string
}

export async function getBalances(request: Request, response: Response, opts: GetBalancesOpts): Promise<void> {
const { callback, balancesApi, pfiDid } = opts

const authzHeader = request.headers['authorization']
if (!authzHeader) {
response.status(401).json({ errors: [{ detail: 'Authorization header required' }] })
return
}

const [_, requestToken] = authzHeader.split('Bearer ')

if (!requestToken) {
response.status(401).json({ errors: [{ detail: 'Malformed Authorization header. Expected: Bearer TOKEN_HERE' }] })
return
}

let requesterDid: string
try {
requesterDid = await TbdexHttpClient.verifyRequestToken({ requestToken: requestToken, pfiDid })
} catch(e) {
response.status(401).json({ errors: [{ detail: `Malformed Authorization header: ${e}` }] })
return
}

const balances = await balancesApi.getBalances({ requesterDid })

if (callback) {
// TODO: figure out what to do with callback result. should we pass through the offerings we've fetched
// and allow the callback to modify what's returned? (issue #11)
await callback({ request, response })
}

response.status(200).json({ data: balances })
}
19 changes: 18 additions & 1 deletion packages/http-server/src/types.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { Request, Response } from 'express'
import type { Close, Exchange, Offering, Order, OrderStatus, Quote, Rfq } from '@tbdex/protocol'
import type { Balance, Close, Exchange, Offering, Order, OrderStatus, Quote, Rfq } from '@tbdex/protocol'


/**
Expand All @@ -20,6 +20,12 @@ export type GetExchangesCallback = (ctx: RequestContext, filter: GetExchangesFil
*/
export type GetOfferingsCallback = (ctx: RequestContext) => any

/**
* Callback handler for GetBalances requests
* @beta
*/
export type GetBalancesCallback = (ctx: RequestContext) => any

/**
* Callback handler for the SubmitRfq requests
* @beta
Expand Down Expand Up @@ -82,6 +88,17 @@ export interface OfferingsApi {
getOfferings(): Promise<Offering[]>
}

/**
* PFI Balances API
* @beta
*/
export interface BalancesApi {
/**
* Retrieve a list of balances
*/
getBalances(opts?: { requesterDid: string }): Promise<Balance[]>
}

/**
* PFI Exchanges API
* @beta
Expand Down
Loading

0 comments on commit 7edf704

Please sign in to comment.