Skip to content

Commit

Permalink
Merge branch 'release/0.81.0'
Browse files Browse the repository at this point in the history
  • Loading branch information
Jericho committed Oct 13, 2024
2 parents 9ed8171 + b1c7560 commit bf2b415
Show file tree
Hide file tree
Showing 19 changed files with 282 additions and 94 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,10 @@
<ItemGroup>
<PackageReference Include="Logzio.DotNet.NLog" Version="1.2.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="8.0.1" />
<PackageReference Include="NLog.Extensions.Logging" Version="5.3.11" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.1" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="8.0.1" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="8.0.2" />
<PackageReference Include="NLog.Extensions.Logging" Version="5.3.14" />
</ItemGroup>

<ItemGroup>
Expand Down
53 changes: 53 additions & 0 deletions Source/ZoomNet.UnitTests/Models/DailyUsageReportTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
using Shouldly;
using System.Text.Json;
using Xunit;
using ZoomNet.Json;
using ZoomNet.Models;

namespace ZoomNet.UnitTests.Models
{
public class DailyUsageReportTests
{
#region FIELDS

internal const string DAILY_USAGE_REPORT_JSON = @"{
""dates"": [
{
""date"": ""2022-03-01"",
""meeting_minutes"": 34,
""meetings"": 2,
""new_users"": 3,
""participants"": 4
}
],
""month"": 3,
""year"": 2022
}";

#endregion

[Fact]
public void Parse_json()
{
// Arrange

// Act
var result = JsonSerializer.Deserialize<DailyUsageReport>(DAILY_USAGE_REPORT_JSON, JsonFormatter.SerializerOptions);

// Assert
result.ShouldNotBeNull();
result.Month.ShouldBe(3);
result.Year.ShouldBe(2022);
result.DailyUsageSummaries.ShouldNotBeEmpty();
result.DailyUsageSummaries.Length.ShouldBe(1);
result.DailyUsageSummaries[0].Date.Year.ShouldBe(2022);
result.DailyUsageSummaries[0].Date.Month.ShouldBe(3);
result.DailyUsageSummaries[0].Date.Day.ShouldBe(1);
result.DailyUsageSummaries[0].MeetingMinutes.ShouldBe(34);
result.DailyUsageSummaries[0].Meetings.ShouldBe(2);
result.DailyUsageSummaries[0].NewUsers.ShouldBe(3);
result.DailyUsageSummaries[0].Participants.ShouldBe(4);

}
}
}
2 changes: 1 addition & 1 deletion Source/ZoomNet.UnitTests/Resources/CloudRecordingsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -552,7 +552,7 @@ public async Task DownloadFileAsync_with_expired_token()
var mockHttp = new MockHttpMessageHandler();
mockHttp // The first time the file is requested, we return "401 Unauthorized" to simulate an expired token.
.Expect(HttpMethod.Get, downloadUrl)
.Respond(HttpStatusCode.Unauthorized, new StringContent("{\"message\":\"access token is expired\"}"));
.Respond(HttpStatusCode.Unauthorized, new StringContent("{\"code\":123456,\"message\":\"access token is expired\"}"));
mockHttp // The second time the file is requested, we return "200 OK" with the file content.
.Expect(HttpMethod.Get, downloadUrl)
.Respond(HttpStatusCode.OK, new StringContent("This is the content of the file"));
Expand Down
4 changes: 2 additions & 2 deletions Source/ZoomNet.UnitTests/ZoomNet.UnitTests.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,15 @@
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.10.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
<PackageReference Include="NSubstitute" Version="5.1.0" />
<PackageReference Include="NSubstitute.Analyzers.CSharp" Version="1.0.17">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="RichardSzalay.MockHttp" Version="7.0.0" />
<PackageReference Include="Shouldly" Version="4.2.1" />
<PackageReference Include="xunit" Version="2.9.0" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
Expand Down
132 changes: 77 additions & 55 deletions Source/ZoomNet/Extensions/Internal.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.Json.Serialization;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using ZoomNet.Json;
Expand Down Expand Up @@ -347,19 +348,19 @@ internal static async Task<PaginatedResponseWithTokenAndDateRange<T>> AsPaginate
return await response.AsPaginatedResponseWithTokenAndDateRange<T>(propertyName, options).ConfigureAwait(false);
}

