Catalog/Catalog.Communication/RaceUploadCommunicationClient.cs
MaddoScientisto 15b1da4371 feat: Add race upload functionality and file transfer endpoints
- Implemented IRaceUploadCommunicationClient with methods for saving races, creating race points, indexing race points, retrieving race details, and uploading files to the receiver.
- Added ReceiveFilePath option to CatalogCommunicationOptions for file transfer configuration.
- Enhanced CatalogCommunicationServiceCollectionExtensions to validate ReceiveFilePath.
- Developed RaceUploadCommunicationClient to handle race-related API interactions, including saving race data and uploading processed images.
- Updated API documentation to reflect new race upload and file transfer endpoints.
- Modified Avalonia UI to support race creation and processed image uploads, including new input fields and buttons.
- Introduced RaceSaveRequest and ReceiveFileUploadRequest models for structured data handling.
2026-02-28 16:54:08 +01:00

609 lines
24 KiB
C#

using System.Net;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using System.Globalization;
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> SaveRaceAsync(RaceSaveRequest request, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
var formFields = new Dictionary<string, string?>
{
["cmd"] = "asq",
["act"] = "save",
["id_gara"] = request.IdGara.ToString(CultureInfo.InvariantCulture),
["descrizione"] = request.Description,
["dataGaraInizio"] = request.StartDate.ToString("dd/MM/yyyy", CultureInfo.InvariantCulture),
["dataGaraFine"] = (request.EndDate ?? request.StartDate).ToString("dd/MM/yyyy", CultureInfo.InvariantCulture),
["id_tipoGara"] = request.TipoGaraId.ToString(CultureInfo.InvariantCulture),
["flgEventoInLinea"] = request.EventoInLinea.ToString(CultureInfo.InvariantCulture),
["flgTipoIndex"] = request.TipoIndicizzazione.ToString(CultureInfo.InvariantCulture),
["flgFree"] = request.FreeEvent.ToString(CultureInfo.InvariantCulture),
["pathBase"] = request.PathBase,
["localita"] = request.Localita,
["codGara"] = request.CodGara?.ToString(CultureInfo.InvariantCulture),
};
return PostFormAsync(GetAdminPagePath("Gara"), formFields, "gara-save", cancellationToken);
}
public Task<RawEndpointResponse> CreateRacePointsAsync(long raceId, CancellationToken cancellationToken = default)
{
var formFields = new Dictionary<string, string?>
{
["cmd"] = "creaPuntiFoto",
["id_gara"] = raceId.ToString(CultureInfo.InvariantCulture),
};
return PostFormAsync(GetAdminPagePath("Gara"), formFields, "gara-create-points", cancellationToken);
}
public Task<RawEndpointResponse> IndexRacePointAsync(long pointId, CancellationToken cancellationToken = default)
{
var formFields = new Dictionary<string, string?>
{
["cmd"] = "indexFoto",
["id_puntoFotoIdx"] = pointId.ToString(CultureInfo.InvariantCulture),
};
return PostFormAsync(GetAdminPagePath("Gara"), formFields, "gara-index-point", cancellationToken);
}
public Task<RawEndpointResponse> GetRaceDetailAsync(long raceId, CancellationToken cancellationToken = default)
{
var formFields = new Dictionary<string, string?>
{
["cmd"] = "search",
["id_gara"] = raceId.ToString(CultureInfo.InvariantCulture),
};
return PostFormAsync(GetAdminPagePath("Gara"), formFields, "gara-detail", cancellationToken);
}
public async Task<RawEndpointResponse> UploadFileToReceiverAsync(ReceiveFileUploadRequest request, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
ArgumentException.ThrowIfNullOrWhiteSpace(request.FileName);
ArgumentException.ThrowIfNullOrWhiteSpace(request.DestinationPath);
ArgumentNullException.ThrowIfNull(request.FileStream);
var payload = await ReadAllBytesAsync(request.FileStream, cancellationToken).ConfigureAwait(false);
var query = new Dictionary<string, string?>
{
["name"] = request.FileName,
["path"] = request.DestinationPath,
["overwriteRemoteFile"] = request.OverwriteRemoteFile ? "true" : "false",
["bs"] = request.BufferSize?.ToString(CultureInfo.InvariantCulture),
};
var path = AppendQuery(GetReceiveFilePath(), query);
return await ExecuteWithResilienceAsync(
() =>
{
var byteContent = new ByteArrayContent(payload);
byteContent.Headers.ContentType = new MediaTypeHeaderValue(request.ContentType ?? "application/octet-stream");
return new HttpRequestMessage(HttpMethod.Post, path)
{
Content = byteContent,
};
},
ToRawResponseAsync,
"receiver-upload",
cancellationToken).ConfigureAwait(false);
}
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";
}
private string GetReceiveFilePath()
{
var value = _options.Value.ReceiveFilePath;
if (string.IsNullOrWhiteSpace(value))
{
return "ReceiveFile.abl";
}
return value.Trim();
}
}