2026-02-28 15:30:57 +01:00
using System.Net ;
using System.Net.Http.Headers ;
using System.Text ;
using System.Text.Json ;
2026-02-28 16:54:08 +01:00
using System.Globalization ;
2026-02-28 15:30:57 +01:00
using Catalog.Communication.Abstractions ;
using Catalog.Communication.Models ;
using Microsoft.Extensions.Logging ;
using Microsoft.Extensions.Options ;
namespace Catalog.Communication ;
2026-03-08 14:30:37 +01:00
public sealed class RaceUploadCommunicationClient : IRaceUploadCommunicationClient , IDisposable
2026-02-28 15:30:57 +01:00
{
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 ;
2026-03-08 14:30:37 +01:00
private bool _disposed ;
2026-02-28 15:30:57 +01:00
public RaceUploadCommunicationClient (
HttpClient httpClient ,
IOptions < CatalogCommunicationOptions > options ,
ILogger < RaceUploadCommunicationClient > logger )
{
_httpClient = httpClient ;
_options = options ;
_logger = logger ;
}
2026-03-08 14:30:37 +01:00
public void Dispose ( )
{
if ( _disposed )
{
return ;
}
_httpClient . Dispose ( ) ;
_disposed = true ;
GC . SuppressFinalize ( this ) ;
}
2026-02-28 15:30:57 +01:00
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 ) ;
}
2026-02-28 16:54:08 +01:00
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 ) ;
}
2026-02-28 15:30:57 +01:00
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" ;
}
2026-02-28 16:54:08 +01:00
private string GetReceiveFilePath ( )
{
var value = _options . Value . ReceiveFilePath ;
if ( string . IsNullOrWhiteSpace ( value ) )
{
return "ReceiveFile.abl" ;
}
return value . Trim ( ) ;
}
2026-02-28 15:30:57 +01:00
}