Add signature workflow models, services, and configurations
Introduced new models (`SignatureDto`, `SignatureCaptureDto`, `EnvelopeReceiverDto`) to support a signature-based workflow. Added services for handling API interactions (`SignatureService`, `AuthService`, `DocumentService`, `EnvelopeReceiverService`, `SignatureCacheService`). Enhanced configuration with `ApiOptions` and `PdfViewerOptions`. Integrated DevExpress features with custom data connection providers, in-memory report storage, and font loading utilities. Marked `AnnotationDto` and `AnnotationService` as `[Obsolete]` in favor of newer implementations. Added detailed documentation for coordinate systems, unit conversions, and usage scenarios.
This commit is contained in:
@@ -0,0 +1,33 @@
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using EnvelopeGenerator.WebUI.Client.Models;
|
||||
using EnvelopeGenerator.WebUI.Client.Options;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace EnvelopeGenerator.WebUI.Client.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves annotation positions from the API.
|
||||
/// The URL is composed as <c>{BaseUrl}/api/Annotation/{envelopeKey}</c>.
|
||||
/// During development, <c>BaseUrl</c> is empty so the request resolves to the
|
||||
/// YARP-proxied route on the same origin, which currently serves
|
||||
/// <c>fake-data/annotations.json</c>. To switch to real data, update the
|
||||
/// YARP route in <c>yarp.json</c> – no code change required.
|
||||
/// </summary>
|
||||
[Obsolete("Use SignatureService.")]
|
||||
public class AnnotationService(HttpClient http, IOptions<ApiOptions> apiOptions)
|
||||
{
|
||||
private static readonly JsonSerializerOptions _jsonOptions = new(JsonSerializerDefaults.Web);
|
||||
|
||||
public async Task<IReadOnlyList<AnnotationDto>> GetAnnotationsAsync(string envelopeKey, CancellationToken cancel = default)
|
||||
{
|
||||
var url = $"{apiOptions.Value.BaseUrl}/api/Annotation/{Uri.EscapeDataString(envelopeKey)}";
|
||||
var response = await http.GetAsync(url, cancel);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
return [];
|
||||
|
||||
var result = await response.Content.ReadFromJsonAsync<List<AnnotationDto>>(_jsonOptions, cancel);
|
||||
return result ?? [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
namespace EnvelopeGenerator.WebUI.Client.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Provides application version for cache busting static assets.
|
||||
/// Version is automatically incremented on each build via AssemblyVersion.
|
||||
/// </summary>
|
||||
public class AppVersionService
|
||||
{
|
||||
/// <summary>
|
||||
/// Current application version (e.g., "1.0.0.0")
|
||||
/// </summary>
|
||||
public string Version { get; }
|
||||
|
||||
public AppVersionService()
|
||||
{
|
||||
// Get version from assembly metadata
|
||||
Version = typeof(AppVersionService).Assembly.GetName().Version?.ToString() ?? "1.0.0.0";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generates versioned URL for static assets (cache busting)
|
||||
/// </summary>
|
||||
/// <param name="path">Asset path (e.g., "css/envelope-viewer.css")</param>
|
||||
/// <returns>Versioned URL (e.g., "css/envelope-viewer.css?v=1.0.0.0")</returns>
|
||||
public string GetVersionedUrl(string path) => $"{path}?v={Version}";
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using EnvelopeGenerator.WebUI.Client.Options;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace EnvelopeGenerator.WebUI.Client.Services;
|
||||
|
||||
public enum EnvelopeLoginResult { Success, InvalidCode, NotFound, Error }
|
||||
|
||||
public enum SenderLoginResult { Success, InvalidCredentials, Error }
|
||||
|
||||
public class AuthService(HttpClient http, IOptions<ApiOptions> apiOptions)
|
||||
{
|
||||
private readonly ApiOptions _api = apiOptions.Value;
|
||||
|
||||
/// <summary>
|
||||
/// Checks whether the current user holds a valid receiver token for the given envelope key.
|
||||
/// Calls GET /api/auth/check/envelope/{envelopeKey}.
|
||||
/// </summary>
|
||||
public async Task<bool> CheckEnvelopeAccessAsync(string envelopeKey, CancellationToken cancel = default)
|
||||
{
|
||||
var response = await http.GetAsync($"{_api.BaseUrl}/api/auth/check/envelope/{Uri.EscapeDataString(envelopeKey)}", cancel);
|
||||
return response.StatusCode == HttpStatusCode.OK;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Submits the access code for the given envelope key.
|
||||
/// Calls POST /api/Auth/envelope-receiver/{key} with multipart/form-data.
|
||||
/// On success the API sets an authentication cookie automatically.
|
||||
/// </summary>
|
||||
public async Task<EnvelopeLoginResult> LoginEnvelopeReceiverAsync(string envelopeKey, string accessCode, CancellationToken cancel = default)
|
||||
{
|
||||
var form = new MultipartFormDataContent();
|
||||
form.Add(new StringContent(accessCode), "AccessCode");
|
||||
|
||||
var response = await http.PostAsync(
|
||||
$"{_api.BaseUrl}/api/Auth/envelope-receiver/{Uri.EscapeDataString(envelopeKey)}",
|
||||
form, cancel);
|
||||
|
||||
return response.StatusCode switch
|
||||
{
|
||||
HttpStatusCode.OK => EnvelopeLoginResult.Success,
|
||||
HttpStatusCode.Unauthorized => EnvelopeLoginResult.InvalidCode,
|
||||
HttpStatusCode.NotFound => EnvelopeLoginResult.NotFound,
|
||||
_ => EnvelopeLoginResult.Error
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes the per-envelope receiver cookie for the given envelope key.
|
||||
/// Calls POST /api/auth/logout/envelope/{envelopeKey}.
|
||||
/// </summary>
|
||||
public async Task<bool> LogoutEnvelopeReceiverAsync(string envelopeKey, CancellationToken cancel = default)
|
||||
{
|
||||
var response = await http.PostAsync(
|
||||
$"{_api.BaseUrl}/api/auth/logout/envelope/{Uri.EscapeDataString(envelopeKey)}",
|
||||
null, cancel);
|
||||
return response.IsSuccessStatusCode;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Authenticates a sender user with username and password.
|
||||
/// Calls POST /api/auth?cookie=true with JSON body.
|
||||
/// On success the API sets an authentication cookie automatically.
|
||||
/// </summary>
|
||||
public async Task<SenderLoginResult> LoginSenderAsync(string username, string password, CancellationToken cancel = default)
|
||||
{
|
||||
var requestBody = new { username, password };
|
||||
|
||||
var response = await http.PostAsJsonAsync(
|
||||
$"{_api.BaseUrl}/api/auth?cookie=true",
|
||||
requestBody, cancel);
|
||||
|
||||
return response.StatusCode switch
|
||||
{
|
||||
HttpStatusCode.OK => SenderLoginResult.Success,
|
||||
HttpStatusCode.Unauthorized => SenderLoginResult.InvalidCredentials,
|
||||
_ => SenderLoginResult.Error
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
using DevExpress.DataAccess.Json;
|
||||
using DevExpress.DataAccess.Web;
|
||||
using DevExpress.DataAccess.Wizard.Services;
|
||||
|
||||
namespace EnvelopeGenerator.WebUI.Client.Services
|
||||
{
|
||||
public class CustomDataSourceWizardJsonDataConnectionStorage : IDataSourceWizardJsonConnectionStorage
|
||||
{
|
||||
public static JsonDataConnection GetDefaultConnection() {
|
||||
var uriJsonSource = new UriJsonSource() {
|
||||
Uri = new Uri(@"https://raw.githubusercontent.com/DevExpress-Examples/DataSources/master/JSON/customers.json"),
|
||||
};
|
||||
return new JsonDataConnection(uriJsonSource) { StoreConnectionNameOnly = true, Name = "NWindProductsJson" };
|
||||
}
|
||||
public static List<JsonDataConnection> GetConnections() {
|
||||
var connections = new List<JsonDataConnection> {
|
||||
GetDefaultConnection()
|
||||
};
|
||||
return connections;
|
||||
}
|
||||
|
||||
bool IJsonConnectionStorageService.CanSaveConnection => false;
|
||||
bool IJsonConnectionStorageService.ContainsConnection(string connectionName) {
|
||||
return GetConnections().Any(x => x.Name == connectionName);
|
||||
}
|
||||
|
||||
IEnumerable<JsonDataConnection> IJsonConnectionStorageService.GetConnections() {
|
||||
return GetConnections();
|
||||
}
|
||||
|
||||
JsonDataConnection IJsonDataConnectionProviderService.GetJsonDataConnection(string name) {
|
||||
var connection = GetConnections().FirstOrDefault(x => x.Name == name);
|
||||
if(connection == null)
|
||||
throw new InvalidOperationException();
|
||||
return connection;
|
||||
}
|
||||
|
||||
void IJsonConnectionStorageService.SaveConnection(string connectionName, JsonDataConnection connection, bool saveCredentials) { }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
using DevExpress.DataAccess.Json;
|
||||
using DevExpress.DataAccess.Web;
|
||||
namespace EnvelopeGenerator.WebUI.Client.Services
|
||||
{
|
||||
public class CustomJsonDataConnectionProviderFactory : IJsonDataConnectionProviderFactory {
|
||||
public IJsonDataConnectionProviderService Create() {
|
||||
return new WebDocumentViewerJsonDataConnectionProvider(CustomDataSourceWizardJsonDataConnectionStorage.GetConnections());
|
||||
}
|
||||
}
|
||||
|
||||
public class WebDocumentViewerJsonDataConnectionProvider : IJsonDataConnectionProviderService
|
||||
{
|
||||
readonly List<JsonDataConnection> jsonDataConnections;
|
||||
public WebDocumentViewerJsonDataConnectionProvider(List<JsonDataConnection> jsonDataConnections) {
|
||||
this.jsonDataConnections = jsonDataConnections;
|
||||
}
|
||||
public JsonDataConnection GetJsonDataConnection(string name) {
|
||||
var connection = jsonDataConnections.FirstOrDefault(x => x.Name == name);
|
||||
if(connection == null)
|
||||
throw new InvalidOperationException();
|
||||
return connection;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using DevExpress.XtraReports.UI;
|
||||
using DevExpress.XtraReports.Services;
|
||||
using EnvelopeGenerator.WebUI.Client.PredefinedReports;
|
||||
|
||||
namespace EnvelopeGenerator.WebUI.Client.Services
|
||||
{
|
||||
public class CustomReportProvider : IReportProviderAsync {
|
||||
private readonly InMemoryReportStorageWebExtension reportStorage;
|
||||
|
||||
public CustomReportProvider(InMemoryReportStorageWebExtension reportStorage) {
|
||||
this.reportStorage = reportStorage;
|
||||
}
|
||||
|
||||
public Task<XtraReport> GetReportAsync(string id, ReportProviderContext context) {
|
||||
if(reportStorage.TryGetReport(id, out var savedReport))
|
||||
return Task.FromResult(savedReport);
|
||||
|
||||
return Task.FromResult(ReportsFactory.GetReport(id));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using Microsoft.Extensions.Options;
|
||||
using EnvelopeGenerator.WebUI.Client.Options;
|
||||
|
||||
namespace EnvelopeGenerator.WebUI.Client.Services;
|
||||
|
||||
public class DocumentService(HttpClient http, IOptions<ApiOptions> apiOptions)
|
||||
{
|
||||
private readonly ApiOptions _api = apiOptions.Value;
|
||||
|
||||
/// <summary>
|
||||
/// Fetches the PDF bytes for the given envelope key from the API.
|
||||
/// Throws HttpRequestException on failure with appropriate status code.
|
||||
/// </summary>
|
||||
/// <exception cref="HttpRequestException">Thrown when the API request fails.</exception>
|
||||
public async Task<byte[]?> GetDocumentAsync(string envelopeKey, CancellationToken cancel = default)
|
||||
{
|
||||
var response = await http.GetAsync($"{_api.BaseUrl}/api/Document/{Uri.EscapeDataString(envelopeKey)}", cancel);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var statusCode = (int)response.StatusCode;
|
||||
var reasonPhrase = response.ReasonPhrase ?? "Unknown error";
|
||||
throw new HttpRequestException(
|
||||
$"Failed to load document. Status: {statusCode} ({reasonPhrase})",
|
||||
null,
|
||||
response.StatusCode);
|
||||
}
|
||||
|
||||
var bytes = await response.Content.ReadAsByteArrayAsync(cancel);
|
||||
return bytes;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using EnvelopeGenerator.WebUI.Client.Models;
|
||||
using EnvelopeGenerator.WebUI.Client.Options;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace EnvelopeGenerator.WebUI.Client.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves the <see cref="EnvelopeReceiverDto"/> for the authenticated receiver
|
||||
/// from <c>GET api/EnvelopeReceiver/{envelopeKey}</c>.
|
||||
/// </summary>
|
||||
public class EnvelopeReceiverService(HttpClient http, IOptions<ApiOptions> apiOptions)
|
||||
{
|
||||
private static readonly JsonSerializerOptions _jsonOptions = new(JsonSerializerDefaults.Web);
|
||||
|
||||
/// <summary>
|
||||
/// Fetches the envelope receiver data for the given envelope key from the API.
|
||||
/// Throws HttpRequestException on failure with appropriate status code.
|
||||
/// </summary>
|
||||
/// <exception cref="HttpRequestException">Thrown when the API request fails.</exception>
|
||||
public async Task<EnvelopeReceiverDto?> GetAsync(string envelopeKey, CancellationToken cancel = default)
|
||||
{
|
||||
var url = $"{apiOptions.Value.BaseUrl}/api/EnvelopeReceiver/{Uri.EscapeDataString(envelopeKey)}";
|
||||
var response = await http.GetAsync(url, cancel);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var statusCode = (int)response.StatusCode;
|
||||
var reasonPhrase = response.ReasonPhrase ?? "Unknown error";
|
||||
throw new HttpRequestException(
|
||||
$"Failed to load envelope receiver data. Status: {statusCode} ({reasonPhrase})",
|
||||
null,
|
||||
response.StatusCode);
|
||||
}
|
||||
|
||||
return await response.Content.ReadFromJsonAsync<EnvelopeReceiverDto>(_jsonOptions, cancel);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
using DevExpress.Drawing;
|
||||
|
||||
namespace EnvelopeGenerator.WebUI.Client.Services {
|
||||
public static class FontLoader {
|
||||
public async static Task LoadFonts(HttpClient httpClient, List<string> fontNames) {
|
||||
foreach(var fontName in fontNames) {
|
||||
var fontBytes = await httpClient.GetByteArrayAsync($"fonts/{fontName}");
|
||||
DXFontRepository.Instance.AddFont(fontBytes);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
using DevExpress.XtraReports.UI;
|
||||
using DevExpress.XtraReports.Web.Extensions;
|
||||
using EnvelopeGenerator.WebUI.Client.PredefinedReports;
|
||||
|
||||
namespace EnvelopeGenerator.WebUI.Client.Services;
|
||||
|
||||
public class InMemoryReportStorageWebExtension : ReportStorageWebExtension
|
||||
{
|
||||
private const string DefaultReportName = "LargeDatasetReport";
|
||||
private static readonly Dictionary<string, byte[]> Reports = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public override bool CanSetData(string url) => IsValidUrl(url);
|
||||
|
||||
public override byte[] GetData(string url)
|
||||
{
|
||||
url = NormalizeUrl(url);
|
||||
|
||||
if (Reports.TryGetValue(url, out var reportLayout))
|
||||
return reportLayout;
|
||||
|
||||
if (ReportsFactory.Reports.TryGetValue(url, out var reportFactory))
|
||||
return SaveReport(reportFactory());
|
||||
|
||||
throw new DevExpress.XtraReports.Web.ClientControls.FaultException($"Report '{url}' was not found.");
|
||||
}
|
||||
|
||||
public override Dictionary<string, string> GetUrls()
|
||||
{
|
||||
var urls = ReportsFactory.Reports.Keys
|
||||
.Concat(Reports.Keys)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToDictionary(name => name, name => name, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
return urls;
|
||||
}
|
||||
|
||||
public override bool IsValidUrl(string url)
|
||||
{
|
||||
return !string.IsNullOrWhiteSpace(url)
|
||||
&& url.IndexOfAny(Path.GetInvalidFileNameChars()) < 0;
|
||||
}
|
||||
|
||||
public override void SetData(XtraReport report, string url)
|
||||
{
|
||||
url = NormalizeUrl(url);
|
||||
Reports[url] = SaveReport(report);
|
||||
}
|
||||
|
||||
public override string SetNewData(XtraReport report, string defaultUrl)
|
||||
{
|
||||
var url = NormalizeUrl(defaultUrl);
|
||||
Reports[url] = SaveReport(report);
|
||||
return url;
|
||||
}
|
||||
|
||||
public bool TryGetReport(string url, out XtraReport report)
|
||||
{
|
||||
url = NormalizeUrl(url);
|
||||
|
||||
if (!Reports.ContainsKey(url))
|
||||
{
|
||||
report = null!;
|
||||
return false;
|
||||
}
|
||||
|
||||
using var stream = new MemoryStream(Reports[url]);
|
||||
report = XtraReport.FromXmlStream(stream, true);
|
||||
report.Name = url;
|
||||
return true;
|
||||
}
|
||||
|
||||
private static string NormalizeUrl(string url)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(url) ? DefaultReportName : url;
|
||||
}
|
||||
|
||||
private static byte[] SaveReport(XtraReport report)
|
||||
{
|
||||
using var stream = new MemoryStream();
|
||||
report.SaveLayoutToXml(stream);
|
||||
return stream.ToArray();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
using DevExpress.DataAccess.Web;
|
||||
|
||||
namespace EnvelopeGenerator.WebUI.Client.Services {
|
||||
public class ObjectDataSourceWizardCustomTypeProvider : IObjectDataSourceWizardTypeProvider {
|
||||
public IEnumerable<Type> GetAvailableTypes(string context) {
|
||||
return new[] { typeof(Data.DataItemList) };
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
using System.Net.Http.Json;
|
||||
using Microsoft.Extensions.Options;
|
||||
using EnvelopeGenerator.WebUI.Client.Options;
|
||||
using EnvelopeGenerator.WebUI.Client.Models;
|
||||
|
||||
namespace EnvelopeGenerator.WebUI.Client.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Client service for managing cached signatures via API.
|
||||
/// </summary>
|
||||
public class SignatureCacheService(HttpClient http, IOptions<ApiOptions> apiOptions)
|
||||
{
|
||||
private readonly ApiOptions _api = apiOptions.Value;
|
||||
|
||||
public async Task SaveSignatureAsync(
|
||||
string envelopeKey,
|
||||
SignatureCaptureDto signature,
|
||||
CancellationToken cancel = default)
|
||||
{
|
||||
var response = await http.PostAsJsonAsync(
|
||||
$"{_api.BaseUrl}/api/Cache/SignatureCapture/{Uri.EscapeDataString(envelopeKey)}",
|
||||
signature,
|
||||
cancel);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var error = await response.Content.ReadAsStringAsync(cancel);
|
||||
throw new HttpRequestException($"Failed to cache signature: {response.StatusCode} - {error}");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<SignatureCaptureDto?> GetSignatureAsync(
|
||||
string envelopeKey,
|
||||
CancellationToken cancel = default)
|
||||
{
|
||||
var response = await http.GetAsync(
|
||||
$"{_api.BaseUrl}/api/Cache/SignatureCapture/{Uri.EscapeDataString(envelopeKey)}",
|
||||
cancel);
|
||||
|
||||
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
|
||||
return null;
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var error = await response.Content.ReadAsStringAsync(cancel);
|
||||
throw new HttpRequestException($"Failed to retrieve signature: {response.StatusCode} - {error}");
|
||||
}
|
||||
|
||||
return await response.Content.ReadFromJsonAsync<SignatureCaptureDto>(cancellationToken: cancel);
|
||||
}
|
||||
|
||||
public async Task DeleteSignatureAsync(
|
||||
string envelopeKey,
|
||||
CancellationToken cancel = default)
|
||||
{
|
||||
var response = await http.DeleteAsync(
|
||||
$"{_api.BaseUrl}/api/Cache/SignatureCapture/{Uri.EscapeDataString(envelopeKey)}",
|
||||
cancel);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var error = await response.Content.ReadAsStringAsync(cancel);
|
||||
throw new HttpRequestException($"Failed to delete signature: {response.StatusCode} - {error}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using EnvelopeGenerator.WebUI.Client.Models;
|
||||
using EnvelopeGenerator.WebUI.Client.Options;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace EnvelopeGenerator.WebUI.Client.Services;
|
||||
|
||||
public class SignatureService(HttpClient http, IOptions<ApiOptions> apiOptions)
|
||||
{
|
||||
private static readonly JsonSerializerOptions _jsonOptions = new(JsonSerializerDefaults.Web);
|
||||
|
||||
public async Task<IReadOnlyList<SignatureDto>> GetAsync(string envelopeKey, CancellationToken cancel = default)
|
||||
{
|
||||
var url = $"{apiOptions.Value.BaseUrl}/api/Signature/{Uri.EscapeDataString(envelopeKey)}";
|
||||
var response = await http.GetAsync(url, cancel);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
throw new HttpRequestException($"Failed to retrieve signatures for envelope {envelopeKey}: {response.StatusCode} {response.ReasonPhrase}");
|
||||
|
||||
var result = await response.Content.ReadFromJsonAsync<List<SignatureDto>>(_jsonOptions, cancel);
|
||||
return result ?? [];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user