/// <summary>Get a raw JSON document representation of the response.</summary>
/// <summary>Get a JSON representation of the response.</summary>
/// <exception cref="ApiException">An error occurred processing the response.</exception>
internal static Task<JsonDocument> AsRawJsonDocument(this IResponse response, string propertyName = null, bool throwIfPropertyIsMissing = true)
internal static Task<JsonElement> AsJson(this IResponse response, string propertyName = null, bool throwIfPropertyIsMissing = true)
{
return response.Message.Content.AsRawJsonDocument(propertyName, throwIfPropertyIsMissing);
return response.Message.Content.AsJson(propertyName, throwIfPropertyIsMissing);
}

/// <summary>Get a raw JSON document representation of the response.</summary>
/// <summary>Get a JSON representation of the response.</summary>
/// <exception cref="ApiException">An error occurred processing the response.</exception>
internal static async Task<JsonDocument> AsRawJsonDocument(this IRequest request, string propertyName = null, bool throwIfPropertyIsMissing = true)
internal static async Task<JsonElement> AsJson(this IRequest request, string propertyName = null, bool throwIfPropertyIsMissing = true)
{
var response = await request.AsResponse().ConfigureAwait(false);
return await response.AsRawJsonDocument(propertyName, throwIfPropertyIsMissing).ConfigureAwait(false);
return await response.AsJson(propertyName, throwIfPropertyIsMissing).ConfigureAwait(false);
}

/// <summary>
Expand Down Expand Up @@ -734,41 +735,31 @@ internal static DiagnosticInfo GetDiagnosticInfo(this IResponse response)
}
*/

var responseContent = await message.Content.ReadAsStringAsync(null).ConfigureAwait(false);

if (!string.IsNullOrEmpty(responseContent))
try
{
try
var jsonResponse = await message.Content.ParseZoomResponseAsync().ConfigureAwait(false);
if (jsonResponse.ValueKind == JsonValueKind.Object)
{
var rootJsonElement = JsonDocument.Parse(responseContent).RootElement;

if (rootJsonElement.ValueKind == JsonValueKind.Object)
errorCode = jsonResponse.TryGetProperty("code", out JsonElement jsonErrorCode) ? jsonErrorCode.GetInt32() : null;
errorMessage = jsonResponse.TryGetProperty("message", out JsonElement jsonErrorMessage) ? jsonErrorMessage.GetString() : errorCode.HasValue ? $"Error code: {errorCode}" : errorMessage;
if (jsonResponse.TryGetProperty("errors", out JsonElement jsonErrorDetails))
{
errorCode = rootJsonElement.TryGetProperty("code", out JsonElement jsonErrorCode) ? jsonErrorCode.GetInt32() : null;
errorMessage = rootJsonElement.TryGetProperty("message", out JsonElement jsonErrorMessage) ? jsonErrorMessage.GetString() : errorCode.HasValue ? $"Error code: {errorCode}" : errorMessage;
if (rootJsonElement.TryGetProperty("errors", out JsonElement jsonErrorDetails))
{
var errorDetails = string.Join(
" ",
jsonErrorDetails
.EnumerateArray()
.Select(jsonErrorDetail =>
{
var errorDetail = jsonErrorDetail.TryGetProperty("message", out JsonElement jsonErrorMessage) ? jsonErrorMessage.GetString() : string.Empty;
return errorDetail;
})
.Where(message => !string.IsNullOrEmpty(message)));

if (!string.IsNullOrEmpty(errorDetails)) errorMessage += $" {errorDetails}";
}

return (errorCode.HasValue, errorMessage, errorCode);
var errorDetails = string.Join(
" ",
jsonErrorDetails
.EnumerateArray()
.Select(jsonErrorDetail => jsonErrorDetail.TryGetProperty("message", out JsonElement jsonErrorMessage) ? jsonErrorMessage.GetString() : string.Empty)
.Where(message => !string.IsNullOrEmpty(message)));

if (!string.IsNullOrEmpty(errorDetails)) errorMessage += $" {errorDetails}";
}

return (errorCode.HasValue, errorMessage, errorCode);
}
catch
{
// Intentionally ignore parsing errors
}
}
catch
{
// Intentionally ignore parsing errors
}

