Skip to content

Commit

Permalink
Merge branch 'feature/download_access_token' into develop
Browse files Browse the repository at this point in the history
Resolves #348
  • Loading branch information
Jericho committed Aug 7, 2024
2 parents da6dadd + f21114d commit e8bed59
Show file tree
Hide file tree
Showing 8 changed files with 93 additions and 78 deletions.
18 changes: 15 additions & 3 deletions Source/ZoomNet.IntegrationTests/Tests/CloudRecordings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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, 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);
}

}
}
}
Expand Down
36 changes: 35 additions & 1 deletion Source/ZoomNet.UnitTests/Resources/CloudRecordingsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -561,7 +561,41 @@ 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();
mockHttp.VerifyNoOutstandingRequest();
result.ShouldNotBeNull();
}

/// <summary>
/// While researching <a href="https://github.com/Jericho/ZoomNet/issues/348">Issue 348</a>, 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.
/// </summary>
[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();
Expand Down
5 changes: 3 additions & 2 deletions Source/ZoomNet/Extensions/Public.cs
Original file line number Diff line number Diff line change
Expand Up @@ -289,13 +289,14 @@ public static Event VerifyAndParseEventWebhook(this IWebhookParser parser, strin
/// </summary>
/// <param name="cloudRecordingsResource">The cloud recordings resource.</param>
/// <param name="recordingFile">The recording file to download.</param>
/// <param name="accessToken">The access token. If this parameter is omitted, the token for the current oAuth session will be used.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>
/// The <see cref="Stream"/> containing the file.
/// </returns>
public static Task<Stream> DownloadFileAsync(this ICloudRecordings cloudRecordingsResource, RecordingFile recordingFile, CancellationToken cancellationToken = default)
public static Task<Stream> 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);
}

/// <summary>
Expand Down
8 changes: 8 additions & 0 deletions Source/ZoomNet/Models/Recording.cs
Original file line number Diff line number Diff line change
Expand Up @@ -75,5 +75,13 @@ public class Recording
/// <summary>Gets or sets the type of recorded meeting or webinar.</summary>
[JsonPropertyName("type")]
public RecordingType Type { get; set; }

/// <summary>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.</summary>
[JsonPropertyName("recording_play_passcode")]
public string PlayPasscode { get; set; }

/// <summary>Gets or sets the token to download the meeting's recording.</summary>
[JsonPropertyName("download_access_token")]
public string DownloadAccessToken { get; set; }
}
}
84 changes: 17 additions & 67 deletions Source/ZoomNet/Resources/CloudRecordings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -32,19 +32,7 @@ internal CloudRecordings(Pathoschild.Http.Client.IClient client)
_client = client;
}

/// <summary>
/// Retrieve all cloud recordings for a user.
/// </summary>
/// <param name="userId">The user Id or email address.</param>
/// <param name="queryTrash">Indicates if you want to list recordings from trash.</param>
/// <param name="from">The start date.</param>
/// <param name="to">The end date.</param>
/// <param name="recordsPerPage">The number of records returned within a single API call.</param>
/// <param name="page">The current page number of returned records.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>
/// An array of <see cref="Recording">recordings</see>.
/// </returns>
/// <inheritdoc/>
[Obsolete("Zoom is in the process of deprecating the \"page number\" and \"page count\" fields.")]
public Task<PaginatedResponseWithTokenAndDateRange<Recording>> GetRecordingsForUserAsync(string userId, bool queryTrash = false, DateTime? from = null, DateTime? to = null, int recordsPerPage = 30, int page = 1, CancellationToken cancellationToken = default)
{
Expand All @@ -64,19 +52,7 @@ public Task<PaginatedResponseWithTokenAndDateRange<Recording>> GetRecordingsForU
.AsPaginatedResponseWithTokenAndDateRange<Recording>("meetings");
}

/// <summary>
/// Retrieve all cloud recordings for a user.
/// </summary>
/// <param name="userId">The user Id or email address.</param>
/// <param name="queryTrash">Indicates if you want to list recordings from trash.</param>
/// <param name="from">The start date.</param>
/// <param name="to">The end date.</param>
/// <param name="recordsPerPage">The number of records returned within a single API call.</param>
/// <param name="pagingToken">The paging token.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>
/// An array of <see cref="Recording">recordings</see>.
/// </returns>
/// <inheritdoc/>
public Task<PaginatedResponseWithTokenAndDateRange<Recording>> 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)
Expand All @@ -95,29 +71,18 @@ public Task<PaginatedResponseWithTokenAndDateRange<Recording>> GetRecordingsForU
.AsPaginatedResponseWithTokenAndDateRange<Recording>("meetings");
}

/// <summary>
/// Retrieve the recording information (which includes recording files and audio files) for a meeting or webinar.
/// </summary>
/// <param name="meetingId">The meeting Id or UUID.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>Details of recordings made for a particular meeding or webinar.</returns>
public Task<Recording> GetRecordingInformationAsync(string meetingId, CancellationToken cancellationToken = default)
/// <inheritdoc/>
public Task<Recording> 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<Recording>();
}

