Skip to content

Commit

Permalink
Merge pull request #208 from Nexmo/webhook_utility
Browse files Browse the repository at this point in the history
Adding utility methods for parsing webhooks
  • Loading branch information
slorello89 authored Jul 17, 2020
2 parents e506f2f + ce00e8e commit 5462d81
Show file tree
Hide file tree
Showing 3 changed files with 353 additions and 5 deletions.
112 changes: 112 additions & 0 deletions Nexmo.Api.Test.Unit/WebhookParserTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
using Microsoft.Extensions.Primitives;
using Newtonsoft.Json;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net.Http;
using System.Text;
using System.Threading.Tasks;

using Xunit;
namespace Nexmo.Api.Test.Unit
{
public class WebhookParserTests
{
[Theory]
[InlineData("application/x-www-form-urlencoded; charset=UTF-8")]
[InlineData("application/json; charset=UTF-8")]
[InlineData("application/trash")]
public void TestParseStream(string contentType)
{
string contentString = "";
if(contentType == "application/x-www-form-urlencoded; charset=UTF-8")
{
contentString = "foo-bar=foo%20bar";
}
else
{
contentString = "{\"foo-bar\":\"foo bar\"}";
}
byte[] contentToBytes = Encoding.UTF8.GetBytes(contentString);
MemoryStream stream = new MemoryStream(contentToBytes);
try
{
var output = Utility.WebhookParser.ParseWebhook<Foo>(stream, contentType);
Assert.Equal("foo bar", output.FooBar);
}
catch (Exception)
{
if (contentType != "application/trash")
throw;
}
}

[Theory]
[InlineData("application/x-www-form-urlencoded")]
[InlineData("application/json")]
[InlineData("application/trash")]
public void TestParseHttpRequestMessage(string contentType)
{
string contentString = "";
if (contentType == "application/x-www-form-urlencoded")
{
contentString = "foo-bar=foo%20bar";
}
else
{
contentString = "{\"foo-bar\":\"foo bar\"}";
}
byte[] contentToBytes = Encoding.UTF8.GetBytes(contentString);
var request = new HttpRequestMessage();
request.Content = new ByteArrayContent(contentToBytes);
request.Content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue(contentType);
try
{
var output = Utility.WebhookParser.ParseWebhook<Foo>(request);
Assert.Equal("foo bar", output.FooBar);
}
catch (Exception)
{
if (contentType != "application/trash")
throw;
}
}

[Fact]
public void TestParseHttpRequestContentWithBadlyEscapedUrl()
{
var contentType = "application/x-www-form-urlencoded";
var contentString = "foo-bar=foo bar";
byte[] contentToBytes = Encoding.UTF8.GetBytes(contentString);
var request = new HttpRequestMessage();
request.Content = new ByteArrayContent(contentToBytes);
request.Content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue(contentType);
var output = Utility.WebhookParser.ParseWebhook<Foo>(request);
Assert.Equal("foo bar", output.FooBar);
}

[Fact]
public void TestParseQueryArgsMvcLegacy()
{
var queryArgs = new List<KeyValuePair<string, string>>();
queryArgs.Add(new KeyValuePair<string, string>("foo-bar", "foo" ));
var output = Utility.WebhookParser.ParseQueryNameValuePairs<Foo>(queryArgs);
Assert.Equal("foo", output.FooBar);
}

[Fact]
public void TestParseQueryArgsCore()
{
var queryArgs = new List<KeyValuePair<string, StringValues>>();
queryArgs.Add(new KeyValuePair<string, StringValues>("foo-bar", "foo"));
var output = Utility.WebhookParser.ParseQuery<Foo>(queryArgs);
}

public class Foo
{
[JsonProperty("foo-bar")]
public string FooBar { get; set; }
}
}
}
150 changes: 150 additions & 0 deletions Nexmo.Api/Utility/WebhookParser.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net.Http;
using System.Security.Cryptography.X509Certificates;
using System.Text;
using System.Threading.Tasks;
using Newtonsoft.Json;
using Nexmo.Api.Messaging;
using System.Net;

