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

feat(open-payments): add create quote #888

Merged
merged 26 commits into from
Jan 13, 2023
Merged

feat(open-payments): add create quote #888

merged 26 commits into from
Jan 13, 2023

Conversation

raducristianpopa
Copy link
Member

@raducristianpopa raducristianpopa commented Dec 20, 2022

Changes proposed in this pull request

  • add create quote functionality to OP Client
  • update types for the quote creation args in backend

Context

Checklist

  • Related issues linked using fixes #number
  • Tests added/updated
  • Documentation added
  • Make sure that all checks pass

@github-actions github-actions bot added pkg: backend Changes in the backend package. pkg: open-payments type: source Changes business logic type: tests Testing related labels Dec 20, 2022
@raducristianpopa raducristianpopa marked this pull request as draft December 20, 2022 07:42
@@ -32,8 +33,15 @@ export const getQuote: QueryResolvers<ApolloContext>['quote'] = async (
export const createQuote: MutationResolvers<ApolloContext>['createQuote'] =
async (parent, args, ctx): Promise<ResolversTypes['QuoteResponse']> => {
const quoteService = await ctx.container.use('quoteService')
const options: CreateQuoteOptions = {
Copy link
Contributor

Choose a reason for hiding this comment

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

Because graphql doesn't do mutually exclusive types (:disappointed:), I think we need to move the old check from the quote service here

if (options.sendAmount && options.receiveAmount) {
return QuoteError.InvalidAmount
}

Suggested change
const options: CreateQuoteOptions = {
if (args.input.sendAmount && args.input.receiveAmount) {
return {
code: errorToCode[QuoteError.InvalidAmount].toString(),
success: false,
message: errorToMessage[QuoteError.InvalidAmount]
}
}
const options: CreateQuoteOptions = {

Copy link
Member Author

Choose a reason for hiding this comment

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

Initially I was looking at the @oneOf directive (RFC). Even though it's available in a couple of packages, is still a feature from the future and the behavior could change slightly. It would be nice if we can enforce the types at the GraphQL schema later on.

While playing around with it, I got to this point:

// schema.graphql

input QuoteAmount @oneOf {
  sendAmount: AmountInput
  receiveAmount: AmountInput
}

input CreateQuoteInput {
  paymentPointerId: String!
  amount: QuoteAmount
  receiver: String!
}
// graphql.generated.ts

export type QuoteAmount =
  { sendAmount: AmountInput; receiveAmount?: never; }
  |  { sendAmount?: never; receiveAmount: AmountInput; };

export type CreateQuoteInput = {
  paymentPointerId: Scalars['String'];
  amount?: InputMaybe<QuoteAmount>;
  receiver: Scalars['String'];
};

I think it partially fulfills our needs for the CreateQuoteInput. 🤔

sendAmount: body.sendAmount && parseAmount(body.sendAmount),
receiveAmount: body.receiveAmount && parseAmount(body.receiveAmount),
clientId: ctx.grant?.clientId
})
Copy link
Contributor

Choose a reason for hiding this comment

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

I think you should be able to make sendAmount and receiveAmount mutually exclusive in CreateBody (like with CreateQuoteOptions) and leave this as it was before.
This is enforced by the OpenAPI request validator thanks to:

Copy link
Member Author

Choose a reason for hiding this comment

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

With the changes to the CreateBody type, it's still complaining:
Types of property 'sendAmount' are incompatible. Type 'Amount' is not assignable to type 'never'

But I think we should keep the changes to CreateBody.

@@ -15,7 +15,15 @@ export async function createOutgoingPayment(
'quoteId'
>
Copy link
Contributor

Choose a reason for hiding this comment

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

If you change options to 👇 you won't need the changes below

  options: CreateTestQuoteOptions &
    Omit<CreateOutgoingPaymentOptions, 'quoteId'>

but then it looks like there's some calls to createOutgoingPayment that would need to updated.

Copy link
Member Author

@raducristianpopa raducristianpopa Dec 22, 2022

Choose a reason for hiding this comment

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

🤔 I think we should keep the type as it was before, since our changes are affecting only the createQuote function ?

Copy link
Member Author

@raducristianpopa raducristianpopa Dec 22, 2022

Choose a reason for hiding this comment

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

What do you think about refactoring createOutgoingPayment later on, to something like:

function createOutgoingPayment(
  deps: IocContract<AppServices>,
  quoteOptions: CreateTestQuoteOptions,
  outgoingPaymentOptions: Omit<CreateOutgoingPaymentOptions, 'quoteId'>
): Promise<OutgoingPayment>

@raducristianpopa raducristianpopa marked this pull request as ready for review December 22, 2022 08:28
mkurapov
mkurapov previously approved these changes Dec 23, 2022
Copy link
Contributor

@mkurapov mkurapov left a comment

Choose a reason for hiding this comment

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

Looks good, approving with some comments

}

export interface QuoteRoutes {
get(args: GetArgs): Promise<Quote>
create(args: PostArgs<CreateQuoteArgs>): Promise<Quote>
Copy link
Contributor

Choose a reason for hiding this comment

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

For create method and list methods, I was thinking we would do something like this:

interface CreateArgs { // or something along these lines
  paymentPointer: string
  accessToken: string
}

```suggestion
  create(createArgs: CreateArgs, createQuoteArgs: CreateQuoteArgs): Promise<Quote>

and then

   const url = `${paymentPointer}${getRSPath('/quotes')}` 

when making the actual POST call. This is so that the client doesn't need to provide the full route.

Let me know what you think

@wilsonianb had a discussion about it here: #823 (comment)

Comment on lines 41 to 42
export type CreateQuoteArgs =
RSOperations['create-quote']['requestBody']['content']['application/json']
Copy link
Contributor

Choose a reason for hiding this comment

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

I think we would also need to do explicitly provide sendAmount: never or receiveAmount: never like you have above here as well, since having just this, typescript won't complain if we do

.create({
          url,
          accessToken,
          body: {
            receiver: quote.receiver,
            sendAmount: {
              assetCode: '4',
              assetScale: 4,
              value: '4'
            },
            receiveAmount: {
              assetCode: '4',
              assetScale: 4,
              value: '4'
            }
          }

Copy link
Contributor

Choose a reason for hiding this comment

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

We could even use the XOR helper type, if you'd like: https://stackoverflow.com/a/53229567

packages/open-payments/src/client/quote.test.ts Outdated Show resolved Hide resolved
Comment on lines 76 to 78
if (options.sendAmount && options.receiveAmount) {
return QuoteError.InvalidAmount
}
Copy link
Contributor

Choose a reason for hiding this comment

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

@raducristianpopa @wilsonianb
I'm leaning on keeping this business logic sanity check here (even though the OpenAPI validator would prevent it). If there are some issues with validator we would still be ok in the service

Copy link
Contributor

Choose a reason for hiding this comment

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

An alternative could be to move the check to routes.ts (like we had to do with the graphql resolver), but it'd make more sense to just keep the one check here.
So I vote we drop the resolver check in favor of this, and maybe we revisit if/when we have graphql @oneOf.

Copy link
Contributor

Choose a reason for hiding this comment

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

🤔 do we only change CreateBody then, and revert CreateQuoteOptions?
I think that'd avoid having to change the tests' createOutgoingPayment

Copy link
Member Author

@raducristianpopa raducristianpopa Jan 5, 2023

Choose a reason for hiding this comment

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

🤔 i think we should keep the changes for the CreateQuoteOptions type for consistency.

Copy link
Contributor

Choose a reason for hiding this comment

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

My only reservation about that is we'd also need to check in the resolver.

Copy link
Contributor

Choose a reason for hiding this comment

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

I think the resolver will need the check in order to be able to call quoteService.create if the options type is:

export type CreateQuoteOptions =
  | QuoteOptionsWithSendAmount
  | QuoteOptionsWithReceiveAmount

Copy link
Member Author

Choose a reason for hiding this comment

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

With the actual changes to the resolver:

export const createQuote: MutationResolvers<ApolloContext>['createQuote'] =
async (parent, args, ctx): Promise<ResolversTypes['QuoteResponse']> => {
const quoteService = await ctx.container.use('quoteService')
const options: CreateQuoteOptions = {
paymentPointerId: args.input.paymentPointerId,
receiver: args.input.receiver
}
if (args.input.sendAmount) options.sendAmount = args.input.sendAmount
if (args.input.receiveAmount)
options.receiveAmount = args.input.receiveAmount
return quoteService
.create(options)
.then((quoteOrErr: Quote | QuoteError) =>
isQuoteError(quoteOrErr)

i was able to make a GraphQL request with both sendAmount and receiveAmount.
quoteService.create gets called properly even with the options type being

export type CreateQuoteOptions =
  | QuoteOptionsWithSendAmount
  | QuoteOptionsWithReceiveAmount

image

Copy link
Contributor

Choose a reason for hiding this comment

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

😕
I think declaring options as CreateQuoteOptions makes the call to quoteService.create happy.
But I'm surprised we don't need the receiveAmount assignment in an else if (args.input.receiveAmount).
Maybe it's due to:

Copy link
Member Author

Choose a reason for hiding this comment

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

I've just enabled strict type checking for the backend package, but TypeScript it's still not complaining about it. 🤔

Copy link
Contributor

Choose a reason for hiding this comment

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

I think it's not complaining because both receiveAmount and sendAmount are optional in their respective types

packages/backend/src/open_payments/quote/service.test.ts Outdated Show resolved Hide resolved
Comment on lines 76 to 78
if (options.sendAmount && options.receiveAmount) {
return QuoteError.InvalidAmount
}
Copy link
Contributor

Choose a reason for hiding this comment

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

An alternative could be to move the check to routes.ts (like we had to do with the graphql resolver), but it'd make more sense to just keep the one check here.
So I vote we drop the resolver check in favor of this, and maybe we revisit if/when we have graphql @oneOf.

Comment on lines 76 to 78
if (options.sendAmount && options.receiveAmount) {
return QuoteError.InvalidAmount
}
Copy link
Contributor

Choose a reason for hiding this comment

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

🤔 do we only change CreateBody then, and revert CreateQuoteOptions?
I think that'd avoid having to change the tests' createOutgoingPayment

mkurapov
mkurapov previously approved these changes Jan 10, 2023
Copy link
Contributor

@mkurapov mkurapov left a comment

Choose a reason for hiding this comment

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

Looks good with the latest changes, just need to resolve merge conflicts and good to go

packages/open-payments/src/types.ts Outdated Show resolved Hide resolved
mkurapov
mkurapov previously approved these changes Jan 10, 2023
Copy link
Contributor

@wilsonianb wilsonianb left a comment

Choose a reason for hiding this comment

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

Sorry, I gave you more conflicts to resolve...

@raducristianpopa
Copy link
Member Author

Resolved the conflicts.

My final thoughts:

  • I tend to think that we should keep both changes to CreateBody and CreateQuoteOptions in order to be type consistent (from schema to service level)
  • the only drawback at the current moment is that we can not enforce the type changes for the GraphQL schema
  • when and if the @oneOf directive will be available, I think it will fulfill our needs for CreateQuoteInput

@mkurapov mkurapov merged commit 30977a6 into main Jan 13, 2023
@mkurapov mkurapov deleted the radup--810 branch January 13, 2023 08:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
pkg: backend Changes in the backend package. type: source Changes business logic type: tests Testing related
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Enforce create quote args with types (open-payments): create quote
3 participants