Skip to content

Commit

Permalink
Merge branch 'release/0.55.0'
Browse files Browse the repository at this point in the history
  • Loading branch information
Jericho committed Dec 23, 2022
2 parents 4606a16 + 12795ee commit f24f190
Show file tree
Hide file tree
Showing 29 changed files with 635 additions and 384 deletions.
134 changes: 112 additions & 22 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@

## About

The ZoomNet library simplifies connecting with the Zoom.us API and interacting with the various endpoints. Additionaly, the library includes a parser that allows you to process inbound webhook messages sent to you by the Zoom API.


## Installation

Expand Down Expand Up @@ -113,6 +115,9 @@ var refreshToken = Environment.GetEnvironmentVariable("ZOOM_OAUTH_REFRESHTOKEN",
var connectionInfo = new OAuthConnectionInfo(clientId, clientSecret, refreshToken, null,
(newRefreshToken, newAccessToken) =>
{
/*
As previously stated, it's important to preserve the refresh token.
*/
Environment.SetEnvironmentVariable("ZOOM_OAUTH_REFRESHTOKEN", newRefreshToken, EnvironmentVariableTarget.User);
});
var zoomClient = new ZoomClient(connectionInfo);
Expand All @@ -137,7 +142,7 @@ var connectionInfo = new OAuthConnectionInfo(clientId, clientSecret, accountId,
/*
Server-to-Server OAuth does not use a refresh token. That's why I used '_' as the first parameter
in this delegate declaration. Furthermore, ZoomNet will take care of getting a new access token
and to refresh it whenever it expires therefore there is no need for you to preserve the token.
and to refresh it whenever it expires therefore there is no need for you to preserve it.
In fact, this delegate is completely optional when using Server-to-Server OAuth. Feel free to pass
a null value in lieu of a delegate.
Expand All @@ -158,7 +163,6 @@ var zoomClient = new ZoomClient(connectionInfo);
Here's a basic example of a .net 6.0 API controller which parses the webhook from Zoom:
```csharp
using Microsoft.AspNetCore.Mvc;
using StrongGrid;

namespace WebApplication1.Controllers
{
Expand All @@ -167,10 +171,9 @@ namespace WebApplication1.Controllers
public class ZoomWebhooksController : ControllerBase
{
[HttpPost]
[Route("Event")]
public async Task<IActionResult> ReceiveEvent()
{
var parser = new WebhookParser();
var parser = new ZoomNet.WebhookParser();
var event = await parser.ParseEventWebhookAsync(Request.Body).ConfigureAwait(false);

// ... do something with the event ...
Expand All @@ -197,8 +200,6 @@ The `WebhookParser` class has a method called `VerifyAndParseEventWebhookAsync`w

```csharp
using Microsoft.AspNetCore.Mvc;
using StrongGrid;
using System.Security;

namespace WebApplication1.Controllers
{
Expand All @@ -209,28 +210,117 @@ namespace WebApplication1.Controllers
[HttpPost]
public async Task<IActionResult> ReceiveEvent()
{
try
{
// Get your secret token
var secretToken = "... your app's secret token ...";
// Your webhook app's secret token
var secretToken = "... your app's secret token ...";

// Get the signature and the timestamp from the request headers
// SIGNATURE_HEADER_NAME and TIMESTAMP_HEADER_NAME are two convenient constants provided by ZoomNet so you don't have to remember the actual names of the headers
var signature = Request.Headers[ZoomNet.WebhookParser.SIGNATURE_HEADER_NAME].SingleOrDefault();
var timestamp = Request.Headers[ZoomNet.WebhookParser.TIMESTAMP_HEADER_NAME].SingleOrDefault();

var parser = new ZoomNet.WebhookParser();

// The signature will be automatically validated and a security exception thrown if unable to validate
var zoomEvent = await parser.VerifyAndParseEventWebhookAsync(Request.Body, secretToken, signature, timestamp).ConfigureAwait(false);

// ... do something with the event...
// Get the signature and the timestamp from the request headers
var signature = Request.Headers[WebhookParser.SIGNATURE_HEADER_NAME].SingleOrDefault(); // SIGNATURE_HEADER_NAME is a convenient constant provided by ZoomNet so you don't have to remember the name of the header
var timestamp = Request.Headers[WebhookParser.TIMESTAMP_HEADER_NAME].SingleOrDefault(); // TIMESTAMP_HEADER_NAME is a convenient constant provided by ZoomNet so you don't have to remember the name of the header
return Ok();
}
}
}
```

### Responding to requests from Zoom to validate your webhook endpoint

// Parse the event. The signature will be automatically validated and a security exception thrown if unable to validate
var parser = new WebhookParser();
var event = await parser.VerifyAndParseEventWebhook(Request.Body, secretToken, signature, timestamp).ConfigureAwait(false);
When you initially configure the URL where you want Zoom to post the webhooks, Zoom will send a request to this URL and you are expected to respond to this validation challenge in a way that can be validated by the Zoom API. Zoom calls this a "Challenge-Response check (CRC)". Assuming this initial validation is successful, the Zoom API will repeat this validation process every 72 hours. You can of course manually craft this reponse by following Zoom's [instructions](https://marketplace.zoom.us/docs/api-reference/webhook-reference/#validate-your-webhook-endpoint).
However, if you want to avoid learning the intricacies of the reponse expected by Zoom and you simply want this response to be conveniently generated for you, ZoomNet can help!
The `EndpointUrlValidationEvent` class has a method called `GenerateUrlValidationResponse` which will generate the string that you must include in your HTTP200 response.
Here's how it works:

// ... do something with the event...
```csharp
using Microsoft.AspNetCore.Mvc;

namespace WebApplication1.Controllers
{
[Route("api/[controller]")]
[ApiController]
public class ZoomWebhooksController : ControllerBase
{
[HttpPost]
public async Task<IActionResult> ReceiveEvent()
{
// Your webhook app's secret token
var secretToken = "... your app's secret token ...";

var parser = new ZoomNet.WebhookParser();
var event = await parser.ParseEventWebhookAsync(Request.Body).ConfigureAwait(false);

var endpointUrlValidationEvent = zoomEvent as EndpointUrlValidationEvent;
var responsePayload = endpointUrlValidationEvent.GenerateUrlValidationResponse(secretToken);
return Ok(responsePayload);
}
}
}
```

### The ultimate webhook controller

Here's the "ultimate" webhook controller which combines all the above features:

```csharp
using Microsoft.AspNetCore.Mvc;

namespace WebApplication1.Controllers
{
[Route("api/[controller]")]
[ApiController]
public class ZoomWebhooksController : ControllerBase
{
[HttpPost]
public async Task<IActionResult> ReceiveEvent()
{
// Your webhook app's secret token
var secretToken = "... your app's secret token ...";

// SIGNATURE_HEADER_NAME and TIMESTAMP_HEADER_NAME are two convenient constants provided by ZoomNet so you don't have to remember the actual name of the headers
var signature = Request.Headers[ZoomNet.WebhookParser.SIGNATURE_HEADER_NAME].SingleOrDefault();
var timestamp = Request.Headers[ZoomNet.WebhookParser.TIMESTAMP_HEADER_NAME].SingleOrDefault();

var parser = new ZoomNet.WebhookParser();
Event zoomEvent;

if (!string.IsNullOrEmpty(signature) && !string.IsNullOrEmpty(timestamp))
{
try
{
zoomEvent = await parser.VerifyAndParseEventWebhookAsync(Request.Body, secretToken, signature, timestamp).ConfigureAwait(false);
}
catch (SecurityException e)
{
// Unable to validate the data. Therefore you should consider the request as suspicious
throw;
}
}
catch (SecurityException e)
else
{
// ... unable to validate the data ...
zoomEvent = await parser.ParseEventWebhookAsync(Request.Body).ConfigureAwait(false);
}

return Ok();
if (zoomEvent.EventType == EventType.EndpointUrlValidation)
{
// It's important to include the payload along with your HTTP200 response. This is how you let Zoom know that your URL is valid
var endpointUrlValidationEvent = zoomEvent as EndpointUrlValidationEvent;
var responsePayload = endpointUrlValidationEvent.GenerateUrlValidationResponse(secretToken);
return Ok(responsePayload);
}
else
{
// ... do something with the event ...
return Ok();
}
}
}
}
}
```
```
5 changes: 4 additions & 1 deletion Source/ZoomNet.IntegrationTests/Tests/Meetings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,10 @@ public async Task RunAsync(User myUser, string[] myPermissions, IZoomClient clie
});
await Task.WhenAll(cleanUpTasks).ConfigureAwait(false);

var templates = await client.Meetings.GetTemplatesAsync(myUser.Id, cancellationToken).ConfigureAwait(false);
// For an unknown reason, using myUser.Id to retrieve meeting templates causes an "Invalid token" exception.
// That's why I use "me" on the following line:
var templates = await client.Meetings.GetTemplatesAsync("me", cancellationToken).ConfigureAwait(false);
await log.WriteLineAsync($"Retrieved {templates.Length} meeting templates").ConfigureAwait(false);

var settings = new MeetingSettings()
{
Expand Down
24 changes: 4 additions & 20 deletions Source/ZoomNet.IntegrationTests/Tests/Reports.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,34 +16,18 @@ public async Task RunAsync(User myUser, string[] myPermissions, IZoomClient clie
await log.WriteLineAsync("\n***** REPORTS *****\n").ConfigureAwait(false);

//GET ALL MEETINGS
var totalMeetings = await client
.Meetings.GetAllAsync(myUser.Id, MeetingListType.Scheduled, 30, null, cancellationToken)
.ConfigureAwait(false);

var pastInstances = new List<PastInstance>();

foreach (var meeting in totalMeetings.Records)
{
var pastMeetingInstances = await client.PastMeetings.GetInstancesAsync(meeting.Id, cancellationToken);

foreach (var instance in pastMeetingInstances)
{
if (instance.StartedOn < DateTime.UtcNow.AddDays(-1) && !instance.Uuid.StartsWith("/"))
{
pastInstances.Add(instance);
}
}
}
var now = DateTime.UtcNow;
var pastMeetings = await client.Reports.GetMeetingsAsync(myUser.Id, now.Subtract(TimeSpan.FromDays(30)), now, ReportMeetingType.Past, 30, null, cancellationToken);

int totalParticipants = 0;

foreach (var meeting in pastInstances)
foreach (var meeting in pastMeetings.Records)
{
var paginatedParticipants = await client.Reports.GetMeetingParticipantsAsync(meeting.Uuid, 30, null, cancellationToken);
totalParticipants += paginatedParticipants.TotalRecords;
}

await log.WriteLineAsync($"There are {pastInstances.Count} past instances of meetings with a total of {totalParticipants} participants for this user.").ConfigureAwait(false);
await log.WriteLineAsync($"There are {pastMeetings.Records.Length} past instances of meetings with a total of {totalParticipants} participants for this user.").ConfigureAwait(false);
}
}
}
2 changes: 1 addition & 1 deletion Source/ZoomNet.UnitTests/ZoomNet.UnitTests.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.4.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.4.1" />
<PackageReference Include="Moq" Version="4.18.3" />
<PackageReference Include="RichardSzalay.MockHttp" Version="6.0.0" />
<PackageReference Include="Shouldly" Version="4.1.0" />
Expand Down
15 changes: 11 additions & 4 deletions Source/ZoomNet/Extensions/Internal.cs
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ internal enum UnixTimePrecision
Milliseconds = 1
}

private static readonly DateTime EPOCH = new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc);
private static readonly DateTime EPOCH = new(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc);

/// <summary>
/// Converts a 'unix time', which is expressed as the number of seconds (or milliseconds) since
Expand Down Expand Up @@ -161,9 +161,12 @@ internal static async Task<string> ReadAsStringAsync(this HttpContent httpConten

if (httpContent != null)
{
#if NET5_0_OR_GREATER
var contentStream = await httpContent.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
#else
var contentStream = await httpContent.ReadAsStreamAsync().ConfigureAwait(false);

if (encoding == null) encoding = httpContent.GetEncoding(Encoding.UTF8);
#endif
encoding ??= httpContent.GetEncoding(Encoding.UTF8);

// This is important: we must make a copy of the response stream otherwise we would get an
// exception on subsequent attempts to read the content of the stream
Expand All @@ -174,7 +177,11 @@ internal static async Task<string> ReadAsStringAsync(this HttpContent httpConten
ms.Position = 0;
using (var sr = new StreamReader(ms, encoding))
{
#if NET7_0_OR_GREATER
content = await sr.ReadToEndAsync(cancellationToken).ConfigureAwait(false);
#else
content = await sr.ReadToEndAsync().ConfigureAwait(false);
#endif
}

// It's important to rewind the stream
Expand Down Expand Up @@ -649,7 +656,7 @@ internal static (WeakReference<HttpRequestMessage> RequestReference, string Diag
return diagnosticInfo;
}

internal static async Task<(bool, string, int?)> GetErrorMessageAsync(this HttpResponseMessage message)
internal static async Task<(bool IsError, string ErrorMessage, int? ErrorCode)> GetErrorMessageAsync(this HttpResponseMessage message)
{
// Default error code
int? errorCode = null;
Expand Down
4 changes: 4 additions & 0 deletions Source/ZoomNet/Json/WebhookEventConverter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,10 @@ public override Event Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSe
webinarParticipantLeftEvent.Participant = payloadJsonProperty.GetProperty("object/participant", true).Value.ToObject<WebhookParticipant>();
webHookEvent = webinarParticipantLeftEvent;
break;
case EventType.EndpointUrlValidation:
var endpointUrlValidationEvent = payloadJsonProperty.ToObject<EndpointUrlValidationEvent>(options);
webHookEvent = endpointUrlValidationEvent;
break;
default:
throw new Exception($"{eventType} is an unknown event type");
}
Expand Down
5 changes: 5 additions & 0 deletions Source/ZoomNet/Json/ZoomNetJsonSerializerContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,7 @@ namespace ZoomNet.Json
[JsonSerializable(typeof(ZoomNet.Models.RegistrationQuestionsForWebinar))]
[JsonSerializable(typeof(ZoomNet.Models.RegistrationType))]
[JsonSerializable(typeof(ZoomNet.Models.ReportMeetingParticipant))]
[JsonSerializable(typeof(ZoomNet.Models.ReportMeetingType))]
[JsonSerializable(typeof(ZoomNet.Models.ReportParticipant))]
[JsonSerializable(typeof(ZoomNet.Models.Role))]
[JsonSerializable(typeof(ZoomNet.Models.RoleInPurchaseProcess))]
Expand Down Expand Up @@ -171,6 +172,7 @@ namespace ZoomNet.Json
[JsonSerializable(typeof(ZoomNet.Models.WaitingRoomSettings))]
[JsonSerializable(typeof(ZoomNet.Models.WebhookParticipant))]
[JsonSerializable(typeof(ZoomNet.Models.Webhooks.AppDeauthorizedEvent), TypeInfoPropertyName = "WebhooksAppDeauthorizedEvent")]
[JsonSerializable(typeof(ZoomNet.Models.Webhooks.EndpointUrlValidationEvent), TypeInfoPropertyName = "WebhooksEndpointUrlValidationEvent")]
[JsonSerializable(typeof(ZoomNet.Models.Webhooks.Event), TypeInfoPropertyName = "WebhooksEvent")]
[JsonSerializable(typeof(ZoomNet.Models.Webhooks.EventType), TypeInfoPropertyName = "WebhooksEventType")]
[JsonSerializable(typeof(ZoomNet.Models.Webhooks.MeetingCreatedEvent), TypeInfoPropertyName = "WebhooksMeetingCreatedEvent")]
Expand Down Expand Up @@ -360,6 +362,7 @@ namespace ZoomNet.Json
[JsonSerializable(typeof(ZoomNet.Models.RegistrationQuestionsForWebinar[]))]
[JsonSerializable(typeof(ZoomNet.Models.RegistrationType[]))]
[JsonSerializable(typeof(ZoomNet.Models.ReportMeetingParticipant[]))]
[JsonSerializable(typeof(ZoomNet.Models.ReportMeetingType[]))]
[JsonSerializable(typeof(ZoomNet.Models.ReportParticipant[]))]
[JsonSerializable(typeof(ZoomNet.Models.Role[]))]
[JsonSerializable(typeof(ZoomNet.Models.RoleInPurchaseProcess[]))]
Expand Down Expand Up @@ -394,6 +397,7 @@ namespace ZoomNet.Json
[JsonSerializable(typeof(ZoomNet.Models.WaitingRoomSettings[]))]
[JsonSerializable(typeof(ZoomNet.Models.WebhookParticipant[]))]
[JsonSerializable(typeof(ZoomNet.Models.Webhooks.AppDeauthorizedEvent[]), TypeInfoPropertyName = "WebhooksAppDeauthorizedEventArray")]
[JsonSerializable(typeof(ZoomNet.Models.Webhooks.EndpointUrlValidationEvent[]), TypeInfoPropertyName = "WebhooksEndpointUrlValidationEventArray")]
[JsonSerializable(typeof(ZoomNet.Models.Webhooks.Event[]), TypeInfoPropertyName = "WebhooksEventArray")]
[JsonSerializable(typeof(ZoomNet.Models.Webhooks.EventType[]), TypeInfoPropertyName = "WebhooksEventTypeArray")]
[JsonSerializable(typeof(ZoomNet.Models.Webhooks.MeetingCreatedEvent[]), TypeInfoPropertyName = "WebhooksMeetingCreatedEventArray")]
Expand Down Expand Up @@ -503,6 +507,7 @@ namespace ZoomNet.Json
[JsonSerializable(typeof(ZoomNet.Models.RegistrationCustomQuestionTypeForWebinar?))]
[JsonSerializable(typeof(ZoomNet.Models.RegistrationField?))]
[JsonSerializable(typeof(ZoomNet.Models.RegistrationType?))]
[JsonSerializable(typeof(ZoomNet.Models.ReportMeetingType?))]
[JsonSerializable(typeof(ZoomNet.Models.RoleInPurchaseProcess?))]
[JsonSerializable(typeof(ZoomNet.Models.ScreenshareContentType?))]
[JsonSerializable(typeof(ZoomNet.Models.StreamingService?))]
Expand Down
4 changes: 2 additions & 2 deletions Source/ZoomNet/Models/ChatChannelRole.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,13 @@ public enum ChatChannelRole
Owner,

/// <summary>
/// Past
/// Past.
/// </summary>
[EnumMember(Value = "admin")]
Administrator,

/// <summary>
/// PastOne
/// PastOne.
/// </summary>
[EnumMember(Value = "member")]
Member
Expand Down
Loading

0 comments on commit f24f190

Please sign in to comment.