Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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
6 changes: 6 additions & 0 deletions .changeset/selfish-geese-beg.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@interledger/open-payments': minor
---

- Adds support for new subject field on grants
- Adds support for new outgoing payment grant spent amounts (`client.outgoingPayment.getGrantSpentAmounts`)
97 changes: 96 additions & 1 deletion packages/open-payments/src/client/grant.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { createGrantRoutes } from './grant'
import { OpenAPI, HttpMethod } from '@interledger/openapi'
import { createTestDeps, mockGrantRequest } from '../test/helpers'
import { createTestDeps, mockGrantRequest, mockSubject } from '../test/helpers'
import * as requestors from './requests'
import { v4 as uuid } from 'uuid'
import { getAuthServerOpenAPI } from '../openapi'
Expand Down Expand Up @@ -136,6 +136,101 @@ describe('grant', (): void => {
}
}
)

test('POST grant request with subject field only', async (): Promise<void> => {
const postSpy = jest.spyOn(requestors, 'post')
const subjectOnlyRequest: Omit<GrantRequest, 'client'> = {
subject: mockSubject(),
interact: {
start: ['redirect'],
finish: {
method: 'redirect',
uri: 'http://localhost:3030/mock-idp/fake-client',
nonce: '456'
}
}
}

await createGrantRoutes({
openApi,
client,
...deps
}).request({ url }, subjectOnlyRequest)

expect(postSpy).toHaveBeenCalledWith(
deps,
{
url,
body: {
...subjectOnlyRequest,
client
}
},
true
)
})

test('POST grant request with both access_token and subject', async (): Promise<void> => {
const postSpy = jest.spyOn(requestors, 'post')
const combinedRequest: Omit<GrantRequest, 'client'> = {
access_token: {
access: [
{
type: 'quote',
actions: ['create', 'read']
}
]
},
subject: mockSubject(),
interact: {
start: ['redirect'],
finish: {
method: 'redirect',
uri: 'http://localhost:3030/mock-idp/fake-client',
nonce: '456'
}
}
}

await createGrantRoutes({
openApi,
client,
...deps
}).request({ url }, combinedRequest)

expect(postSpy).toHaveBeenCalledWith(
deps,
{
url,
body: {
...combinedRequest,
client
}
},
true
)
})

test('POST grant request with neither access_token nor subject fails', async (): Promise<void> => {
const emptyRequest: any = {
interact: {
start: ['redirect'],
finish: {
method: 'redirect',
uri: 'http://localhost:3030/mock-idp/fake-client',
nonce: '456'
}
}
}

await expect(
createGrantRoutes({
openApi,
client,
...deps
}).request({ url }, emptyRequest)
).rejects.toThrow('Invalid Grant Request')
})
})

