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 AS types & grant requests support to open-payments client #727

Merged
merged 19 commits into from
Nov 17, 2022
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
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
32 changes: 24 additions & 8 deletions packages/open-payments/scripts/generate-types.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,32 @@
import fs from 'fs'
import openapiTS from 'openapi-typescript'
import config from '../src/config'

const generateTypesFromOpenApi = async (
specUrl: string,
outputFileName: string
) => {
const generatedTypesOutput = await openapiTS(specUrl)

fs.writeFile(outputFileName, generatedTypesOutput, (error) => {
if (error) {
console.log(`Error when writing types to ${outputFileName}`, { error })
}
})
}

;(async () => {
try {
const output = await openapiTS(config.OPEN_PAYMENTS_OPEN_API_URL)
const fileName = 'src/generated/types.ts'
const rootFolder = `src/generated`

fs.writeFile(fileName, output, (error) => {
if (error) {
console.log(`Error when writing types to ${fileName}`, { error })
}
})
try {
await generateTypesFromOpenApi(
config.OPEN_PAYMENTS_RS_OPEN_API_URL,
`${rootFolder}/resource-server-types.ts`
)
await generateTypesFromOpenApi(
config.OPEN_PAYMENTS_AS_OPEN_API_URL,
`${rootFolder}/auth-server-types.ts`
)
} catch (error) {
console.log('Error when generating types', {
error
Expand Down
31 changes: 31 additions & 0 deletions packages/open-payments/src/client/grant.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { createGrantRoutes } from './grant'
import { OpenAPI, HttpMethod, createOpenAPI } from 'openapi'
import config from '../config'
import { defaultAxiosInstance, silentLogger } from '../test/helpers'

describe('grant', (): void => {
let openApi: OpenAPI

beforeAll(async () => {
openApi = await createOpenAPI(config.OPEN_PAYMENTS_AS_OPEN_API_URL)
})

const axiosInstance = defaultAxiosInstance
const logger = silentLogger

describe('createGrantRoutes', (): void => {
test('creates response validators for grant requests', async (): Promise<void> => {
jest.spyOn(openApi, 'createResponseValidator')

createGrantRoutes({ axiosInstance, openApi, logger })
expect(openApi.createResponseValidator).toHaveBeenNthCalledWith(1, {
path: '/',
method: HttpMethod.POST
})
expect(openApi.createResponseValidator).toHaveBeenNthCalledWith(2, {
path: '/',
method: HttpMethod.POST
})
})
})
})
58 changes: 58 additions & 0 deletions packages/open-payments/src/client/grant.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { HttpMethod } from 'openapi'
import { ClientDeps } from '.'
import {
getASPath,
InteractiveGrant,
InteractiveGrantRequest,
NonInteractiveGrant,
NonInteractiveGrantRequest
} from '../types'
import { post } from './requests'

interface RequestGrantArgs<T> {
url: string
request: T
}

export interface GrantRoutes {
requestInteractiveGrant(
args: RequestGrantArgs<InteractiveGrantRequest>
): Promise<InteractiveGrant>
Copy link
Contributor

Choose a reason for hiding this comment

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

My concern with splitting requestInteractiveGrant/requestNonInteractiveGrant is that I don't think we've standardized what grant requests are interactive. And thus, a client might not be expected to know when to use either.
For example, Rafiki has made incoming-payment-only grants non-interactive, but I don't know that that's required:

@sabineschaller or @njlie or @adrianhopebailie might be able to correct me on this.

A compromise could be to do:

Suggested change
requestInteractiveGrant(
args: RequestGrantArgs<InteractiveGrantRequest>
): Promise<InteractiveGrant>
requestInteractiveGrant(
args: RequestGrantArgs<InteractiveGrantRequest>
): Promise<InteractiveGrant | NonInteractiveGrant>

Copy link
Contributor

Choose a reason for hiding this comment

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

requestNonInteractiveGrant seems fine as is, since the request should just fail if the AS requires RO interaction for that grant.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I separated these, since at some point, I thought the client need to know that they need to pass in interact ?
https://github.com/interledger/open-payments/blob/main/openapi/auth-server.yaml#L93-L94

Copy link
Contributor

Choose a reason for hiding this comment

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

Given our mix of grants for different resources needing interact or not, I've been assuming a client might just always assume a grant request requires interaction, but also be able to handle getting back a NonInteractiveGrant response.

Copy link
Contributor

Choose a reason for hiding this comment

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

I'm still leaning towards

Suggested change
requestInteractiveGrant(
args: RequestGrantArgs<InteractiveGrantRequest>
): Promise<InteractiveGrant>
requestInteractiveGrant(
args: RequestGrantArgs<InteractiveGrantRequest>
): Promise<InteractiveGrant | NonInteractiveGrant>

and clients can utilize isInteractiveGrant/isNonInteractiveGrant.

I got the impression from Adrian that their policy agent AS might be determining what grant requests require interaction (vs having it Open Payments standardized).

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Ah, I see what you mean, it's more of a configurable, per-instance thing instead of standard, makes sense.

Going to make changes to assume client will always require interaction, but call might return either or.

Copy link
Contributor

Choose a reason for hiding this comment

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

I'm ok also keeping:

  requestNonInteractiveGrant(
    args: RequestGrantArgs<NonInteractiveGrantRequest>
  ): Promise<NonInteractiveGrant>

But maybe requiring the interact field to be specified isn't unreasonable? 🤔

Copy link
Contributor Author

@mkurapov mkurapov Nov 17, 2022

Choose a reason for hiding this comment

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

Yeah even in the demo Postman example, there is an interact field present when requesting the incoming payment grant -> which returns a non-interactive one.
We could run with a single call to make things simple for now. Going to play around w it

requestNonInteractiveGrant(
args: RequestGrantArgs<NonInteractiveGrantRequest>
): Promise<NonInteractiveGrant>
}

export const createGrantRoutes = (clientDeps: ClientDeps): GrantRoutes => {
Copy link
Contributor

Choose a reason for hiding this comment

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

We'll also need the grant continuation route, in order to support the full interactive grant flow.
https://github.com/interledger/open-payments/blob/62c4b4a9875e3adaa21f89f597e88db43016fe0b/openapi/auth-server.yaml#L163-L171

const { axiosInstance, openApi, logger } = clientDeps

const createInteractiveGrantValidator =
openApi.createResponseValidator<InteractiveGrant>({
path: getASPath('/'),
method: HttpMethod.POST
})
const createNonInteractiveGrantValidator =
openApi.createResponseValidator<NonInteractiveGrant>({
path: getASPath('/'),
method: HttpMethod.POST
})
Copy link
Contributor Author

Choose a reason for hiding this comment

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

unfourtunately, needed both to pass type checking when given as arguments to the request*Grant methods

Copy link
Contributor

Choose a reason for hiding this comment

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

If we keep requestInteractiveGrant/requestNonInteractiveGrant separate, I think we'll need custom validators that include the openApi.createResponseValidator as well as a subsequent type guard that the response is the expected InteractiveGrant or NonInteractiveGrant. (In which case, we might be able to have a single openApi.createResponseValidator for both?)
As is, I think the OpenAPI validator would allow a NonInteractiveGrant response for requestInteractiveGrant and vice versa.


return {
requestInteractiveGrant: (
args: RequestGrantArgs<InteractiveGrantRequest>
) =>
post(
{ axiosInstance, logger },
{ url: args.url, body: args.request },
createInteractiveGrantValidator
),
requestNonInteractiveGrant: (
args: RequestGrantArgs<NonInteractiveGrantRequest>
) =>
post(
{ axiosInstance, logger },
{ url: args.url, body: args.request },
createNonInteractiveGrantValidator
)
}
Copy link
Contributor Author

Choose a reason for hiding this comment

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

At first, I wanted to do something tricky with types & method overloading, where we have one grant.create method that determined whether an "interactive" or "non-interactive" grant arguments were passed in, and returned with the correct response, but I decided something more explicit is much better to read and use.

}
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ describe('ilp-stream-connection', (): void => {
let openApi: OpenAPI

beforeAll(async () => {
openApi = await createOpenAPI(config.OPEN_PAYMENTS_OPEN_API_URL)
openApi = await createOpenAPI(config.OPEN_PAYMENTS_RS_OPEN_API_URL)
})

const axiosInstance = defaultAxiosInstance
Expand Down
4 changes: 2 additions & 2 deletions packages/open-payments/src/client/ilp-stream-connection.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { HttpMethod } from 'openapi'
import { ClientDeps } from '.'
import { getPath, ILPStreamConnection } from '../types'
import { getRSPath, ILPStreamConnection } from '../types'
import { get } from './requests'

interface GetArgs {
Expand All @@ -18,7 +18,7 @@ export const createILPStreamConnectionRoutes = (

const getILPStreamConnectionValidator =
openApi.createResponseValidator<ILPStreamConnection>({
path: getPath('/connections/{id}'),
path: getRSPath('/connections/{id}'),
method: HttpMethod.GET
})

Expand Down
2 changes: 1 addition & 1 deletion packages/open-payments/src/client/incoming-payment.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ describe('incoming-payment', (): void => {
let openApi: OpenAPI

beforeAll(async () => {
openApi = await createOpenAPI(config.OPEN_PAYMENTS_OPEN_API_URL)
openApi = await createOpenAPI(config.OPEN_PAYMENTS_RS_OPEN_API_URL)
})

const axiosInstance = defaultAxiosInstance
Expand Down
4 changes: 2 additions & 2 deletions packages/open-payments/src/client/incoming-payment.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { HttpMethod, ResponseValidator } from 'openapi'
import { ClientDeps } from '.'
import { IncomingPayment, getPath } from '../types'
import { IncomingPayment, getRSPath } from '../types'
import { get } from './requests'

interface GetArgs {
Expand All @@ -19,7 +19,7 @@ export const createIncomingPaymentRoutes = (

const getIncomingPaymentOpenApiValidator =
openApi.createResponseValidator<IncomingPayment>({
path: getPath('/incoming-payments/{id}'),
path: getRSPath('/incoming-payments/{id}'),
method: HttpMethod.GET
})

Expand Down
33 changes: 28 additions & 5 deletions packages/open-payments/src/client/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
} from './payment-pointer'
import { createAxiosInstance } from './requests'
import { AxiosInstance } from 'axios'
import { createGrantRoutes, GrantRoutes } from './grant'

export interface CreateOpenPaymentClientArgs {
requestTimeoutMs?: number
Expand All @@ -31,6 +32,7 @@ export interface OpenPaymentsClient {
incomingPayment: IncomingPaymentRoutes
ilpStreamConnection: ILPStreamConnectionRoutes
paymentPointer: PaymentPointerRoutes
grant: GrantRoutes
}

export const createClient = async (
Expand All @@ -40,13 +42,34 @@ export const createClient = async (
requestTimeoutMs:
args?.requestTimeoutMs ?? config.DEFAULT_REQUEST_TIMEOUT_MS
})
const openApi = await createOpenAPI(config.OPEN_PAYMENTS_OPEN_API_URL)
const resourceServerOpenApi = await createOpenAPI(
config.OPEN_PAYMENTS_RS_OPEN_API_URL
)
const authorizationServerOpenApi = await createOpenAPI(
config.OPEN_PAYMENTS_AS_OPEN_API_URL
)
const logger = args?.logger ?? createLogger()
const deps = { axiosInstance, openApi, logger }

return {
incomingPayment: createIncomingPaymentRoutes(deps),
ilpStreamConnection: createILPStreamConnectionRoutes(deps),
paymentPointer: createPaymentPointerRoutes(deps)
incomingPayment: createIncomingPaymentRoutes({
axiosInstance,
openApi: resourceServerOpenApi,
logger
}),
ilpStreamConnection: createILPStreamConnectionRoutes({
axiosInstance,
openApi: resourceServerOpenApi,
logger
}),
paymentPointer: createPaymentPointerRoutes({
axiosInstance,
openApi: resourceServerOpenApi,
logger
}),
grant: createGrantRoutes({
axiosInstance,
openApi: authorizationServerOpenApi,
logger
})
}
}
2 changes: 1 addition & 1 deletion packages/open-payments/src/client/payment-pointer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ describe('payment-pointer', (): void => {
let openApi: OpenAPI

beforeAll(async () => {
openApi = await createOpenAPI(config.OPEN_PAYMENTS_OPEN_API_URL)
openApi = await createOpenAPI(config.OPEN_PAYMENTS_RS_OPEN_API_URL)
})

const axiosInstance = defaultAxiosInstance
Expand Down
4 changes: 2 additions & 2 deletions packages/open-payments/src/client/payment-pointer.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { HttpMethod } from 'openapi'
import { ClientDeps } from '.'
import { PaymentPointer, getPath } from '../types'
import { PaymentPointer, getRSPath } from '../types'
import { get } from './requests'

interface GetArgs {
Expand All @@ -18,7 +18,7 @@ export const createPaymentPointerRoutes = (

const getPaymentPaymentValidator =
openApi.createResponseValidator<PaymentPointer>({
path: getPath('/'),
path: getRSPath('/'),
method: HttpMethod.GET
})

Expand Down
79 changes: 78 additions & 1 deletion packages/open-payments/src/client/requests.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/* eslint-disable @typescript-eslint/no-empty-function */
import { createAxiosInstance, get } from './requests'
import { createAxiosInstance, get, post } from './requests'
import nock from 'nock'
import { mockOpenApiResponseValidators, silentLogger } from '../test/helpers'

Expand Down Expand Up @@ -114,4 +114,81 @@ describe('requests', (): void => {
).rejects.toThrow(/Failed to validate OpenApi response/)
})
})

describe('post', (): void => {
const axiosInstance = createAxiosInstance({ requestTimeoutMs: 0 })
const baseUrl = 'http://localhost:1000'
const responseValidators = mockOpenApiResponseValidators()

beforeAll(() => {
jest.spyOn(axiosInstance, 'post')
})

test('properly POSTs request', async (): Promise<void> => {
const status = 200
const body = {
id: 'id'
}

nock(baseUrl).post('/grant', body).reply(status, body)

await post(
{ axiosInstance, logger },
{
url: `${baseUrl}/grant`,
body
},
responseValidators.successfulValidator
)

expect(axiosInstance.post).toHaveBeenCalledWith(`${baseUrl}/grant`, body)
})

test('calls validator function properly', async (): Promise<void> => {
const status = 200
const body = {
id: 'id'
}

nock(baseUrl).post('/grant', body).reply(status, body)

const responseValidatorSpy = jest.spyOn(
responseValidators,
'successfulValidator'
)

await post(
{ axiosInstance, logger },
{
url: `${baseUrl}/grant`,
body
},
responseValidators.successfulValidator
)

expect(responseValidatorSpy).toHaveBeenCalledWith({
body,
status
})
})

test('throws if response validator function fails', async (): Promise<void> => {
const status = 200
const body = {
id: 'id'
}
nock(baseUrl).post('/grant', body).reply(status, body)

await expect(
post(
{ axiosInstance, logger },
{
url: `${baseUrl}/grant`,
body
},
responseValidators.failedValidator
)
).rejects.toThrow(/Failed to validate OpenApi response/)
})
})
})
Loading