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 13 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
159 changes: 159 additions & 0 deletions packages/open-payments/src/client/grant.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
import nock from 'nock'
import {
createGrantRoutes,
isInteractiveGrant,
isNonInteractiveGrant,
requestInteractiveGrant,
requestNonInteractiveGrant
} from './grant'
import { OpenAPI, HttpMethod, createOpenAPI } from 'openapi'
import config from '../config'
import {
defaultAxiosInstance,
mockInteractiveGrant,
mockInteractiveGrantRequest,
mockNonInteractiveGrant,
mockNonInteractiveGrantRequest,
mockOpenApiResponseValidators,
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
const baseUrl = 'http://localhost:1000'
const openApiValidators = mockOpenApiResponseValidators()

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

createGrantRoutes({ axiosInstance, openApi, logger })
expect(openApi.createResponseValidator).toHaveBeenCalledTimes(1)
expect(openApi.createResponseValidator).toHaveBeenCalledWith({
path: '/',
method: HttpMethod.POST
})
})
})

describe('requestInteractiveGrant', (): void => {
test('returns interactive grant if passes validation', async (): Promise<void> => {
const interactiveGrant = mockInteractiveGrant()

nock(baseUrl).post('/').reply(200, interactiveGrant)

const result = await requestInteractiveGrant(
{
axiosInstance,
logger
},
{
url: `${baseUrl}/`,
request: mockInteractiveGrantRequest()
},
openApiValidators.successfulValidator
)
expect(result).toStrictEqual(interactiveGrant)
})

test('throws if interactive grant does not pass validation', async (): Promise<void> => {
const incorrectInteractiveGrant = mockInteractiveGrant({
interact: undefined
})

nock(baseUrl).post('/').reply(200, incorrectInteractiveGrant)

expect(
requestInteractiveGrant(
{
axiosInstance,
logger
},
{
url: `${baseUrl}/`,
request: mockInteractiveGrantRequest()
},
openApiValidators.successfulValidator
)
).rejects.toThrow('Could not validate interactive grant')
})
})

describe('requestNonInteractiveGrant', (): void => {
test('returns non-interactive grant if passes validation', async (): Promise<void> => {
const nonInteractiveGrant = mockNonInteractiveGrant()

nock(baseUrl).post('/').reply(200, nonInteractiveGrant)

const result = await requestNonInteractiveGrant(
{
axiosInstance,
logger
},
{
url: `${baseUrl}/`,
request: mockNonInteractiveGrantRequest()
},
openApiValidators.successfulValidator
)
expect(result).toStrictEqual(nonInteractiveGrant)
})

test('throws if non-interactive grant does not pass validation', async (): Promise<void> => {
const incorrectNonInteractiveGrant = mockNonInteractiveGrant({
access_token: undefined
})

nock(baseUrl).post('/').reply(200, incorrectNonInteractiveGrant)

expect(
requestNonInteractiveGrant(
{
axiosInstance,
logger
},
{
url: `${baseUrl}/`,
request: mockNonInteractiveGrantRequest()
},
openApiValidators.successfulValidator
)
).rejects.toThrow('Could not validate non-interactive grant')
})
})

describe('isInteractiveGrant', (): void => {
test('returns true if has interact property', async (): Promise<void> => {
expect(isInteractiveGrant(mockInteractiveGrant())).toBe(true)
})

test('returns false if has access_token property', async (): Promise<void> => {
const grant = mockInteractiveGrant()

grant['access_token'] = { value: 'token' }

expect(isInteractiveGrant(grant)).toBe(false)
})
})