describe('cancel', () => {
Expand Down
30 changes: 20 additions & 10 deletions packages/open-payments/src/client/grant.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,20 +60,30 @@ export const createGrantRoutes = (deps: GrantRouteDeps): GrantRoutes => {
{ url }: UnauthenticatedResourceRequestArgs,
args: Omit<GrantRequest, 'client'>
) => {
const outgoingPaymentAccess = args.access_token.access.find(
(el) => el.type === 'outgoing-payment'
)
if (
(outgoingPaymentAccess?.limits as AccessOutgoingWithDebitAmount)
?.debitAmount &&
(outgoingPaymentAccess?.limits as AccessOutgoingWithReceiveAmount)
?.receiveAmount
) {
if (!args.access_token && !args.subject) {
throw new OpenPaymentsClientError('Invalid Grant Request', {
description:
'Only one of "debitAmount" or "receiveAmount" may be specified.'
'Grant request must include at least one of "access_token" or "subject".'
})
}

if (args.access_token) {
const outgoingPaymentAccess = args.access_token.access.find(
(el) => el.type === 'outgoing-payment'
)
if (
(outgoingPaymentAccess?.limits as AccessOutgoingWithDebitAmount)
?.debitAmount &&
(outgoingPaymentAccess?.limits as AccessOutgoingWithReceiveAmount)
?.receiveAmount
) {
throw new OpenPaymentsClientError('Invalid Grant Request', {
description:
'Only one of "debitAmount" or "receiveAmount" may be specified.'
})
}
}

return post(
baseDeps,
{
Expand Down
88 changes: 88 additions & 0 deletions packages/open-payments/src/client/outgoing-payment.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,15 @@ import {
createOutgoingPayment,
createOutgoingPaymentRoutes,
getOutgoingPayment,
getOutgoingPaymentGrantSpentAmounts,
listOutgoingPayments,
validateOutgoingPayment
} from './outgoing-payment'
import { OpenAPI, HttpMethod } from '@interledger/openapi'
import {
mockOutgoingPayment,
mockOutgoingPaymentWithSpentAmounts,
mockOutgoingPaymentGrantSpentAmounts,
mockOpenApiResponseValidators,
mockOutgoingPaymentPaginationResult,
createTestDeps
Expand Down Expand Up @@ -632,5 +634,91 @@ describe('outgoing-payment', (): void => {
}
)
})

describe('getGrantSpentAmounts', (): void => {
test.each`
validateResponses | description
${true} | ${'with response validation'}
${false} | ${'without response validation'}
`(
'calls get method $description',
async ({ validateResponses }): Promise<void> => {
const mockResponseValidator = ({ path, method }) =>
path === '/outgoing-payment-grant' && method === HttpMethod.GET

jest
.spyOn(openApi, 'createResponseValidator')
// eslint-disable-next-line @typescript-eslint/no-explicit-any
.mockImplementation(mockResponseValidator as any)

const getSpy = jest
.spyOn(requestors, 'get')
.mockResolvedValueOnce(mockOutgoingPaymentGrantSpentAmounts())

await createOutgoingPaymentRoutes({
openApi: validateResponses ? openApi : undefined,
...deps
}).getGrantSpentAmounts({
url: serverAddress,
accessToken: 'accessToken'
})

expect(getSpy).toHaveBeenCalledWith(
deps,
{
url: `${serverAddress}/outgoing-payment-grant`,
accessToken: 'accessToken'
},
validateResponses ? true : undefined
)
}
)
})
})

describe('getOutgoingPaymentGrantSpentAmounts', (): void => {
test('returns grant spent amounts', async (): Promise<void> => {
const grantSpentAmounts = mockOutgoingPaymentGrantSpentAmounts()

const scope = nock(serverAddress)
.get('/outgoing-payment-grant')
.reply(200, grantSpentAmounts)

const result = await getOutgoingPaymentGrantSpentAmounts(
deps,
{
url: serverAddress,
accessToken: 'accessToken'
},
openApiValidators.successfulValidator
)

expect(result).toEqual(grantSpentAmounts)
scope.done()
})

test('returns grant spent amounts with partial data', async (): Promise<void> => {
const grantSpentAmounts = mockOutgoingPaymentGrantSpentAmounts({
spentReceiveAmount: undefined
})

const scope = nock(serverAddress)
.get('/outgoing-payment-grant')
.reply(200, grantSpentAmounts)

const result = await getOutgoingPaymentGrantSpentAmounts(
deps,
{
url: serverAddress,
accessToken: 'accessToken'
},
openApiValidators.successfulValidator
)

expect(result).toEqual(grantSpentAmounts)
expect(result.spentDebitAmount).toBeDefined()
expect(result.spentReceiveAmount).toBeUndefined()
scope.done()
})
})
})
34 changes: 34 additions & 0 deletions packages/open-payments/src/client/outgoing-payment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
CreateOutgoingPaymentArgs,
getRSPath,
OutgoingPayment,
OutgoingPaymentGrantSpentAmounts,
OutgoingPaymentPaginationResult,
OutgoingPaymentWithSpentAmounts,
PaginationArgs
Expand All @@ -26,6 +27,9 @@ export interface OutgoingPaymentRoutes {
requestArgs: ResourceRequestArgs,
createArgs: CreateOutgoingPaymentArgs
): Promise<OutgoingPaymentWithSpentAmounts>
getGrantSpentAmounts(
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

called like client.outgoingPayment.getGrantSpentAmounts. Makes it discoverable through outgoing payment which seems appropriate given the use case ("how much has been spent against the outgoing payemnt?") and a top-level method (client.outgoingPaymentGrant.getSpentAmounts) seemed like overkill and im not sure we intend on having more details for the outgoing payment grant exposed on this new endpoint, although it kinda fits the pattern more (/outgoing-payment, /incoming-payment, outgoing-payment-grant etc.). I think this ended up being the most reasonable way.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Makes sense to me, since it's still "scoped" to the outgoing payments.

args: ResourceRequestArgs
): Promise<OutgoingPaymentGrantSpentAmounts>
}

