viceroypenguin / Going.Plaid

Plaid API .NET library
https://plaid.com
MIT License
72 stars 33 forks source link

Getting Webhook Object #203

Closed SudoWatson closed 8 months ago

SudoWatson commented 8 months ago

I'm struggling to get webhooks setup with my application. I can setup my webhook url with any items created and Plaid will make attempts to send webhooks to the endpoint I directed it to, however, I can't get the webhook object that Plaid sends to my API. I tried using WebhookBase as the object type it receives in my API endpoint method:

[HttpPost("webhook")]
public async Task<IActionResult> ReceiveWebhook(WebhookBase webhook)

But anytime Plaid sends a webhook to this endpoint I get the following error:

System.NotSupportedException: Deserialization of types without a parameterless constructor, a singular parameterized constructor, or a parameterized constructor annotated with 'JsonConstructorAttribute' is not supported. Type 'Going.Plaid.WebhookBase'. Path: $ | LineNumber: 0 | BytePositionInLine: 1.
       ---> System.NotSupportedException: Deserialization of types without a parameterless constructor, a singular parameterized constructor, or a parameterized constructor annotated with 'JsonConstructorAttribute' is not supported. Type 'Going.Plaid.WebhookBase'.
         --- End of inner exception stack trace ---
         at System.Text.Json.ThrowHelper.ThrowNotSupportedException(ReadStack& state, Utf8JsonReader& reader, NotSupportedException ex)
         at System.Text.Json.Serialization.Converters.ObjectDefaultConverter`1.OnTryRead(Utf8JsonReader& reader, Type typeToConvert, JsonSerializerOptions options, ReadStack& state, T& value)
         at System.Text.Json.Serialization.JsonConverter`1.TryRead(Utf8JsonReader& reader, Type typeToConvert, JsonSerializerOptions options, ReadStack& state, T& value, Boolean& isPopulatedValue)
         at System.Text.Json.Serialization.JsonConverter`1.ReadCore(Utf8JsonReader& reader, JsonSerializerOptions options, ReadStack& state)
         at System.Text.Json.Serialization.Metadata.JsonTypeInfo`1.ContinueDeserialize(ReadBufferState& bufferState, JsonReaderState& jsonReaderState, ReadStack& readStack)
         at System.Text.Json.Serialization.Metadata.JsonTypeInfo`1.DeserializeAsync(Stream utf8Json, CancellationToken cancellationToken)
         at System.Text.Json.Serialization.Metadata.JsonTypeInfo`1.DeserializeAsObjectAsync(Stream utf8Json, CancellationToken cancellationToken)
         at Microsoft.AspNetCore.Mvc.Formatters.SystemTextJsonInputFormatter.ReadRequestBodyAsync(InputFormatterContext context, Encoding encoding)
         at Microsoft.AspNetCore.Mvc.Formatters.SystemTextJsonInputFormatter.ReadRequestBodyAsync(InputFormatterContext context, Encoding encoding)
         at Microsoft.AspNetCore.Mvc.ModelBinding.Binders.BodyModelBinder.BindModelAsync(ModelBindingContext bindingContext)
         at Microsoft.AspNetCore.Mvc.ModelBinding.ParameterBinder.BindModelAsync(ActionContext actionContext, IModelBinder modelBinder, IValueProvider valueProvider, ParameterDescriptor parameter, ModelMetadata metadata, Object value, Object container)
         at Microsoft.AspNetCore.Mvc.Controllers.ControllerBinderDelegateProvider.<>c__DisplayClass0_0.<<CreateBinderDelegate>g__Bind|0>d.MoveNext()
      --- End of stack trace from previous location ---
         at Microsoft.AspNetCore.Mvc.Infrastructure.ControllerActionInvoker.<InvokeInnerFilterAsync>g__Awaited|13_0(ControllerActionInvoker invoker, Task lastTask, State next, Scope scope, Object state, Boolean isCompleted)
         at Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker.<InvokeFilterPipelineAsync>g__Awaited|20_0(ResourceInvoker invoker, Task lastTask, State next, Scope scope, Object state, Boolean isCompleted)
         at Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker.<InvokeAsync>g__Logged|17_1(ResourceInvoker invoker)
         at Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker.<InvokeAsync>g__Logged|17_1(ResourceInvoker invoker)
         at Microsoft.AspNetCore.Routing.EndpointMiddleware.<Invoke>g__AwaitRequestTask|7_0(Endpoint endpoint, Task requestTask, ILogger logger)
         at Microsoft.AspNetCore.Authorization.AuthorizationMiddleware.Invoke(HttpContext context)
         at Swashbuckle.AspNetCore.SwaggerUI.SwaggerUIMiddleware.Invoke(HttpContext httpContext)
         at Swashbuckle.AspNetCore.Swagger.SwaggerMiddleware.Invoke(HttpContext httpContext, ISwaggerProvider swaggerProvider)
         at Microsoft.AspNetCore.Diagnostics.DeveloperExceptionPageMiddlewareImpl.Invoke(HttpContext context)

In my API project Program.cs I added the PlaidConverters to hopefully fix this error but it results in the same error

builder.Services.Configure<JsonOptions>(o => {
    o.SerializerOptions.AddPlaidConverters();
});

