Added avalonia integration and remote proof of concept
This commit is contained in:
parent
775080a178
commit
4a0973b681
23 changed files with 2043 additions and 6 deletions
506
Catalog.Communication/RaceUploadCommunicationClient.cs
Normal file
506
Catalog.Communication/RaceUploadCommunicationClient.cs
Normal file
|
|
@ -0,0 +1,506 @@
|
|||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Catalog.Communication.Abstractions;
|
||||
using Catalog.Communication.Models;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Catalog.Communication;
|
||||
|
||||
public sealed class RaceUploadCommunicationClient : IRaceUploadCommunicationClient
|
||||
{
|
||||
private const string AdminMenuPath = "admin/menu/Menu4.abl";
|
||||
private const string PublicLogonPath = "Logon.abl";
|
||||
private const string UsersPath = "Users.abl";
|
||||
private const string Foto2Path = "Foto2.abl";
|
||||
private const string ThumbnailPath = "foto";
|
||||
private const string OriginalPath = "fotoOriginali";
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
};
|
||||
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly ILogger<RaceUploadCommunicationClient> _logger;
|
||||
private readonly IOptions<CatalogCommunicationOptions> _options;
|
||||
|
||||
public RaceUploadCommunicationClient(
|
||||
HttpClient httpClient,
|
||||
IOptions<CatalogCommunicationOptions> options,
|
||||
ILogger<RaceUploadCommunicationClient> logger)
|
||||
{
|
||||
_httpClient = httpClient;
|
||||
_options = options;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public Task<RawEndpointResponse> LoginAdminAsync(AdminLoginRequest request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var formFields = new Dictionary<string, string?>
|
||||
{
|
||||
["login"] = request.Login,
|
||||
["pwd"] = request.Password,
|
||||
["cmdIU"] = request.Command,
|
||||
};
|
||||
|
||||
return PostFormAsync(AdminMenuPath, formFields, "admin-login", cancellationToken);
|
||||
}
|
||||
|
||||
public Task<RawEndpointResponse> LogoutAdminAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var formFields = new Dictionary<string, string?>
|
||||
{
|
||||
["cmdIU"] = "login",
|
||||
};
|
||||
|
||||
return PostFormAsync(AdminMenuPath, formFields, "admin-logout", cancellationToken);
|
||||
}
|
||||
|
||||
public Task<UploadImageResponse?> UploadRaceImageAsync(RaceImageUploadRequest request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
return PostMultipartAndParseUploadAsync<UploadImageResponse>(
|
||||
GetAdminPagePath("Gara"),
|
||||
"gara-upload-image",
|
||||
request.FileStream,
|
||||
request.FileName,
|
||||
request.FormFieldName,
|
||||
request.ContentType,
|
||||
static fields =>
|
||||
{
|
||||
fields["cmd"] = "loadImg";
|
||||
},
|
||||
new Dictionary<string, string?>
|
||||
{
|
||||
["id"] = request.Id.ToString(System.Globalization.CultureInfo.InvariantCulture),
|
||||
["codImage"] = request.CodImage.ToString(System.Globalization.CultureInfo.InvariantCulture),
|
||||
["totImgNumber"] = request.TotImgNumber?.ToString(System.Globalization.CultureInfo.InvariantCulture),
|
||||
},
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
public Task<UploadImageResponse?> RemoveRaceImageAsync(RaceImageRemoveRequest request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var fields = new Dictionary<string, string?>
|
||||
{
|
||||
["cmd"] = "removeImg",
|
||||
["id"] = request.Id.ToString(System.Globalization.CultureInfo.InvariantCulture),
|
||||
["codImage"] = request.CodImage.ToString(System.Globalization.CultureInfo.InvariantCulture),
|
||||
["totImgNumber"] = request.TotImgNumber?.ToString(System.Globalization.CultureInfo.InvariantCulture),
|
||||
};
|
||||
|
||||
return PostFormAndParseUploadAsync<UploadImageResponse>(GetAdminPagePath("Gara"), fields, "gara-remove-image", cancellationToken);
|
||||
}
|
||||
|
||||
public Task<UploadFileResponse?> UploadRaceFileAsync(RaceFileUploadRequest request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
return PostMultipartAndParseUploadAsync<UploadFileResponse>(
|
||||
GetAdminPagePath("Gara"),
|
||||
"gara-upload-file",
|
||||
request.FileStream,
|
||||
request.FileName,
|
||||
request.FormFieldName,
|
||||
request.ContentType,
|
||||
static fields =>
|
||||
{
|
||||
fields["cmd"] = "saveFile";
|
||||
},
|
||||
new Dictionary<string, string?>
|
||||
{
|
||||
["codFile"] = request.CodFile?.ToString(System.Globalization.CultureInfo.InvariantCulture),
|
||||
["id"] = request.Id?.ToString(System.Globalization.CultureInfo.InvariantCulture),
|
||||
},
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
public Task<RawEndpointResponse> ExecuteGaraCommandAsync(IReadOnlyDictionary<string, string?> formFields, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return PostFormAsync(GetAdminPagePath("Gara"), formFields, "gara-command", cancellationToken);
|
||||
}
|
||||
|
||||
public Task<RawEndpointResponse> ExecuteAdminPhotoCommandAsync(AdminPhotoEndpoint endpoint, IReadOnlyDictionary<string, string?> formFields, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var path = endpoint switch
|
||||
{
|
||||
AdminPhotoEndpoint.Foto => GetAdminPagePath("Foto"),
|
||||
AdminPhotoEndpoint.TipoGara => GetAdminPagePath("TipoGara"),
|
||||
AdminPhotoEndpoint.LogFoto => GetAdminPagePath("LogFoto"),
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(endpoint), endpoint, "Unsupported endpoint."),
|
||||
};
|
||||
|
||||
return PostFormAsync(path, formFields, $"photo-admin-{endpoint}", cancellationToken);
|
||||
}
|
||||
|
||||
public Task<RawEndpointResponse> ExecutePublicLogonAsync(IReadOnlyDictionary<string, string?> formFields, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return PostFormAsync(PublicLogonPath, formFields, "public-logon", cancellationToken);
|
||||
}
|
||||
|
||||
public Task<RawEndpointResponse> ExecuteUsersAsync(HttpMethod method, IReadOnlyDictionary<string, string?>? formFields = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return SendCommandAsync(method, UsersPath, formFields, "public-users", cancellationToken);
|
||||
}
|
||||
|
||||
public Task<RawEndpointResponse> ExecuteFoto2Async(HttpMethod method, IReadOnlyDictionary<string, string?>? formFields = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return SendCommandAsync(method, Foto2Path, formFields, "public-foto2", cancellationToken);
|
||||
}
|
||||
|
||||
public Task<MediaFileResponse> DownloadThumbnailAsync(string filename, long? idFoto = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(filename);
|
||||
return DownloadFileAsync(ThumbnailPath, filename, idFoto, "media-thumbnail", cancellationToken);
|
||||
}
|
||||
|
||||
public Task<MediaFileResponse> DownloadOriginalAsync(string filename, long? idFoto = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(filename);
|
||||
return DownloadFileAsync(OriginalPath, filename, idFoto, "media-original", cancellationToken);
|
||||
}
|
||||
|
||||
private Task<RawEndpointResponse> SendCommandAsync(HttpMethod method, string path, IReadOnlyDictionary<string, string?>? formFields, string operationName, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(method);
|
||||
|
||||
if (method == HttpMethod.Get)
|
||||
{
|
||||
var relativePath = AppendQuery(path, formFields);
|
||||
return GetAsync(relativePath, operationName, cancellationToken);
|
||||
}
|
||||
|
||||
if (method == HttpMethod.Post)
|
||||
{
|
||||
return PostFormAsync(path, formFields ?? new Dictionary<string, string?>(), operationName, cancellationToken);
|
||||
}
|
||||
|
||||
throw new NotSupportedException($"Only GET and POST are supported. Requested method: {method}.");
|
||||
}
|
||||
|
||||
private Task<RawEndpointResponse> GetAsync(string relativePath, string operationName, CancellationToken cancellationToken)
|
||||
{
|
||||
return ExecuteWithResilienceAsync(
|
||||
() => new HttpRequestMessage(HttpMethod.Get, relativePath),
|
||||
ToRawResponseAsync,
|
||||
operationName,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
private Task<RawEndpointResponse> PostFormAsync(string relativePath, IReadOnlyDictionary<string, string?> formFields, string operationName, CancellationToken cancellationToken)
|
||||
{
|
||||
return ExecuteWithResilienceAsync(
|
||||
() =>
|
||||
{
|
||||
var request = new HttpRequestMessage(HttpMethod.Post, relativePath)
|
||||
{
|
||||
Content = BuildFormContent(formFields),
|
||||
};
|
||||
|
||||
return request;
|
||||
},
|
||||
ToRawResponseAsync,
|
||||
operationName,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
private Task<TUpload?> PostFormAndParseUploadAsync<TUpload>(string relativePath, IReadOnlyDictionary<string, string?> formFields, string operationName, CancellationToken cancellationToken)
|
||||
{
|
||||
return ExecuteWithResilienceAsync(
|
||||
() =>
|
||||
{
|
||||
var request = new HttpRequestMessage(HttpMethod.Post, relativePath)
|
||||
{
|
||||
Content = BuildFormContent(formFields),
|
||||
};
|
||||
|
||||
return request;
|
||||
},
|
||||
async (response, token) =>
|
||||
{
|
||||
var raw = await ToRawResponseAsync(response, token).ConfigureAwait(false);
|
||||
return ParseSingleItemArray<TUpload>(raw.Body);
|
||||
},
|
||||
operationName,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<TUpload?> PostMultipartAndParseUploadAsync<TUpload>(
|
||||
string relativePath,
|
||||
string operationName,
|
||||
Stream fileStream,
|
||||
string fileName,
|
||||
string formFieldName,
|
||||
string? contentType,
|
||||
Action<Dictionary<string, string?>> configureRequiredFields,
|
||||
IReadOnlyDictionary<string, string?> optionalFields,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(fileStream);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(fileName);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(formFieldName);
|
||||
|
||||
var payload = await ReadAllBytesAsync(fileStream, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return await ExecuteWithResilienceAsync(
|
||||
() =>
|
||||
{
|
||||
var multipart = new MultipartFormDataContent();
|
||||
var fields = new Dictionary<string, string?>();
|
||||
configureRequiredFields(fields);
|
||||
|
||||
foreach (var field in fields)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(field.Value))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
multipart.Add(new StringContent(field.Value, Encoding.UTF8), field.Key);
|
||||
}
|
||||
|
||||
foreach (var field in optionalFields)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(field.Value))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
multipart.Add(new StringContent(field.Value, Encoding.UTF8), field.Key);
|
||||
}
|
||||
|
||||
var fileContent = new ByteArrayContent(payload);
|
||||
fileContent.Headers.ContentType = new MediaTypeHeaderValue(contentType ?? "application/octet-stream");
|
||||
multipart.Add(fileContent, formFieldName, fileName);
|
||||
|
||||
return new HttpRequestMessage(HttpMethod.Post, relativePath)
|
||||
{
|
||||
Content = multipart,
|
||||
};
|
||||
},
|
||||
async (response, token) =>
|
||||
{
|
||||
var raw = await ToRawResponseAsync(response, token).ConfigureAwait(false);
|
||||
return ParseSingleItemArray<TUpload>(raw.Body);
|
||||
},
|
||||
operationName,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private Task<MediaFileResponse> DownloadFileAsync(string basePath, string filename, long? idFoto, string operationName, CancellationToken cancellationToken)
|
||||
{
|
||||
var escapedFileName = Uri.EscapeDataString(filename);
|
||||
var relativePath = idFoto.HasValue
|
||||
? $"{basePath}/{escapedFileName}?id_foto={idFoto.Value.ToString(System.Globalization.CultureInfo.InvariantCulture)}"
|
||||
: $"{basePath}/{escapedFileName}";
|
||||
|
||||
return ExecuteWithResilienceAsync(
|
||||
() => new HttpRequestMessage(HttpMethod.Get, relativePath),
|
||||
async (response, token) =>
|
||||
{
|
||||
var content = await response.Content.ReadAsByteArrayAsync(token).ConfigureAwait(false);
|
||||
return new MediaFileResponse
|
||||
{
|
||||
StatusCode = response.StatusCode,
|
||||
Content = content,
|
||||
ContentType = response.Content.Headers.ContentType?.MediaType,
|
||||
FileName = response.Content.Headers.ContentDisposition?.FileNameStar ?? response.Content.Headers.ContentDisposition?.FileName,
|
||||
Headers = BuildHeaders(response),
|
||||
};
|
||||
},
|
||||
operationName,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<TResponse> ExecuteWithResilienceAsync<TResponse>(
|
||||
Func<HttpRequestMessage> requestFactory,
|
||||
Func<HttpResponseMessage, CancellationToken, Task<TResponse>> responseFactory,
|
||||
string operationName,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var options = _options.Value;
|
||||
Exception? lastException = null;
|
||||
|
||||
for (var attempt = 0; attempt <= options.RetryCount; attempt++)
|
||||
{
|
||||
using var request = requestFactory();
|
||||
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
timeoutCts.CancelAfter(options.RequestTimeout);
|
||||
|
||||
HttpResponseMessage? response = null;
|
||||
|
||||
try
|
||||
{
|
||||
response = await _httpClient
|
||||
.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, timeoutCts.Token)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (IsRetryableStatusCode(response.StatusCode) && attempt < options.RetryCount)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Operation {OperationName} received retryable status code {StatusCode} at attempt {Attempt}/{MaxAttempts}.",
|
||||
operationName,
|
||||
(int)response.StatusCode,
|
||||
attempt + 1,
|
||||
options.RetryCount + 1);
|
||||
|
||||
await DelayBeforeRetryAsync(options, attempt, cancellationToken).ConfigureAwait(false);
|
||||
continue;
|
||||
}
|
||||
|
||||
return await responseFactory(response, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException ex) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
lastException = ex;
|
||||
|
||||
if (attempt < options.RetryCount)
|
||||
{
|
||||
_logger.LogWarning(ex, "Operation {OperationName} timed out at attempt {Attempt}/{MaxAttempts}.", operationName, attempt + 1, options.RetryCount + 1);
|
||||
await DelayBeforeRetryAsync(options, attempt, cancellationToken).ConfigureAwait(false);
|
||||
continue;
|
||||
}
|
||||
|
||||
_logger.LogError(ex, "Operation {OperationName} timed out after {MaxAttempts} attempts.", operationName, options.RetryCount + 1);
|
||||
throw;
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
lastException = ex;
|
||||
|
||||
if (attempt < options.RetryCount)
|
||||
{
|
||||
_logger.LogWarning(ex, "Operation {OperationName} failed with transient HTTP error at attempt {Attempt}/{MaxAttempts}.", operationName, attempt + 1, options.RetryCount + 1);
|
||||
await DelayBeforeRetryAsync(options, attempt, cancellationToken).ConfigureAwait(false);
|
||||
continue;
|
||||
}
|
||||
|
||||
_logger.LogError(ex, "Operation {OperationName} failed with HTTP error after {MaxAttempts} attempts.", operationName, options.RetryCount + 1);
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Operation {OperationName} failed with unexpected error at attempt {Attempt}/{MaxAttempts}.", operationName, attempt + 1, options.RetryCount + 1);
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
response?.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
throw new HttpRequestException($"Operation '{operationName}' failed after {options.RetryCount + 1} attempts.", lastException);
|
||||
}
|
||||
|
||||
private static bool IsRetryableStatusCode(HttpStatusCode statusCode)
|
||||
{
|
||||
return statusCode is HttpStatusCode.RequestTimeout
|
||||
or HttpStatusCode.TooManyRequests
|
||||
or HttpStatusCode.BadGateway
|
||||
or HttpStatusCode.ServiceUnavailable
|
||||
or HttpStatusCode.GatewayTimeout
|
||||
or HttpStatusCode.InternalServerError;
|
||||
}
|
||||
|
||||
private static async Task DelayBeforeRetryAsync(CatalogCommunicationOptions options, int attempt, CancellationToken cancellationToken)
|
||||
{
|
||||
var delay = TimeSpan.FromMilliseconds(options.RetryBaseDelay.TotalMilliseconds * Math.Pow(2, attempt));
|
||||
await Task.Delay(delay, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static FormUrlEncodedContent BuildFormContent(IReadOnlyDictionary<string, string?> formFields)
|
||||
{
|
||||
var pairs = formFields
|
||||
.Where(kvp => !string.IsNullOrWhiteSpace(kvp.Value))
|
||||
.Select(kvp => new KeyValuePair<string, string>(kvp.Key, kvp.Value!));
|
||||
|
||||
return new FormUrlEncodedContent(pairs);
|
||||
}
|
||||
|
||||
private static string AppendQuery(string path, IReadOnlyDictionary<string, string?>? query)
|
||||
{
|
||||
if (query is null || query.Count == 0)
|
||||
{
|
||||
return path;
|
||||
}
|
||||
|
||||
var encodedPairs = query
|
||||
.Where(kvp => !string.IsNullOrWhiteSpace(kvp.Value))
|
||||
.Select(kvp => $"{Uri.EscapeDataString(kvp.Key)}={Uri.EscapeDataString(kvp.Value!)}")
|
||||
.ToArray();
|
||||
|
||||
if (encodedPairs.Length == 0)
|
||||
{
|
||||
return path;
|
||||
}
|
||||
|
||||
var separator = path.Contains('?', StringComparison.Ordinal) ? "&" : "?";
|
||||
return string.Concat(path, separator, string.Join("&", encodedPairs));
|
||||
}
|
||||
|
||||
private static async Task<RawEndpointResponse> ToRawResponseAsync(HttpResponseMessage response, CancellationToken cancellationToken)
|
||||
{
|
||||
var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return new RawEndpointResponse
|
||||
{
|
||||
StatusCode = response.StatusCode,
|
||||
Body = body,
|
||||
Headers = BuildHeaders(response),
|
||||
};
|
||||
}
|
||||
|
||||
private static IReadOnlyDictionary<string, IReadOnlyList<string>> BuildHeaders(HttpResponseMessage response)
|
||||
{
|
||||
var headers = new Dictionary<string, IReadOnlyList<string>>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var header in response.Headers)
|
||||
{
|
||||
headers[header.Key] = header.Value.ToArray();
|
||||
}
|
||||
|
||||
foreach (var header in response.Content.Headers)
|
||||
{
|
||||
headers[header.Key] = header.Value.ToArray();
|
||||
}
|
||||
|
||||
return headers;
|
||||
}
|
||||
|
||||
private static TUpload? ParseSingleItemArray<TUpload>(string json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
{
|
||||
return default;
|
||||
}
|
||||
|
||||
var items = JsonSerializer.Deserialize<List<TUpload>>(json, JsonOptions);
|
||||
return items is { Count: > 0 } ? items[0] : default;
|
||||
}
|
||||
|
||||
private static async Task<byte[]> ReadAllBytesAsync(Stream stream, CancellationToken cancellationToken)
|
||||
{
|
||||
if (stream.CanSeek)
|
||||
{
|
||||
stream.Position = 0;
|
||||
}
|
||||
|
||||
using var memoryStream = new MemoryStream();
|
||||
await stream.CopyToAsync(memoryStream, cancellationToken).ConfigureAwait(false);
|
||||
return memoryStream.ToArray();
|
||||
}
|
||||
|
||||
private string GetAdminPagePath(string pageName)
|
||||
{
|
||||
var basePath = _options.Value.AdminPageBasePath.Trim('/');
|
||||
return $"{basePath}/{pageName}.abl";
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue