diff --git a/DocumentOperator.API/Controllers/ZugferdController.cs b/DocumentOperator.API/Controllers/ZugferdController.cs new file mode 100644 index 0000000..a5761a3 --- /dev/null +++ b/DocumentOperator.API/Controllers/ZugferdController.cs @@ -0,0 +1,182 @@ +using DocumentOperator.Application.Common.DTOs; +using DocumentOperator.Application.ExtractZugferd; +using DocumentOperator.Application.HasZugferd.Queries; +using DocumentOperator.Domain.Common.Exceptions; +using MediatR; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +namespace DocumentOperator.API.Controllers; + +/// +/// Controller for ZUGFeRD operations (detection, extraction) +/// +[ApiController] +[Route("api/pdf/zugferd")] +[Produces("application/json")] +public class ZugferdController(IMediator mediator) : ControllerBase +{ + /// + /// Checks if a PDF contains ZUGFeRD XML attachment. + /// Supports multipart/form-data file upload. + /// + /// The PDF file to check for ZUGFeRD + /// Cancellation token + /// ZUGFeRD check result with metadata + /// PDF successfully checked - returns ZUGFeRD status + /// Invalid input (file missing, not a PDF, or corrupted) + /// Internal server error during PDF processing + [HttpPost("has-zugferd")] + [Consumes("multipart/form-data")] + [ProducesResponseType(typeof(ZugferdCheckResult), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status500InternalServerError)] + public async Task HasZugferdFromFile( + IFormFile file, + CancellationToken cancellationToken) + { + // Use IFormFile stream directly (no intermediate byte[] conversion) + using var pdfStream = file.OpenReadStream(); + + // Send query to MediatR (ValidationBehavior runs automatically) + var query = new HasZugferdQuery { PdfStream = pdfStream }; + var result = await mediator.Send(query, cancellationToken); + + return Ok(result); + } + + /// + /// Checks if a PDF contains ZUGFeRD XML attachment. + /// Supports Base64-encoded PDF via JSON payload. + /// + /// Request containing Base64-encoded PDF + /// Cancellation token + /// ZUGFeRD check result with metadata + /// PDF successfully checked - returns ZUGFeRD status + /// Invalid input (Base64 format error, not a PDF, or corrupted) + /// Internal server error during PDF processing + [HttpPost("has-zugferd")] + [Consumes("application/json")] + [ProducesResponseType(typeof(ZugferdCheckResult), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status500InternalServerError)] + public async Task HasZugferdFromBase64( + [FromBody] HasZugferdRequest request, + CancellationToken cancellationToken) + { + // Convert Base64 to stream (wrap in try-catch to throw BadRequestException) + byte[] pdfBytes; + try + { + pdfBytes = Convert.FromBase64String(request.Base64Pdf); + } + catch (FormatException ex) + { + throw new BadRequestException("Invalid Base64 format: " + ex.Message); + } + + using var pdfStream = new MemoryStream(pdfBytes); + + // Send query to MediatR (ValidationBehavior runs automatically) + var query = new HasZugferdQuery { PdfStream = pdfStream }; + var result = await mediator.Send(query, cancellationToken); + + return Ok(result); + } + + /// + /// Extracts ZUGFeRD XML from a PDF document. + /// Supports multipart/form-data file upload. + /// + /// The PDF file to extract ZUGFeRD from + /// Cancellation token + /// ZUGFeRD XML content and metadata + /// ZUGFeRD XML extracted successfully + /// Invalid input (file missing, not a PDF, or corrupted) + /// PDF contains no ZUGFeRD XML + /// Internal server error during PDF processing + [HttpPost("extract")] + [Consumes("multipart/form-data")] + [ProducesResponseType(typeof(ZugferdExtractionResult), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status500InternalServerError)] + public async Task ExtractZugferdFromFile( + IFormFile file, + CancellationToken cancellationToken) + { + // Use IFormFile stream directly (no intermediate byte[] conversion) + using var pdfStream = file.OpenReadStream(); + + // Send command to MediatR + var command = new ExtractZugferdCommand { PdfStream = pdfStream }; + var result = await mediator.Send(command, cancellationToken); + + return Ok(result); + } + + /// + /// Extracts ZUGFeRD XML from a PDF document. + /// Supports Base64-encoded PDF via JSON payload. + /// + /// Request containing Base64-encoded PDF + /// Cancellation token + /// ZUGFeRD XML content and metadata + /// ZUGFeRD XML extracted successfully + /// Invalid input (Base64 format error, not a PDF, or corrupted) + /// PDF contains no ZUGFeRD XML + /// Internal server error during PDF processing + [HttpPost("extract")] + [Consumes("application/json")] + [ProducesResponseType(typeof(ZugferdExtractionResult), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status500InternalServerError)] + public async Task ExtractZugferdFromBase64( + [FromBody] ExtractZugferdRequest request, + CancellationToken cancellationToken) + { + // Convert Base64 to stream (wrap in try-catch to throw BadRequestException) + byte[] pdfBytes; + try + { + pdfBytes = Convert.FromBase64String(request.Base64Pdf); + } + catch (FormatException ex) + { + throw new BadRequestException("Invalid Base64 format: " + ex.Message); + } + + using var pdfStream = new MemoryStream(pdfBytes); + + // Send command to MediatR + var command = new ExtractZugferdCommand { PdfStream = pdfStream }; + var result = await mediator.Send(command, cancellationToken); + + return Ok(result); + } +} + +/// +/// Request DTO for Base64-encoded PDF ZUGFeRD check +/// +public record HasZugferdRequest +{ + /// + /// PDF document encoded as Base64 string + /// + /// JVBERi0xLjQKJeLjz9MKMSAwIG9iago8PC9UeXBlL0NhdGFsb2c... + public string Base64Pdf { get; init; } = string.Empty; +} + +/// +/// Request DTO for Base64-encoded PDF ZUGFeRD extraction +/// +public record ExtractZugferdRequest +{ + /// + /// PDF document encoded as Base64 string + /// + /// JVBERi0xLjQKJeLjz9MKMSAwIG9iago8PC9UeXBlL0NhdGFsb2c... + public required string Base64Pdf { get; init; } +} diff --git a/DocumentOperator.API/Program.cs b/DocumentOperator.API/Program.cs index 28de095..204d052 100644 --- a/DocumentOperator.API/Program.cs +++ b/DocumentOperator.API/Program.cs @@ -5,6 +5,7 @@ using Serilog.Ui.Web.Extensions; using Scalar.AspNetCore; using DocumentOperator.Infrastructure.Configuration; using DocumentOperator.Application; +using DocumentOperator.Application.Common.Configuration; using DocumentOperator.Infrastructure; using DocumentOperator.API.Middleware; using DocumentOperator.API.Configuration; @@ -32,6 +33,9 @@ try builder.Services.Configure( builder.Configuration.GetSection(DocumentOperatorSettings.SectionName)); + builder.Services.Configure( + builder.Configuration.GetSection("ZugferdSettings")); + builder.Services.Configure( builder.Configuration.GetSection(RedisSettings.SectionName)); diff --git a/DocumentOperator.API/appsettings.json b/DocumentOperator.API/appsettings.json index 76798eb..8d6ba3d 100644 --- a/DocumentOperator.API/appsettings.json +++ b/DocumentOperator.API/appsettings.json @@ -61,6 +61,22 @@ "EnableDetailedLogging": true }, + "ZugferdSettings": { + "ZugferdFileNames": [ + "factur-x.xml", + "zugferd-invoice.xml", + "ZUGFeRD-invoice.xml", + "xrechnung.xml", + "XRechnung.xml" + ], + "ZugferdFileNamePatterns": [ + "factur", + "zugferd", + "xrechnung", + "peppol" + ] + }, + "RedisSettings": { "ConnectionString": "localhost:6379", "InstanceName": "DocumentOperator:", diff --git a/DocumentOperator.Application/Common/Configuration/ZugferdSettings.cs b/DocumentOperator.Application/Common/Configuration/ZugferdSettings.cs new file mode 100644 index 0000000..1207eaf --- /dev/null +++ b/DocumentOperator.Application/Common/Configuration/ZugferdSettings.cs @@ -0,0 +1,29 @@ +namespace DocumentOperator.Application.Common.Configuration; + +/// +/// Configuration settings for ZUGFeRD/Factur-X/XRechnung detection +/// +public class ZugferdSettings +{ + /// + /// Exact ZUGFeRD/Factur-X/XRechnung file names to check (case-insensitive) + /// + public List ZugferdFileNames { get; set; } = new() + { + "factur-x.xml", + "zugferd-invoice.xml", + "ZUGFeRD-invoice.xml", + "xrechnung.xml" + }; + + /// + /// Partial filename patterns for ZUGFeRD detection (case-insensitive) + /// + public List ZugferdFileNamePatterns { get; set; } = new() + { + "factur", + "zugferd", + "xrechnung", + "peppol" + }; +} diff --git a/DocumentOperator.Application/Common/DTOs/ZugferdCheckResult.cs b/DocumentOperator.Application/Common/DTOs/ZugferdCheckResult.cs new file mode 100644 index 0000000..b5d3a98 --- /dev/null +++ b/DocumentOperator.Application/Common/DTOs/ZugferdCheckResult.cs @@ -0,0 +1,27 @@ +namespace DocumentOperator.Application.Common.DTOs; + +/// +/// DTO for ZUGFeRD check result +/// +public record ZugferdCheckResult +{ + /// + /// Indicates whether the PDF contains ZUGFeRD XML attachment + /// + public bool HasZugferd { get; init; } + + /// + /// ZUGFeRD XML file name (e.g., "factur-x.xml") + /// + public string? ZugferdFileName { get; init; } + + /// + /// ZUGFeRD XML file size in bytes + /// + public long? ZugferdFileSize { get; init; } + + /// + /// MIME type of the ZUGFeRD XML file + /// + public string? ZugferdMimeType { get; init; } +} diff --git a/DocumentOperator.Application/ExtractZugferd/ExtractZugferdCommand.cs b/DocumentOperator.Application/ExtractZugferd/ExtractZugferdCommand.cs new file mode 100644 index 0000000..dc85f0a --- /dev/null +++ b/DocumentOperator.Application/ExtractZugferd/ExtractZugferdCommand.cs @@ -0,0 +1,113 @@ +using DocumentOperator.Application.Common.Configuration; +using DocumentOperator.Application.Common.Interfaces; +using DocumentOperator.Domain.Common.Exceptions; +using FluentValidation; +using MediatR; +using Microsoft.Extensions.Options; + +namespace DocumentOperator.Application.ExtractZugferd; + +/// +/// Command to extract ZUGFeRD XML from a PDF document +/// +public record ExtractZugferdCommand : IRequest +{ + /// + /// PDF document stream. Must be positioned at the beginning (Position = 0). + /// + public required Stream PdfStream { get; init; } +} + +/// +/// Handler for ExtractZugferdCommand. +/// Extracts ZUGFeRD XML from PDF and returns XML content +/// +public class ExtractZugferdCommandHandler( + IPdfProcessor pdfProcessor, + IOptions settings) + : IRequestHandler +{ + private readonly ZugferdSettings _settings = settings.Value; + + public async Task Handle(ExtractZugferdCommand request, CancellationToken cancellationToken) + { + // Get all attachments + var attachmentInfo = await pdfProcessor.CheckAttachmentsAsync(request.PdfStream); + + // Find ZUGFeRD XML file using configured names and patterns + var zugferdAttachment = attachmentInfo.Attachments.FirstOrDefault(a => + _settings.ZugferdFileNames.Any(name => + a.FileName.Equals(name, StringComparison.OrdinalIgnoreCase)) || + _settings.ZugferdFileNamePatterns.Any(pattern => + a.FileName.Contains(pattern, StringComparison.OrdinalIgnoreCase))); + + if (zugferdAttachment == null) + { + throw new NotFoundException("ZUGFeRD XML not found in PDF attachments"); + } + + // Reset stream position for extraction + request.PdfStream.Position = 0; + + // Extract all attachments as ZIP + byte[] zipBytes = await pdfProcessor.ExtractAttachmentsAsync(request.PdfStream); + + // Find ZUGFeRD XML in ZIP + using var zipStream = new MemoryStream(zipBytes); + using var zipArchive = new System.IO.Compression.ZipArchive(zipStream, System.IO.Compression.ZipArchiveMode.Read); + + var zugferdEntry = zipArchive.Entries.FirstOrDefault(e => + e.Name.Equals(zugferdAttachment.FileName, StringComparison.OrdinalIgnoreCase)); + + if (zugferdEntry == null) + { + throw new NotFoundException($"ZUGFeRD XML '{zugferdAttachment.FileName}' not found in extracted attachments"); + } + + // Read XML content + using var entryStream = zugferdEntry.Open(); + using var reader = new StreamReader(entryStream); + string xmlContent = await reader.ReadToEndAsync(cancellationToken); + + return new ZugferdExtractionResult + { + FileName = zugferdAttachment.FileName, + XmlContent = xmlContent, + FileSize = zugferdAttachment.SizeBytes + }; + } +} + +/// +/// Validator for ExtractZugferdCommand. +/// +public class ExtractZugferdCommandValidator : AbstractValidator +{ + public ExtractZugferdCommandValidator() + { + RuleFor(x => x.PdfStream) + .NotNull() + .WithMessage("PDF stream is required"); + } +} + +/// +/// Result DTO for ZUGFeRD extraction +/// +public record ZugferdExtractionResult +{ + /// + /// ZUGFeRD XML file name + /// + public required string FileName { get; init; } + + /// + /// ZUGFeRD XML content as string + /// + public required string XmlContent { get; init; } + + /// + /// File size in bytes + /// + public long FileSize { get; init; } +} diff --git a/DocumentOperator.Application/HasZugferd/Queries/HasZugferdQuery.cs b/DocumentOperator.Application/HasZugferd/Queries/HasZugferdQuery.cs new file mode 100644 index 0000000..c7c1c31 --- /dev/null +++ b/DocumentOperator.Application/HasZugferd/Queries/HasZugferdQuery.cs @@ -0,0 +1,62 @@ +using DocumentOperator.Application.Common.Configuration; +using DocumentOperator.Application.Common.DTOs; +using DocumentOperator.Application.Common.Interfaces; +using MediatR; +using Microsoft.Extensions.Options; + +namespace DocumentOperator.Application.HasZugferd.Queries; + +/// +/// Query for checking if PDF contains ZUGFeRD XML attachment +/// +public record HasZugferdQuery : IRequest +{ + /// + /// PDF as stream (caller is responsible for disposal) + /// + public required Stream PdfStream { get; init; } +} + +/// +/// Handler for HasZugferdQuery +/// Checks if PDF contains ZUGFeRD/Factur-X XML attachment +/// +public class HasZugferdQueryHandler( + IPdfProcessor pdfProcessor, + IOptions settings) + : IRequestHandler +{ + private readonly ZugferdSettings _settings = settings.Value; + + /// + /// Checks if PDF contains ZUGFeRD XML and returns metadata + /// + public async Task Handle(HasZugferdQuery request, CancellationToken cancellationToken) + { + // Get all attachments + var attachmentInfo = await pdfProcessor.CheckAttachmentsAsync(request.PdfStream); + + // Check for ZUGFeRD/Factur-X XML files using configured names and patterns + var zugferdAttachment = attachmentInfo.Attachments.FirstOrDefault(a => + _settings.ZugferdFileNames.Any(name => + a.FileName.Equals(name, StringComparison.OrdinalIgnoreCase)) || + _settings.ZugferdFileNamePatterns.Any(pattern => + a.FileName.Contains(pattern, StringComparison.OrdinalIgnoreCase))); + + if (zugferdAttachment != null) + { + return new ZugferdCheckResult + { + HasZugferd = true, + ZugferdFileName = zugferdAttachment.FileName, + ZugferdFileSize = zugferdAttachment.SizeBytes, + ZugferdMimeType = zugferdAttachment.MimeType + }; + } + + return new ZugferdCheckResult + { + HasZugferd = false + }; + } +} diff --git a/DocumentOperator.Application/HasZugferd/Queries/HasZugferdQueryValidator.cs b/DocumentOperator.Application/HasZugferd/Queries/HasZugferdQueryValidator.cs new file mode 100644 index 0000000..04ee98b --- /dev/null +++ b/DocumentOperator.Application/HasZugferd/Queries/HasZugferdQueryValidator.cs @@ -0,0 +1,16 @@ +using FluentValidation; + +namespace DocumentOperator.Application.HasZugferd.Queries; + +/// +/// Validator for HasZugferdQuery +/// +public class HasZugferdQueryValidator : AbstractValidator +{ + public HasZugferdQueryValidator() + { + RuleFor(x => x.PdfStream) + .NotNull() + .WithMessage("PDF stream is required"); + } +} diff --git a/DocumentOperator.Tests/TestData/Pdfs/ZUGFeRD-Example.pdf b/DocumentOperator.Tests/TestData/Pdfs/ZUGFeRD-Example.pdf new file mode 100644 index 0000000..09546b8 Binary files /dev/null and b/DocumentOperator.Tests/TestData/Pdfs/ZUGFeRD-Example.pdf differ