Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feat/fax api faxes #48

Merged
merged 56 commits into from
May 23, 2024
Merged
Show file tree
Hide file tree
Changes from 40 commits
Commits
Show all changes
56 commits
Select commit Hold shift + click to select a range
c5bf9ee
WIP: POC of fax, small PR to see if i am on the right path
spacedsweden Jan 12, 2024
74b0a0e
some small renaming
spacedsweden Jan 12, 2024
50f2cea
Update src/Sinch/Faxes/Barcode.cs
spacedsweden Jan 17, 2024
d9b8eca
Update src/Sinch/Faxes/FaxClient.cs
spacedsweden Jan 17, 2024
5f45573
Update src/Sinch/Faxes/Utils.cs
spacedsweden Jan 17, 2024
c0917cb
Update src/Sinch/Faxes/Utils.cs
spacedsweden Jan 17, 2024
7df8629
refactor of multipart
spacedsweden Jan 18, 2024
5170a8f
Merge branch 'FaxPOC' of https://github.com/sinch/sinch-sdk-dotnet in…
spacedsweden Jan 18, 2024
519dc10
Documentation additions
spacedsweden Jan 18, 2024
5b80034
doc update
spacedsweden Jan 19, 2024
386809c
code clean
spacedsweden Jan 20, 2024
360dc59
added some more
spacedsweden Feb 26, 2024
97cc4bc
The most significant changes include the modification of the `CreateT…
spacedsweden Mar 4, 2024
c3308d1
merge main
Dovchik Apr 2, 2024
b65e74a
refactor: rename sinch/faxes to fax
Dovchik Apr 3, 2024
06f8b78
refactor: reorganize fax files, introduce interfaces
Dovchik Apr 3, 2024
9f65f37
refactor: move out class, adjust private prop names
Dovchik Apr 3, 2024
d4eb74c
merge main into current
Dovchik Apr 3, 2024
de26908
merge main into current
Dovchik May 7, 2024
7af39ba
fix: format
Dovchik May 7, 2024
8902bd4
refactor: update Fax model
Dovchik May 7, 2024
85b1722
refactor: adjust fax and fax request models
Dovchik May 7, 2024
2964687
feat: add FaxRegion and resolve url based on it
Dovchik May 8, 2024
e3f9d18
refactor: create fax request take either content url, stream or filePath
Dovchik May 8, 2024
28e8752
refactor: list query create query param
Dovchik May 8, 2024
4f22ab7
feat: add fax autolist
Dovchik May 8, 2024
1704b0c
chore: add string output to debug for json responses
Dovchik May 13, 2024
3a86f7d
feat: upload fax as multipart/form data
Dovchik May 13, 2024
d891dc3
feat: send multipart adjustment, process other kind of api error
Dovchik May 14, 2024
0714b8c
feat(test): check for null argument in pascalcase renamer
Dovchik May 14, 2024
cf62923
feat(tests): send fax content url tests
Dovchik May 14, 2024
03891dc
feat: impl get fax, wip list
Dovchik May 15, 2024
932488d
feat: implement download fax, add example
Dovchik May 15, 2024
10f8d86
feat: implement delete
Dovchik May 15, 2024
442cf9e
feat: implement list
Dovchik May 16, 2024
ba3b60d
Merge branch 'main' into feat/fax-api-faxes
Dovchik May 16, 2024
2c6b51d
chore: update doc comment
Dovchik May 16, 2024
90727dc
chore: update list fax doc comments
Dovchik May 16, 2024
637a5c7
chore: fix test data
Dovchik May 20, 2024
b829de6
feat: add fax mock service
Dovchik May 20, 2024
b6971c3
fix(examples): create path if not exists
Dovchik May 21, 2024
9d30634
chore(http): add test for multipart content
Dovchik May 21, 2024
e59f702
feat: add test for ToIsoStrings
Dovchik May 21, 2024
ce232d1
fix: update fax model
Dovchik May 21, 2024
1b0c112
fix: SendFaxRequest.cs remove commented field, adjust prop name
Dovchik May 21, 2024
a88d162
fix: To is an array
Dovchik May 21, 2024
de502ff
chore: clarify ToQueryString in listFaxes
Dovchik May 21, 2024
4d4bb59
refactor: send fax return a list
Dovchik May 21, 2024
c26c479
feat: for last page add calculate if first page starting at 1
Dovchik May 21, 2024
c567ac3
fix: add comment for unused StreamExtensions.cs
Dovchik May 21, 2024
222aed4
fix: list auto faxes use first page as start
Dovchik May 21, 2024
7c22887
chore(example): use faxId in file name
Dovchik May 22, 2024
03f69a5
feat: add multiple or single send fax
Dovchik May 22, 2024
4eac7a3
chore: update comment for send
Dovchik May 22, 2024
8f99e78
feat: return contentresult with stream and filename for content download
Dovchik May 22, 2024
b242712
fix(test): send multipart
Dovchik May 23, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
15 changes: 15 additions & 0 deletions examples/Console/Fax/DownloadFax.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
using Sinch;

namespace Examples.Fax
{
public class DownloadFax
{
public static async Task Example()
{
var sinchClient = new SinchClient("PROJECT_ID", "KEY_ID", "KEY_SECRET");
await using var responseStream = await sinchClient.Fax.Faxes.DownloadContent("FAX_ID");
await using var fileStream = new FileStream("C:\\Downloads\\fax.pdf", FileMode.Create, FileAccess.Write);
asein-sinch marked this conversation as resolved.
Show resolved Hide resolved
asein-sinch marked this conversation as resolved.
Show resolved Hide resolved
await responseStream.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
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";
}
}
}
124 changes: 119 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,87 @@ 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 = new MultipartFormDataContent();
asein-sinch marked this conversation as resolved.
Show resolved Hide resolved
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>)!)
asein-sinch marked this conversation as resolved.
Show resolved Hide resolved
{
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);
}
}
}

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);

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 +226,35 @@ 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(Stream))
{
throw new InvalidOperationException(
"Received pdf, but expected response type is not a Stream.");
}

return (TResponse)(object)await result.Content.ReadAsStreamAsync(cancellationToken);
}

if (result.IsJson())
return await result.Content.ReadFromJsonAsync<TResponse>(cancellationToken: cancellationToken,
options: _jsonSerializerOptions)
Expand Down Expand Up @@ -186,5 +283,22 @@ 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);
}

public Task<TResponse> SendMultipart<TRequest, TResponse>(Uri uri, HttpMethod httpMethod, TRequest request,
asein-sinch marked this conversation as resolved.
Show resolved Hide resolved
Stream stream, string fileName, CancellationToken cancellationToken = default)
{
throw new NotImplementedException();
}
}
}
23 changes: 23 additions & 0 deletions src/Sinch/Core/StreamExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
using System;
using System.IO;

namespace Sinch.Core
{
internal static class StreamExtensions
{
public static string ConvertToBase64(this Stream stream)
asein-sinch marked this conversation as resolved.
Show resolved Hide resolved
{
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)
asein-sinch marked this conversation as resolved.
Show resolved Hide resolved
{
return date.ToString("O", CultureInfo.InvariantCulture);
}

public static string ToIso8601NoTicks(DateTime date)
{
return date.ToString("yyyy-MM-ddTHH:mm:ssZ", CultureInfo.InvariantCulture);
}
}
}
34 changes: 34 additions & 0 deletions src/Sinch/Core/Utils.cs
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,40 @@ public static string ToSnakeCaseQueryString<T>(T obj) where T : class
return StringUtils.ToQueryString(list);
}

public static string ToQueryString<T>(T obj, Func<string?, string?> namingConverter)
asein-sinch marked this conversation as resolved.
Show resolved Hide resolved
{
var props = typeof(T).GetProperties(BindingFlags.Instance | BindingFlags.Public |
BindingFlags.DeclaredOnly);
var list = new List<KeyValuePair<string, string>>();
foreach (var prop in props)
{
if (!prop.CanRead)
continue;
var propVal = prop.GetValue(obj);

if (propVal is null) continue;

var propName = namingConverter.Invoke(prop.Name);
if (string.IsNullOrEmpty(propName))
{
continue;
}

var propType = prop.PropertyType;
if (typeof(IEnumerable).IsAssignableFrom(propType) &&
propType != typeof(string))
{
list.AddRange(ParamsFromObject(propName, (propVal as IEnumerable)!));
}
else
{
list.Add(new(propName, ToQueryParamString(propVal)));
}
}

return StringUtils.ToQueryString(list);
}

private static IEnumerable<KeyValuePair<string, string>> ParamsFromObject(string paramName, IEnumerable obj)
{
return obj.Cast<object>().Select(o =>
Expand Down
Loading
Loading