From 874df48f364349ede63cf8e11409ecfdea8ff635 Mon Sep 17 00:00:00 2001 From: jericho Date: Thu, 1 Aug 2024 10:09:50 -0400 Subject: [PATCH 01/20] Enhance extension methods --- Source/ZoomNet/Extensions/Internal.cs | 81 +++++++++++++++++++++++++-- 1 file changed, 77 insertions(+), 4 deletions(-) diff --git a/Source/ZoomNet/Extensions/Internal.cs b/Source/ZoomNet/Extensions/Internal.cs index 61b62734..ab169d60 100644 --- a/Source/ZoomNet/Extensions/Internal.cs +++ b/Source/ZoomNet/Extensions/Internal.cs @@ -21,7 +21,6 @@ using ZoomNet.Json; using ZoomNet.Models; using ZoomNet.Utilities; -using static ZoomNet.Utilities.DiagnosticHandler; namespace ZoomNet { @@ -37,6 +36,7 @@ internal enum UnixTimePrecision } private static readonly DateTime EPOCH = new(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc); + private static readonly int DEFAULT_DEGREE_OF_PARALLELISM = Environment.ProcessorCount > 1 ? Environment.ProcessorCount / 2 : 1; /// /// Converts a 'unix time', which is expressed as the number of seconds (or milliseconds) since @@ -518,6 +518,10 @@ internal static T GetPropertyValue(this JsonElement element, string[] names) return element.GetPropertyValue(names, default, true); } + internal static Task ForEachAsync(this IEnumerable items, Func> action) => ForEachAsync(items, action, DEFAULT_DEGREE_OF_PARALLELISM); + + internal static Task ForEachAsync(this IEnumerable items, Func> action) => ForEachAsync(items, action, DEFAULT_DEGREE_OF_PARALLELISM); + internal static async Task ForEachAsync(this IEnumerable items, Func> action, int maxDegreeOfParalellism) { var allTasks = new List>(); @@ -543,6 +547,35 @@ internal static async Task ForEachAsync(this IEnumerable< return results; } + internal static async Task ForEachAsync(this IEnumerable items, Func> action, int maxDegreeOfParalellism) + { + var allTasks = new List>(); + using var throttler = new SemaphoreSlim(initialCount: maxDegreeOfParalellism); + foreach (var (item, index) in items.Select((value, i) => (value, i))) + { + await throttler.WaitAsync(); + allTasks.Add( + Task.Run(async () => + { + try + { + return await action(item, index).ConfigureAwait(false); + } + finally + { + throttler.Release(); + } + })); + } + + var results = await Task.WhenAll(allTasks).ConfigureAwait(false); + return results; + } + + internal static Task ForEachAsync(this IEnumerable items, Func action) => ForEachAsync(items, action, DEFAULT_DEGREE_OF_PARALLELISM); + + internal static Task ForEachAsync(this IEnumerable items, Func action) => ForEachAsync(items, action, DEFAULT_DEGREE_OF_PARALLELISM); + internal static async Task ForEachAsync(this IEnumerable items, Func action, int maxDegreeOfParalellism) { var allTasks = new List(); @@ -567,6 +600,30 @@ internal static async Task ForEachAsync(this IEnumerable items, Func(this IEnumerable items, Func action, int maxDegreeOfParalellism) + { + var allTasks = new List(); + using var throttler = new SemaphoreSlim(initialCount: maxDegreeOfParalellism); + foreach (var (item, index) in items.Select((value, i) => (value, i))) + { + await throttler.WaitAsync(); + allTasks.Add( + Task.Run(async () => + { + try + { + await action(item, index).ConfigureAwait(false); + } + finally + { + throttler.Release(); + } + })); + } + + await Task.WhenAll(allTasks).ConfigureAwait(false); + } + /// /// Gets the attribute of the specified type. /// @@ -644,8 +701,8 @@ internal static IEnumerable> ParseQuerystring(this internal static DiagnosticInfo GetDiagnosticInfo(this IResponse response) { - var diagnosticId = response.Message.RequestMessage.Headers.GetValue(DIAGNOSTIC_ID_HEADER_NAME); - DiagnosticsInfo.TryGetValue(diagnosticId, out DiagnosticInfo diagnosticInfo); + var diagnosticId = response.Message.RequestMessage.Headers.GetValue(DiagnosticHandler.DIAGNOSTIC_ID_HEADER_NAME); + DiagnosticHandler.DiagnosticsInfo.TryGetValue(diagnosticId, out DiagnosticInfo diagnosticInfo); return diagnosticInfo; } @@ -889,6 +946,11 @@ internal static string ToExactLength(this string source, int totalWidth, string return result; } + internal static bool IsNullableType(this Type type) + { + return type.IsGenericType && type.GetGenericTypeDefinition() == typeof(Nullable<>); + } + /// Asynchronously converts the JSON encoded content and convert it to an object of the desired type. /// The response model to deserialize into. /// The content. @@ -1102,8 +1164,10 @@ private static T GetPropertyValue(this JsonElement element, string[] names, T }; } - if (typeOfT.IsGenericType && typeOfT.GetGenericTypeDefinition() == typeof(Nullable<>)) + if (typeOfT.IsNullableType()) { + if (property.Value.ValueKind == JsonValueKind.Null) return (T)default; + var underlyingType = Nullable.GetUnderlyingType(typeOfT); var getElementValue = typeof(Internal) .GetMethod(nameof(GetElementValue), BindingFlags.Static | BindingFlags.NonPublic) @@ -1114,6 +1178,8 @@ private static T GetPropertyValue(this JsonElement element, string[] names, T if (typeOfT.IsArray) { + if (property.Value.ValueKind == JsonValueKind.Null) return (T)default; + var elementType = typeOfT.GetElementType(); var getElementValue = typeof(Internal) .GetMethod(nameof(GetElementValue), BindingFlags.Static | BindingFlags.NonPublic) @@ -1136,6 +1202,13 @@ private static T GetElementValue(this JsonElement element) { var typeOfT = typeof(T); + if (element.ValueKind == JsonValueKind.Null) + { + return typeOfT.IsNullableType() + ? (T)default + : throw new Exception($"JSON contains a null value but {typeOfT.FullName} is not nullable"); + } + return typeOfT switch { Type boolType when boolType == typeof(bool) => (T)(object)element.GetBoolean(), From 5f1aeea3a8210912c6fb650989ce24f52c2b50f7 Mon Sep 17 00:00:00 2001 From: jericho Date: Thu, 1 Aug 2024 10:10:16 -0400 Subject: [PATCH 02/20] XML comment ot explain that ZoomClient should be long lived. --- Source/ZoomNet/ZoomClient.cs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/Source/ZoomNet/ZoomClient.cs b/Source/ZoomNet/ZoomClient.cs index 5d8c6426..e644e9e2 100644 --- a/Source/ZoomNet/ZoomClient.cs +++ b/Source/ZoomNet/ZoomClient.cs @@ -15,6 +15,14 @@ namespace ZoomNet /// /// REST client for interacting with Zoom's API. /// + /// + /// Don't be fooled by the fact that this class implements the IDisposable interface: it is not meant to be short-lived and instantiated with every request. + /// It is meant to be long-lived and re-used throughout the life of an application. + /// The reason is: we use Microsoft's HttpClient to dispatch requests which itself is meant to be long-lived and re-used. + /// Instantiating an HttpClient class for every request will exhaust the number of sockets available under heavy loads and will result in SocketException errors. + /// + /// See this discussion for more information about managing the lifetime of your client instance. + /// public class ZoomClient : IZoomClient, IDisposable { #region FIELDS From e87032187d2d96e73ad91e9eb848d3c53dfd45cd Mon Sep 17 00:00:00 2001 From: jericho Date: Sat, 3 Aug 2024 09:38:28 -0400 Subject: [PATCH 03/20] Add "Async" postfix to CreateTemplateFromExistingMeeting Resolves #356 --- Source/ZoomNet/Resources/IMeetings.cs | 2 +- Source/ZoomNet/Resources/Meetings.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Source/ZoomNet/Resources/IMeetings.cs b/Source/ZoomNet/Resources/IMeetings.cs index 3b291b56..286c75df 100644 --- a/Source/ZoomNet/Resources/IMeetings.cs +++ b/Source/ZoomNet/Resources/IMeetings.cs @@ -629,7 +629,7 @@ public interface IMeetings /// Indicates whether an existing meeting template from the same meeting should be overwritten or not. /// Cancellation token. /// The template ID. - Task CreateTemplateFromExistingMeeting(string userId, long meetingId, string templateName, bool saveRecurrence = false, bool overwrite = false, CancellationToken cancellationToken = default); + Task CreateTemplateFromExistingMeetingAsync(string userId, long meetingId, string templateName, bool saveRecurrence = false, bool overwrite = false, CancellationToken cancellationToken = default); /// /// Get a meeting's closed caption token. diff --git a/Source/ZoomNet/Resources/Meetings.cs b/Source/ZoomNet/Resources/Meetings.cs index dae44e89..0c51d9cc 100644 --- a/Source/ZoomNet/Resources/Meetings.cs +++ b/Source/ZoomNet/Resources/Meetings.cs @@ -1072,7 +1072,7 @@ public Task InviteParticipantsAsync(long meetingId, IEnumerable emailAdd } /// - public Task CreateTemplateFromExistingMeeting(string userId, long meetingId, string templateName, bool saveRecurrence = false, bool overwrite = false, CancellationToken cancellationToken = default) + public Task CreateTemplateFromExistingMeetingAsync(string userId, long meetingId, string templateName, bool saveRecurrence = false, bool overwrite = false, CancellationToken cancellationToken = default) { var data = new JsonObject { From da6daddcd6b717a9e2c0e84a823bf596179642aa Mon Sep 17 00:00:00 2001 From: jericho Date: Sun, 4 Aug 2024 10:53:53 -0400 Subject: [PATCH 04/20] Refresh build script including changes for GitVersion 6.0 --- GitVersion.yml | 27 +-------------------------- build.cake | 14 +++++++------- 2 files changed, 8 insertions(+), 33 deletions(-) diff --git a/GitVersion.yml b/GitVersion.yml index c43da67e..89ee6ad4 100644 --- a/GitVersion.yml +++ b/GitVersion.yml @@ -1,26 +1 @@ -mode: ContinuousDelivery -branches: - master: - regex: (master|main) - mode: ContinuousDelivery - tag: - increment: Patch - prevent-increment-of-merged-branch-version: true - track-merge-target: false - feature: - regex: feature(s)?[/-] - mode: ContinuousDeployment - develop: - regex: dev(elop)?(ment)?$ - mode: ContinuousDeployment - tag: beta - hotfix: - regex: hotfix(es)?[/-] - mode: ContinuousDeployment - tag: hotfix - release: - regex: release(s)?[/-] - mode: ContinuousDeployment - tag: rc -ignore: - sha: [] +workflow: GitFlow/v1 # https://github.com/GitTools/GitVersion/blob/main/docs/input/docs/reference/configuration.md#snippet-/docs/workflows/GitFlow/v1.yml diff --git a/build.cake b/build.cake index 3befc266..98dcb0b9 100644 --- a/build.cake +++ b/build.cake @@ -1,10 +1,10 @@ // Install tools. -#tool dotnet:?package=GitVersion.Tool&version=5.12.0 +#tool dotnet:?package=GitVersion.Tool&version=6.0.0 #tool dotnet:?package=coveralls.net&version=4.0.1 -#tool nuget:https://f.feedz.io/jericho/jericho/nuget/?package=GitReleaseManager&version=0.17.0-collaborators0007 -#tool nuget:?package=ReportGenerator&version=5.3.7 +#tool nuget:https://f.feedz.io/jericho/jericho/nuget/?package=GitReleaseManager&version=0.17.0-collaborators0008 +#tool nuget:?package=ReportGenerator&version=5.3.8 #tool nuget:?package=xunit.runner.console&version=2.9.0 -#tool nuget:?package=CodecovUploader&version=0.7.3 +#tool nuget:?package=CodecovUploader&version=0.8.0 // Install addins. #addin nuget:?package=Cake.Coveralls&version=4.0.0 @@ -124,7 +124,7 @@ Setup(context => milestone = versionInfo.MajorMinorPatch; Information("Building version {0} of {1} ({2}, {3}) using version {4} of Cake", - versionInfo.LegacySemVerPadded, + versionInfo.FullSemVer, libraryName, configuration, target, @@ -252,7 +252,7 @@ Task("Build") NoRestore = true, MSBuildSettings = new DotNetMSBuildSettings { - Version = versionInfo.LegacySemVerPadded, + Version = versionInfo.SemVer, AssemblyVersion = versionInfo.MajorMinorPatch, FileVersion = versionInfo.MajorMinorPatch, InformationalVersion = versionInfo.InformationalVersion, @@ -378,7 +378,7 @@ Task("Create-NuGet-Package") MSBuildSettings = new DotNetMSBuildSettings { PackageReleaseNotes = releaseNotesUrl, - PackageVersion = versionInfo.LegacySemVerPadded + PackageVersion = versionInfo.SemVer } }; From bb208fb3e92e5b42fdebc53aadaa633567cfeb93 Mon Sep 17 00:00:00 2001 From: jericho Date: Thu, 1 Aug 2024 15:03:41 -0400 Subject: [PATCH 05/20] Add DownloadAccessToken to the Recording model class --- Source/ZoomNet/Models/Recording.cs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/Source/ZoomNet/Models/Recording.cs b/Source/ZoomNet/Models/Recording.cs index ce43721e..8cb5a77e 100644 --- a/Source/ZoomNet/Models/Recording.cs +++ b/Source/ZoomNet/Models/Recording.cs @@ -75,5 +75,13 @@ public class Recording /// Gets or sets the type of recorded meeting or webinar. [JsonPropertyName("type")] public RecordingType Type { get; set; } + + /// Gets or sets the cloud recording's passcode to be used in the URL. Directly splice this recording's passcode in play_url or share_url with ?pwd= to access and play. + [JsonPropertyName("recording_play_passcode")] + public string PlayPasscode { get; set; } + + /// The token to download the meeting's recording. + [JsonPropertyName("download_access_token")] + public string DownloadAccessToken { get; set; } } } From 9ab19c77256941021c724e542e496b6e1e37cbfb Mon Sep 17 00:00:00 2001 From: jericho Date: Thu, 1 Aug 2024 15:05:51 -0400 Subject: [PATCH 06/20] Modify CloudRecordings.GetRecordingInformationAsync to retrieve the access token and modify DownloadFileAsync to accept a token --- .../Tests/CloudRecordings.cs | 2 +- Source/ZoomNet/Extensions/Public.cs | 5 ++-- Source/ZoomNet/Resources/CloudRecordings.cs | 25 +++++++++---------- Source/ZoomNet/Resources/ICloudRecordings.cs | 6 +++-- 4 files changed, 20 insertions(+), 18 deletions(-) diff --git a/Source/ZoomNet.IntegrationTests/Tests/CloudRecordings.cs b/Source/ZoomNet.IntegrationTests/Tests/CloudRecordings.cs index c412f7b2..3c3bb71e 100644 --- a/Source/ZoomNet.IntegrationTests/Tests/CloudRecordings.cs +++ b/Source/ZoomNet.IntegrationTests/Tests/CloudRecordings.cs @@ -21,7 +21,7 @@ public async Task RunAsync(User myUser, string[] myPermissions, IZoomClient clie // DOWNLOAD THE FILES foreach (var recordingFile in paginatedRecordings.Records.SelectMany(record => record.RecordingFiles)) { - var stream = await client.CloudRecordings.DownloadFileAsync(recordingFile, cancellationToken).ConfigureAwait(false); + var stream = await client.CloudRecordings.DownloadFileAsync(recordingFile, null, cancellationToken).ConfigureAwait(false); await log.WriteLineAsync($"Downloaded {recordingFile.DownloadUrl}").ConfigureAwait(false); } } diff --git a/Source/ZoomNet/Extensions/Public.cs b/Source/ZoomNet/Extensions/Public.cs index 237e9e2b..df34b309 100644 --- a/Source/ZoomNet/Extensions/Public.cs +++ b/Source/ZoomNet/Extensions/Public.cs @@ -289,13 +289,14 @@ public static Event VerifyAndParseEventWebhook(this IWebhookParser parser, strin /// /// The cloud recordings resource. /// The recording file to download. + /// The access token. If this parameter is omitted, the token for the current oAuth session will be used. /// Cancellation token. /// /// The containing the file. /// - public static Task DownloadFileAsync(this ICloudRecordings cloudRecordingsResource, RecordingFile recordingFile, CancellationToken cancellationToken = default) + public static Task DownloadFileAsync(this ICloudRecordings cloudRecordingsResource, RecordingFile recordingFile, string accessToken = null, CancellationToken cancellationToken = default) { - return cloudRecordingsResource.DownloadFileAsync(recordingFile.DownloadUrl, cancellationToken); + return cloudRecordingsResource.DownloadFileAsync(recordingFile.DownloadUrl, accessToken, cancellationToken); } /// diff --git a/Source/ZoomNet/Resources/CloudRecordings.cs b/Source/ZoomNet/Resources/CloudRecordings.cs index 1895ad03..004b5106 100644 --- a/Source/ZoomNet/Resources/CloudRecordings.cs +++ b/Source/ZoomNet/Resources/CloudRecordings.cs @@ -95,16 +95,13 @@ public Task> GetRecordingsForU .AsPaginatedResponseWithTokenAndDateRange("meetings"); } - /// - /// Retrieve the recording information (which includes recording files and audio files) for a meeting or webinar. - /// - /// The meeting Id or UUID. - /// The cancellation token. - /// Details of recordings made for a particular meeding or webinar. - public Task GetRecordingInformationAsync(string meetingId, CancellationToken cancellationToken = default) + /// + public Task GetRecordingInformationAsync(string meetingId, int ttl = 60 * 5, CancellationToken cancellationToken = default) { return _client .GetAsync($"meetings/{meetingId}/recordings") + .WithArgument("include_fields", "download_access_token") + .WithArgument("ttl", ttl) .WithCancellationToken(cancellationToken) .AsObject(); } @@ -359,7 +356,7 @@ public Task RejectRegistrantsAsync(long meetingId, IEnumerable registran } /// - public async Task DownloadFileAsync(string downloadUrl, CancellationToken cancellationToken = default) + public Task DownloadFileAsync(string downloadUrl, string accessToken = null, CancellationToken cancellationToken = default) { // Prepare the request var request = _client @@ -367,6 +364,12 @@ public async Task DownloadFileAsync(string downloadUrl, CancellationToke .WithOptions(completeWhen: HttpCompletionOption.ResponseHeadersRead) .WithCancellationToken(cancellationToken); + // Use an alternate token if provided. Otherwise, the oAuth token for the current session will be used. + if (!string.IsNullOrEmpty(accessToken)) + { + request = request.WithBearerAuthentication(accessToken); + } + // Remove our custom error handler because it reads the content of the response to check for error messages returned from the Zoom API. // This is problematic because we want the content of the response to be streamed. request = request.WithoutFilter(); @@ -376,11 +379,7 @@ public async Task DownloadFileAsync(string downloadUrl, CancellationToke request = request.WithFilter(new DefaultErrorFilter()); // Dispatch the request - var response = await request - .AsStream() - .ConfigureAwait(false); - - return response; + return request.AsStream(); } private Task UpdateRegistrantsStatusAsync(long meetingId, IEnumerable registrantIds, string status, CancellationToken cancellationToken = default) diff --git a/Source/ZoomNet/Resources/ICloudRecordings.cs b/Source/ZoomNet/Resources/ICloudRecordings.cs index 1c4f7fe8..d6d59a83 100644 --- a/Source/ZoomNet/Resources/ICloudRecordings.cs +++ b/Source/ZoomNet/Resources/ICloudRecordings.cs @@ -50,9 +50,10 @@ public interface ICloudRecordings /// Retrieve the recording information (which includes recording files and audio files) for a meeting or webinar. /// /// The meeting Id or UUID. + /// The download access token Time to Live (TTL) value expressed in seconds. The default value is 604800 which also is the max value allowed by Zoom. This default value represents 7 days (60 seconds * 60 minutes * 24 hours * 7 days = 604,800). /// The cancellation token. /// Details of recording made for a particular meeding or webinar. - Task GetRecordingInformationAsync(string meetingId, CancellationToken cancellationToken = default); + Task GetRecordingInformationAsync(string meetingId, int ttl = 60 * 5, CancellationToken cancellationToken = default); /// /// Delete recording files for a meeting. @@ -208,10 +209,11 @@ public interface ICloudRecordings /// Download the recording file. /// /// The URL of the recording file to download. + /// The token to download the recording file. If this value is omitted, the token for the current session will be used. /// Cancellation token. /// /// The containing the file. /// - Task DownloadFileAsync(string downloadUrl, CancellationToken cancellationToken = default); + Task DownloadFileAsync(string downloadUrl, string accessToken = null, CancellationToken cancellationToken = default); } } From fc1edf3872f335a860304db1c1c9b030e0e166d6 Mon Sep 17 00:00:00 2001 From: jericho Date: Thu, 1 Aug 2024 15:08:20 -0400 Subject: [PATCH 07/20] Fix XML comment --- Source/ZoomNet/Models/Recording.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Source/ZoomNet/Models/Recording.cs b/Source/ZoomNet/Models/Recording.cs index 8cb5a77e..3a8b29a5 100644 --- a/Source/ZoomNet/Models/Recording.cs +++ b/Source/ZoomNet/Models/Recording.cs @@ -80,7 +80,7 @@ public class Recording [JsonPropertyName("recording_play_passcode")] public string PlayPasscode { get; set; } - /// The token to download the meeting's recording. + /// Gets or sets the token to download the meeting's recording. [JsonPropertyName("download_access_token")] public string DownloadAccessToken { get; set; } } From f90771b3aa82d16cb1bdffb5258336a228fe6810 Mon Sep 17 00:00:00 2001 From: jericho Date: Thu, 1 Aug 2024 15:09:29 -0400 Subject: [PATCH 08/20] Fix unit test --- Source/ZoomNet.UnitTests/Resources/CloudRecordingsTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Source/ZoomNet.UnitTests/Resources/CloudRecordingsTests.cs b/Source/ZoomNet.UnitTests/Resources/CloudRecordingsTests.cs index 498d8e59..4518c2a9 100644 --- a/Source/ZoomNet.UnitTests/Resources/CloudRecordingsTests.cs +++ b/Source/ZoomNet.UnitTests/Resources/CloudRecordingsTests.cs @@ -561,7 +561,7 @@ public async Task DownloadFileAsync_with_expired_token() var recordings = new CloudRecordings(client); // Act - var result = await recordings.DownloadFileAsync(downloadUrl, CancellationToken.None).ConfigureAwait(true); + var result = await recordings.DownloadFileAsync(downloadUrl, null, CancellationToken.None).ConfigureAwait(true); // Assert mockHttp.VerifyNoOutstandingExpectation(); From 8876f4d6f4283a02703dce8d9bb3fc2592b83e78 Mon Sep 17 00:00:00 2001 From: jericho Date: Thu, 1 Aug 2024 15:15:00 -0400 Subject: [PATCH 09/20] Change the default TTL to 7 days (rather than 5 minutes) --- Source/ZoomNet/Resources/ICloudRecordings.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Source/ZoomNet/Resources/ICloudRecordings.cs b/Source/ZoomNet/Resources/ICloudRecordings.cs index d6d59a83..f9ef5897 100644 --- a/Source/ZoomNet/Resources/ICloudRecordings.cs +++ b/Source/ZoomNet/Resources/ICloudRecordings.cs @@ -53,7 +53,7 @@ public interface ICloudRecordings /// The download access token Time to Live (TTL) value expressed in seconds. The default value is 604800 which also is the max value allowed by Zoom. This default value represents 7 days (60 seconds * 60 minutes * 24 hours * 7 days = 604,800). /// The cancellation token. /// Details of recording made for a particular meeding or webinar. - Task GetRecordingInformationAsync(string meetingId, int ttl = 60 * 5, CancellationToken cancellationToken = default); + Task GetRecordingInformationAsync(string meetingId, int ttl = 604800, CancellationToken cancellationToken = default); /// /// Delete recording files for a meeting. From e5ebd60b3b27e81098e07b9088d7ba30dc05473a Mon Sep 17 00:00:00 2001 From: jericho Date: Mon, 5 Aug 2024 20:46:18 -0400 Subject: [PATCH 10/20] use inheritdoc instead of duplicating XML comment --- Source/ZoomNet/Resources/CloudRecordings.cs | 28 ++------------------- 1 file changed, 2 insertions(+), 26 deletions(-) diff --git a/Source/ZoomNet/Resources/CloudRecordings.cs b/Source/ZoomNet/Resources/CloudRecordings.cs index 004b5106..df068597 100644 --- a/Source/ZoomNet/Resources/CloudRecordings.cs +++ b/Source/ZoomNet/Resources/CloudRecordings.cs @@ -32,19 +32,7 @@ internal CloudRecordings(Pathoschild.Http.Client.IClient client) _client = client; } - /// - /// Retrieve all cloud recordings for a user. - /// - /// The user Id or email address. - /// Indicates if you want to list recordings from trash. - /// The start date. - /// The end date. - /// The number of records returned within a single API call. - /// The current page number of returned records. - /// The cancellation token. - /// - /// An array of recordings. - /// + /// [Obsolete("Zoom is in the process of deprecating the \"page number\" and \"page count\" fields.")] public Task> GetRecordingsForUserAsync(string userId, bool queryTrash = false, DateTime? from = null, DateTime? to = null, int recordsPerPage = 30, int page = 1, CancellationToken cancellationToken = default) { @@ -64,19 +52,7 @@ public Task> GetRecordingsForU .AsPaginatedResponseWithTokenAndDateRange("meetings"); } - /// - /// Retrieve all cloud recordings for a user. - /// - /// The user Id or email address. - /// Indicates if you want to list recordings from trash. - /// The start date. - /// The end date. - /// The number of records returned within a single API call. - /// The paging token. - /// The cancellation token. - /// - /// An array of recordings. - /// + /// public Task> GetRecordingsForUserAsync(string userId, bool queryTrash = false, DateTime? from = null, DateTime? to = null, int recordsPerPage = 30, string pagingToken = null, CancellationToken cancellationToken = default) { if (recordsPerPage < 1 || recordsPerPage > 300) From b726580964254725ad4059095c38c1011b649b85 Mon Sep 17 00:00:00 2001 From: jericho Date: Mon, 5 Aug 2024 20:46:33 -0400 Subject: [PATCH 11/20] Improve XML comment --- Source/ZoomNet/Resources/ICloudRecordings.cs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Source/ZoomNet/Resources/ICloudRecordings.cs b/Source/ZoomNet/Resources/ICloudRecordings.cs index f9ef5897..610ee66e 100644 --- a/Source/ZoomNet/Resources/ICloudRecordings.cs +++ b/Source/ZoomNet/Resources/ICloudRecordings.cs @@ -44,6 +44,10 @@ public interface ICloudRecordings /// /// An array of recordings. /// + /// + /// The Zoom API response omits DownloadAccessToken, Password as well as the audio files. + /// To get the missing fields, use the method. + /// Task> GetRecordingsForUserAsync(string userId, bool queryTrash = false, DateTime? from = null, DateTime? to = null, int recordsPerPage = 30, string pagingToken = null, CancellationToken cancellationToken = default); /// From ca093ddd60502dd504b3d602d1495346f5c88220 Mon Sep 17 00:00:00 2001 From: jericho Date: Mon, 5 Aug 2024 20:48:41 -0400 Subject: [PATCH 12/20] Refresh build script to fix problem when calculating PackageVersion --- build.cake | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/build.cake b/build.cake index 98dcb0b9..10dff405 100644 --- a/build.cake +++ b/build.cake @@ -1,5 +1,5 @@ // Install tools. -#tool dotnet:?package=GitVersion.Tool&version=6.0.0 +#tool dotnet:?package=GitVersion.Tool&version=6.0.1 #tool dotnet:?package=coveralls.net&version=4.0.1 #tool nuget:https://f.feedz.io/jericho/jericho/nuget/?package=GitReleaseManager&version=0.17.0-collaborators0008 #tool nuget:?package=ReportGenerator&version=5.3.8 @@ -378,7 +378,7 @@ Task("Create-NuGet-Package") MSBuildSettings = new DotNetMSBuildSettings { PackageReleaseNotes = releaseNotesUrl, - PackageVersion = versionInfo.SemVer + PackageVersion = $"{versionInfo.MajorMinorPatch}-{versionInfo.PreReleaseLabel}-{versionInfo.CommitsSinceVersionSource}".Replace('_', '-') } }; From f66a8b5da2b3dacd9d27b7b83f22080a877f925e Mon Sep 17 00:00:00 2001 From: jericho Date: Mon, 5 Aug 2024 20:56:09 -0400 Subject: [PATCH 13/20] Unit test to demonstrate the problem with alternate token being ignored when invoking CloudRecordings.DownloadFileAsync --- .../Resources/CloudRecordingsTests.cs | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/Source/ZoomNet.UnitTests/Resources/CloudRecordingsTests.cs b/Source/ZoomNet.UnitTests/Resources/CloudRecordingsTests.cs index 4518c2a9..2f9a0fd2 100644 --- a/Source/ZoomNet.UnitTests/Resources/CloudRecordingsTests.cs +++ b/Source/ZoomNet.UnitTests/Resources/CloudRecordingsTests.cs @@ -568,5 +568,39 @@ public async Task DownloadFileAsync_with_expired_token() mockHttp.VerifyNoOutstandingRequest(); result.ShouldNotBeNull(); } + + /// + /// While researching Issue 348, it was discovered + /// that the OAuth session token took precendence over the alternate token specified when invoking DownloadFileAsync. + /// This unit test was used to demonstrate the problem and ultimately to demonstrate that it was fixed. + /// + [Fact] + public async Task DownloadFileAsync_with_alternate_token() + { + // Arrange + var downloadUrl = "http://dummywebsite.com/dummyfile.txt"; + + var mockHttp = new MockHttpMessageHandler(); + mockHttp + .Expect(HttpMethod.Get, downloadUrl) + .With(request => request.Headers.Authorization?.Parameter == "alternate_download_token") + .Respond(HttpStatusCode.OK, new StringContent("This is the content of the file")); + + var connectionInfo = OAuthConnectionInfo.ForServerToServer( + "MyClientId", + "MyClientSecret", + "MyAccountId", + accessToken: "Expired_token"); + + var client = new ZoomClient(connectionInfo, mockHttp.ToHttpClient(), null, null); + + // Act + var result = await client.CloudRecordings.DownloadFileAsync(downloadUrl, "alternate_download_token", CancellationToken.None).ConfigureAwait(true); + + // Assert + mockHttp.VerifyNoOutstandingExpectation(); + mockHttp.VerifyNoOutstandingRequest(); + result.ShouldNotBeNull(); + } } } From 11f4f56a5ea0942875f68cc8ff5ce585293717ce Mon Sep 17 00:00:00 2001 From: jericho Date: Mon, 5 Aug 2024 20:57:26 -0400 Subject: [PATCH 14/20] Fix the problem with alternate token being ignored when invoking CloudRecordings.DownloadFileAsync --- Source/ZoomNet/Utilities/OAuthTokenHandler.cs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/Source/ZoomNet/Utilities/OAuthTokenHandler.cs b/Source/ZoomNet/Utilities/OAuthTokenHandler.cs index 05f088d5..fbed88c3 100644 --- a/Source/ZoomNet/Utilities/OAuthTokenHandler.cs +++ b/Source/ZoomNet/Utilities/OAuthTokenHandler.cs @@ -55,7 +55,11 @@ public OAuthTokenHandler(OAuthConnectionInfo connectionInfo, HttpClient httpClie /// The HTTP request. public void OnRequest(IRequest request) { - request.WithBearerAuthentication(Token); + // Do not overwrite the Authorization header if it is already set. + // One example where it's important to preserve the Authorization + // header is CloudRecordings.DownloadFileAsync where developers can + // specify a custom bearer token which must take precedence. + if (request.Message.Headers.Authorization == null) request.WithBearerAuthentication(Token); } /// Method invoked just after the HTTP response is received. This method can modify the incoming HTTP response. From 9959af4d30ae1488f1c185dc13ff0eb57aa4b853 Mon Sep 17 00:00:00 2001 From: jericho Date: Mon, 5 Aug 2024 20:59:51 -0400 Subject: [PATCH 15/20] Improve integration test to specify download token when invoking CloudRecordings.DownloadFileAsync --- .../Tests/CloudRecordings.cs | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/Source/ZoomNet.IntegrationTests/Tests/CloudRecordings.cs b/Source/ZoomNet.IntegrationTests/Tests/CloudRecordings.cs index 3c3bb71e..21b4f4ad 100644 --- a/Source/ZoomNet.IntegrationTests/Tests/CloudRecordings.cs +++ b/Source/ZoomNet.IntegrationTests/Tests/CloudRecordings.cs @@ -18,11 +18,23 @@ public async Task RunAsync(User myUser, string[] myPermissions, IZoomClient clie var paginatedRecordings = await client.CloudRecordings.GetRecordingsForUserAsync(myUser.Id, false, null, null, 100, null, cancellationToken).ConfigureAwait(false); await log.WriteLineAsync($"User {myUser.Id} has {paginatedRecordings.TotalRecords} recordings stored in the cloud").ConfigureAwait(false); + // GROUP THE RECORDINGS BY MEETING + var recordingFilesGroupedByMeeting = paginatedRecordings.Records + .SelectMany(record => record.RecordingFiles) + .GroupBy(recordingFile => recordingFile.MeetingId); + // DOWNLOAD THE FILES - foreach (var recordingFile in paginatedRecordings.Records.SelectMany(record => record.RecordingFiles)) + foreach (var group in recordingFilesGroupedByMeeting) { - var stream = await client.CloudRecordings.DownloadFileAsync(recordingFile, null, cancellationToken).ConfigureAwait(false); - await log.WriteLineAsync($"Downloaded {recordingFile.DownloadUrl}").ConfigureAwait(false); + const int ttl = 60 * 5; // 5 minutes + var recordingInfo = await client.CloudRecordings.GetRecordingInformationAsync(group.Key, ttl, cancellationToken).ConfigureAwait(false); + + foreach (var recordingFile in group) + { + var stream = await client.CloudRecordings.DownloadFileAsync(recordingFile.DownloadUrl, recordingInfo.DownloadAccessToken, cancellationToken).ConfigureAwait(false); + await log.WriteLineAsync($"Downloaded {recordingFile.DownloadUrl}").ConfigureAwait(false); + } + } } } From f53c088977716bdf5edd2be8f5fb7ab626d3665d Mon Sep 17 00:00:00 2001 From: jericho Date: Mon, 5 Aug 2024 21:00:54 -0400 Subject: [PATCH 16/20] Remove duplicate XML comment --- Source/ZoomNet/Resources/CloudRecordings.cs | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/Source/ZoomNet/Resources/CloudRecordings.cs b/Source/ZoomNet/Resources/CloudRecordings.cs index df068597..7f1e6463 100644 --- a/Source/ZoomNet/Resources/CloudRecordings.cs +++ b/Source/ZoomNet/Resources/CloudRecordings.cs @@ -82,15 +82,7 @@ public Task GetRecordingInformationAsync(string meetingId, int ttl = .AsObject(); } - /// - /// Delete recording files for a meeting. - /// - /// The meeting Id or UUID. - /// When true, files are deleted permanently; when false, files are moved to the trash. - /// The cancellation token. - /// - /// The async task. - /// + /// public Task DeleteRecordingFilesAsync(string meetingId, bool deletePermanently = false, CancellationToken cancellationToken = default) { return _client From ee25e3ca307ccdb7c5d51df6406a66d5a8f94ccd Mon Sep 17 00:00:00 2001 From: jericho Date: Mon, 5 Aug 2024 21:01:16 -0400 Subject: [PATCH 17/20] Remove duplicate XML comment --- Source/ZoomNet/Resources/CloudRecordings.cs | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/Source/ZoomNet/Resources/CloudRecordings.cs b/Source/ZoomNet/Resources/CloudRecordings.cs index 7f1e6463..a4566c03 100644 --- a/Source/ZoomNet/Resources/CloudRecordings.cs +++ b/Source/ZoomNet/Resources/CloudRecordings.cs @@ -92,16 +92,7 @@ public Task DeleteRecordingFilesAsync(string meetingId, bool deletePermanently = .AsMessage(); } - /// - /// Delete a specific recording file for a meeting. - /// - /// The meeting Id or UUID. - /// The recording file id. - /// When true, files are deleted permanently; when false, files are moved to the trash. - /// The cancellation token. - /// - /// The async task. - /// + /// public Task DeleteRecordingFileAsync(string meetingId, string recordingFileId, bool deletePermanently = false, CancellationToken cancellationToken = default) { return _client From 1afe8b2bd3038cda9e2b604bbf11c186b88d9cc5 Mon Sep 17 00:00:00 2001 From: jericho Date: Mon, 5 Aug 2024 21:02:20 -0400 Subject: [PATCH 18/20] Remove duplicate XML comment --- Source/ZoomNet/Resources/CloudRecordings.cs | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/Source/ZoomNet/Resources/CloudRecordings.cs b/Source/ZoomNet/Resources/CloudRecordings.cs index a4566c03..3c0696f5 100644 --- a/Source/ZoomNet/Resources/CloudRecordings.cs +++ b/Source/ZoomNet/Resources/CloudRecordings.cs @@ -102,15 +102,7 @@ public Task DeleteRecordingFileAsync(string meetingId, string recordingFileId, b .AsMessage(); } - /// - /// Recover all deleted recording files of a meeting from trash. - /// - /// The meeting Id or UUID. - /// The cancellation token. - /// - /// The async task. - /// - /// Zoom allows recording files to be recovered from trash for up to 30 days from deletion date. + /// public Task RecoverRecordingFilesAsync(string meetingId, CancellationToken cancellationToken = default) { return _client From f21114d5ddbbb4e88349ed0ee41198f209e756cb Mon Sep 17 00:00:00 2001 From: jericho Date: Mon, 5 Aug 2024 21:17:00 -0400 Subject: [PATCH 19/20] Use FullSemVer to calculate PackageVersion --- build.cake | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.cake b/build.cake index 10dff405..6c0b4024 100644 --- a/build.cake +++ b/build.cake @@ -378,7 +378,7 @@ Task("Create-NuGet-Package") MSBuildSettings = new DotNetMSBuildSettings { PackageReleaseNotes = releaseNotesUrl, - PackageVersion = $"{versionInfo.MajorMinorPatch}-{versionInfo.PreReleaseLabel}-{versionInfo.CommitsSinceVersionSource}".Replace('_', '-') + PackageVersion = versionInfo.FullSemVer } }; From e8d0ae4cd0e8c3c593ef239df06d8b07ab21b297 Mon Sep 17 00:00:00 2001 From: jericho Date: Wed, 7 Aug 2024 11:37:47 -0400 Subject: [PATCH 20/20] Remove '+' character from SemVer when generating nuget package --- build.cake | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.cake b/build.cake index 6c0b4024..f5742da2 100644 --- a/build.cake +++ b/build.cake @@ -378,7 +378,7 @@ Task("Create-NuGet-Package") MSBuildSettings = new DotNetMSBuildSettings { PackageReleaseNotes = releaseNotesUrl, - PackageVersion = versionInfo.FullSemVer + PackageVersion = versionInfo.FullSemVer.Replace('+', '-') } };