export const createOutgoingPaymentRoutes = (
Expand All @@ -36,6 +40,7 @@ export const createOutgoingPaymentRoutes = (
let getOutgoingPaymentOpenApiValidator: ResponseValidator<OutgoingPayment>
let listOutgoingPaymentOpenApiValidator: ResponseValidator<OutgoingPaymentPaginationResult>
let createOutgoingPaymentOpenApiValidator: ResponseValidator<OutgoingPayment>
let getGrantSpentAmountsOpenApiValidator: ResponseValidator<OutgoingPaymentGrantSpentAmounts>

if (openApi) {
getOutgoingPaymentOpenApiValidator = openApi.createResponseValidator({
Expand All @@ -52,6 +57,11 @@ export const createOutgoingPaymentRoutes = (
path: getRSPath('/outgoing-payments'),
method: HttpMethod.POST
})

getGrantSpentAmountsOpenApiValidator = openApi.createResponseValidator({
path: getRSPath('/outgoing-payment-grant'),
method: HttpMethod.GET
})
}

return {
Expand All @@ -77,6 +87,12 @@ export const createOutgoingPaymentRoutes = (
requestArgs,
createOutgoingPaymentOpenApiValidator,
createArgs
),
getGrantSpentAmounts: (requestArgs: ResourceRequestArgs) =>
getOutgoingPaymentGrantSpentAmounts(
baseDeps,
requestArgs,
getGrantSpentAmountsOpenApiValidator
)
}
}
Expand Down Expand Up @@ -194,3 +210,21 @@ export const validateOutgoingPayment = (

return payment
}

export const getOutgoingPaymentGrantSpentAmounts = async (
deps: BaseDeps,
requestArgs: ResourceRequestArgs,
validateOpenApiResponse: ResponseValidator<OutgoingPaymentGrantSpentAmounts>
): Promise<OutgoingPaymentGrantSpentAmounts> => {
const { url: baseUrl, accessToken } = requestArgs
const url = `${baseUrl}${getRSPath('/outgoing-payment-grant')}`

return await get(
deps,
{
url,
accessToken
},
validateOpenApiResponse
)
}
8 changes: 6 additions & 2 deletions packages/open-payments/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export {
Quote,
OutgoingPayment,
OutgoingPaymentWithSpentAmounts,
OutgoingPaymentGrantSpentAmounts,
PendingGrant,
Grant,
isPendingGrant,
Expand All @@ -20,7 +21,8 @@ export {
AccessType,
AccessAction,
AccessToken,
AccessItem
AccessItem,
Subject
} from './types'

export {
Expand All @@ -44,6 +46,7 @@ export {
mockIncomingPaymentWithPaymentMethods,
mockIlpPaymentMethod,
mockOutgoingPayment,
mockOutgoingPaymentGrantSpentAmounts,
mockIncomingPaymentPaginationResult,
mockOutgoingPaymentPaginationResult,
mockQuote,
Expand All @@ -52,5 +55,6 @@ export {
mockContinuationRequest,
mockGrantRequest,
mockGrant,
mockPendingGrant
mockPendingGrant,
mockSubject
} from './test/helpers'
Loading