Refactor WorkflowsClient for unified result handling

Refactored `ExtractSwissQrCodeAsync` to `InspectSwissQrCodeAsync`, introducing `SwissQrCodeResult` to encapsulate both validation and extraction results. Removed `throwIfInvalid` parameter, ensuring validation details are always returned without exceptions. Added extension methods for file path handling to improve usability.

Updated tests to reflect the new behavior, ensuring extraction is never attempted on invalid documents and validation results are always populated. Simplified API surface by removing redundant overloads and improving stream handling. Enhanced documentation and performed general code cleanup for better maintainability.
This commit is contained in:
2026-08-31 14:44:57 +02:00
parent 71dd32f11c
commit 092d283a2b
4 changed files with 148 additions and 371 deletions

View File

@@ -1,5 +1,6 @@
using DocumentService.Application.Common.DTOs;
using DocumentService.Client.Interfaces;
using DocumentService.Client.Models.Results;
namespace DocumentService.Client.Clients;
@@ -10,75 +11,63 @@ namespace DocumentService.Client.Clients;
public class WorkflowsClient(IPdfValidationClient validation, ISwissQrCodeClient swissQrCode) : IWorkflowsClient
{
/// <inheritdoc />
public async Task<SwissQrCodeExtractionResult?> ExtractSwissQrCodeAsync(
public async Task<SwissQrCodeResult> InspectSwissQrCodeAsync(
byte[] pdfBytes,
bool raw = false,
bool throwIfInvalid = true,
CancellationToken cancellationToken = default)
{
var validationResult = await validation.ValidateAsync(pdfBytes, cancellationToken);
if (!validationResult.IsValid)
return throwIfInvalid
? throw new InvalidOperationException(
$"PDF validation failed (IsEncrypted={validationResult.IsEncrypted}, " +
$"PageCount={validationResult.PageCount}, FileSize={validationResult.FileSizeBytes}).")
: (SwissQrCodeExtractionResult?)null;
return await swissQrCode.ExtractAsync(pdfBytes, raw, cancellationToken);
var qrCode = validationResult.IsValid
? await swissQrCode.ExtractAsync(pdfBytes, raw, cancellationToken)
: null;
return new SwissQrCodeResult(qrCode, validationResult);
}
/// <inheritdoc />
public async Task<SwissQrCodeExtractionResult?> ExtractSwissQrCodeAsync(
public async Task<SwissQrCodeResult> InspectSwissQrCodeAsync(
Stream pdfStream,
bool raw = false,
bool throwIfInvalid = true,
CancellationToken cancellationToken = default)
{
var validationResult = await validation.ValidateAsync(pdfStream, cancellationToken);
if (!validationResult.IsValid)
return throwIfInvalid
? throw new InvalidOperationException(
$"PDF validation failed (IsEncrypted={validationResult.IsEncrypted}, " +
$"PageCount={validationResult.PageCount}, FileSize={validationResult.FileSizeBytes}).")
: (SwissQrCodeExtractionResult?)null;
return new SwissQrCodeResult(null, validationResult);
// Stream was consumed by validation – reset if possible, otherwise re-open is caller's responsibility.
// Since callers typically pass a FileStream or MemoryStream, Position reset covers the common case.
if (pdfStream.CanSeek)
pdfStream.Position = 0;
return await swissQrCode.ExtractAsync(pdfStream, raw, cancellationToken);
}
/// <inheritdoc />
public async Task<SwissQrCodeExtractionResult?> ExtractSwissQrCodeAsync(
string filePath,
bool raw = false,
bool throwIfInvalid = true,
CancellationToken cancellationToken = default)
{
#if NETFRAMEWORK
using var stream = File.OpenRead(filePath);
var validationResult = await validation.ValidateAsync(stream, cancellationToken);
#else
var pdfBytes = await File.ReadAllBytesAsync(filePath, cancellationToken);
var validationResult = await validation.ValidateAsync(pdfBytes, cancellationToken);
#endif
if (!validationResult.IsValid)
return throwIfInvalid
? throw new InvalidOperationException(
$"PDF validation failed (IsEncrypted={validationResult.IsEncrypted}, " +
$"PageCount={validationResult.PageCount}, FileSize={validationResult.FileSizeBytes}).")
: (SwissQrCodeExtractionResult?)null;
#if NETFRAMEWORK
using var extractStream = File.OpenRead(filePath);
return await swissQrCode.ExtractAsync(extractStream, raw, cancellationToken);
#else
return await swissQrCode.ExtractAsync(pdfBytes, raw, cancellationToken);
#endif
var qrCode = await swissQrCode.ExtractAsync(pdfStream, raw, cancellationToken);
return new SwissQrCodeResult(qrCode, validationResult);
}
}
/// <summary>
/// Convenience extensions for <see cref="IWorkflowsClient"/> that accept a file path as input.
/// Defined alongside <see cref="WorkflowsClient"/> so they are discoverable without an additional using directive.
/// </summary>
public static class WorkflowsClientExtensions
{
/// <summary>
/// Validates the PDF at <paramref name="filePath"/> and attempts Swiss QR Code extraction
/// in a single call. Opens a <see cref="FileStream"/> and delegates to
/// <see cref="IWorkflowsClient.InspectSwissQrCodeAsync(Stream, bool, CancellationToken)"/>.
/// </summary>
/// <param name="client">The workflows client.</param>
/// <param name="filePath">Absolute or relative path to the PDF file.</param>
/// <param name="raw">
/// <c>false</c> (default) – returns a parsed Bill object.<br/>
/// <c>true</c> – returns raw QR text lines without parsing.
/// </param>
/// <param name="cancellationToken">Cancellation token.</param>
public static async Task<SwissQrCodeResult> InspectSwissQrCodeAsync(
this IWorkflowsClient client,
string filePath,
bool raw = false,
CancellationToken cancellationToken = default)
{
using var stream = File.OpenRead(filePath);
return await client.InspectSwissQrCodeAsync(stream, raw, cancellationToken);
}
}

View File

@@ -1,4 +1,5 @@
using DocumentService.Application.Common.DTOs;
using DocumentService.Client.Models.Results;
namespace DocumentService.Client.Interfaces;
@@ -9,51 +10,33 @@ namespace DocumentService.Client.Interfaces;
public interface IWorkflowsClient
{
/// <summary>
/// Validates the PDF and extracts its Swiss QR Code in a single call.
/// Validation runs first; extraction is only attempted on valid documents.
/// Validates the PDF and attempts Swiss QR Code extraction in a single call,
/// returning both results together. The method never throws on invalid documents –
/// the caller receives the full <see cref="PdfValidationResult"/> regardless of outcome,
/// enabling detailed logging, diagnostics, or batch processing without try/catch.
/// </summary>
/// <param name="pdfBytes">PDF file as byte array.</param>
/// <param name="raw">
/// <c>false</c> (default) – returns a parsed <see cref="SwissQrCodeExtractionResult.Bill"/> object.<br/>
/// <c>true</c> – returns raw QR text lines without parsing.
/// </param>
/// <param name="throwIfInvalid">
/// <c>true</c> (default) – throws <see cref="InvalidOperationException"/> when the PDF fails validation.<br/>
/// <c>false</c> – returns <c>null</c> instead, allowing the caller to handle the case without a try/catch.
/// </param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>
/// Swiss QR Code extraction result, or <c>null</c> if the document is invalid and
/// <paramref name="throwIfInvalid"/> is <c>false</c>.
/// A <see cref="SwissQrCodeResult"/> containing the <see cref="PdfValidationResult"/>
/// (always populated) and the <see cref="SwissQrCodeExtractionResult"/> (<c>null</c> if the
/// document was invalid).
/// </returns>
/// <exception cref="InvalidOperationException">
/// Thrown when the PDF fails validation and <paramref name="throwIfInvalid"/> is <c>true</c>.
/// </exception>
Task<SwissQrCodeExtractionResult?> ExtractSwissQrCodeAsync(
Task<SwissQrCodeResult> InspectSwissQrCodeAsync(
byte[] pdfBytes,
bool raw = false,
bool throwIfInvalid = true,
CancellationToken cancellationToken = default);
/// <inheritdoc cref="ExtractSwissQrCodeAsync(byte[], bool, bool, CancellationToken)"/>
/// <inheritdoc cref="InspectSwissQrCodeAsync(byte[], bool, CancellationToken)"/>
/// <param name="pdfStream">PDF file as a stream.</param>
/// <param name="raw"><inheritdoc cref="ExtractSwissQrCodeAsync(byte[], bool, bool, CancellationToken)" path="/param[@name='raw']"/></param>
/// <param name="throwIfInvalid"><inheritdoc cref="ExtractSwissQrCodeAsync(byte[], bool, bool, CancellationToken)" path="/param[@name='throwIfInvalid']"/></param>
/// <param name="raw"><inheritdoc cref="InspectSwissQrCodeAsync(byte[], bool, CancellationToken)" path="/param[@name=''raw'']"/></param>
/// <param name="cancellationToken">Cancellation token.</param>
Task<SwissQrCodeExtractionResult?> ExtractSwissQrCodeAsync(
Task<SwissQrCodeResult> InspectSwissQrCodeAsync(
Stream pdfStream,
bool raw = false,
bool throwIfInvalid = true,
CancellationToken cancellationToken = default);
/// <inheritdoc cref="ExtractSwissQrCodeAsync(byte[], bool, bool, CancellationToken)"/>
/// <param name="filePath">Absolute or relative path to the PDF file.</param>
/// <param name="raw"><inheritdoc cref="ExtractSwissQrCodeAsync(byte[], bool, bool, CancellationToken)" path="/param[@name='raw']"/></param>
/// <param name="throwIfInvalid"><inheritdoc cref="ExtractSwissQrCodeAsync(byte[], bool, bool, CancellationToken)" path="/param[@name='throwIfInvalid']"/></param>
/// <param name="cancellationToken">Cancellation token.</param>
Task<SwissQrCodeExtractionResult?> ExtractSwissQrCodeAsync(
string filePath,
bool raw = false,
bool throwIfInvalid = true,
CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,21 @@
using DocumentService.Application.Common.DTOs;
namespace DocumentService.Client.Models.Results;
/// <summary>
/// Combined result of a PDF validation followed by a Swiss QR Code extraction.
/// Both operations are always executed; <see cref="QrCode"/> is <c>null</c> when
/// the document fails validation.
/// </summary>
/// <param name="QrCode">
/// Extracted Swiss QR Code data, or <c>null</c> if the PDF was invalid or did not
/// contain a recognisable Swiss QR Code.
/// </param>
/// <param name="Validation">
/// PDF validation result. Always populated regardless of whether extraction succeeded.
/// Use <see cref="DocumentService.Application.Common.DTOs.PdfValidationResult.IsValid"/>
/// to determine whether the extraction was attempted.
/// </param>
public record SwissQrCodeResult(
SwissQrCodeExtractionResult? QrCode,
PdfValidationResult Validation);