Skip to content

Commit

Permalink
Feat/fax api faxes (#48)
Browse files Browse the repository at this point in the history
* The versions of the `Microsoft.Extensions.Http` and `System.Text.Json` packages in the `Sinch.csproj` file have been updated. 

* feat: add FaxRegion and resolve url based on it

* refactor: create fax request take either content url, stream or filePath

* refactor: list query create query param

* feat: add fax autolist

* chore: add string output to debug for json responses

* feat: upload fax as multipart/form data

* feat:process other kind of api error

* feat: impl get fax

* feat: implement download fax, add example

* feat: implement delete

* feat: implement list

* feat: add fax mock service

* chore(http): add test for multipart content


* feat: for last page add calculate if first page starting at 1

* chore: add comment for unused StreamExtensions.cs

* feat: add multiple or single send fax

* feat: return contentresult with stream and filename for content download

---------

Co-authored-by: spacedsweden <christian@sinch.com>
  • Loading branch information
Dovchik and spacedsweden authored May 23, 2024
1 parent bb7fe2b commit ca8c550
Show file tree
Hide file tree
Showing 34 changed files with 2,207 additions and 23 deletions.
3 changes: 2 additions & 1 deletion .github/workflows/build.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ env:
MOCK_CONVERSATION_PORT: 6042
MOCK_VERIFICATION_PORT: 6043
MOCK_VOICE_PORT: 6044
MOCK_FAX_PORT: 6046

jobs:
build:
Expand All @@ -33,7 +34,7 @@ jobs:
- 6042:6042
- 6043:6043
- 6044:6044

- 6046:6046
steps:
- uses: actions/checkout@v4
with:
Expand Down
25 changes: 25 additions & 0 deletions examples/Console/Fax/DownloadFax.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
using Sinch;

namespace Examples.Fax
{
public class DownloadFax
{
public static async Task Example()
{
var sinchClient = new SinchClient("PROJECT_ID", "KEY_ID", "KEY_SECRET");
const string faxId = "FAX_ID";

await using var contentResult = await sinchClient.Fax.Faxes.DownloadContent("faxId");
const string directory = @"C:\Downloads\";
if (!Path.Exists(directory))
{
Directory.CreateDirectory(directory);
}

await using var fileStream =
new FileStream(Path.Combine(directory, contentResult.FileName ?? $"{faxId}.pdf"), FileMode.Create,
FileAccess.Write);
await contentResult.Stream.CopyToAsync(fileStream);
}
}
}
2 changes: 1 addition & 1 deletion src/Sinch/ApiError.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ internal sealed class ApiErrorResponse
{
public ApiError? Error { get; set; }

public string? Code { get; set; }
public int? Code { get; set; }

public string? Text { get; set; }
}
Expand Down
29 changes: 29 additions & 0 deletions src/Sinch/Core/ContentResult.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
using System;
using System.IO;
using System.Threading.Tasks;

namespace Sinch.Core
{
public sealed class ContentResult : IDisposable, IAsyncDisposable
{
/// <summary>
/// The Stream containing data of the file
/// </summary>
public Stream Stream { get; init; } = null!;

/// <summary>
/// Name of the file, if available.
/// </summary>
public string? FileName { get; init; }

public void Dispose()
{
Stream.Dispose();
}

public async ValueTask DisposeAsync()
{
await Stream.DisposeAsync();
}
}
}
33 changes: 26 additions & 7 deletions src/Sinch/Core/Extensions.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using System.Net.Http;
using System.Net.Http.Json;
using System.Text.Json;
using System.Threading.Tasks;

namespace Sinch.Core
Expand All @@ -10,27 +11,45 @@ internal static class Extensions
/// Throws an exception if the IsSuccessStatusCode property for the HTTP response is false.
/// </summary>
/// <param name="httpResponseMessage">HttpResponseMessage to check for an error.</param>
/// <param name="options"></param>
/// <exception cref="SinchApiException">An exception which represents API error with additional info.</exception>
public static async Task EnsureSuccessApiStatusCode(this HttpResponseMessage httpResponseMessage)
public static async Task EnsureSuccessApiStatusCode(this HttpResponseMessage httpResponseMessage,
JsonSerializerOptions options)
{
if (httpResponseMessage.IsSuccessStatusCode) return;
ApiErrorResponse? apiErrorResponse = null;
if (httpResponseMessage.IsJson())
{
var content = await httpResponseMessage.Content.ReadAsStringAsync();
apiErrorResponse = JsonSerializer.Deserialize<ApiErrorResponse>(content, options);
if (apiErrorResponse?.Error == null && apiErrorResponse?.Text == null)
{
var anotherError = JsonSerializer.Deserialize<ApiError>(content, options);
throw new SinchApiException(httpResponseMessage.StatusCode, httpResponseMessage.ReasonPhrase, null,
anotherError);
}
}

var apiError = await httpResponseMessage.TryGetJson<ApiErrorResponse>();

throw new SinchApiException(httpResponseMessage.StatusCode, httpResponseMessage.ReasonPhrase, null, apiError);
throw new SinchApiException(httpResponseMessage.StatusCode, httpResponseMessage.ReasonPhrase, null,
apiErrorResponse);
}

public static async Task<T?> TryGetJson<T>(this HttpResponseMessage httpResponseMessage)
{
var authResponse = default(T);
if (httpResponseMessage.IsJson()) authResponse = await httpResponseMessage.Content.ReadFromJsonAsync<T>();
var response = default(T);
if (httpResponseMessage.IsJson()) response = await httpResponseMessage.Content.ReadFromJsonAsync<T>();

return authResponse;
return response;
}

public static bool IsJson(this HttpResponseMessage httpResponseMessage)
{
return httpResponseMessage.Content.Headers.ContentType?.MediaType == "application/json";
}

public static bool IsPdf(this HttpResponseMessage httpResponseMessage)
{
return httpResponseMessage.Content.Headers.ContentType?.MediaType == "application/pdf";
}
}
}
141 changes: 136 additions & 5 deletions src/Sinch/Core/Http.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Net;
using System.Net.Http;
Expand All @@ -13,6 +15,7 @@
using System.Threading;
using System.Threading.Tasks;
using Sinch.Auth;
using Sinch.Fax.Faxes;
using Sinch.Logger;