namespace Nexmo.Api.Utility
{
public sealed class WebhookParser
{

/// <summary>
/// Parses the stream into the given type
/// This is anticipated to be used by ASP.NET Core MVC/API requests where the content is in the Body of the inbound request
/// </summary>
/// <typeparam name="T"></typeparam>
/// <param name="request"></param>
/// <param name="contentType">The content type of the request, must be of the type application/json or application/x-www-form-urlencoded</param>
/// <exception cref="ArgumentException">Thrown if Content type does not contain application/json or application/x-www-form-urlencoded</exception>
/// <returns></returns>
public static async Task<T> ParseWebhookAsync<T>(Stream content, string contentType)
{
if (contentType.Contains("application/json"))
{
using (var reader = new StreamReader(content))
{
var json = await reader.ReadToEndAsync();
return JsonConvert.DeserializeObject<T>(json);
}
}
else if (contentType.Contains("application/x-www-form-urlencoded"))
{
using(var reader = new StreamReader(content))
{
var contentString = await reader.ReadToEndAsync();
return ParseUrlFormString<T>(contentString);
}
}
else
{
throw new ArgumentException("Invalid Content Type");
}
}


/// <summary>
/// Parses the HttpRequestMessag's content into the given type
/// </summary>
/// <typeparam name="T"></typeparam>
/// <param name="request"></param>
/// <exception cref="ArgumentException">Thrown if Content type does not contain application/json or application/x-www-form-urlencoded</exception>
/// <returns></returns>
public static async Task<T> ParseWebhookAsync<T>(HttpRequestMessage request)
{
if(request.Content.Headers.GetValues("Content-Type").First().Contains("application/json"))
{
var json = await request.Content.ReadAsStringAsync();
return JsonConvert.DeserializeObject<T>(json);
}
else if (request.Content.Headers.GetValues("Content-Type").First().Contains("application/x-www-form-urlencoded"))
{
var contentString = await request.Content.ReadAsStringAsync();
return ParseUrlFormString<T>(contentString);
}
throw new ArgumentException("Invalid Content Type");
}

/// <summary>
/// Synchronous Implementation of ParseWebhook
/// Meant to be called from ASP.NET Core MVC with only the Content of the body
/// </summary>
/// <typeparam name="T"></typeparam>
/// <param name="content"></param>
/// <returns></returns>
public static T ParseWebhook<T>(Stream content, string contentType)
{
return ParseWebhookAsync<T>(content,contentType).Result;
}

/// <summary>
/// Synchronous implementation of the ParseWehbook method, meant to be called from
/// Legacy ASP.NET Web Api with an HttpRequestMessage
/// </summary>
/// <typeparam name="T"></typeparam>
/// <param name="request"></param>
/// <returns></returns>
public static T ParseWebhook<T>(HttpRequestMessage request)
{
return ParseWebhookAsync<T>(request).Result;
}

/// <summary>
/// Used to Parse Query parameters into a givent type
/// This Method will convert the string pairs into a dictioanry and then use
/// Newtonsoft to convert the pairs to JSON - finally resolving the object from JSON
/// </summary>
/// <typeparam name="T"></typeparam>
/// <param name="requestData"></param>
/// <returns></returns>
public static T ParseQuery<T>(IEnumerable<KeyValuePair<string, Microsoft.Extensions.Primitives.StringValues>> requestData)
{
var dict = requestData.ToDictionary(x => x.Key, x => x.Value.ToString());
var json = JsonConvert.SerializeObject(dict);
return JsonConvert.DeserializeObject<T>(json);
}


/// <summary>
/// Used to Parse Query parameters into a givent type
/// This Method will convert the string pairs into a dictioanry and then use
/// Newtonsoft to convert the pairs to JSON - finally resolving the object from JSON
/// </summary>
/// <typeparam name="T"></typeparam>
/// <param name="requestData"></param>
/// <returns></returns>
public static T ParseQueryNameValuePairs<T>(IEnumerable<KeyValuePair<string, string>> requestData)
{
var dict = requestData.ToDictionary(x => x.Key, x => x.Value);
var json = JsonConvert.SerializeObject(dict);
return JsonConvert.DeserializeObject<T>(json);
}

/// <summary>
/// Parses URL content into the given object type
/// This uses Newtonsoft.Json - abnormally named fields should be decorated with the 'JsonPropertyAttribute'
/// </summary>
/// <typeparam name="T"></typeparam>
/// <param name="contentString"></param>
/// <returns></returns>
public static T ParseUrlFormString<T>(string contentString)
{
var split_parameters = contentString.Split(new[] { '&' });
var content_dictonary = new Dictionary<string, string>();
foreach (var param in split_parameters)
{
var split = param.Split('=');
content_dictonary.Add(split[0], WebUtility.UrlDecode(split[1]));
}
var json = JsonConvert.SerializeObject(content_dictonary);
return JsonConvert.DeserializeObject<T>(json);
}

}
}
96 changes: 91 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -221,28 +221,114 @@ var response = nexmoClient.SmsClient.SendAnSms(new Nexmo.Api.Messaging.SendSmsRe

Use [Nexmo's SMS API][doc_sms] to receive an SMS message. Assumes your Nexmo endpoint is configured.

The best method for receiving an SMS will vary depending on whether you configure your webhooks to be GET or POST. Will Also Vary between ASP.NET MVC and ASP.NET MVC Core.

#### ASP.NET MVC Core

##### GET

```csharp
[HttpGet("webhooks/inbound-sms")]
public async Task<IActionResult> InboundSmsGet()
{
var inbound = Nexmo.Api.Utility.WebhookParser.ParseQuery<InboundSms>(Request.Query);
return NoContent();
}
```

##### POST

```csharp
[HttpPost("webhooks/inbound-sms")]
public IActionResult InboundSms([FromBody]InboundSms sms)
public async Task<IActionResult> InboundSms()
{
Console.WriteLine($"SMS Received with message: {sms.Text}");
var inbound = await Nexmo.Api.Utility.WebhookParser.ParseWebhookAsync<InboundSms>(Request.Body, Request.ContentType);
return NoContent();
}
```

#### ASP.NET MVC

##### GET

```csharp
[HttpGet]
[Route("webhooks/inbound-sms")]
public async Task<HttpResponseMessage> GetInbound()
{
var inboundSms = WebhookParser.ParseQueryNameValuePairs<InboundSms>(Request.GetQueryNameValuePairs());
return new HttpResponseMessage(HttpStatusCode.NoContent);
}
```

##### POST

```csharp
[HttpPost]
[Route("webhooks/inbound-sms")]
public async Task<HttpResponseMessage> PostInbound()
{
var inboundSms = WebhookParser.ParseWebhook<InboundSms>(Request);
return new HttpResponseMessage(HttpStatusCode.NoContent);
}
```

### Receiving a Message Delivery Receipt

Use [Nexmo's SMS API][doc_sms] to receive an SMS delivery receipt. Assumes your Nexmo endpoint is configured.

The best method for receiving an SMS will vary depending on whether you configure your webhooks to be GET or POST. Will Also Vary between ASP.NET MVC and ASP.NET MVC Core.

#### ASP.NET MVC Core

##### GET

```csharp
[HttpGet("webhooks/dlr")]
public async Task<IActionResult> InboundSmsGet()
{
var dlr = Nexmo.Api.Utility.WebhookParser.ParseQuery<DeliveryReceipt>(Request.Query);
return NoContent();
}
```

##### POST

```csharp
[HttpGet("webhooks/delivery-receipt")]
public IActionResult DeliveryReceipt([FromQuery]DeliveryReceipt dlr)
[HttpPost("webhooks/dlr")]
public async Task<IActionResult> InboundSms()
{
Console.WriteLine($"Delivery receipt received for messages {dlr.MessageId}");
var dlr = await Nexmo.Api.Utility.WebhookParser.ParseWebhookAsync<DeliveryReceipt>(Request.Body, Request.ContentType);
return NoContent();
}
```

#### ASP.NET MVC

##### GET

```csharp
[HttpGet]
[Route("webhooks/dlr")]
public async Task<HttpResponseMessage> GetInbound()
{
var dlr = WebhookParser.ParseQueryNameValuePairs<DeliveryReceipt>(Request.GetQueryNameValuePairs());
return new HttpResponseMessage(HttpStatusCode.NoContent);
}
```

##### POST

```csharp
[HttpPost]
[Route("webhooks/dlr")]
public async Task<HttpResponseMessage> PostInbound()
{
var dlr = WebhookParser.ParseWebhook<DeliveryReceipt>(Request);
return new HttpResponseMessage(HttpStatusCode.NoContent);
}
```

### Redacting a message

Use [Nexmo's Redact API][doc_redact] to redact a SMS message.
Expand Down

0 comments on commit 5462d81

Please # to comment.