From d6e3a5fda1abfcfb7da2c940aac2742139ea2429 Mon Sep 17 00:00:00 2001 From: TekH Date: Tue, 7 Jul 2026 19:01:05 +0200 Subject: [PATCH] refactor: Restructure Application layer with vertical slices and AutoMapper Vertical slice architecture: - Move Features/Documents/{UseCase}/ to {UseCase}/Queries/ - Query + Handler in SAME file (co-located) - Validator in separate file (single responsibility) New structure: - ValidatePdf/Queries/ValidatePdfQuery.cs (Query + Handler) - ValidatePdf/Queries/ValidatePdfQueryValidator.cs - SwissQrCode/Queries/ExtractSwissQrCodeQuery.cs (Query + Handler) - SwissQrCode/Queries/ExtractSwissQrCodeQueryValidator.cs AutoMapper integration: - Add Common/Mapping/MappingProfile.cs - Map PdfMetadata -> PdfValidationResult (domain -> DTO) - Map SwissQrCodeData -> SwissQrCodeExtractionResult (domain -> DTO) - Controllers now thin: pass request to MediatR, AutoMapper handles mapping DTO improvements: - Rename: ValidatePdfResponse -> PdfValidationResult (business-friendly) - Rename: ExtractSwissQrCodeResponse -> SwissQrCodeExtractionResult - Support BOTH byte[] and Base64Pdf string (XOR validation) - Use modern C# 12 collection expressions Code quality: - Use PascalCase for primary constructor parameters - Fix LoggingBehavior logging format Deleted old structure: - Features/Documents/ValidatePdf/ (old horizontal structure) - Features/Documents/ExtractSwissQrCode/ (old horizontal structure) - Common/DTOs/{Request|Response} (replaced with {Result}) Result: Vertical slices, AutoMapper v16.2.0, thin controllers --- .../Common/Behaviors/LoggingBehavior.cs | 18 ++---- .../Common/DTOs/ExtractSwissQrCodeRequest.cs | 18 ------ ...ePdfResponse.cs => PdfValidationResult.cs} | 10 ++-- ...onse.cs => SwissQrCodeExtractionResult.cs} | 2 +- .../Common/DTOs/ValidatePdfRequest.cs | 7 --- .../Common/Mapping/MappingProfile.cs | 24 ++++++++ .../ExtractSwissQrCodeHandler.cs | 33 ----------- .../ExtractSwissQrCodeQuery.cs | 26 -------- .../ExtractSwissQrCodeValidator.cs | 21 ------- .../ValidatePdf/ValidatePdfHandler.cs | 33 ----------- .../Documents/ValidatePdf/ValidatePdfQuery.cs | 10 ---- .../ValidatePdf/ValidatePdfValidator.cs | 17 ------ .../Queries/ExtractSwissQrCodeQuery.cs | 56 ++++++++++++++++++ .../ExtractSwissQrCodeQueryValidator.cs | 59 +++++++++++++++++++ .../ValidatePdf/Queries/ValidatePdfQuery.cs | 45 ++++++++++++++ .../Queries/ValidatePdfQueryValidator.cs | 59 +++++++++++++++++++ 16 files changed, 253 insertions(+), 185 deletions(-) delete mode 100644 DocumentOperator.Application/Common/DTOs/ExtractSwissQrCodeRequest.cs rename DocumentOperator.Application/Common/DTOs/{ValidatePdfResponse.cs => PdfValidationResult.cs} (54%) rename DocumentOperator.Application/Common/DTOs/{ExtractSwissQrCodeResponse.cs => SwissQrCodeExtractionResult.cs} (98%) delete mode 100644 DocumentOperator.Application/Common/DTOs/ValidatePdfRequest.cs create mode 100644 DocumentOperator.Application/Common/Mapping/MappingProfile.cs delete mode 100644 DocumentOperator.Application/Features/Documents/ExtractSwissQrCode/ExtractSwissQrCodeHandler.cs delete mode 100644 DocumentOperator.Application/Features/Documents/ExtractSwissQrCode/ExtractSwissQrCodeQuery.cs delete mode 100644 DocumentOperator.Application/Features/Documents/ExtractSwissQrCode/ExtractSwissQrCodeValidator.cs delete mode 100644 DocumentOperator.Application/Features/Documents/ValidatePdf/ValidatePdfHandler.cs delete mode 100644 DocumentOperator.Application/Features/Documents/ValidatePdf/ValidatePdfQuery.cs delete mode 100644 DocumentOperator.Application/Features/Documents/ValidatePdf/ValidatePdfValidator.cs create mode 100644 DocumentOperator.Application/SwissQrCode/Queries/ExtractSwissQrCodeQuery.cs create mode 100644 DocumentOperator.Application/SwissQrCode/Queries/ExtractSwissQrCodeQueryValidator.cs create mode 100644 DocumentOperator.Application/ValidatePdf/Queries/ValidatePdfQuery.cs create mode 100644 DocumentOperator.Application/ValidatePdf/Queries/ValidatePdfQueryValidator.cs diff --git a/DocumentOperator.Application/Common/Behaviors/LoggingBehavior.cs b/DocumentOperator.Application/Common/Behaviors/LoggingBehavior.cs index 35db151..7e2b998 100644 --- a/DocumentOperator.Application/Common/Behaviors/LoggingBehavior.cs +++ b/DocumentOperator.Application/Common/Behaviors/LoggingBehavior.cs @@ -8,16 +8,9 @@ namespace DocumentOperator.Application.Common.Behaviors; /// MediatR Pipeline Behavior that logs requests and tracks performance /// Executes AFTER ValidationBehavior, BEFORE Handler /// -public class LoggingBehavior : IPipelineBehavior +public class LoggingBehavior(ILogger> Logger) : IPipelineBehavior where TRequest : IRequest { - private readonly ILogger> _logger; - - public LoggingBehavior(ILogger> logger) - { - _logger = logger; - } - public async Task Handle( TRequest request, RequestHandlerDelegate next, @@ -25,21 +18,18 @@ public class LoggingBehavior : IPipelineBehavior : IPipelineBehavior -/// Request to extract Swiss QR Code from a PDF document. -/// The QR code must be located on the last page of the document. -/// -/// Array of reference strings to pass through in the response -/// PDF document encoded as Base64 string -/// -/// { -/// "references": ["REF-001", "REF-002"], -/// "base64Pdf": "JVBERi0xLjQK..." -/// } -/// -public record ExtractSwissQrCodeRequest( - IReadOnlyList References, - string Base64Pdf -); diff --git a/DocumentOperator.Application/Common/DTOs/ValidatePdfResponse.cs b/DocumentOperator.Application/Common/DTOs/PdfValidationResult.cs similarity index 54% rename from DocumentOperator.Application/Common/DTOs/ValidatePdfResponse.cs rename to DocumentOperator.Application/Common/DTOs/PdfValidationResult.cs index c533d61..5b977aa 100644 --- a/DocumentOperator.Application/Common/DTOs/ValidatePdfResponse.cs +++ b/DocumentOperator.Application/Common/DTOs/PdfValidationResult.cs @@ -4,12 +4,12 @@ namespace DocumentOperator.Application.Common.DTOs; /// Response mit PDF-Metadaten /// /// Anzahl der Seiten -/// Dateigröße in Bytes -/// Dateigröße in MB (gerundet auf 2 Dezimalstellen) +/// Dateigröße in Bytes +/// Dateigröße in MB (gerundet auf 2 Dezimalstellen) /// PDF-Version (z.B. "1.4") -/// Hat das PDF Anhänge? -/// Anzahl der Anhänge -public record ValidatePdfResponse( +/// Hat das PDF Anhänge? +/// Anzahl der Anhänge +public record PdfValidationResult( int PageCount, long FileSizeBytes, double FileSizeMB, diff --git a/DocumentOperator.Application/Common/DTOs/ExtractSwissQrCodeResponse.cs b/DocumentOperator.Application/Common/DTOs/SwissQrCodeExtractionResult.cs similarity index 98% rename from DocumentOperator.Application/Common/DTOs/ExtractSwissQrCodeResponse.cs rename to DocumentOperator.Application/Common/DTOs/SwissQrCodeExtractionResult.cs index ebf1b1a..4db1e7a 100644 --- a/DocumentOperator.Application/Common/DTOs/ExtractSwissQrCodeResponse.cs +++ b/DocumentOperator.Application/Common/DTOs/SwissQrCodeExtractionResult.cs @@ -29,7 +29,7 @@ namespace DocumentOperator.Application.Common.DTOs; /// } /// } /// -public record ExtractSwissQrCodeResponse( +public record SwissQrCodeExtractionResult( IReadOnlyList References, SwissQrCodeDataDto QrCodeData ); diff --git a/DocumentOperator.Application/Common/DTOs/ValidatePdfRequest.cs b/DocumentOperator.Application/Common/DTOs/ValidatePdfRequest.cs deleted file mode 100644 index 6b8f963..0000000 --- a/DocumentOperator.Application/Common/DTOs/ValidatePdfRequest.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace DocumentOperator.Application.Common.DTOs; - -/// -/// Request für PDF-Validierung -/// -/// Base64-encodiertes PDF-Dokument -public record ValidatePdfRequest(string Base64Pdf); diff --git a/DocumentOperator.Application/Common/Mapping/MappingProfile.cs b/DocumentOperator.Application/Common/Mapping/MappingProfile.cs new file mode 100644 index 0000000..f1711d3 --- /dev/null +++ b/DocumentOperator.Application/Common/Mapping/MappingProfile.cs @@ -0,0 +1,24 @@ +using AutoMapper; +using DocumentOperator.Application.Common.DTOs; +using DocumentOperator.Domain.Models.ValueObjects; +using DocumentOperator.Domain.ValueObjects; + +namespace DocumentOperator.Application.Common.Mapping; + +/// +/// AutoMapper profile for mapping domain entities to DTOs +/// +public class MappingProfile : Profile +{ + public MappingProfile() + { + // PdfMetadata -> PdfValidationResult + CreateMap(); + + // SwissQrCodeData -> SwissQrCodeDataDto + CreateMap(); + + // AddressData -> AddressDataDto + CreateMap(); + } +} diff --git a/DocumentOperator.Application/Features/Documents/ExtractSwissQrCode/ExtractSwissQrCodeHandler.cs b/DocumentOperator.Application/Features/Documents/ExtractSwissQrCode/ExtractSwissQrCodeHandler.cs deleted file mode 100644 index 54e6d10..0000000 --- a/DocumentOperator.Application/Features/Documents/ExtractSwissQrCode/ExtractSwissQrCodeHandler.cs +++ /dev/null @@ -1,33 +0,0 @@ -using DocumentOperator.Application.Common.Interfaces; -using MediatR; - -namespace DocumentOperator.Application.Features.Documents.ExtractSwissQrCode; - -/// -/// Handles extraction of Swiss QR Code from PDF documents. -/// Uses ISwissQrCodeProcessor to extract QR code from the last page and parse it. -/// -public sealed class ExtractSwissQrCodeHandler : IRequestHandler -{ - private readonly ISwissQrCodeProcessor _qrCodeProcessor; - - public ExtractSwissQrCodeHandler(ISwissQrCodeProcessor qrCodeProcessor) - { - _qrCodeProcessor = qrCodeProcessor; - } - - public async Task Handle(ExtractSwissQrCodeQuery request, CancellationToken cancellationToken) - { - // Convert Base64 string to byte array - byte[] pdfBytes = request.PdfContent.ToByteArray(); - - // Extract and parse Swiss QR Code from last page - var qrCodeData = await _qrCodeProcessor.ExtractSwissQrCodeAsync(pdfBytes, cancellationToken); - - // Return references (passed through) + QR code data - return new ExtractSwissQrCodeResult( - References: request.References, - QrCodeData: qrCodeData - ); - } -} diff --git a/DocumentOperator.Application/Features/Documents/ExtractSwissQrCode/ExtractSwissQrCodeQuery.cs b/DocumentOperator.Application/Features/Documents/ExtractSwissQrCode/ExtractSwissQrCodeQuery.cs deleted file mode 100644 index 7259588..0000000 --- a/DocumentOperator.Application/Features/Documents/ExtractSwissQrCode/ExtractSwissQrCodeQuery.cs +++ /dev/null @@ -1,26 +0,0 @@ -using DocumentOperator.Domain.Models.ValueObjects; -using DocumentOperator.Domain.ValueObjects; -using MediatR; - -namespace DocumentOperator.Application.Features.Documents.ExtractSwissQrCode; - -/// -/// Query to extract Swiss QR Code data from a PDF document. -/// Returns references (passed through) and parsed QR code data. -/// -/// Array of reference strings to pass through in the response -/// PDF document content as Base64 string -public record ExtractSwissQrCodeQuery( - IReadOnlyList References, - Base64String PdfContent -) : IRequest; - -/// -/// Result containing passed-through references and extracted Swiss QR Code data -/// -/// Reference strings passed through from request -/// Parsed Swiss QR Code data from the last page of the PDF -public record ExtractSwissQrCodeResult( - IReadOnlyList References, - SwissQrCodeData QrCodeData -); diff --git a/DocumentOperator.Application/Features/Documents/ExtractSwissQrCode/ExtractSwissQrCodeValidator.cs b/DocumentOperator.Application/Features/Documents/ExtractSwissQrCode/ExtractSwissQrCodeValidator.cs deleted file mode 100644 index d7c48f2..0000000 --- a/DocumentOperator.Application/Features/Documents/ExtractSwissQrCode/ExtractSwissQrCodeValidator.cs +++ /dev/null @@ -1,21 +0,0 @@ -using FluentValidation; - -namespace DocumentOperator.Application.Features.Documents.ExtractSwissQrCode; - -/// -/// Validates ExtractSwissQrCodeQuery before handler execution. -/// Ensures references array and PDF content are provided. -/// -public sealed class ExtractSwissQrCodeValidator : AbstractValidator -{ - public ExtractSwissQrCodeValidator() - { - RuleFor(x => x.References) - .NotNull() - .WithMessage("References array is required."); - - RuleFor(x => x.PdfContent) - .NotNull() - .WithMessage("PDF content is required."); - } -} diff --git a/DocumentOperator.Application/Features/Documents/ValidatePdf/ValidatePdfHandler.cs b/DocumentOperator.Application/Features/Documents/ValidatePdf/ValidatePdfHandler.cs deleted file mode 100644 index 50b6b09..0000000 --- a/DocumentOperator.Application/Features/Documents/ValidatePdf/ValidatePdfHandler.cs +++ /dev/null @@ -1,33 +0,0 @@ -using DocumentOperator.Application.Common.Interfaces; -using DocumentOperator.Domain.Models.ValueObjects; -using MediatR; - -namespace DocumentOperator.Application.Features.Documents.ValidatePdf; - -/// -/// Handler for ValidatePdfQuery -/// Orchestrates PDF validation using IPdfProcessor -/// -public class ValidatePdfHandler : IRequestHandler -{ - private readonly IPdfProcessor _pdfProcessor; - - public ValidatePdfHandler(IPdfProcessor pdfProcessor) - { - _pdfProcessor = pdfProcessor; - } - - /// - /// Validates PDF and returns metadata - /// - public async Task Handle(ValidatePdfQuery request, CancellationToken cancellationToken) - { - // Value Object ? Byte Array - byte[] pdfBytes = request.PdfContent.ToByteArray(); - - // DevExpress Service aufrufen (kann PdfProcessingException werfen) - var metadata = await _pdfProcessor.ValidateAsync(pdfBytes); - - return metadata; - } -} diff --git a/DocumentOperator.Application/Features/Documents/ValidatePdf/ValidatePdfQuery.cs b/DocumentOperator.Application/Features/Documents/ValidatePdf/ValidatePdfQuery.cs deleted file mode 100644 index 04d02a0..0000000 --- a/DocumentOperator.Application/Features/Documents/ValidatePdf/ValidatePdfQuery.cs +++ /dev/null @@ -1,10 +0,0 @@ -using DocumentOperator.Domain.Models.ValueObjects; -using MediatR; - -namespace DocumentOperator.Application.Features.Documents.ValidatePdf; - -/// -/// Query to validate a PDF document and return metadata -/// -/// PDF content as Base64 string (validated by Value Object) -public record ValidatePdfQuery(Base64String PdfContent) : IRequest; diff --git a/DocumentOperator.Application/Features/Documents/ValidatePdf/ValidatePdfValidator.cs b/DocumentOperator.Application/Features/Documents/ValidatePdf/ValidatePdfValidator.cs deleted file mode 100644 index 17ec5f6..0000000 --- a/DocumentOperator.Application/Features/Documents/ValidatePdf/ValidatePdfValidator.cs +++ /dev/null @@ -1,17 +0,0 @@ -using FluentValidation; - -namespace DocumentOperator.Application.Features.Documents.ValidatePdf; - -/// -/// Validator for ValidatePdfQuery -/// Validates that PdfContent is not null (Base64String already validates format in its constructor) -/// -public class ValidatePdfValidator : AbstractValidator -{ - public ValidatePdfValidator() - { - RuleFor(x => x.PdfContent) - .NotNull() - .WithMessage("PDF content is required"); - } -} diff --git a/DocumentOperator.Application/SwissQrCode/Queries/ExtractSwissQrCodeQuery.cs b/DocumentOperator.Application/SwissQrCode/Queries/ExtractSwissQrCodeQuery.cs new file mode 100644 index 0000000..c1f3447 --- /dev/null +++ b/DocumentOperator.Application/SwissQrCode/Queries/ExtractSwissQrCodeQuery.cs @@ -0,0 +1,56 @@ +using AutoMapper; +using DocumentOperator.Application.Common.DTOs; +using DocumentOperator.Application.Common.Interfaces; +using MediatR; + +namespace DocumentOperator.Application.SwissQrCode.Queries; + +/// +/// Query for extracting Swiss QR Code from PDF (supports both byte array and Base64 input) +/// +public record ExtractSwissQrCodeQuery : IRequest +{ + /// + /// PDF as byte array (direct upload) + /// + public byte[]? PdfBytes { get; init; } + + /// + /// PDF as Base64 string (API clients) + /// + public string? Base64Pdf { get; init; } + + /// + /// Optional reference strings (passed through to response for external tracking) + /// + public IReadOnlyList? References { get; init; } +} + +/// +/// Handler for ExtractSwissQrCodeQuery +/// Orchestrates Swiss QR Code extraction using ISwissQrCodeProcessor and AutoMapper +/// +public class ExtractSwissQrCodeQueryHandler(ISwissQrCodeProcessor qrCodeProcessor, IMapper mapper) + : IRequestHandler +{ + /// + /// Extracts and parses Swiss QR Code from the last page of the PDF + /// + public async Task Handle(ExtractSwissQrCodeQuery request, CancellationToken cancellationToken) + { + // Use byte[] if available, otherwise convert Base64 + byte[] pdfBytes = request.PdfBytes ?? Convert.FromBase64String(request.Base64Pdf!); + + // Extract and parse Swiss QR Code from last page (can throw PdfProcessingException or QrCodeNotFoundException) + var qrCodeData = await qrCodeProcessor.ExtractSwissQrCodeAsync(pdfBytes, cancellationToken); + + // Map domain value object to DTO using AutoMapper + var qrCodeDto = mapper.Map(qrCodeData); + + // Return references (passed through) + QR code data + return new SwissQrCodeExtractionResult( + References: request.References ?? [], + QrCodeData: qrCodeDto + ); + } +} diff --git a/DocumentOperator.Application/SwissQrCode/Queries/ExtractSwissQrCodeQueryValidator.cs b/DocumentOperator.Application/SwissQrCode/Queries/ExtractSwissQrCodeQueryValidator.cs new file mode 100644 index 0000000..5e99612 --- /dev/null +++ b/DocumentOperator.Application/SwissQrCode/Queries/ExtractSwissQrCodeQueryValidator.cs @@ -0,0 +1,59 @@ +using DocumentOperator.Application.SwissQrCode.Queries; +using FluentValidation; + +namespace DocumentOperator.Application.SwissQrCode.Queries; + +/// +/// Validates ExtractSwissQrCodeQuery before handler execution. +/// Ensures exactly ONE input method is provided (either PdfBytes OR Base64Pdf, not both, not none). +/// +public sealed class ExtractSwissQrCodeQueryValidator : AbstractValidator +{ + public ExtractSwissQrCodeQueryValidator() + { + RuleFor(x => x) + .Must(HasExactlyOneInput) + .WithMessage("Either PdfBytes or Base64Pdf must be provided, but not both"); + + // Validate Base64 format if provided + When(x => !string.IsNullOrWhiteSpace(x.Base64Pdf), () => + { + RuleFor(x => x.Base64Pdf) + .Must(BeValidBase64) + .WithMessage("Invalid Base64 format"); + }); + + // Validate byte array if provided + When(x => x.PdfBytes != null, () => + { + RuleFor(x => x.PdfBytes) + .NotEmpty() + .WithMessage("PdfBytes cannot be empty"); + }); + } + + private static bool HasExactlyOneInput(ExtractSwissQrCodeQuery request) + { + var hasPdfBytes = request.PdfBytes != null && request.PdfBytes.Length > 0; + var hasBase64 = !string.IsNullOrWhiteSpace(request.Base64Pdf); + + // XOR: exactly one must be true + return hasPdfBytes ^ hasBase64; + } + + private static bool BeValidBase64(string? base64) + { + if (string.IsNullOrWhiteSpace(base64)) + return false; + + try + { + Convert.FromBase64String(base64); + return true; + } + catch (FormatException) + { + return false; + } + } +} diff --git a/DocumentOperator.Application/ValidatePdf/Queries/ValidatePdfQuery.cs b/DocumentOperator.Application/ValidatePdf/Queries/ValidatePdfQuery.cs new file mode 100644 index 0000000..349bb93 --- /dev/null +++ b/DocumentOperator.Application/ValidatePdf/Queries/ValidatePdfQuery.cs @@ -0,0 +1,45 @@ +using AutoMapper; +using DocumentOperator.Application.Common.DTOs; +using DocumentOperator.Application.Common.Interfaces; +using MediatR; + +namespace DocumentOperator.Application.ValidatePdf.Queries; + +/// +/// Query for PDF validation (supports both byte array and Base64 input) +/// +public record ValidatePdfQuery : IRequest +{ + /// + /// PDF as byte array (direct upload) + /// + public byte[]? PdfBytes { get; init; } + + /// + /// PDF as Base64 string (API clients) + /// + public string? Base64Pdf { get; init; } +} + +/// +/// Handler for ValidatePdfQuery +/// Orchestrates PDF validation using IPdfProcessor and AutoMapper +/// +public class ValidatePdfQueryHandler(IPdfProcessor PdfProcessor, IMapper Mapper) + : IRequestHandler +{ + /// + /// Validates PDF and returns metadata + /// + public async Task Handle(ValidatePdfQuery request, CancellationToken cancellationToken) + { + // Use byte[] if available, otherwise convert Base64 + byte[] pdfBytes = request.PdfBytes ?? Convert.FromBase64String(request.Base64Pdf!); + + // Call DevExpress service (can throw PdfProcessingException) + var metadata = await PdfProcessor.ValidateAsync(pdfBytes); + + // Map domain entity to DTO using AutoMapper + return Mapper.Map(metadata); + } +} diff --git a/DocumentOperator.Application/ValidatePdf/Queries/ValidatePdfQueryValidator.cs b/DocumentOperator.Application/ValidatePdf/Queries/ValidatePdfQueryValidator.cs new file mode 100644 index 0000000..1e9bb5b --- /dev/null +++ b/DocumentOperator.Application/ValidatePdf/Queries/ValidatePdfQueryValidator.cs @@ -0,0 +1,59 @@ +using DocumentOperator.Application.ValidatePdf.Queries; +using FluentValidation; + +namespace DocumentOperator.Application.ValidatePdf.Queries; + +/// +/// Validator for ValidatePdfQuery +/// Ensures exactly ONE input method is provided (either PdfBytes OR Base64Pdf, not both, not none) +/// +public class ValidatePdfQueryValidator : AbstractValidator +{ + public ValidatePdfQueryValidator() + { + RuleFor(x => x) + .Must(HasExactlyOneInput) + .WithMessage("Either PdfBytes or Base64Pdf must be provided, but not both"); + + // Validate Base64 format if provided + When(x => !string.IsNullOrWhiteSpace(x.Base64Pdf), () => + { + RuleFor(x => x.Base64Pdf) + .Must(BeValidBase64) + .WithMessage("Invalid Base64 format"); + }); + + // Validate byte array if provided + When(x => x.PdfBytes != null, () => + { + RuleFor(x => x.PdfBytes) + .NotEmpty() + .WithMessage("PdfBytes cannot be empty"); + }); + } + + private static bool HasExactlyOneInput(ValidatePdfQuery request) + { + var hasPdfBytes = request.PdfBytes != null && request.PdfBytes.Length > 0; + var hasBase64 = !string.IsNullOrWhiteSpace(request.Base64Pdf); + + // XOR: exactly one must be true + return hasPdfBytes ^ hasBase64; + } + + private static bool BeValidBase64(string? base64) + { + if (string.IsNullOrWhiteSpace(base64)) + return false; + + try + { + Convert.FromBase64String(base64); + return true; + } + catch (FormatException) + { + return false; + } + } +}