namespace Sinch.Core
Expand All @@ -33,6 +36,9 @@ internal interface IHttp
Task<TResponse> Send<TResponse>(Uri uri, HttpMethod httpMethod,
CancellationToken cancellationToken = default);

Task<TResponse> SendMultipart<TRequest, TResponse>(Uri uri, TRequest request, Stream stream, string fileName,
CancellationToken cancellationToken = default);

/// <summary>
/// Use to send http request with a body
/// </summary>
Expand Down Expand Up @@ -80,23 +86,104 @@ public Http(ISinchAuth auth, HttpClient httpClient, ILoggerAdapter<IHttp>? logge
$"sinch-sdk/{sdkVersion} (csharp/{RuntimeInformation.FrameworkDescription};;)";
}

public Task<TResponse> SendMultipart<TRequest, TResponse>(Uri uri, TRequest request, Stream stream,
string fileName, CancellationToken cancellationToken = default)
{
var content = BuildMultipartFormDataContent(request);

stream.Position = 0;
var isContentType = new FileExtensionContentTypeProvider().TryGetContentType(fileName, out var contentType);
var streamContent = new StreamContent(stream)
{
Headers =
{
ContentType = isContentType ? new MediaTypeHeaderValue(contentType!) : null
}
};
content.Add(streamContent, "file", fileName);


return SendHttpContent<TResponse>(uri, HttpMethod.Post, content, cancellationToken);
}

/// <summary>
/// Builds multi-part form data. Not to generic solutions as it handles some types specifically for SendFax request
/// As map{string, string{>} without nested typing as map{string,list{string}}
/// So, for any future use, keep that in mind to make the solution more generic.
/// </summary>
/// <param name="request"></param>
/// <typeparam name="TRequest"></typeparam>
/// <returns></returns>
private static MultipartFormDataContent BuildMultipartFormDataContent<TRequest>(TRequest request)
{
{
var content = new MultipartFormDataContent();
var props = request!.GetType().GetProperties(BindingFlags.Instance | BindingFlags.Public |
BindingFlags.DeclaredOnly)
.Where(DoesntHaveJsonIgnoreAttribute).Where(HasNonNullValue);
foreach (var prop in props)
{
var value = prop.GetValue(request);
if (value == null)
{
continue;
}

var type = value.GetType();
if (type == typeof(List<string>))
{
var asString = string.Join(',', (value as List<string>)!);
content.Add(new StringContent(asString), prop.Name);
}
else if (type == typeof(Dictionary<string, string>))
{
foreach (var (key, val) in (value as Dictionary<string, string>)!)
{
var strVal = prop.Name + "[" + key + "]";
content.Add(new StringContent(val), strVal);
}
}
else
{
var str = value.ToString();
if (!string.IsNullOrEmpty(str))
{
content.Add(new StringContent(str), prop.Name);
}
}
}

return content;
}

bool DoesntHaveJsonIgnoreAttribute(PropertyInfo prop)
{
return !prop.GetCustomAttributes(typeof(JsonIgnoreAttribute)).Any();
}

bool HasNonNullValue(PropertyInfo x)
{
return x.GetValue(request) != null;
}
}

public Task<TResponse> Send<TResponse>(Uri uri, HttpMethod httpMethod,
CancellationToken cancellationToken = default)
{
return Send<object, TResponse>(uri, httpMethod, null, cancellationToken);
return Send<EmptyResponse, TResponse>(uri, httpMethod, null, cancellationToken);
}