return (!message.IsSuccessStatusCode, errorMessage, errorCode);
Expand Down Expand Up @@ -951,6 +942,34 @@ internal static bool IsNullableType(this Type type)
return type.IsGenericType && type.GetGenericTypeDefinition() == typeof(Nullable<>);
}

private static async Task<JsonElement> ParseZoomResponseAsync(this HttpContent responseFromZoomApi, CancellationToken cancellationToken = default)
{
var responseContent = await responseFromZoomApi.ReadAsStringAsync(null, cancellationToken).ConfigureAwait(false);
if (string.IsNullOrEmpty(responseContent)) return default; // the 'ValueKind' property of the default JsonElement is JsonValueKind.Undefined

const string pattern = @"(.*?)(?<=""message"":"")(.*?)(?=""})(.*?$)";
var matches = Regex.Match(responseContent, pattern, RegexOptions.IgnoreCase | RegexOptions.Singleline | RegexOptions.Compiled);
var prefix = matches.Groups[1].Value;
var message = matches.Groups[2].Value;
var postfix = matches.Groups[3].Value;

if (string.IsNullOrEmpty(message)) return JsonDocument.Parse(responseContent).RootElement;

/*
Sometimes the error message is malformed due to the presence of double quotes that are not properly escaped.
See: https://devforum.zoom.us/t/list-events-endpoint-returns-invalid-json-in-the-payload/115792 for more info.
One instance where this problem was observed is when retrieving the list of events without having the necessary permissions to do so.
The result is the following response with unescaped double-quotes in the error message:
{
"code": 104,
"message": "Invalid access token, does not contain scopes:["zoom_events_basic:read","zoom_events_basic:read:admin"]"
}
*/
var escapedMessage = Regex.Replace(message, @"(?<!\\)""", "\\\"", RegexOptions.Compiled); // Replace un-escaped double-quotes with properly escaped double-quotes
var result = $"{prefix}{escapedMessage}{postfix}";
return JsonDocument.Parse(result).RootElement;
}

/// <summary>Asynchronously converts the JSON encoded content and convert it to an object of the desired type.</summary>
/// <typeparam name="T">The response model to deserialize into.</typeparam>
/// <param name="httpContent">The content.</param>
Expand Down Expand Up @@ -984,28 +1003,30 @@ private static async Task<T> AsObject<T>(this HttpContent httpContent, string pr
}
}