/// <summary>
/// Delete recording files for a meeting.
/// </summary>
/// <param name="meetingId">The meeting Id or UUID.</param>
/// <param name="deletePermanently">When true, files are deleted permanently; when false, files are moved to the trash.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>
/// The async task.
/// </returns>
/// <inheritdoc/>
public Task DeleteRecordingFilesAsync(string meetingId, bool deletePermanently = false, CancellationToken cancellationToken = default)
{
return _client
Expand All @@ -127,16 +92,7 @@ public Task DeleteRecordingFilesAsync(string meetingId, bool deletePermanently =
.AsMessage();
}

/// <summary>
/// Delete a specific recording file for a meeting.
/// </summary>
/// <param name="meetingId">The meeting Id or UUID.</param>
/// <param name="recordingFileId">The recording file id.</param>
/// <param name="deletePermanently">When true, files are deleted permanently; when false, files are moved to the trash.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>
/// The async task.
/// </returns>
/// <inheritdoc/>
public Task DeleteRecordingFileAsync(string meetingId, string recordingFileId, bool deletePermanently = false, CancellationToken cancellationToken = default)
{
return _client
Expand All @@ -146,15 +102,7 @@ public Task DeleteRecordingFileAsync(string meetingId, string recordingFileId, b
.AsMessage();
}

/// <summary>
/// Recover all deleted recording files of a meeting from trash.
/// </summary>
/// <param name="meetingId">The meeting Id or UUID.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>
/// The async task.
/// </returns>
/// <remarks>Zoom allows recording files to be recovered from trash for up to 30 days from deletion date.</remarks>
/// <inheritdoc/>
public Task RecoverRecordingFilesAsync(string meetingId, CancellationToken cancellationToken = default)
{
return _client
Expand Down Expand Up @@ -359,14 +307,20 @@ public Task RejectRegistrantsAsync(long meetingId, IEnumerable<string> registran
}

/// <inheritdoc/>
public async Task<Stream> DownloadFileAsync(string downloadUrl, CancellationToken cancellationToken = default)
public Task<Stream> DownloadFileAsync(string downloadUrl, string accessToken = null, CancellationToken cancellationToken = default)
{
// Prepare the request
var request = _client
.GetAsync(downloadUrl)
.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<ZoomErrorHandler>();
Expand All @@ -376,11 +330,7 @@ public async Task<Stream> 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<string> registrantIds, string status, CancellationToken cancellationToken = default)
Expand Down
10 changes: 8 additions & 2 deletions Source/ZoomNet/Resources/ICloudRecordings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -44,15 +44,20 @@ public interface ICloudRecordings
/// <returns>
/// An array of <see cref="Recording">recordings</see>.
/// </returns>
/// <remarks>
/// The Zoom API response omits DownloadAccessToken, Password as well as the audio files.
/// To get the missing fields, use the <see cref="GetRecordingInformationAsync(string, int, CancellationToken)"/> method.
/// </remarks>
Task<PaginatedResponseWithTokenAndDateRange<Recording>> GetRecordingsForUserAsync(string userId, bool queryTrash = false, DateTime? from = null, DateTime? to = null, int recordsPerPage = 30, string pagingToken = null, CancellationToken cancellationToken = default);

/// <summary>
/// Retrieve the recording information (which includes recording files and audio files) for a meeting or webinar.
/// </summary>
/// <param name="meetingId">The meeting Id or UUID.</param>
/// <param name="ttl">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).</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>Details of recording made for a particular meeding or webinar.</returns>
Task<Recording> GetRecordingInformationAsync(string meetingId, CancellationToken cancellationToken = default);
Task<Recording> GetRecordingInformationAsync(string meetingId, int ttl = 604800, CancellationToken cancellationToken = default);

/// <summary>
/// Delete recording files for a meeting.
Expand Down Expand Up @@ -208,10 +213,11 @@ public interface ICloudRecordings
/// Download the recording file.
/// </summary>
/// <param name="downloadUrl">The URL of the recording file to download.</param>
/// <param name="accessToken">The token to download the recording file. If this value is omitted, the token for the current session will be used.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>
/// The <see cref="Stream"/> containing the file.
/// </returns>
Task<Stream> DownloadFileAsync(string downloadUrl, CancellationToken cancellationToken = default);
Task<Stream> DownloadFileAsync(string downloadUrl, string accessToken = null, CancellationToken cancellationToken = default);
}
}
6 changes: 5 additions & 1 deletion Source/ZoomNet/Utilities/OAuthTokenHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,11 @@ public OAuthTokenHandler(OAuthConnectionInfo connectionInfo, HttpClient httpClie
/// <param name="request">The HTTP request.</param>
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);
}

/// <summary>Method invoked just after the HTTP response is received. This method can modify the incoming HTTP response.</summary>
Expand Down
4 changes: 2 additions & 2 deletions build.cake
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -378,7 +378,7 @@ Task("Create-NuGet-Package")
MSBuildSettings = new DotNetMSBuildSettings
{
PackageReleaseNotes = releaseNotesUrl,
PackageVersion = versionInfo.SemVer
PackageVersion = versionInfo.FullSemVer
}
};

Expand Down

0 comments on commit e8bed59

Please sign in to comment.