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.
This commit is contained in:
MaddoScientisto 2026-02-28 16:54:08 +01:00
commit 15b1da4371
11 changed files with 675 additions and 97 deletions

View file

@ -14,6 +14,16 @@ public interface IRaceUploadCommunicationClient
Task<UploadFileResponse?> UploadRaceFileAsync(RaceFileUploadRequest request, CancellationToken cancellationToken = default);
Task<RawEndpointResponse> SaveRaceAsync(RaceSaveRequest request, CancellationToken cancellationToken = default);
Task<RawEndpointResponse> CreateRacePointsAsync(long raceId, CancellationToken cancellationToken = default);
Task<RawEndpointResponse> IndexRacePointAsync(long pointId, CancellationToken cancellationToken = default);
Task<RawEndpointResponse> GetRaceDetailAsync(long raceId, CancellationToken cancellationToken = default);
Task<RawEndpointResponse> UploadFileToReceiverAsync(ReceiveFileUploadRequest request, CancellationToken cancellationToken = default);
Task<RawEndpointResponse> ExecuteGaraCommandAsync(IReadOnlyDictionary<string, string?> formFields, CancellationToken cancellationToken = default);
Task<RawEndpointResponse> ExecuteAdminPhotoCommandAsync(AdminPhotoEndpoint endpoint, IReadOnlyDictionary<string, string?> formFields, CancellationToken cancellationToken = default);

View file

@ -6,6 +6,8 @@ public sealed class CatalogCommunicationOptions
public string AdminPageBasePath { get; set; } = "admin/pg";
public string ReceiveFilePath { get; set; } = "ReceiveFile.abl";
public TimeSpan RequestTimeout { get; set; } = TimeSpan.FromSeconds(30);
public int RetryCount { get; set; } = 2;

View file

@ -18,6 +18,7 @@ public static class CatalogCommunicationServiceCollectionExtensions
.Configure(configure)
.Validate(o => o.BaseUri is not null, "CatalogCommunicationOptions.BaseUri is required.")
.Validate(o => !string.IsNullOrWhiteSpace(o.AdminPageBasePath), "AdminPageBasePath is required.")
.Validate(o => !string.IsNullOrWhiteSpace(o.ReceiveFilePath), "ReceiveFilePath is required.")
.Validate(o => o.RequestTimeout > TimeSpan.Zero, "RequestTimeout must be greater than zero.")
.Validate(o => o.RetryCount >= 0 && o.RetryCount <= 10, "RetryCount must be between 0 and 10.")
.Validate(o => o.RetryBaseDelay > TimeSpan.Zero, "RetryBaseDelay must be greater than zero.");

View file

@ -0,0 +1,26 @@
namespace Catalog.Communication.Models;
public sealed class RaceSaveRequest
{
public long IdGara { get; init; }
public required string Description { get; init; }
public required DateOnly StartDate { get; init; }
public DateOnly? EndDate { get; init; }
public required long TipoGaraId { get; init; }
public int EventoInLinea { get; init; }
public int TipoIndicizzazione { get; init; }
public int FreeEvent { get; init; }
public string? PathBase { get; init; }
public string? Localita { get; init; }
public long? CodGara { get; init; }
}

View file

@ -0,0 +1,16 @@
namespace Catalog.Communication.Models;
public sealed class ReceiveFileUploadRequest
{
public required string FileName { get; init; }
public required Stream FileStream { get; init; }
public required string DestinationPath { get; init; }
public bool OverwriteRemoteFile { get; init; }
public int? BufferSize { get; init; }
public string? ContentType { get; init; }
}

View file

@ -2,6 +2,7 @@ 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;
@ -123,6 +124,97 @@ public sealed class RaceUploadCommunicationClient : IRaceUploadCommunicationClie
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);
@ -503,4 +595,15 @@ public sealed class RaceUploadCommunicationClient : IRaceUploadCommunicationClie
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();
}
}