public async Task<TResponse> Send<TRequest, TResponse>(Uri uri, HttpMethod httpMethod, TRequest? request,
private async Task<TResponse> SendHttpContent<TResponse>(Uri uri, HttpMethod httpMethod,
HttpContent? httpContent,
CancellationToken cancellationToken = default)
{
var retry = true;
while (true)
{
_logger?.LogDebug("Sending request to {uri}", uri);
HttpContent? httpContent =
request == null ? null : JsonContent.Create(request, options: _jsonSerializerOptions);

#if DEBUG
Debug.WriteLine($"Http Method: {httpMethod}");
Debug.WriteLine($"Request uri: {uri}");
Debug.WriteLine($"Request body: {httpContent?.ReadAsStringAsync(cancellationToken).Result}");
#endif
Expand Down Expand Up @@ -156,8 +243,41 @@ public async Task<TResponse> Send<TRequest, TResponse>(Uri uri, HttpMethod httpM
}
}

await result.EnsureSuccessApiStatusCode();
await result.EnsureSuccessApiStatusCode(_jsonSerializerOptions);
_logger?.LogDebug("Finished processing request for {uri}", uri);

#if DEBUG
try
{
var responseStr = await result.Content.ReadAsStringAsync(cancellationToken);
Debug.WriteLine($"Response string: {responseStr}");
using var jDoc = JsonDocument.Parse(responseStr);
Debug.WriteLine(
$"Response content: {JsonSerializer.Serialize(jDoc, new JsonSerializerOptions() { WriteIndented = true })}");
}
catch (Exception e)
{
Debug.WriteLine($"Failed to parse json {e.Message}");
}
#endif
// NOTE: there wil probably be other files supported in the future
if (result.IsPdf())
{
if (typeof(TResponse) != typeof(ContentResult))
{
throw new InvalidOperationException(
$"Received pdf, but expected response type is not a {nameof(ContentResult)}.");
}

// yes, the header currently returns double quotes ""IFOFJSLJ12313.pdf""
var fileName = result.Content.Headers.ContentDisposition?.FileName?.Trim('"');
return (TResponse)(object)new ContentResult()
{
Stream = await result.Content.ReadAsStreamAsync(cancellationToken),
FileName = fileName
};
}

if (result.IsJson())
return await result.Content.ReadFromJsonAsync<TResponse>(cancellationToken: cancellationToken,
options: _jsonSerializerOptions)
Expand Down Expand Up @@ -186,5 +306,16 @@ public async Task<TResponse> Send<TRequest, TResponse>(Uri uri, HttpMethod httpM
throw new InvalidOperationException("The response is not Json or EmptyResponse");
}
}

public async Task<TResponse> Send<TRequest, TResponse>(Uri uri, HttpMethod httpMethod, TRequest? request,
CancellationToken cancellationToken = default)
{
HttpContent? httpContent =
request == null ? null : JsonContent.Create(request, options: _jsonSerializerOptions);


return await SendHttpContent<TResponse>(uri: uri, httpMethod: httpMethod, httpContent,
cancellationToken: cancellationToken);
}
}
}
24 changes: 24 additions & 0 deletions src/Sinch/Core/StreamExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
using System;
using System.IO;

namespace Sinch.Core
{
internal static class StreamExtensions
{
// NOTE: not used, may be used in send fax json request
public static string ConvertToBase64(this Stream stream)
{
if (stream is MemoryStream memoryStream)
{
return Convert.ToBase64String(memoryStream.ToArray());
}

var bytes = new Byte[(int)stream.Length];

stream.Seek(0, SeekOrigin.Begin);
stream.Read(bytes, 0, (int)stream.Length);

return Convert.ToBase64String(bytes);
}
}
}
20 changes: 20 additions & 0 deletions src/Sinch/Core/StringUtils.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,16 @@ public static string ToSnakeCase(string str)
.ToLower();
}

public static string PascalToCamelCase(string str)
{
if (string.IsNullOrEmpty(str))
{
throw new ArgumentNullException(nameof(str));
}

return char.ToLower(str[0]) + str[1..];
}

public static string ToQueryString(IEnumerable<KeyValuePair<string, string>> queryParams, bool encode = true)
{
return string.Join("&", queryParams.Select(kvp =>
Expand All @@ -27,5 +37,15 @@ public static string ToIso8601(DateTime date)
{
return date.ToString("O", CultureInfo.InvariantCulture);
}

public static string ToIso8601(DateOnly date)
{
return date.ToString("O", CultureInfo.InvariantCulture);
}

public static string ToIso8601NoTicks(DateTime date)
{
return date.ToString("yyyy-MM-ddTHH:mm:ssZ", CultureInfo.InvariantCulture);
}
}
}
Loading

0 comments on commit ca8c550

Please sign in to comment.