/// <summary>Get a raw JSON object representation of the response.</summary>
/// <summary>Get a JSON representation of the response.</summary>
/// <param name="httpContent">The content.</param>
/// <param name="propertyName">The name of the JSON property (or null if not applicable) where the desired data is stored.</param>
/// <param name="throwIfPropertyIsMissing">Indicates if an exception should be thrown when the specified JSON property is missing from the response.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>Returns the response body, or <c>null</c> if the response has no body.</returns>
/// <returns>Returns the response body, or a JsonElement with its 'ValueKind' set to 'Undefined' if the response has no body.</returns>
/// <exception cref="ApiException">An error occurred processing the response.</exception>
private static async Task<JsonDocument> AsRawJsonDocument(this HttpContent httpContent, string propertyName = null, bool throwIfPropertyIsMissing = true, CancellationToken cancellationToken = default)
private static async Task<JsonElement> AsJson(this HttpContent httpContent, string propertyName = null, bool throwIfPropertyIsMissing = true, CancellationToken cancellationToken = default)
{
var responseContent = await httpContent.ReadAsStringAsync(null, cancellationToken).ConfigureAwait(false);

var jsonDoc = JsonDocument.Parse(responseContent, default);
var jsonResponse = await httpContent.ParseZoomResponseAsync(cancellationToken).ConfigureAwait(false);

if (string.IsNullOrEmpty(propertyName))
{
return jsonDoc;
return jsonResponse;
}

if (jsonDoc.RootElement.TryGetProperty(propertyName, out JsonElement property))
if (jsonResponse.ValueKind != JsonValueKind.Object)
{
throw new Exception("The response from the Zomm API does not contain a valid JSON string");
}
else if (jsonResponse.TryGetProperty(propertyName, out JsonElement property))
{
var propertyContent = property.GetRawText();
return JsonDocument.Parse(propertyContent, default);
return JsonDocument.Parse(propertyContent, default).RootElement;
}
else if (throwIfPropertyIsMissing)
{
Expand All @@ -1027,9 +1048,8 @@ private static async Task<JsonDocument> AsRawJsonDocument(this HttpContent httpC
/// <exception cref="ApiException">An error occurred processing the response.</exception>
private static async Task<PaginatedResponse<T>> AsPaginatedResponse<T>(this HttpContent httpContent, string propertyName, JsonSerializerOptions options = null, CancellationToken cancellationToken = default)
{
// Get the content as a queryable json document
var doc = await httpContent.AsRawJsonDocument(null, false, cancellationToken).ConfigureAwait(false);
var rootElement = doc.RootElement;
// Get the content as a JSON element
var rootElement = await httpContent.AsJson(null, false, cancellationToken).ConfigureAwait(false);

// Get the various metadata properties
var pageCount = rootElement.GetPropertyValue("page_count", 0);
Expand Down Expand Up @@ -1068,9 +1088,8 @@ private static async Task<PaginatedResponse<T>> AsPaginatedResponse<T>(this Http
/// <exception cref="ApiException">An error occurred processing the response.</exception>
private static async Task<PaginatedResponseWithToken<T>> AsPaginatedResponseWithToken<T>(this HttpContent httpContent, string propertyName, JsonSerializerOptions options = null, CancellationToken cancellationToken = default)
{
// Get the content as a queryable json document
var doc = await httpContent.AsRawJsonDocument(null, false, cancellationToken).ConfigureAwait(false);
var rootElement = doc.RootElement;
// Get the content as a JSON element
var rootElement = await httpContent.AsJson(null, false, cancellationToken).ConfigureAwait(false);

// Get the various metadata properties
var nextPageToken = rootElement.GetPropertyValue("next_page_token", string.Empty);
Expand Down Expand Up @@ -1107,9 +1126,8 @@ private static async Task<PaginatedResponseWithToken<T>> AsPaginatedResponseWith
/// <exception cref="ApiException">An error occurred processing the response.</exception>
private static async Task<PaginatedResponseWithTokenAndDateRange<T>> AsPaginatedResponseWithTokenAndDateRange<T>(this HttpContent httpContent, string propertyName, JsonSerializerOptions options = null, CancellationToken cancellationToken = default)
{
// Get the content as a queryable json document
var doc = await httpContent.AsRawJsonDocument(null, false, cancellationToken).ConfigureAwait(false);
var rootElement = doc.RootElement;
// Get the content as a JSON element
var rootElement = await httpContent.AsJson(null, false, cancellationToken).ConfigureAwait(false);

// Get the various metadata properties
var from = DateTime.ParseExact(rootElement.GetPropertyValue("from", string.Empty), "yyyy-MM-dd", CultureInfo.InvariantCulture);
Expand Down Expand Up @@ -1150,7 +1168,11 @@ private static T GetPropertyValue<T>(this JsonElement element, string[] names, T
if (property.HasValue) break;
}

if (!property.HasValue) return defaultValue;
if (!property.HasValue)
{
if (throwIfMissing) throw new Exception($"Unable to find {string.Join(", ", names)} in the Json document");
else return defaultValue;
}

var typeOfT = typeof(T);

Expand Down
37 changes: 37 additions & 0 deletions Source/ZoomNet/Json/DateOnlyConverter.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
using System;
using System.Text.Json;

namespace ZoomNet.Json
{
/// <summary>
/// Converts a DateOnly (which is represented by 3 integer values: year, month and day) to or from JSON.
/// </summary>
/// <seealso cref="ZoomNetJsonConverter{T}"/>
internal class DateOnlyConverter : ZoomNetJsonConverter<(int Year, int Month, int Day)>
{
public override (int Year, int Month, int Day) Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
switch (reader.TokenType)
{
case JsonTokenType.None:
case JsonTokenType.Null:
case JsonTokenType.String when string.IsNullOrEmpty(reader.GetString()):
throw new JsonException("Unable to convert a null value to DateOnly");

case JsonTokenType.String:
var rawValue = reader.GetString();
var parts = rawValue.Split('-');
if (parts.Length != 3) throw new JsonException($"Unable to convert {rawValue} to DateOnly");
return (int.Parse(parts[0]), int.Parse(parts[1]), int.Parse(parts[2]));

default:
throw new JsonException($"Unable to convert {reader.TokenType.ToEnumString()} to DateOnly");
}
}

public override void Write(Utf8JsonWriter writer, (int Year, int Month, int Day) value, JsonSerializerOptions options)
{
writer.WriteStringValue($"{value.Year:D4}-{value.Month:D2}-{value.Day:D2}");
}
}
}
2 changes: 2 additions & 0 deletions Source/ZoomNet/Json/ZoomNetJsonSerializerContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -385,6 +385,7 @@ namespace ZoomNet.Json
[JsonSerializable(typeof(ZoomNet.Models.CrcPortsHourUsage[]))]
[JsonSerializable(typeof(ZoomNet.Models.CrcPortsUsage[]))]
[JsonSerializable(typeof(ZoomNet.Models.CustomAttribute[]))]
[JsonSerializable(typeof(ZoomNet.Models.DailyUsageReport[]))]
[JsonSerializable(typeof(ZoomNet.Models.DashboardMeetingMetrics[]))]
[JsonSerializable(typeof(ZoomNet.Models.DashboardMeetingMetricsPaginationObject[]))]
[JsonSerializable(typeof(ZoomNet.Models.DashboardMeetingParticipant[]))]
Expand All @@ -394,6 +395,7 @@ namespace ZoomNet.Json
[JsonSerializable(typeof(ZoomNet.Models.DashboardParticipant[]))]
[JsonSerializable(typeof(ZoomNet.Models.DashboardParticipantQos[]))]
[JsonSerializable(typeof(ZoomNet.Models.DataCenterRegion[]))]
[JsonSerializable(typeof(ZoomNet.Models.DailyUsageSummary[]))]
[JsonSerializable(typeof(ZoomNet.Models.EmailNotificationUserSettings[]))]
[JsonSerializable(typeof(ZoomNet.Models.EmergencyAddress[]))]
[JsonSerializable(typeof(ZoomNet.Models.EncryptionType[]))]
Expand Down
22 changes: 22 additions & 0 deletions Source/ZoomNet/Models/DailyUsageReport.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
using System.Text.Json.Serialization;

namespace ZoomNet.Models
{
/// <summary>
/// Daily Usage Report.
/// </summary>
public class DailyUsageReport
{
/// <summary>Gets or sets the daily usage summaries.</summary>
[JsonPropertyName("dates")]
public DailyUsageSummary[] DailyUsageSummaries { get; set; }

/// <summary>Gets or sets the month.</summary>
[JsonPropertyName("month")]
public int Month { get; set; }

/// <summary>Gets or sets the year.</summary>
[JsonPropertyName("year")]
public int Year { get; set; }
}
}
Loading

0 comments on commit bf2b415

Please sign in to comment.