describe('isNonInteractiveGrant', (): void => {
test('returns true if has access_token property', async (): Promise<void> => {
expect(isNonInteractiveGrant(mockNonInteractiveGrant())).toBe(true)
})

test('returns false if has interact property', async (): Promise<void> => {
const grant = mockNonInteractiveGrant()

grant['interact'] = { redirect: 'http://example.com/redirect' }

expect(isNonInteractiveGrant(grant)).toBe(false)
})
})
})
117 changes: 117 additions & 0 deletions packages/open-payments/src/client/grant.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import { HttpMethod, ResponseValidator } from 'openapi'
import { RouteDeps } 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 = (deps: RouteDeps): GrantRoutes => {
const { axiosInstance, openApi, logger } = deps

const requestGrantValidator = openApi.createResponseValidator<
InteractiveGrant | NonInteractiveGrant
>({
path: getASPath('/'),
method: HttpMethod.POST
})
return {
requestInteractiveGrant: (
args: RequestGrantArgs<InteractiveGrantRequest>
) =>
requestInteractiveGrant(
{ axiosInstance, logger },
args,
requestGrantValidator
),
requestNonInteractiveGrant: (
args: RequestGrantArgs<NonInteractiveGrantRequest>
) =>
requestNonInteractiveGrant(
{ axiosInstance, logger },
args,
requestGrantValidator
)
}
}

export const requestInteractiveGrant = async (
deps: Pick<RouteDeps, 'axiosInstance' | 'logger'>,
args: RequestGrantArgs<InteractiveGrantRequest>,
validateOpenApiResponse: ResponseValidator<
InteractiveGrant | NonInteractiveGrant
>
) => {
const { axiosInstance, logger } = deps
const { url } = args

const grant = await post(
{ axiosInstance, logger },
{ url: args.url, body: args.request },
validateOpenApiResponse
)

if (!isInteractiveGrant(grant)) {
const errorMessage = 'Could not validate interactive grant'
logger.error({ url, grant }, errorMessage)
throw new Error(errorMessage)
}

return grant
}

export const requestNonInteractiveGrant = async (
deps: Pick<RouteDeps, 'axiosInstance' | 'logger'>,
args: RequestGrantArgs<NonInteractiveGrantRequest>,
validateOpenApiResponse: ResponseValidator<
InteractiveGrant | NonInteractiveGrant
>
) => {
const { axiosInstance, logger } = deps
const { url } = args

const grant = await post(
{ axiosInstance, logger },
{ url: args.url, body: args.request },
validateOpenApiResponse
)

if (!isNonInteractiveGrant(grant)) {
const errorMessage = 'Could not validate non-interactive grant'
logger.error({ url, grant }, errorMessage)
throw new Error(errorMessage)
}

return grant
}

export const isInteractiveGrant = (
grant: InteractiveGrant | NonInteractiveGrant
): grant is InteractiveGrant =>
!('access_token' in grant) &&
'interact' in grant &&
!!(grant as InteractiveGrant).interact
Copy link
Contributor

Choose a reason for hiding this comment

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

Could this just be

Suggested change
!('access_token' in grant) &&
'interact' in grant &&
!!(grant as InteractiveGrant).interact
!!(grant as InteractiveGrant).interact

I'm assuming the OpenAPI validator would disallow access_token + interact.
ditto below

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yeah I wasn't sure how lenient the validator was with oneOf in the spec, but this can be less strict


export const isNonInteractiveGrant = (
grant: InteractiveGrant | NonInteractiveGrant
): grant is NonInteractiveGrant =>
!('interact' in grant) &&
'access_token' in grant &&
!!(grant as NonInteractiveGrant).access_token
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
10 changes: 5 additions & 5 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 { RouteDeps } from '.'
import { getRSPath, ILPStreamConnection } from '../types'
import { get } from './requests'

interface GetArgs {
Expand All @@ -12,13 +12,13 @@ export interface ILPStreamConnectionRoutes {
}

export const createILPStreamConnectionRoutes = (
clientDeps: ClientDeps
deps: RouteDeps
): ILPStreamConnectionRoutes => {
const { axiosInstance, openApi, logger } = clientDeps
const { axiosInstance, openApi, logger } = deps

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
Loading