From ca8c550174b83265e27960149af3ac810f690713 Mon Sep 17 00:00:00 2001 From: Volodymyr Lisovskyi Date: Thu, 23 May 2024 12:34:52 +0200 Subject: [PATCH] Feat/fax api faxes (#48) * 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 --- .github/workflows/build.yaml | 3 +- examples/Console/Fax/DownloadFax.cs | 25 ++ src/Sinch/ApiError.cs | 2 +- src/Sinch/Core/ContentResult.cs | 29 ++ src/Sinch/Core/Extensions.cs | 33 +- src/Sinch/Core/Http.cs | 141 +++++- src/Sinch/Core/StreamExtensions.cs | 24 ++ src/Sinch/Core/StringUtils.cs | 20 + src/Sinch/Core/Utils.cs | 55 ++- src/Sinch/Fax/FaxClient.cs | 27 ++ src/Sinch/Fax/FaxRegion.cs | 13 + src/Sinch/Fax/Faxes/Barcode.cs | 36 ++ src/Sinch/Fax/Faxes/Direction.cs | 22 + src/Sinch/Fax/Faxes/Email.cs | 105 +++++ src/Sinch/Fax/Faxes/ErrorType.cs | 20 + src/Sinch/Fax/Faxes/Fax.cs | 235 ++++++++++ src/Sinch/Fax/Faxes/FaxStatus.cs | 20 + src/Sinch/Fax/Faxes/FaxesClient.cs | 235 ++++++++++ src/Sinch/Fax/Faxes/ImageConversionMethod.cs | 21 + src/Sinch/Fax/Faxes/ListFaxResponse.cs | 32 ++ src/Sinch/Fax/Faxes/ListFaxesRequest.cs | 138 ++++++ src/Sinch/Fax/Faxes/Money.cs | 41 ++ src/Sinch/Fax/Faxes/SendFaxRequest.cs | 184 ++++++++ src/Sinch/Fax/Faxes/Utils.cs | 431 +++++++++++++++++++ src/Sinch/Sinch.csproj | 4 +- src/Sinch/SinchApiException.cs | 11 +- src/Sinch/SinchClient.cs | 37 +- src/Sinch/SinchOptions.cs | 11 + tests/Sinch.Tests/Core/HttpTests.cs | 33 ++ tests/Sinch.Tests/StringUtilsTests.cs | 43 ++ tests/Sinch.Tests/UtilsTests.cs | 36 +- tests/Sinch.Tests/e2e/Fax/FaxTestBase.cs | 14 + tests/Sinch.Tests/e2e/Fax/FaxesTests.cs | 146 +++++++ tests/Sinch.Tests/e2e/TestBase.cs | 3 +- 34 files changed, 2207 insertions(+), 23 deletions(-) create mode 100644 examples/Console/Fax/DownloadFax.cs create mode 100644 src/Sinch/Core/ContentResult.cs create mode 100644 src/Sinch/Core/StreamExtensions.cs create mode 100644 src/Sinch/Fax/FaxClient.cs create mode 100644 src/Sinch/Fax/FaxRegion.cs create mode 100644 src/Sinch/Fax/Faxes/Barcode.cs create mode 100644 src/Sinch/Fax/Faxes/Direction.cs create mode 100644 src/Sinch/Fax/Faxes/Email.cs create mode 100644 src/Sinch/Fax/Faxes/ErrorType.cs create mode 100644 src/Sinch/Fax/Faxes/Fax.cs create mode 100644 src/Sinch/Fax/Faxes/FaxStatus.cs create mode 100644 src/Sinch/Fax/Faxes/FaxesClient.cs create mode 100644 src/Sinch/Fax/Faxes/ImageConversionMethod.cs create mode 100644 src/Sinch/Fax/Faxes/ListFaxResponse.cs create mode 100644 src/Sinch/Fax/Faxes/ListFaxesRequest.cs create mode 100644 src/Sinch/Fax/Faxes/Money.cs create mode 100644 src/Sinch/Fax/Faxes/SendFaxRequest.cs create mode 100644 src/Sinch/Fax/Faxes/Utils.cs create mode 100644 tests/Sinch.Tests/StringUtilsTests.cs create mode 100644 tests/Sinch.Tests/e2e/Fax/FaxTestBase.cs create mode 100644 tests/Sinch.Tests/e2e/Fax/FaxesTests.cs diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 026fff5e..fabf9b9d 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -8,6 +8,7 @@ env: MOCK_CONVERSATION_PORT: 6042 MOCK_VERIFICATION_PORT: 6043 MOCK_VOICE_PORT: 6044 + MOCK_FAX_PORT: 6046 jobs: build: @@ -33,7 +34,7 @@ jobs: - 6042:6042 - 6043:6043 - 6044:6044 - + - 6046:6046 steps: - uses: actions/checkout@v4 with: diff --git a/examples/Console/Fax/DownloadFax.cs b/examples/Console/Fax/DownloadFax.cs new file mode 100644 index 00000000..7b7e754d --- /dev/null +++ b/examples/Console/Fax/DownloadFax.cs @@ -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); + } + } +} diff --git a/src/Sinch/ApiError.cs b/src/Sinch/ApiError.cs index fc21a46f..d693e6ed 100644 --- a/src/Sinch/ApiError.cs +++ b/src/Sinch/ApiError.cs @@ -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; } } diff --git a/src/Sinch/Core/ContentResult.cs b/src/Sinch/Core/ContentResult.cs new file mode 100644 index 00000000..ded668ba --- /dev/null +++ b/src/Sinch/Core/ContentResult.cs @@ -0,0 +1,29 @@ +using System; +using System.IO; +using System.Threading.Tasks; + +namespace Sinch.Core +{ + public sealed class ContentResult : IDisposable, IAsyncDisposable + { + /// + /// The Stream containing data of the file + /// + public Stream Stream { get; init; } = null!; + + /// + /// Name of the file, if available. + /// + public string? FileName { get; init; } + + public void Dispose() + { + Stream.Dispose(); + } + + public async ValueTask DisposeAsync() + { + await Stream.DisposeAsync(); + } + } +} diff --git a/src/Sinch/Core/Extensions.cs b/src/Sinch/Core/Extensions.cs index cc88b743..885653c5 100644 --- a/src/Sinch/Core/Extensions.cs +++ b/src/Sinch/Core/Extensions.cs @@ -1,5 +1,6 @@ using System.Net.Http; using System.Net.Http.Json; +using System.Text.Json; using System.Threading.Tasks; namespace Sinch.Core @@ -10,27 +11,45 @@ internal static class Extensions /// Throws an exception if the IsSuccessStatusCode property for the HTTP response is false. /// /// HttpResponseMessage to check for an error. + /// /// An exception which represents API error with additional info. - 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(content, options); + if (apiErrorResponse?.Error == null && apiErrorResponse?.Text == null) + { + var anotherError = JsonSerializer.Deserialize(content, options); + throw new SinchApiException(httpResponseMessage.StatusCode, httpResponseMessage.ReasonPhrase, null, + anotherError); + } + } - var apiError = await httpResponseMessage.TryGetJson(); - - throw new SinchApiException(httpResponseMessage.StatusCode, httpResponseMessage.ReasonPhrase, null, apiError); + throw new SinchApiException(httpResponseMessage.StatusCode, httpResponseMessage.ReasonPhrase, null, + apiErrorResponse); } public static async Task TryGetJson(this HttpResponseMessage httpResponseMessage) { - var authResponse = default(T); - if (httpResponseMessage.IsJson()) authResponse = await httpResponseMessage.Content.ReadFromJsonAsync(); + var response = default(T); + if (httpResponseMessage.IsJson()) response = await httpResponseMessage.Content.ReadFromJsonAsync(); - 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"; + } } } diff --git a/src/Sinch/Core/Http.cs b/src/Sinch/Core/Http.cs index a079b625..1ccf23ed 100644 --- a/src/Sinch/Core/Http.cs +++ b/src/Sinch/Core/Http.cs @@ -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; @@ -13,6 +15,7 @@ using System.Threading; using System.Threading.Tasks; using Sinch.Auth; +using Sinch.Fax.Faxes; using Sinch.Logger; namespace Sinch.Core @@ -33,6 +36,9 @@ internal interface IHttp Task Send(Uri uri, HttpMethod httpMethod, CancellationToken cancellationToken = default); + Task SendMultipart(Uri uri, TRequest request, Stream stream, string fileName, + CancellationToken cancellationToken = default); + /// /// Use to send http request with a body /// @@ -80,23 +86,104 @@ public Http(ISinchAuth auth, HttpClient httpClient, ILoggerAdapter? logge $"sinch-sdk/{sdkVersion} (csharp/{RuntimeInformation.FrameworkDescription};;)"; } + public Task SendMultipart(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(uri, HttpMethod.Post, content, cancellationToken); + } + + /// + /// 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. + /// + /// + /// + /// + private static MultipartFormDataContent BuildMultipartFormDataContent(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)) + { + var asString = string.Join(',', (value as List)!); + content.Add(new StringContent(asString), prop.Name); + } + else if (type == typeof(Dictionary)) + { + foreach (var (key, val) in (value as Dictionary)!) + { + 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 Send(Uri uri, HttpMethod httpMethod, CancellationToken cancellationToken = default) { - return Send(uri, httpMethod, null, cancellationToken); + return Send(uri, httpMethod, null, cancellationToken); } - public async Task Send(Uri uri, HttpMethod httpMethod, TRequest? request, + private async Task SendHttpContent(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 @@ -156,8 +243,41 @@ public async Task Send(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(cancellationToken: cancellationToken, options: _jsonSerializerOptions) @@ -186,5 +306,16 @@ public async Task Send(Uri uri, HttpMethod httpM throw new InvalidOperationException("The response is not Json or EmptyResponse"); } } + + public async Task Send(Uri uri, HttpMethod httpMethod, TRequest? request, + CancellationToken cancellationToken = default) + { + HttpContent? httpContent = + request == null ? null : JsonContent.Create(request, options: _jsonSerializerOptions); + + + return await SendHttpContent(uri: uri, httpMethod: httpMethod, httpContent, + cancellationToken: cancellationToken); + } } } diff --git a/src/Sinch/Core/StreamExtensions.cs b/src/Sinch/Core/StreamExtensions.cs new file mode 100644 index 00000000..0fc10ed3 --- /dev/null +++ b/src/Sinch/Core/StreamExtensions.cs @@ -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); + } + } +} diff --git a/src/Sinch/Core/StringUtils.cs b/src/Sinch/Core/StringUtils.cs index b250542d..8d9b054c 100644 --- a/src/Sinch/Core/StringUtils.cs +++ b/src/Sinch/Core/StringUtils.cs @@ -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> queryParams, bool encode = true) { return string.Join("&", queryParams.Select(kvp => @@ -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); + } } } diff --git a/src/Sinch/Core/Utils.cs b/src/Sinch/Core/Utils.cs index 1460e2b9..277e2e98 100644 --- a/src/Sinch/Core/Utils.cs +++ b/src/Sinch/Core/Utils.cs @@ -73,9 +73,20 @@ public static T ParseEnum(string value) throw new InvalidOperationException($"Failed to parse {enumType.Name} enum for value {value}"); } - public static bool IsLastPage(int page, int pageSize, int totalCount) + public static bool IsLastPage(int page, int pageSize, int totalCount, PageStart pageStart = PageStart.Zero) { - return (page + 1) * pageSize >= totalCount; + switch (pageStart) + { + case PageStart.Zero: + page += 1; + break; + case PageStart.One: + break; + default: + throw new ArgumentOutOfRangeException(nameof(pageStart), pageStart, null); + } + + return page * pageSize >= totalCount; } public static string ToSnakeCaseQueryString(T obj) where T : class @@ -107,6 +118,40 @@ public static string ToSnakeCaseQueryString(T obj) where T : class return StringUtils.ToQueryString(list); } + public static string ToQueryString(T obj, Func namingConverter) + { + var props = typeof(T).GetProperties(BindingFlags.Instance | BindingFlags.Public | + BindingFlags.DeclaredOnly); + var list = new List>(); + 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> ParamsFromObject(string paramName, IEnumerable obj) { return obj.Cast().Select(o => @@ -144,4 +189,10 @@ public static void ThrowNullDeserialization(Type type) throw new InvalidOperationException($"{type.Name} deserialization result is null"); } } + + internal enum PageStart + { + Zero = 0, + One = 1, + } } diff --git a/src/Sinch/Fax/FaxClient.cs b/src/Sinch/Fax/FaxClient.cs new file mode 100644 index 00000000..10f9e80d --- /dev/null +++ b/src/Sinch/Fax/FaxClient.cs @@ -0,0 +1,27 @@ +using System; +using Sinch.Core; +using Sinch.Fax.Faxes; +using Sinch.Logger; + +namespace Sinch.Fax +{ + /// + /// Our Fax API offers collision avoidance features, business integrations, + /// and a pay-as-you-go model suited for large scalability, designed with you, the developer, in mind. + /// + public interface ISinchFax + { + /// + public ISinchFaxFaxes Faxes { get; } + } + + internal class FaxClient : ISinchFax + { + internal FaxClient(string projectId, Uri baseAddress, LoggerFactory? loggerFactory, IHttp http) + { + Faxes = new FaxesClient(projectId, baseAddress, loggerFactory?.Create(), http); + } + + public ISinchFaxFaxes Faxes { get; } + } +} diff --git a/src/Sinch/Fax/FaxRegion.cs b/src/Sinch/Fax/FaxRegion.cs new file mode 100644 index 00000000..47ce2ce2 --- /dev/null +++ b/src/Sinch/Fax/FaxRegion.cs @@ -0,0 +1,13 @@ +using Sinch.Core; + +namespace Sinch.Fax +{ + public record FaxRegion(string Value) : EnumRecord(Value) + { + public static readonly FaxRegion UsEastCost = new("use1"); + public static readonly FaxRegion Europe = new("eu1"); + public static readonly FaxRegion SouthAmerica = new("sae1"); + public static readonly FaxRegion SouthEastAsia1 = new("apse1"); + public static readonly FaxRegion SouthEastAsia2 = new("apse2"); + } +} diff --git a/src/Sinch/Fax/Faxes/Barcode.cs b/src/Sinch/Fax/Faxes/Barcode.cs new file mode 100644 index 00000000..2ea3aafe --- /dev/null +++ b/src/Sinch/Fax/Faxes/Barcode.cs @@ -0,0 +1,36 @@ +using System.Text.Json.Serialization; +using Sinch.Core; + +namespace Sinch.Fax.Faxes +{ + [JsonConverter(typeof(EnumRecordJsonConverter))] + public record BarCodeType(string Value) : EnumRecord(Value) + { + public static readonly BarCodeType Code128 = new("CODE_128"); + public static readonly BarCodeType DataMatrix = new("DATA_MATRIX"); + } + + /// + /// The bar codes found in the fax. This field is populated when sinch detects bar codes on incoming faxes. + /// + public class Barcode + { + /// + /// The type of barcode found. + /// + [JsonPropertyName("type")] + public BarCodeType? Type { get; set; } + + /// + /// The page number on which the barcode was found. + /// + [JsonPropertyName("page")] + public int Page { get; set; } + + /// + /// The information of the barcode. + /// + [JsonPropertyName("value")] + public string? Value { get; set; } + } +} diff --git a/src/Sinch/Fax/Faxes/Direction.cs b/src/Sinch/Fax/Faxes/Direction.cs new file mode 100644 index 00000000..06da9a1e --- /dev/null +++ b/src/Sinch/Fax/Faxes/Direction.cs @@ -0,0 +1,22 @@ +using System.Text.Json.Serialization; +using Sinch.Core; + +namespace Sinch.Fax.Faxes +{ + /// + /// The direction of the fax. + /// + [JsonConverter(typeof(EnumRecordJsonConverter))] + public record Direction(string Value) : EnumRecord(Value) + { + /// + /// The fax was received on one of your sinch numbers. + /// + public static readonly Direction Inbound = new("INBOUND"); + + /// + /// The fax was sent by you via the api. + /// + public static readonly Direction Outbound = new("OUTBOUND"); + } +} diff --git a/src/Sinch/Fax/Faxes/Email.cs b/src/Sinch/Fax/Faxes/Email.cs new file mode 100644 index 00000000..fa332148 --- /dev/null +++ b/src/Sinch/Fax/Faxes/Email.cs @@ -0,0 +1,105 @@ +using System; +using System.Collections.Generic; +using System.Net.Http; +using System.Text; +using System.Text.Json.Serialization; +using System.Threading.Tasks; +using Sinch.Core; +using Sinch.Logger; + +namespace Sinch.Fax.Faxes +{ + public interface ISinchFaxEmails + { + } + + public class Emails : ISinchFaxEmails + { + private readonly string _projectId; + private readonly Uri _uri; + + private readonly Http _http; + private ILoggerAdapter _loggerAdapter; + private FileExtensionContentTypeProvider _mimeMapper; + + + internal Emails(string projectId, Uri uri, ILoggerAdapter loggerAdapter, Http httpClient) + { + _projectId = projectId; + + _loggerAdapter = loggerAdapter; + _http = httpClient; + _mimeMapper = new FileExtensionContentTypeProvider(); + uri = new Uri(uri, $"/v3/projects/{projectId}/emails"); + _uri = uri; + } + + public async Task ListEmails(string email) + { + var result = await _http.Send(_uri, HttpMethod.Get); + return result; + } + + public async Task Uppdate(EmailAddress email) + { + var url = new Uri(_uri, $"/{email.Email}"); + var result = await _http.Send(url, HttpMethod.Put, email); + return result; + } + + public async Task Add(EmailAddress email) + { + var result = await _http.Send(_uri, HttpMethod.Post, email); + return result; + } + + public async Task Delete(EmailAddress email) + { + var url = new Uri(_uri, $"/{email.Email}"); + var result = await _http.Send(url, HttpMethod.Delete, email); + return result; + } + } + + /// + /// Object from emails/ endoint that is used to send and recieve a fax via email + /// + public class EmailAddress + { + /// + /// Gets or Sets VarEmail + /// + [JsonPropertyName("email")] + public string? Email { get; set; } + + + /// + /// Numbers you want to associate with this email. + /// + [JsonPropertyName("phoneNumbers")] + public List? PhoneNumbers { get; set; } + + + /// + /// The `Id` of the project associated with the call. + /// + [JsonPropertyName("projectId")] + public string? ProjectId { get; private set; } + + + /// + /// Returns the string presentation of the object + /// + /// String presentation of the object + public override string ToString() + { + var sb = new StringBuilder(); + sb.Append($"class {nameof(Email)} {{\n"); + sb.Append($" {nameof(Email)}: ").Append(Email).Append('\n'); + sb.Append($" {nameof(PhoneNumbers)}: ").Append(PhoneNumbers).Append('\n'); + sb.Append($" {nameof(ProjectId)}: ").Append(ProjectId).Append('\n'); + sb.Append("}\n"); + return sb.ToString(); + } + } +} diff --git a/src/Sinch/Fax/Faxes/ErrorType.cs b/src/Sinch/Fax/Faxes/ErrorType.cs new file mode 100644 index 00000000..ec417783 --- /dev/null +++ b/src/Sinch/Fax/Faxes/ErrorType.cs @@ -0,0 +1,20 @@ +using System.Text.Json.Serialization; +using Sinch.Core; + +namespace Sinch.Fax.Faxes +{ + /// + /// When your receive a callback from the fax api notifying you of a failure, it will contain two values that can help you understand what went wrong: an errorCode and an type. + /// The type will give you a general idea of why the operation failed, whereas the errorCode describes the issue in more detail.Below we list the error_codes for the API, segmented by their corresponding error_type. + /// + /// + [JsonConverter(typeof(EnumRecordJsonConverter))] + public record ErrorType(string Value) : EnumRecord(Value) + { + public static readonly ErrorType DocumentConversionError = new("DOCUMENT_CONVERSION_ERROR"); + public static readonly ErrorType CallError = new("CALL_ERROR"); + public static readonly ErrorType FaxError = new("FAX_ERROR"); + public static readonly ErrorType FatalError = new("FATAL_ERROR"); + public static readonly ErrorType GeneralError = new("GENERAL_ERROR"); + } +} diff --git a/src/Sinch/Fax/Faxes/Fax.cs b/src/Sinch/Fax/Faxes/Fax.cs new file mode 100644 index 00000000..258ffe84 --- /dev/null +++ b/src/Sinch/Fax/Faxes/Fax.cs @@ -0,0 +1,235 @@ +using System; +using System.Collections.Generic; +using System.Text; +using System.Text.Json.Serialization; +using Sinch.Core; + +namespace Sinch.Fax.Faxes +{ + /// + /// Fax object, see https://developers.sinch.com/docs/fax for more information + /// + public sealed class Fax + { + /// + /// The id of a fax + /// + [JsonPropertyName("id")] + public string? Id { get; set; } + + /// + /// Direction fax was sent, inbound someone sent a fax to your sinch number, outbound you sent a fax to someone + /// + [JsonPropertyName("direction")] + public Direction? Direction { get; set; } + + /// + /// A phone number in [E.164](https://community.sinch.com/t5/Glossary/E-164/ta-p/7537) format, including the leading '+'. + /// + [JsonPropertyName("from")] + public string? From { get; set; } + + /// + /// A phone number in [E.164](https://community.sinch.com/t5/Glossary/E-164/ta-p/7537) format, including the leading '+'. + /// + [JsonPropertyName("to")] + public string? To { get; set; } + + /// + /// Give us any URL on the Internet (including ones with basic authentication) At least one file or contentUrl parameter is required.

+ /// Please note: If you are passing fax a secure URL (starting with https://), make sure that your SSL certificate (including your intermediate cert, if you have one) is installed properly, valid, and up-to-date. + /// If the file parameter is specified as well, content from URLs will be rendered before content from files. + ///
+ [JsonPropertyName("contentUrl")] + public List? ContentUrl { get; set; } + + /// + /// The number of pages in the fax. + /// + [JsonPropertyName("numberOfPages")] + public int NumberOfPages { get; set; } + + /// + /// Gets or Sets Status + /// + [JsonPropertyName("status")] + public FaxStatus? Status { get; set; } + + /// + /// Gets or Sets Price + /// + [JsonPropertyName("price")] + public Money? Price { get; set; } + + /// + /// The bar codes found in the fax. This field is populated when sinch detects bar codes on incoming faxes. + /// + [JsonPropertyName("barCodes")] + public List? BarCodes { get; set; } + + /// + /// A timestamp representing the time when the initial API call was made. + /// + [JsonPropertyName("createTime")] + public DateTime CreateTime { get; set; } + + /// + /// If the job is complete, this is a timestamp representing the time the job was completed. + /// + [JsonPropertyName("completedTime")] + public DateTime? CompletedTime { get; set; } + + /// + /// Text that will be displayed at the top of each page of the fax. 50 characters maximum. Default header text is \"-\". Note that the header is not applied until the fax is transmitted, so it will not appear on fax PDFs or thumbnails. + /// + [JsonPropertyName("headerText")] + public string? HeaderText { get; set; } + + /// + /// If true, page numbers will be displayed in the header. Default is true. + /// + [JsonPropertyName("headerPageNumbers")] + public bool? HeaderPageNumbers { get; set; } + + /// + /// A [TZ database name](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) string specifying the timezone for the header timestamp. + /// + [JsonPropertyName("headerTimeZone")] + public string? HeaderTimeZone { get; set; } + + /// + /// The number of seconds to wait between retries if the fax is not yet completed. + /// + [JsonPropertyName("retryDelaySeconds")] + public int? RetryDelaySeconds { get; set; } + + [JsonPropertyName("cancelTimeoutMinutes")] + public int? CancelTimeoutMinutes { get; set; } + + /// + /// You can use this to attach labels to your call that you can use in your applications. It is a key value store. + /// + [JsonPropertyName("labels")] + public Dictionary? Labels { get; set; } + + /// + /// The URL to which a callback will be sent when the fax is completed. The callback will be sent as a POST request with a JSON body. The callback will be sent to the URL specified in the `callbackUrl` parameter, if provided, otherwise it will be sent to the URL specified in the `callbackUrl` field of the Fax Service object. + /// + [JsonPropertyName("callbackUrl")] + public string? CallbackUrl { get; set; } + + /// + /// The content type of the callback. + /// + [JsonPropertyName("callbackUrlContentType")] + public CallbackUrlContentType? CallbackUrlContentType { get; set; } + + /// + /// Determines how documents are converted to black and white. Defaults to value selected on Fax Service object. + /// + [JsonPropertyName("imageConversionMethod")] + public ImageConversionMethod? ImageConversionMethod { get; set; } + + /// + /// Gets or Sets ErrorType + /// + [JsonPropertyName("errorType")] + public ErrorType? ErrorType { get; set; } + + /// + /// One of the error codes listed in the [Fax Error Messages section](https://developers.sinch.com/docs/fax/api-reference/fax/tag/Error-Messages). + /// + [JsonPropertyName("errorCode")] + public int? ErrorCode { get; set; } + + /// + /// One of the error codes listed in the [Fax Error Messages section](https://developers.sinch.com/docs/fax/api-reference/fax/tag/Error-Messages). + /// + [JsonPropertyName("errorMessage")] + public string? ErrorMessage { get; set; } + + /// + /// The `Id` of the project associated with the call. + /// + [JsonPropertyName("projectId")] + public string? ProjectId { get; set; } + + /// + /// ID of the fax service used. + /// + [JsonPropertyName("serviceId")] + public string? ServiceId { get; set; } + + /// + /// | The number of times the fax will be retired before cancel. Default value is set in your fax service. | The maximum number of retries is 5. + /// + [JsonPropertyName("maxRetries")] + public int? MaxRetries { get; set; } + + /// + /// The number of times the fax has been retried. + /// + [JsonPropertyName("retryCount")] + public int? RetryCount { get; set; } + + /// + /// Only shown on the fax result. This indicates if the content of the fax is stored with Sinch. (true or false) + /// + [JsonPropertyName("hasFile")] + public bool? HasFile { get; set; } + + /// + /// Returns the string presentation of the object + /// + /// String presentation of the object + public override string ToString() + { + var sb = new StringBuilder(); + sb.Append($"class {nameof(Fax)} {{\n"); + sb.Append($" {nameof(Id)}: ").Append(Id).Append('\n'); + sb.Append($" {nameof(Direction)}: ").Append(Direction).Append('\n'); + sb.Append($" {nameof(From)}: ").Append(From).Append('\n'); + sb.Append($" {nameof(To)}: ").Append(To).Append('\n'); + sb.Append($" {nameof(ContentUrl)}: ").Append(ContentUrl).Append('\n'); + sb.Append($" {nameof(NumberOfPages)}: ").Append(NumberOfPages).Append('\n'); + sb.Append($" {nameof(Status)}: ").Append(Status).Append('\n'); + sb.Append($" {nameof(Price)}: ").Append(Price).Append('\n'); + sb.Append($" {nameof(BarCodes)}: ").Append(BarCodes).Append('\n'); + sb.Append($" {nameof(CreateTime)}: ").Append(CreateTime).Append('\n'); + sb.Append($" {nameof(CompletedTime)}: ").Append(CompletedTime).Append('\n'); + sb.Append($" {nameof(HeaderText)}: ").Append(HeaderText).Append('\n'); + sb.Append($" {nameof(HeaderPageNumbers)}: ").Append(HeaderPageNumbers).Append('\n'); + sb.Append($" {nameof(HeaderTimeZone)}: ").Append(HeaderTimeZone).Append('\n'); + sb.Append($" {nameof(RetryDelaySeconds)}: ").Append(RetryDelaySeconds).Append('\n'); + sb.Append($" {nameof(Labels)}: ").Append(Labels).Append('\n'); + sb.Append($" {nameof(CallbackUrl)}: ").Append(CallbackUrl).Append('\n'); + sb.Append($" {nameof(CallbackUrlContentType)}: ").Append(CallbackUrlContentType).Append('\n'); + sb.Append($" {nameof(ImageConversionMethod)}: ").Append(ImageConversionMethod).Append('\n'); + sb.Append($" {nameof(ErrorType)}: ").Append(ErrorType).Append('\n'); + sb.Append($" {nameof(ErrorCode)}: ").Append(ErrorCode).Append('\n'); + sb.Append($" {nameof(ProjectId)}: ").Append(ProjectId).Append('\n'); + sb.Append($" {nameof(ServiceId)}: ").Append(ServiceId).Append('\n'); + sb.Append($" {nameof(MaxRetries)}: ").Append(MaxRetries).Append('\n'); + sb.Append($" {nameof(RetryCount)}: ").Append(RetryCount).Append('\n'); + sb.Append($" {nameof(HasFile)}: ").Append(HasFile).Append('\n'); + sb.Append("}\n"); + return sb.ToString(); + } + } + + /// + /// The content type of the callback. + /// + /// The content type of the callback. + [JsonConverter(typeof(EnumRecordJsonConverter))] + public record CallbackUrlContentType(string Value) : EnumRecord(Value) + { + public static readonly CallbackUrlContentType MultipartFormData = new("multipart/form-data"); + public static readonly CallbackUrlContentType ApplicationJson = new("application/json"); + + public override string ToString() + { + return base.ToString(); + } + } +} diff --git a/src/Sinch/Fax/Faxes/FaxStatus.cs b/src/Sinch/Fax/Faxes/FaxStatus.cs new file mode 100644 index 00000000..f0b163cf --- /dev/null +++ b/src/Sinch/Fax/Faxes/FaxStatus.cs @@ -0,0 +1,20 @@ +using System.Text.Json.Serialization; +using Sinch.Core; + +namespace Sinch.Fax.Faxes +{ + /// + /// The status of the fax + /// + /// The status of the fax + [JsonConverter(typeof(EnumRecordJsonConverter))] + public record FaxStatus(string Value) : EnumRecord(Value) + { + + public static readonly FaxStatus Queued = new("QUEUED"); + public static readonly FaxStatus InProgress = new("IN_PROGRESS"); + public static readonly FaxStatus Completed = new("COMPLETED"); + public static readonly FaxStatus Failure = new("FAILURE"); + } + +} diff --git a/src/Sinch/Fax/Faxes/FaxesClient.cs b/src/Sinch/Fax/Faxes/FaxesClient.cs new file mode 100644 index 00000000..65cc7feb --- /dev/null +++ b/src/Sinch/Fax/Faxes/FaxesClient.cs @@ -0,0 +1,235 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net.Http; +using System.Runtime.CompilerServices; +using System.Text.Json.Serialization; +using System.Threading; +using System.Threading.Tasks; +using Sinch.Core; +using Sinch.Logger; + +namespace Sinch.Fax.Faxes +{ + /// + /// The Fax API allows you to send and receive faxes. + /// You can send faxes to a single recipient or to multiple recipients. + /// You can also receive faxes and download them. + /// + public interface ISinchFaxFaxes + { + /// + /// Create and send a fax.

+ /// Fax content may be supplied via one or more files or URLs of supported filetypes.

+ /// If you supply a callbackUrl the callback will be sent as multipart/form-data with the content + /// of the fax as an attachment to the body, unless you specify callbackUrlContentType as application/json. + ///
+ /// A phone number in [E.164](https://community.sinch.com/t5/Glossary/E-164/ta-p/7537) format, including the leading '+'. + /// + /// + /// + public Task Send(string to, SendFaxRequest request, CancellationToken cancellationToken = default); + + + /// + /// Create and send a fax to multiple receivers.

+ /// Fax content may be supplied via one or more files or URLs of supported filetypes.

+ /// If you supply a callbackUrl the callback will be sent as multipart/form-data with the content + /// of the fax as an attachment to the body, unless you specify callbackUrlContentType as application/json. + ///
+ /// A list of phone numbers in [E.164](https://community.sinch.com/t5/Glossary/E-164/ta-p/7537) format, including the leading '+'. + /// + /// + /// + public Task> Send(List to, SendFaxRequest request, + CancellationToken cancellationToken = default); + + /// + /// List faxes sent (OUTBOUND) or received (INBOUND), set parameters to filter the list. + /// + /// + /// + /// + Task List(ListFaxesRequest listFaxesRequest, CancellationToken cancellationToken = default); + + /// + /// Automatically List faxes sent (OUTBOUND) or received (INBOUND), set parameters to filter the list. + /// + /// + /// + /// + IAsyncEnumerable ListAuto(ListFaxesRequest listFaxesRequest, + CancellationToken cancellationToken = default); + + /// + /// Get fax information using the ID number of the fax. + /// + /// The ID of the fax. + /// + /// + Task Get(string id, CancellationToken cancellationToken = default); + + /// + /// Delete the fax content for a fax using the ID number of the fax. Please note that this only deletes the content of the fax from storage. + /// + /// The ID of the fax. + /// + /// Successful task if response is 204. + Task DeleteContent(string id, CancellationToken cancellationToken = default); + + /// + /// Download the fax content. Currently, supports only pdf. + /// + /// + /// + /// + Task DownloadContent(string id, CancellationToken cancellationToken = default); + } + + internal sealed class FaxesClient : ISinchFaxFaxes + { + private readonly Uri _uri; + private readonly IHttp _http; + private readonly ILoggerAdapter? _loggerAdapter; + + internal FaxesClient(string projectId, Uri uri, ILoggerAdapter? loggerAdapter, IHttp httpClient) + { + _loggerAdapter = loggerAdapter; + _http = httpClient; + _uri = new Uri(uri, $"/v3/projects/{projectId}/faxes"); + } + + /// + public async Task Send(string to, SendFaxRequest request, CancellationToken cancellationToken = default) + { + if (string.IsNullOrEmpty(to)) + { + throw new ArgumentNullException(nameof(to), "Should have a value"); + } + + var faxes = await Send(new List() { to }, request, cancellationToken); + return faxes.First(); + } + + + // the fax will return a PLAIN fax if there is ONE TO number, but an array if there is > 1 + private class SendFaxResponse + { + [JsonPropertyName("faxes")] + public List Faxes { get; set; } + } + + /// + public async Task> Send(List to, SendFaxRequest request, + CancellationToken cancellationToken = default) + { + request.SetTo(to); + if (request.FileContent is not null) + { + _loggerAdapter?.LogInformation("Sending fax with file content..."); + if (request.To!.Count > 1) + { + var faxResponse = await _http.SendMultipart(_uri, request, + request.FileContent, + request.FileName!, cancellationToken: cancellationToken); + return faxResponse.Faxes; + } + + var fax = await _http.SendMultipart(_uri, request, request.FileContent, + request.FileName!, cancellationToken: cancellationToken); + return new List() { fax }; + } + + if (request.ContentUrl?.Any() == true) + { + _loggerAdapter?.LogInformation("Sending fax with content urls..."); + if (request.To!.Count > 1) + { + var faxResponse = await _http.Send(_uri, HttpMethod.Post, request, + cancellationToken: cancellationToken); + return faxResponse.Faxes; + } + + var fax = await _http.Send(_uri, HttpMethod.Post, request, + cancellationToken: cancellationToken); + return new List() { fax }; + } + + throw new InvalidOperationException( + "Neither content urls or file content provided for a create fax request."); + } + + /// + public async Task List(ListFaxesRequest listFaxesRequest, + CancellationToken cancellationToken = default) + { + _loggerAdapter?.LogInformation("Fetching a list of faxes..."); + var uriBuilder = new UriBuilder(_uri) + { + Query = listFaxesRequest.ToQueryString() + }; + + return await _http.Send(uriBuilder.Uri, HttpMethod.Get, cancellationToken); + } + + /// + public async IAsyncEnumerable ListAuto(ListFaxesRequest listFaxesRequest, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + _loggerAdapter?.LogDebug("Auto Listing faxes"); + + var response = await List(listFaxesRequest, cancellationToken); + while (!Utils.IsLastPage(response.PageNumber, response.PageSize, response.TotalItems, PageStart.One)) + { + if (response.Faxes != null) + foreach (var contact in response.Faxes) + yield return contact; + listFaxesRequest.Page = (response.PageNumber + 1); + response = await List(listFaxesRequest, cancellationToken); + } + } + + /// + public Task Get(string id, CancellationToken cancellationToken = default) + { + if (string.IsNullOrEmpty(id)) + { + throw new ArgumentNullException(nameof(id), "Fax id should have a value."); + } + + _loggerAdapter?.LogInformation("Getting the fax with {id}", id); + var uriBuilder = new UriBuilder(_uri); + uriBuilder.Path += "/" + id; + return _http.Send(uriBuilder.Uri, HttpMethod.Get, cancellationToken); + } + + /// + public Task DeleteContent(string id, CancellationToken cancellationToken = default) + { + if (string.IsNullOrEmpty(id)) + { + throw new ArgumentNullException(nameof(id), "Fax id should have a value."); + } + + _loggerAdapter?.LogInformation("Deleting the content of the fax with {id}", id); + var uriBuilder = new UriBuilder(_uri); + uriBuilder.Path += $"/{id}/file"; + return _http.Send(uriBuilder.Uri, HttpMethod.Delete, cancellationToken); + } + + /// + public Task DownloadContent(string id, CancellationToken cancellationToken = default) + { + if (string.IsNullOrEmpty(id)) + { + throw new ArgumentNullException(nameof(id), "Fax id should have a value."); + } + + _loggerAdapter?.LogInformation("Downloading the content of the fax with {id}", id); + var uriBuilder = new UriBuilder(_uri); + uriBuilder.Path += $"/{id}/file.pdf"; // only pdf is supported for now + return _http.Send(uriBuilder.Uri, HttpMethod.Get, cancellationToken); + } + } +} diff --git a/src/Sinch/Fax/Faxes/ImageConversionMethod.cs b/src/Sinch/Fax/Faxes/ImageConversionMethod.cs new file mode 100644 index 00000000..2045d9b6 --- /dev/null +++ b/src/Sinch/Fax/Faxes/ImageConversionMethod.cs @@ -0,0 +1,21 @@ +using System.Text.Json.Serialization; +using Sinch.Core; + +namespace Sinch.Fax.Faxes +{ + /// + /// Determines how documents are converted to black and white. Defaults to value selected on Fax Service object. + /// + /// Determines how documents are converted to black and white. Defaults to value selected on Fax Service object. + [JsonConverter(typeof(EnumRecordJsonConverter))] + public record ImageConversionMethod(string Value) : EnumRecord(Value) + { + public static readonly ImageConversionMethod Halftone = new("HALFTONE"); + public static readonly ImageConversionMethod Monochrome = new("MONOCHROME"); + + public override string ToString() + { + return base.ToString(); + } + } +} diff --git a/src/Sinch/Fax/Faxes/ListFaxResponse.cs b/src/Sinch/Fax/Faxes/ListFaxResponse.cs new file mode 100644 index 00000000..7dcaa702 --- /dev/null +++ b/src/Sinch/Fax/Faxes/ListFaxResponse.cs @@ -0,0 +1,32 @@ +using System.Collections.Generic; + +namespace Sinch.Fax.Faxes +{ + public class ListFaxResponse + { + /// + /// Current page + /// + public int PageNumber { get; set; } + + /// + /// Total number of pages. + /// + public int TotalPages { get; set; } + + /// + /// Number of items per page. + /// + public int PageSize { get; set; } + + /// + /// Total size of the result. + /// + public int TotalItems { get; set; } + + /// + /// An list of faxes + /// + public List? Faxes { get; set; } + } +} diff --git a/src/Sinch/Fax/Faxes/ListFaxesRequest.cs b/src/Sinch/Fax/Faxes/ListFaxesRequest.cs new file mode 100644 index 00000000..ed7a365b --- /dev/null +++ b/src/Sinch/Fax/Faxes/ListFaxesRequest.cs @@ -0,0 +1,138 @@ +using System; +using System.Collections.Generic; +using Sinch.Core; + +namespace Sinch.Fax.Faxes +{ + public class ListFaxesRequest + { + /// + /// Does not specify createTime filter, the default value of the server is used. + /// + public ListFaxesRequest() + { + } + + /// + /// Filters faxes created on the specified date in UTC + /// + /// + public ListFaxesRequest(DateOnly createTime) + { + CreateTime = createTime; + } + + /// + /// Filters faxes created in a time range in UTC + /// + /// Faxes created after the date inclusive. + /// Faxes created before the date inclusive. + public ListFaxesRequest(DateTime? createTimeAfter, DateTime? createTimeBefore) + { + CreateTimeAfter = createTimeAfter; + CreateTimeBefore = createTimeBefore; + } + + public DateOnly? CreateTime { get; private set; } + + public DateTime? CreateTimeAfter { get; private set; } + + + public DateTime? CreateTimeBefore { get; private set; } + + /// + /// Limits results to faxes with the specified direction. + /// + public Direction? Direction { get; set; } + + /// + /// Limits results to faxes with the specified status. + /// + public FaxStatus? Status { get; set; } + + /// + /// A phone number that you want to use to filter results. The parameter search with startsWith, + /// so you can pass a partial number to get all faxes sent to numbers that start with the number you passed. + /// + /// +14155552222 + public string? To { get; set; } + + /// + /// A phone number that you want to use to filter results. The parameter search with startsWith, + /// so you can pass a partial number to get all faxes sent to numbers that start with the number you passed. + /// + /// +15551235656 + public string? From { get; set; } + + /// + /// The page you want to retrieve returned from a previous List request, if any + /// + public int? Page { get; set; } + + /// + /// The maximum number of items to return per request. The default is 100 and the maximum is 1000. + /// If you need to export larger amounts and pagination is not suitable for you can use + /// the Export function in the dashboard. + /// + public int? PageSize { get; set; } + + public string ToQueryString() + { + var queryString = new List>(); + + if (CreateTime.HasValue) // mutually exclusive + { + queryString.Add(new("createTime", + StringUtils.ToIso8601(CreateTime.Value))); + } + else + { + if (CreateTimeAfter.HasValue) + { + // result will be inclusive createTime>= + queryString.Add(new("createTime>", + StringUtils.ToIso8601NoTicks(CreateTimeAfter.Value))); + } + + if (CreateTimeBefore.HasValue) + { + // result will be inclusive createTime<= + queryString.Add(new("createTime<", + StringUtils.ToIso8601NoTicks(CreateTimeBefore.Value))); + } + } + + if (Direction != null) + { + queryString.Add(new("direction", Direction.Value)); + } + + if (Status != null) + { + queryString.Add(new("status", Status.Value)); + } + + if (To != null) + { + queryString.Add(new("to", To)); + } + + if (From != null) + { + queryString.Add(new("from", From)); + } + + if (Page.HasValue) + { + queryString.Add(new("page", Page.Value.ToString())); + } + + if (PageSize.HasValue) + { + queryString.Add(new("pageSize", PageSize.Value.ToString())); + } + + return StringUtils.ToQueryString(queryString); + } + } +} diff --git a/src/Sinch/Fax/Faxes/Money.cs b/src/Sinch/Fax/Faxes/Money.cs new file mode 100644 index 00000000..f9dadbc0 --- /dev/null +++ b/src/Sinch/Fax/Faxes/Money.cs @@ -0,0 +1,41 @@ +using System.Text; +using System.Text.Json.Serialization; + +namespace Sinch.Fax.Faxes +{ + /// + /// + /// + public class Money + + { + /// + /// The 3-letter currency code defined in ISO 4217. + /// + [JsonPropertyName("currencyCode")] + [JsonInclude] + public string? CurrencyCode { get; private set; } + + + /// + /// The amount with 4 decimals and decimal delimiter `.`. + /// + [JsonPropertyName("amount")] + [JsonInclude] + public float Amount { get; private set; } + + /// + /// Returns the string presentation of the object + /// + /// String presentation of the object + public override string ToString() + { + var sb = new StringBuilder(); + sb.Append($"class {nameof(Money)} {{\n"); + sb.Append($" {nameof(CurrencyCode)}: ").Append(CurrencyCode).Append('\n'); + sb.Append($" {nameof(Amount)}: ").Append(Amount).Append('\n'); + sb.Append("}\n"); + return sb.ToString(); + } + } +} diff --git a/src/Sinch/Fax/Faxes/SendFaxRequest.cs b/src/Sinch/Fax/Faxes/SendFaxRequest.cs new file mode 100644 index 00000000..6efc1db4 --- /dev/null +++ b/src/Sinch/Fax/Faxes/SendFaxRequest.cs @@ -0,0 +1,184 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.Json.Serialization; +using System.Threading.Tasks; +using Sinch.Core; + +namespace Sinch.Fax.Faxes +{ + public class SendFaxRequest : IDisposable, IAsyncDisposable + { + [Obsolete("Required for system text json", true)] + public SendFaxRequest() + { + } + + /// + /// Creates a fax with contentUrls + /// + /// + public SendFaxRequest(List contentUrl) + { + ContentUrl = contentUrl; + } + + [JsonIgnore] + internal Stream? FileContent { get; } + + [JsonIgnore] + internal string? FileName { get; } + + /// + /// Creates a fax with attached content + /// + /// + /// + public SendFaxRequest(Stream fileContent, string fileName) + { + FileContent = fileContent; + FileName = fileName; + } + + /// + /// Creates a fax with attached content from a path + /// + /// + public SendFaxRequest(string filePath) + { + FileContent = File.OpenRead(filePath); + FileName = Path.GetFileName(filePath); + } + + /// + /// A list of phone numbers in [E.164](https://community.sinch.com/t5/Glossary/E-164/ta-p/7537) format, including the leading '+'. + /// + [JsonInclude] + [JsonPropertyName("to")] + public List? To { get; private set; } + + internal void SetTo(List to) + { + To = to.ToList(); + } + + /// + /// A phone number in [E.164](https://community.sinch.com/t5/Glossary/E-164/ta-p/7537) format, including the leading '+'. + /// + [JsonPropertyName("from")] + public string? From { get; set; } + + /// + /// Give us any URL on the Internet (including ones with basic authentication) At least one file or contentUrl parameter is required.

+ /// Please note: If you are passing fax a secure URL (starting with https://), make sure that your SSL certificate (including your intermediate cert, if you have one) is installed properly, valid, and up-to-date. + /// If the file parameter is specified as well, content from URLs will be rendered before content from files. + ///
+ [JsonPropertyName("contentUrl")] + [JsonInclude] + public List? ContentUrl { get; private set; } + + /// + /// Text that will be displayed at the top of each page of the fax. 50 characters maximum. Default header text is \"-\". Note that the header is not applied until the fax is transmitted, so it will not appear on fax PDFs or thumbnails. + /// + [JsonPropertyName("headerText")] + public string? HeaderText { get; set; } + + /// + /// If true, page numbers will be displayed in the header. Default is true. + /// + [JsonPropertyName("headerPageNumbers")] + public bool? HeaderPageNumbers { get; set; } + + /// + /// A [TZ database name](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) string specifying the timezone for the header timestamp. + /// + [JsonPropertyName("headerTimeZone")] + public string? HeaderTimeZone { get; set; } + + + /// + /// The number of seconds to wait between retries if the fax is not yet completed. + /// + [JsonPropertyName("retryDelaySeconds")] + public int? RetryDelaySeconds { get; set; } + + /// + /// You can use this to attach labels to your call that you can use in your applications. It is a key value store. + /// + [JsonPropertyName("labels")] + public Dictionary? Labels { get; set; } + + /// + /// The URL to which a callback will be sent when the fax is completed. The callback will be sent as a POST request with a JSON body. The callback will be sent to the URL specified in the `callbackUrl` parameter, if provided, otherwise it will be sent to the URL specified in the `callbackUrl` field of the Fax Service object. + /// + [JsonPropertyName("callbackUrl")] + public string? CallbackUrl { get; set; } + + /// + /// The content type of the callback. + /// + [JsonPropertyName("callbackUrlContentType")] + public CallbackUrlContentType? CallbackUrlContentType { get; set; } + + /// + /// Determines how documents are converted to black and white. Defaults to value selected on Fax Service object. + /// + [JsonPropertyName("imageConversionMethod")] + public ImageConversionMethod? ImageConversionMethod { get; set; } + + /// + /// ID of the fax service used. + /// + [JsonPropertyName("serviceId")] + public string? ServiceId { get; set; } + + /// + /// | The number of times the fax will be retired before cancel. Default value is set in your fax service. | The maximum number of retries is 5. + /// + [JsonPropertyName("maxRetries")] + public int? MaxRetries { get; set; } + + public void Dispose() + { + FileContent?.Dispose(); + } + + public ValueTask DisposeAsync() + { + if (FileContent != null) return FileContent.DisposeAsync(); + return ValueTask.CompletedTask; + } + } + + /// TODO: think about if it's worth implementing base64 send + internal sealed class Base64File + { + /// + /// Base64 encoded file content. + /// + [JsonPropertyName("file")] + public string? File { get; set; } + + [JsonPropertyName("fileType")] + public FileType? FileType { get; set; } + } + + [JsonConverter(typeof(EnumRecordJsonConverter))] + internal record FileType(string Value) : EnumRecord(Value) + { + // ReSharper disable InconsistentNaming + + public static readonly FileType DOC = new("DOC"); + public static readonly FileType DOCX = new("DOCX"); + public static readonly FileType PDF = new("PDF"); + public static readonly FileType TIF = new("TIF"); + public static readonly FileType JPG = new("JPG"); + public static readonly FileType ODT = new("ODT"); + public static readonly FileType TXT = new("TXT"); + public static readonly FileType PNG = new("PNG"); + public static readonly FileType HTML = new("HTML"); + + // ReSharper restore InconsistentNaming + } +} diff --git a/src/Sinch/Fax/Faxes/Utils.cs b/src/Sinch/Fax/Faxes/Utils.cs new file mode 100644 index 00000000..64004fdd --- /dev/null +++ b/src/Sinch/Fax/Faxes/Utils.cs @@ -0,0 +1,431 @@ +using System; +using System.Collections.Generic; + +namespace Sinch.Fax.Faxes +{ + internal class FileExtensionContentTypeProvider + { + private IDictionary Mappings { get; set; } + + + internal FileExtensionContentTypeProvider() + : this(new Dictionary(StringComparer.OrdinalIgnoreCase) + { + { ".323", "text/h323" }, + { ".3g2", "video/3gpp2" }, + { ".3gp2", "video/3gpp2" }, + { ".3gp", "video/3gpp" }, + { ".3gpp", "video/3gpp" }, + { ".aac", "audio/aac" }, + { ".aaf", "application/octet-stream" }, + { ".aca", "application/octet-stream" }, + { ".accdb", "application/msaccess" }, + { ".accde", "application/msaccess" }, + { ".accdt", "application/msaccess" }, + { ".acx", "application/internet-property-stream" }, + { ".adt", "audio/vnd.dlna.adts" }, + { ".adts", "audio/vnd.dlna.adts" }, + { ".afm", "application/octet-stream" }, + { ".ai", "application/postscript" }, + { ".aif", "audio/x-aiff" }, + { ".aifc", "audio/aiff" }, + { ".aiff", "audio/aiff" }, + { ".appcache", "text/cache-manifest" }, + { ".application", "application/x-ms-application" }, + { ".art", "image/x-jg" }, + { ".asd", "application/octet-stream" }, + { ".asf", "video/x-ms-asf" }, + { ".asi", "application/octet-stream" }, + { ".asm", "text/plain" }, + { ".asr", "video/x-ms-asf" }, + { ".asx", "video/x-ms-asf" }, + { ".atom", "application/atom+xml" }, + { ".au", "audio/basic" }, + { ".avi", "video/x-msvideo" }, + { ".axs", "application/olescript" }, + { ".bas", "text/plain" }, + { ".bcpio", "application/x-bcpio" }, + { ".bin", "application/octet-stream" }, + { ".bmp", "image/bmp" }, + { ".c", "text/plain" }, + { ".cab", "application/vnd.ms-cab-compressed" }, + { ".calx", "application/vnd.ms-office.calx" }, + { ".cat", "application/vnd.ms-pki.seccat" }, + { ".cdf", "application/x-cdf" }, + { ".chm", "application/octet-stream" }, + { ".class", "application/x-java-applet" }, + { ".clp", "application/x-msclip" }, + { ".cmx", "image/x-cmx" }, + { ".cnf", "text/plain" }, + { ".cod", "image/cis-cod" }, + { ".cpio", "application/x-cpio" }, + { ".cpp", "text/plain" }, + { ".crd", "application/x-mscardfile" }, + { ".crl", "application/pkix-crl" }, + { ".crt", "application/x-x509-ca-cert" }, + { ".csh", "application/x-csh" }, + { ".css", "text/css" }, + { ".csv", "application/octet-stream" }, + { ".cur", "application/octet-stream" }, + { ".dcr", "application/x-director" }, + { ".deploy", "application/octet-stream" }, + { ".der", "application/x-x509-ca-cert" }, + { ".dib", "image/bmp" }, + { ".dir", "application/x-director" }, + { ".disco", "text/xml" }, + { ".dlm", "text/dlm" }, + { ".doc", "application/msword" }, + { ".docm", "application/vnd.ms-word.document.macroEnabled.12" }, + { ".docx", "application/vnd.openxmlformats-officedocument.wordprocessingml.document" }, + { ".dot", "application/msword" }, + { ".dotm", "application/vnd.ms-word.template.macroEnabled.12" }, + { ".dotx", "application/vnd.openxmlformats-officedocument.wordprocessingml.template" }, + { ".dsp", "application/octet-stream" }, + { ".dtd", "text/xml" }, + { ".dvi", "application/x-dvi" }, + { ".dvr-ms", "video/x-ms-dvr" }, + { ".dwf", "drawing/x-dwf" }, + { ".dwp", "application/octet-stream" }, + { ".dxr", "application/x-director" }, + { ".eml", "message/rfc822" }, + { ".emz", "application/octet-stream" }, + { ".eot", "application/vnd.ms-fontobject" }, + { ".eps", "application/postscript" }, + { ".etx", "text/x-setext" }, + { ".evy", "application/envoy" }, + { ".fdf", "application/vnd.fdf" }, + { ".fif", "application/fractals" }, + { ".fla", "application/octet-stream" }, + { ".flr", "x-world/x-vrml" }, + { ".flv", "video/x-flv" }, + { ".gif", "image/gif" }, + { ".gtar", "application/x-gtar" }, + { ".gz", "application/x-gzip" }, + { ".h", "text/plain" }, + { ".hdf", "application/x-hdf" }, + { ".hdml", "text/x-hdml" }, + { ".hhc", "application/x-oleobject" }, + { ".hhk", "application/octet-stream" }, + { ".hhp", "application/octet-stream" }, + { ".hlp", "application/winhlp" }, + { ".hqx", "application/mac-binhex40" }, + { ".hta", "application/hta" }, + { ".htc", "text/x-component" }, + { ".htm", "text/html" }, + { ".html", "text/html" }, + { ".htt", "text/webviewhtml" }, + { ".hxt", "text/html" }, + { ".ical", "text/calendar" }, + { ".icalendar", "text/calendar" }, + { ".ico", "image/x-icon" }, + { ".ics", "text/calendar" }, + { ".ief", "image/ief" }, + { ".ifb", "text/calendar" }, + { ".iii", "application/x-iphone" }, + { ".inf", "application/octet-stream" }, + { ".ins", "application/x-internet-signup" }, + { ".isp", "application/x-internet-signup" }, + { ".IVF", "video/x-ivf" }, + { ".jar", "application/java-archive" }, + { ".java", "application/octet-stream" }, + { ".jck", "application/liquidmotion" }, + { ".jcz", "application/liquidmotion" }, + { ".jfif", "image/pjpeg" }, + { ".jpb", "application/octet-stream" }, + { ".jpe", "image/jpeg" }, + { ".jpeg", "image/jpeg" }, + { ".jpg", "image/jpeg" }, + { ".js", "application/javascript" }, + { ".json", "application/json" }, + { ".jsx", "text/jscript" }, + { ".latex", "application/x-latex" }, + { ".lit", "application/x-ms-reader" }, + { ".lpk", "application/octet-stream" }, + { ".lsf", "video/x-la-asf" }, + { ".lsx", "video/x-la-asf" }, + { ".lzh", "application/octet-stream" }, + { ".m13", "application/x-msmediaview" }, + { ".m14", "application/x-msmediaview" }, + { ".m1v", "video/mpeg" }, + { ".m2ts", "video/vnd.dlna.mpeg-tts" }, + { ".m3u", "audio/x-mpegurl" }, + { ".m4a", "audio/mp4" }, + { ".m4v", "video/mp4" }, + { ".man", "application/x-troff-man" }, + { ".manifest", "application/x-ms-manifest" }, + { ".map", "text/plain" }, + { ".markdown", "text/markdown" }, + { ".md", "text/markdown" }, + { ".mdb", "application/x-msaccess" }, + { ".mdp", "application/octet-stream" }, + { ".me", "application/x-troff-me" }, + { ".mht", "message/rfc822" }, + { ".mhtml", "message/rfc822" }, + { ".mid", "audio/mid" }, + { ".midi", "audio/mid" }, + { ".mix", "application/octet-stream" }, + { ".mmf", "application/x-smaf" }, + { ".mno", "text/xml" }, + { ".mny", "application/x-msmoney" }, + { ".mov", "video/quicktime" }, + { ".movie", "video/x-sgi-movie" }, + { ".mp2", "video/mpeg" }, + { ".mp3", "audio/mpeg" }, + { ".mp4", "video/mp4" }, + { ".mp4v", "video/mp4" }, + { ".mpa", "video/mpeg" }, + { ".mpe", "video/mpeg" }, + { ".mpeg", "video/mpeg" }, + { ".mpg", "video/mpeg" }, + { ".mpp", "application/vnd.ms-project" }, + { ".mpv2", "video/mpeg" }, + { ".ms", "application/x-troff-ms" }, + { ".msi", "application/octet-stream" }, + { ".mso", "application/octet-stream" }, + { ".mvb", "application/x-msmediaview" }, + { ".mvc", "application/x-miva-compiled" }, + { ".nc", "application/x-netcdf" }, + { ".nsc", "video/x-ms-asf" }, + { ".nws", "message/rfc822" }, + { ".ocx", "application/octet-stream" }, + { ".oda", "application/oda" }, + { ".odc", "text/x-ms-odc" }, + { ".ods", "application/oleobject" }, + { ".oga", "audio/ogg" }, + { ".ogg", "video/ogg" }, + { ".ogv", "video/ogg" }, + { ".ogx", "application/ogg" }, + { ".one", "application/onenote" }, + { ".onea", "application/onenote" }, + { ".onetoc", "application/onenote" }, + { ".onetoc2", "application/onenote" }, + { ".onetmp", "application/onenote" }, + { ".onepkg", "application/onenote" }, + { ".osdx", "application/opensearchdescription+xml" }, + { ".otf", "font/otf" }, + { ".p10", "application/pkcs10" }, + { ".p12", "application/x-pkcs12" }, + { ".p7b", "application/x-pkcs7-certificates" }, + { ".p7c", "application/pkcs7-mime" }, + { ".p7m", "application/pkcs7-mime" }, + { ".p7r", "application/x-pkcs7-certreqresp" }, + { ".p7s", "application/pkcs7-signature" }, + { ".pbm", "image/x-portable-bitmap" }, + { ".pcx", "application/octet-stream" }, + { ".pcz", "application/octet-stream" }, + { ".pdf", "application/pdf" }, + { ".pfb", "application/octet-stream" }, + { ".pfm", "application/octet-stream" }, + { ".pfx", "application/x-pkcs12" }, + { ".pgm", "image/x-portable-graymap" }, + { ".pko", "application/vnd.ms-pki.pko" }, + { ".pma", "application/x-perfmon" }, + { ".pmc", "application/x-perfmon" }, + { ".pml", "application/x-perfmon" }, + { ".pmr", "application/x-perfmon" }, + { ".pmw", "application/x-perfmon" }, + { ".png", "image/png" }, + { ".pnm", "image/x-portable-anymap" }, + { ".pnz", "image/png" }, + { ".pot", "application/vnd.ms-powerpoint" }, + { ".potm", "application/vnd.ms-powerpoint.template.macroEnabled.12" }, + { ".potx", "application/vnd.openxmlformats-officedocument.presentationml.template" }, + { ".ppam", "application/vnd.ms-powerpoint.addin.macroEnabled.12" }, + { ".ppm", "image/x-portable-pixmap" }, + { ".pps", "application/vnd.ms-powerpoint" }, + { ".ppsm", "application/vnd.ms-powerpoint.slideshow.macroEnabled.12" }, + { ".ppsx", "application/vnd.openxmlformats-officedocument.presentationml.slideshow" }, + { ".ppt", "application/vnd.ms-powerpoint" }, + { ".pptm", "application/vnd.ms-powerpoint.presentation.macroEnabled.12" }, + { ".pptx", "application/vnd.openxmlformats-officedocument.presentationml.presentation" }, + { ".prf", "application/pics-rules" }, + { ".prm", "application/octet-stream" }, + { ".prx", "application/octet-stream" }, + { ".ps", "application/postscript" }, + { ".psd", "application/octet-stream" }, + { ".psm", "application/octet-stream" }, + { ".psp", "application/octet-stream" }, + { ".pub", "application/x-mspublisher" }, + { ".qt", "video/quicktime" }, + { ".qtl", "application/x-quicktimeplayer" }, + { ".qxd", "application/octet-stream" }, + { ".ra", "audio/x-pn-realaudio" }, + { ".ram", "audio/x-pn-realaudio" }, + { ".rar", "application/octet-stream" }, + { ".ras", "image/x-cmu-raster" }, + { ".rf", "image/vnd.rn-realflash" }, + { ".rgb", "image/x-rgb" }, + { ".rm", "application/vnd.rn-realmedia" }, + { ".rmi", "audio/mid" }, + { ".roff", "application/x-troff" }, + { ".rpm", "audio/x-pn-realaudio-plugin" }, + { ".rtf", "application/rtf" }, + { ".rtx", "text/richtext" }, + { ".scd", "application/x-msschedule" }, + { ".sct", "text/scriptlet" }, + { ".sea", "application/octet-stream" }, + { ".setpay", "application/set-payment-initiation" }, + { ".setreg", "application/set-registration-initiation" }, + { ".sgml", "text/sgml" }, + { ".sh", "application/x-sh" }, + { ".shar", "application/x-shar" }, + { ".sit", "application/x-stuffit" }, + { ".sldm", "application/vnd.ms-powerpoint.slide.macroEnabled.12" }, + { ".sldx", "application/vnd.openxmlformats-officedocument.presentationml.slide" }, + { ".smd", "audio/x-smd" }, + { ".smi", "application/octet-stream" }, + { ".smx", "audio/x-smd" }, + { ".smz", "audio/x-smd" }, + { ".snd", "audio/basic" }, + { ".snp", "application/octet-stream" }, + { ".spc", "application/x-pkcs7-certificates" }, + { ".spl", "application/futuresplash" }, + { ".spx", "audio/ogg" }, + { ".src", "application/x-wais-source" }, + { ".ssm", "application/streamingmedia" }, + { ".sst", "application/vnd.ms-pki.certstore" }, + { ".stl", "application/vnd.ms-pki.stl" }, + { ".sv4cpio", "application/x-sv4cpio" }, + { ".sv4crc", "application/x-sv4crc" }, + { ".svg", "image/svg+xml" }, + { ".svgz", "image/svg+xml" }, + { ".swf", "application/x-shockwave-flash" }, + { ".t", "application/x-troff" }, + { ".tar", "application/x-tar" }, + { ".tcl", "application/x-tcl" }, + { ".tex", "application/x-tex" }, + { ".texi", "application/x-texinfo" }, + { ".texinfo", "application/x-texinfo" }, + { ".tgz", "application/x-compressed" }, + { ".thmx", "application/vnd.ms-officetheme" }, + { ".thn", "application/octet-stream" }, + { ".tif", "image/tiff" }, + { ".tiff", "image/tiff" }, + { ".toc", "application/octet-stream" }, + { ".tr", "application/x-troff" }, + { ".trm", "application/x-msterminal" }, + { ".ts", "video/vnd.dlna.mpeg-tts" }, + { ".tsv", "text/tab-separated-values" }, + { ".ttc", "application/x-font-ttf" }, + { ".ttf", "application/x-font-ttf" }, + { ".tts", "video/vnd.dlna.mpeg-tts" }, + { ".txt", "text/plain" }, + { ".u32", "application/octet-stream" }, + { ".uls", "text/iuls" }, + { ".ustar", "application/x-ustar" }, + { ".vbs", "text/vbscript" }, + { ".vcf", "text/x-vcard" }, + { ".vcs", "text/plain" }, + { ".vdx", "application/vnd.ms-visio.viewer" }, + { ".vml", "text/xml" }, + { ".vsd", "application/vnd.visio" }, + { ".vss", "application/vnd.visio" }, + { ".vst", "application/vnd.visio" }, + { ".vsto", "application/x-ms-vsto" }, + { ".vsw", "application/vnd.visio" }, + { ".vsx", "application/vnd.visio" }, + { ".vtx", "application/vnd.visio" }, + { ".wasm", "application/wasm" }, + { ".wav", "audio/wav" }, + { ".wax", "audio/x-ms-wax" }, + { ".wbmp", "image/vnd.wap.wbmp" }, + { ".wcm", "application/vnd.ms-works" }, + { ".wdb", "application/vnd.ms-works" }, + { ".webm", "video/webm" }, + { ".webp", "image/webp" }, + { ".wks", "application/vnd.ms-works" }, + { ".wm", "video/x-ms-wm" }, + { ".wma", "audio/x-ms-wma" }, + { ".wmd", "application/x-ms-wmd" }, + { ".wmf", "application/x-msmetafile" }, + { ".wml", "text/vnd.wap.wml" }, + { ".wmlc", "application/vnd.wap.wmlc" }, + { ".wmls", "text/vnd.wap.wmlscript" }, + { ".wmlsc", "application/vnd.wap.wmlscriptc" }, + { ".wmp", "video/x-ms-wmp" }, + { ".wmv", "video/x-ms-wmv" }, + { ".wmx", "video/x-ms-wmx" }, + { ".wmz", "application/x-ms-wmz" }, + { ".woff", "application/font-woff" }, + { ".woff2", "font/woff2" }, + { ".wps", "application/vnd.ms-works" }, + { ".wri", "application/x-mswrite" }, + { ".wrl", "x-world/x-vrml" }, + { ".wrz", "x-world/x-vrml" }, + { ".wsdl", "text/xml" }, + { ".wtv", "video/x-ms-wtv" }, + { ".wvx", "video/x-ms-wvx" }, + { ".x", "application/directx" }, + { ".xaf", "x-world/x-vrml" }, + { ".xaml", "application/xaml+xml" }, + { ".xap", "application/x-silverlight-app" }, + { ".xbap", "application/x-ms-xbap" }, + { ".xbm", "image/x-xbitmap" }, + { ".xdr", "text/plain" }, + { ".xht", "application/xhtml+xml" }, + { ".xhtml", "application/xhtml+xml" }, + { ".xla", "application/vnd.ms-excel" }, + { ".xlam", "application/vnd.ms-excel.addin.macroEnabled.12" }, + { ".xlc", "application/vnd.ms-excel" }, + { ".xlm", "application/vnd.ms-excel" }, + { ".xls", "application/vnd.ms-excel" }, + { ".xlsb", "application/vnd.ms-excel.sheet.binary.macroEnabled.12" }, + { ".xlsm", "application/vnd.ms-excel.sheet.macroEnabled.12" }, + { ".xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" }, + { ".xlt", "application/vnd.ms-excel" }, + { ".xltm", "application/vnd.ms-excel.template.macroEnabled.12" }, + { ".xltx", "application/vnd.openxmlformats-officedocument.spreadsheetml.template" }, + { ".xlw", "application/vnd.ms-excel" }, + { ".xml", "text/xml" }, + { ".xof", "x-world/x-vrml" }, + { ".xpm", "image/x-xpixmap" }, + { ".xps", "application/vnd.ms-xpsdocument" }, + { ".xsd", "text/xml" }, + { ".xsf", "text/xml" }, + { ".xsl", "text/xml" }, + { ".xslt", "text/xml" }, + { ".xsn", "application/octet-stream" }, + { ".xtp", "application/octet-stream" }, + { ".xwd", "image/x-xwindowdump" }, + { ".z", "application/x-compress" }, + { ".zip", "application/x-zip-compressed" } + }) + { + } + + + private FileExtensionContentTypeProvider(IDictionary mapping) + { + if (mapping == null) + { + throw new ArgumentNullException(nameof(mapping)); + } + + Mappings = mapping; + } + + + internal bool TryGetContentType(string subpath, out string? contentType) + { + var extension = GetExtension(subpath); + if (extension == null) + { + contentType = null; + return false; + } + + return Mappings.TryGetValue(extension, out contentType); + } + + private static string? GetExtension(string path) + { + if (string.IsNullOrWhiteSpace(path)) + { + return null; + } + + var num = path.LastIndexOf('.'); + return num < 0 ? null : path[num..]; + } + } +} diff --git a/src/Sinch/Sinch.csproj b/src/Sinch/Sinch.csproj index 7018e9a5..5c7e87de 100644 --- a/src/Sinch/Sinch.csproj +++ b/src/Sinch/Sinch.csproj @@ -31,8 +31,8 @@ - - + + diff --git a/src/Sinch/SinchApiException.cs b/src/Sinch/SinchApiException.cs index 5bbaa7d9..53f6e035 100644 --- a/src/Sinch/SinchApiException.cs +++ b/src/Sinch/SinchApiException.cs @@ -25,11 +25,20 @@ internal SinchApiException(HttpStatusCode statusCode, string? message, Exception // there can be nested error object or simple { text: "", code: "code" } not nested object with api errors // nested object takes precedence in fields population var details = authApiError?.Error; - Status = details?.Status ?? authApiError?.Code; + Status = details?.Status ?? authApiError?.Code?.ToString(); DetailedMessage = details?.Message ?? authApiError?.Text; Details = details?.Details ?? new List(); } + internal SinchApiException(HttpStatusCode statusCode, string? message, Exception? inner, + ApiError? apiError) + : this($"{message}:{apiError?.Message}", inner, statusCode) + { + Status = apiError?.Status; + DetailedMessage = apiError?.Message; + Details = apiError?.Details ?? new List(); + } + public string? DetailedMessage { get; init; } public string? Status { get; init; } diff --git a/src/Sinch/SinchClient.cs b/src/Sinch/SinchClient.cs index 5ce1e3fa..67a2bf51 100644 --- a/src/Sinch/SinchClient.cs +++ b/src/Sinch/SinchClient.cs @@ -6,6 +6,7 @@ using Sinch.Auth; using Sinch.Conversation; using Sinch.Core; +using Sinch.Fax; using Sinch.Logger; using Sinch.Numbers; using Sinch.SMS; @@ -61,6 +62,8 @@ public interface ISinchClient /// public ISinchConversation Conversation { get; } + public ISinchFax Fax { get; } + /// /// Verify users with SMS, flash calls (missed calls), a regular call, or data verification. /// This document serves as a user guide and documentation on how to use the Sinch Verification REST APIs. @@ -119,6 +122,8 @@ public class SinchClient : ISinchClient private const string NumbersApiUrl = "https://numbers.api.sinch.com/"; private const string SmsApiUrlTemplate = "https://zt.{0}.sms.api.sinch.com"; private const string SmsApiServicePlanIdUrlTemplate = "https://{0}.sms.api.sinch.com"; + private const string FaxApiUrl = "https://fax.api.sinch.com/"; + private const string FaxApiUrlTemplate = "https://{0}.fax.api.sinch.com/"; private const string ConversationApiUrlTemplate = "https://{0}.conversation.api.sinch.com/"; private const string VoiceApiUrlTemplate = "https://{0}.api.sinch.com/"; @@ -140,9 +145,9 @@ public class SinchClient : ISinchClient private readonly LoggerFactory? _loggerFactory; private readonly ISinchNumbers _numbers; - private readonly ISinchSms _sms; private readonly ILoggerAdapter? _logger; + private readonly ISinchFax _fax; /// /// Initialize a new @@ -202,9 +207,27 @@ public SinchClient(string? projectId, string? keyId, string? keySecret, , templatesBaseAddress, _loggerFactory, httpSnakeCaseOAuth); + var faxUrl = ResolveFaxUrl(optionsObj.FaxRegion); + _fax = new FaxClient(projectId!, faxUrl, _loggerFactory, httpCamelCase); + _logger?.LogInformation("SinchClient initialized."); } + private Uri ResolveFaxUrl(FaxRegion? faxRegion) + { + if (!string.IsNullOrEmpty(_apiUrlOverrides?.FaxUrl)) + { + return new Uri(_apiUrlOverrides.FaxUrl); + } + + if (!string.IsNullOrEmpty(faxRegion?.Value)) + { + return new Uri(string.Format(FaxApiUrlTemplate, faxRegion.Value)); + } + + return new Uri(FaxApiUrl); + } + /// public ISinchNumbers Numbers { @@ -247,7 +270,17 @@ public ISinchAuth Auth } } - /// + /// + public ISinchFax Fax + { + get + { + ValidateCommonCredentials(); + return _fax; + } + } + + /// public ISinchVerificationClient Verification(string appKey, string appSecret, AuthStrategy authStrategy = AuthStrategy.ApplicationSign) { diff --git a/src/Sinch/SinchOptions.cs b/src/Sinch/SinchOptions.cs index 862cc73b..70184753 100644 --- a/src/Sinch/SinchOptions.cs +++ b/src/Sinch/SinchOptions.cs @@ -2,6 +2,7 @@ using System.Net.Http; using Microsoft.Extensions.Logging; using Sinch.Conversation; +using Sinch.Fax; using Sinch.SMS; namespace Sinch @@ -37,6 +38,11 @@ public sealed class SinchOptions /// public ConversationRegion ConversationRegion { get; set; } = ConversationRegion.Us; + /// + /// Set's the regions for the Fax api. + /// + public FaxRegion? FaxRegion { get; set; } + /// public ApiUrlOverrides? ApiUrlOverrides { get; set; } @@ -131,5 +137,10 @@ public sealed class ApiUrlOverrides /// Overrides Numbers api base url /// public string? NumbersUrl { get; init; } + + /// + /// Overrides Fax api base url + /// + public string? FaxUrl { get; init; } } } diff --git a/tests/Sinch.Tests/Core/HttpTests.cs b/tests/Sinch.Tests/Core/HttpTests.cs index ee6dd7aa..0b1032a9 100644 --- a/tests/Sinch.Tests/Core/HttpTests.cs +++ b/tests/Sinch.Tests/Core/HttpTests.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.IO; using System.Net; using System.Net.Http; using System.Reflection; @@ -10,6 +11,7 @@ using RichardSzalay.MockHttp; using Sinch.Auth; using Sinch.Core; +using Sinch.Fax.Faxes; using Xunit; namespace Sinch.Tests.Core @@ -161,5 +163,36 @@ public async Task SendSinchHeader() _httpMessageHandlerMock.VerifyNoOutstandingExpectation(); } + + [Fact] + public async Task SendMultipartFormData() + { + var uri = new Uri("http://hello.fax"); + _httpMessageHandlerMock.Expect(HttpMethod.Post, uri.ToString()) + .WithPartialContent("To\r\n\r\n123,456") + .WithPartialContent("MaxRetries\r\n\r\n3") + .WithPartialContent("\"Labels[hello]\"\r\n\r\nworld") + .WithPartialContent("\"Labels[no]\"\r\n\r\nidea") + .WithPartialContent("HeaderPageNumbers\r\n\r\nTrue") + .Respond(HttpStatusCode.OK); + var httpClient = new HttpClient(_httpMessageHandlerMock); + var http = new Http(_tokenManagerMock, httpClient, null, new SnakeCaseNamingPolicy()); + var faxRequest = new SendFaxRequest(new MemoryStream(), "file.pdf") + { + MaxRetries = 3, + Labels = new Dictionary() + { + { "hello", "world" }, + { "no", "idea" } + }, + HeaderPageNumbers = true, + }; + faxRequest.SetTo(new List() { "123", "456" }); + + await http.SendMultipart(uri, faxRequest, + faxRequest.FileContent!, faxRequest.FileName!); + + _httpMessageHandlerMock.VerifyNoOutstandingExpectation(); + } } } diff --git a/tests/Sinch.Tests/StringUtilsTests.cs b/tests/Sinch.Tests/StringUtilsTests.cs new file mode 100644 index 00000000..8f4cf651 --- /dev/null +++ b/tests/Sinch.Tests/StringUtilsTests.cs @@ -0,0 +1,43 @@ +using System; +using FluentAssertions; +using Sinch.Core; +using Xunit; + +namespace Sinch.Tests +{ + public class StringUtilsTests + { + [Theory] + [InlineData("Name", "name")] + [InlineData("DataBroker", "dataBroker")] + [InlineData("HelloWorldAgain", "helloWorldAgain")] + [InlineData("test", "test")] + public void ToCamelCase(string input, string output) + { + StringUtils.PascalToCamelCase(input).Should().BeEquivalentTo(output); + } + + [Theory] + [InlineData("")] + [InlineData(null)] + public void ThrowCamelCase(string input) + { + var op = () => StringUtils.PascalToCamelCase(input); + op.Should().Throw(); + } + + [Fact] + public void Iso8160NoTicks() + { + StringUtils.ToIso8601NoTicks(new DateTime(2024, 05, 21, 12, 23, 11)).Should() + .BeEquivalentTo("2024-05-21T12:23:11Z"); + } + + [Fact] + public void Iso8160Ticks() + { + StringUtils.ToIso8601(new DateTime(2024, 05, 21, 12, 23, 11, 586)).Should() + .BeEquivalentTo("2024-05-21T12:23:11.5860000"); + } + } +} diff --git a/tests/Sinch.Tests/UtilsTests.cs b/tests/Sinch.Tests/UtilsTests.cs index a7e50489..2f6a5fe3 100644 --- a/tests/Sinch.Tests/UtilsTests.cs +++ b/tests/Sinch.Tests/UtilsTests.cs @@ -25,6 +25,22 @@ public void NotLastPage() Utils.IsLastPage(4, 1, 6).Should().BeFalse(); } + [Fact] + public void LastPageFirst() + { + Utils.IsLastPage(1, 10, 2, PageStart.One).Should().BeTrue(); + Utils.IsLastPage(1, 10, 9, PageStart.One).Should().BeTrue(); + Utils.IsLastPage(4, 1, 4, PageStart.One).Should().BeTrue(); + } + + [Fact] + public void NotLastPageFirst() + { + Utils.IsLastPage(1, 10, 12, PageStart.One).Should().BeFalse(); + Utils.IsLastPage(1, 10, 11, PageStart.One).Should().BeFalse(); + Utils.IsLastPage(4, 1, 6, PageStart.One).Should().BeFalse(); + } + class Root { @@ -55,7 +71,25 @@ public void QueryString() } }; var str = Utils.ToSnakeCaseQueryString(root); - str.Should().Be("date=2022-07-12T00%3A00%3A00.0000000&desc_long=descri&type=LOCAL&types=LOCAL&types=MOBILE"); + str.Should() + .Be("date=2022-07-12T00%3A00%3A00.0000000&desc_long=descri&type=LOCAL&types=LOCAL&types=MOBILE"); + } + + [Fact] + public void ToQueryStringCamelCase() + { + var root = new Root() + { + Type = Types.Local, + Date = new DateTime(2022, 7, 12), + DescLong = "descri", + Types = new List() + { + Types.Local, Types.Mobile + } + }; + var str = Utils.ToQueryString(root, StringUtils.PascalToCamelCase); + str.Should().Be("date=2022-07-12T00%3A00%3A00.0000000&descLong=descri&type=LOCAL&types=LOCAL&types=MOBILE"); } } } diff --git a/tests/Sinch.Tests/e2e/Fax/FaxTestBase.cs b/tests/Sinch.Tests/e2e/Fax/FaxTestBase.cs new file mode 100644 index 00000000..2a62f54d --- /dev/null +++ b/tests/Sinch.Tests/e2e/Fax/FaxTestBase.cs @@ -0,0 +1,14 @@ +using Sinch.Fax; + +namespace Sinch.Tests.e2e.Fax +{ + public class FaxTestBase : TestBase + { + protected readonly ISinchFax FaxClient; + + protected FaxTestBase() + { + FaxClient = SinchClientMockServer.Fax; + } + } +} diff --git a/tests/Sinch.Tests/e2e/Fax/FaxesTests.cs b/tests/Sinch.Tests/e2e/Fax/FaxesTests.cs new file mode 100644 index 00000000..f1f0a4df --- /dev/null +++ b/tests/Sinch.Tests/e2e/Fax/FaxesTests.cs @@ -0,0 +1,146 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Threading.Tasks; +using FluentAssertions; +using Sinch.Fax.Faxes; +using Xunit; + +namespace Sinch.Tests.e2e.Fax +{ + public class FaxesTests : FaxTestBase + { + private Sinch.Fax.Faxes.Fax _fax = new Sinch.Fax.Faxes.Fax() + { + Id = "01HXVD9FPQ8MAJ2650W0KTY7D4", + Direction = Direction.Outbound, + To = "+12015555555", + ContentUrl = new List() + { + "https://developers.sinch.com/fax/fax.pdf", + "https://developers.sinch.com/fax/fax.pdf" + }, + NumberOfPages = 0, + Status = FaxStatus.InProgress, + CreateTime = DateTime.Parse("2024-05-14T11:20:05Z", null, DateTimeStyles.AdjustToUniversal), + HeaderPageNumbers = true, + HeaderTimeZone = "America/New_York", + RetryDelaySeconds = 60, + ImageConversionMethod = ImageConversionMethod.Halftone, + ServiceId = "01HXGS1GE2SXS6HKQDMPYM1JHY", + MaxRetries = 3, + HasFile = false, + ProjectId = ProjectId + }; + + [Fact] + public async Task SendContentUrls() + { + var response = await FaxClient.Faxes.Send("+12015555555", + new SendFaxRequest(new List() { "http://fax-db/fax1.pdf", "http://fax-db/fax2.pdf" })); + response.Should().BeEquivalentTo(new Sinch.Fax.Faxes.Fax() + { + Id = "01HXVD9FPQ8MAJ2650W0KTY7D4", + Direction = Direction.Outbound, + Status = FaxStatus.InProgress, + CreateTime = DateTime.Parse("2024-05-14T11:20:05Z", null, DateTimeStyles.AdjustToUniversal), + HeaderPageNumbers = true, + HeaderTimeZone = "America/New_York", + ServiceId = "01HXGS1GE2SXS6HKQDMPYM1JHY", + ProjectId = ProjectId, + MaxRetries = 3, + ImageConversionMethod = ImageConversionMethod.Halftone, + To = "+12015555555", + RetryDelaySeconds = 60, + CallbackUrlContentType = CallbackUrlContentType.MultipartFormData, + ContentUrl = new List() + { + "http://fax-db/fax1.pdf", + "http://fax-db/fax2.pdf", + } + }); + } + + [Fact] + public async Task SendManyRecipients() + { + var response = await FaxClient.Faxes.Send(new List() { "+12015555555", "+12015555554" }, + new SendFaxRequest(new List() { "http://fax-db/fax1.pdf" })); + response.Should().HaveCount(2); + } + + [Fact] + public async Task GetFax() + { + var fax = await FaxClient.Faxes.Get("01HXVD9FPQ8MAJ2650W0KTY7D4"); + fax.Should().BeEquivalentTo(_fax); + } + + [Fact] + public async Task DeleteContent() + { + var op = () => FaxClient.Faxes.DeleteContent("01HXVD9FPQ8MAJ2650W0KTY7D4"); + await op.Should().NotThrowAsync(); + } + + [Fact] + public async Task ListDate() + { + var listFaxes = await FaxClient.Faxes.List(new ListFaxesRequest(new DateOnly(2024, 05, 14))); + listFaxes.Should().BeEquivalentTo(new ListFaxResponse() + { + PageNumber = 1, + TotalPages = 1, + PageSize = 100, + TotalItems = 18, + Faxes = new List() + { + _fax + } + }); + } + + [Fact] + public async Task ListAfterBeforeDate() + { + var listFaxes = await FaxClient.Faxes.List(new ListFaxesRequest(new DateTime(2024, 05, 14), + new DateTime(2024, 05, 15, 14, 0, 0))); + listFaxes.Should().BeEquivalentTo(new ListFaxResponse() + { + PageNumber = 1, + TotalPages = 1, + PageSize = 100, + TotalItems = 18, + Faxes = new List() + { + _fax + } + }); + } + + [Fact] + public async Task ListNoDateOtherParams() + { + var listFaxes = await FaxClient.Faxes.List(new ListFaxesRequest() + { + Direction = Direction.Inbound, + From = "+555", + Page = 3, + Status = FaxStatus.Failure, + PageSize = 2, + To = "+111" + }); + listFaxes.Should().BeEquivalentTo(new ListFaxResponse() + { + PageNumber = 1, + TotalPages = 1, + PageSize = 100, + TotalItems = 1, + Faxes = new List() + { + _fax + } + }); + } + } +} diff --git a/tests/Sinch.Tests/e2e/TestBase.cs b/tests/Sinch.Tests/e2e/TestBase.cs index 86adf023..2edcc4c8 100644 --- a/tests/Sinch.Tests/e2e/TestBase.cs +++ b/tests/Sinch.Tests/e2e/TestBase.cs @@ -8,7 +8,7 @@ public class TestBase /// /// It's the same value as in doppleganger common.defaultProjectId, so it's shared and common. /// - private const string ProjectId = "e15b2651-daac-4ccb-92e8-e3066d1d033b"; + protected const string ProjectId = "e15b2651-daac-4ccb-92e8-e3066d1d033b"; protected readonly ISinchClient SinchClientMockStudio; @@ -41,6 +41,7 @@ protected TestBase() VerificationUrl = GetTestUrl("MOCK_VERIFICATION_PORT"), // templates treated as conversation api in doppelganger TemplatesUrl = GetTestUrl("MOCK_CONVERSATION_PORT"), + FaxUrl = GetTestUrl("MOCK_FAX_PORT"), }; }); }