What am I missing to be able to get the webhook that Plaid sends?

viceroypenguin commented 8 months ago

You're close. Instead of Configure<JsonOptions>(), do:

builder.Services
  .AddMvc()
  .AddJsonOptions(o =>
    o.SerializerOptions.AddPlaidConverters());
SudoWatson commented 8 months ago

That worked, thank you! However, I am coming across a new problem now. I'm creating a new item which causes Plaid to send 2 webhooks, HistoricalUpdate and InitialUpdate both for Transaction. The HistoricalUpdate webhook request Plaid sends looks like this

{
    "environment": "sandbox",
    "error": null,
    "item_id": "wag1dBKe9rfqA9gzb98EH63JedemqycrQ8VWn",
    "new_transactions": 190,
    "webhook_code": "HISTORICAL_UPDATE",
    "webhook_type": "TRANSACTIONS"
}

Which makes sense to me and is what I am expecting. However, apparently the webhook converting is attempting to convert this into a ProcessorHistoricalUpdateWebhook instead of just a HistoricalUpdateWebhook which is what I would expect it to be. Because it's trying to convert it to this processor webhook, it's expecting an account_id instead of the item_id and then causes a bad request

{
    "type": "https://tools.ietf.org/html/rfc9110#section-15.5.1",
    "title": "One or more validation errors occurred.",
    "status": 400,
    "errors": {
        "AccountId": [
            "The AccountId field is required."
        ]
    },
    "traceId": "00-dcac59d3186de5168ae06b3de4d80a02-a07f664856c46a02-00"
}

Is there something I'm supposed to do to get it to not convert to a Processor webhook or am I going about something wrong? Looking at the src/Plaid/Converters/WebhookBaseConverter.Map.cs file it doesn't seem like there is a way to get a non-processor webhook for webhooks like the Initial Update, Historical Update, Sync Updates Available, etc.

viceroypenguin commented 8 months ago

Looks like this is a bug in the spec, specifically here, where it states that the account_id (aka AccountId) is a required field. I've opened a ticket on the spec repo; they should get an update this month, at which point I can update Going.Plaid and we can get this fixed for you.

At the moment, your alternative is to use JsonElement as the controller parameter type, check the webhook_type field, and handle accordingly.

Sorry for the issue.

SudoWatson commented 8 months ago

I could definitely be wrong about anything I'm about to say but I don't see that as being the issue, at least with my understanding of Plaid.

Looking at the Plaid documentation Processor webhooks are in fact a thing and they do indeed take an account_id instead of the item_id. If I was receiving a processor webhook from Plaid, I would definitely expect it to contain the correct data. However, these Processor webhooks are only sent to Plaid processing partners. I am not a Plaid processing partner and thus am not expecting any processor webhooks from them. What I am doing is creating a new item with the transaction product, which starts Plaid on retrieving the transaction data. When Plaid has received some of this transaction data, I am expecting to receive a regular InitialUpdate Transaction webhook from them. The request they send me does line up with what I expect to be receiving from them as the regular initial update webhook, since I am not a processing partner. Thus, I expect the webhook base to be interpreted as a InitialUpdateWebhook, which contains the fields the expected request is in fact sending. However, the webhook I receive is instead being interpreted as a ProcessorInitialUpdateWebhook, which is a webhook I should never be receiving. The converter seems to be mapped in a way that I will never be able to convert a webhook to the correct InitialUpdateWebhook, as it only converts to the processor version if it exists.

While I could definitely be wrong about any of this, my understanding leads me to think that the spec lines up correctly with the Plaid documentation and what I expect it to be. The only unexpected thing is Going.Plaid converting the webhook to a processor version of the webhook when I have not received a processor's version of a webhook.

I'll definitely look into implementing that alternative for now.

viceroypenguin commented 8 months ago

Ah, you're right. My apologies. I've not worked with the webhooks at all, I only added them upon request, and did not research deeply. I assumed that all (type, code) were unique, which does not appear to be the case.

Looks like I really need to have two different webhook bases, one for regular webhooks and one for processor webhooks. The (type, code) would be unique for each base type, I'm guessing. This will be a bit more involved, so I will have to spend some time researching how to parse the openapi for this.

SudoWatson commented 8 months ago

From what I can tell, the Code and Type are indeed unique for either regular or processor webhooks. However, it seems the processor webhooks are only sent to Plaid processing partners like Stripe and Square, where Plaid integrates with their systems, and are never sent to regular applications that use Plaid. Even the Plaid Academy Webhooks Tutorial makes it seem like going on just the Type and Code is sufficient. Unless any Plaid partners are using this package, I would just change the Mapping to map to the regular webhooks instead of the processor ones. But that is of course not my decision to make and totally reasonable to go the extra mile

viceroypenguin commented 8 months ago

I know that at least one of the processor partners is using Going.Plaid, because I've gotten bug reports from them. As such, I'll do my best to accommodate them.

SudoWatson commented 8 months ago

I see, in that case absolutely do accommodate them. I've overwritten the converter class to use the non-processor varients for my application, so I've got it working at the moment.

viceroypenguin commented 8 months ago

Fixed with v6.14.0. You can use WebhookBase and it'll select only regular webhook types now. Anyone using processor webhooks will have to switch to ProcessorWebhookBase.