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

Add balance resource and todos #212

Merged
merged 15 commits into from
Mar 29, 2024
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 @@
SubmitCloseCallback,
GetExchangesCallback,
GetOfferingsCallback,
GetBalancesCallback,
GetExchangeCallback,
BalancesApi,
} from './types.js'

import type { Express, Request, Response } from 'express'
Expand All @@ -20,6 +22,7 @@
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 @@
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 @@
*/
offeringsApi: OfferingsApi

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

/**
* PFI DID
*/
Expand All @@ -79,6 +93,7 @@

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 @@
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 @@
})
)

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

// 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
Loading