Compare commits
93 Commits
45bc90b8b8
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 9cb964379f | |||
| 4363b5e961 | |||
| 0059a70055 | |||
| d08e40d551 | |||
| 757f56b9f8 | |||
| 8c72502913 | |||
| 058cb9327d | |||
| 14250f0b4b | |||
| b3a07f1348 | |||
| d477eb5a28 | |||
| 3da6ca8323 | |||
| 0e88b349d7 | |||
| b6bb894257 | |||
| 9cff7ee459 | |||
| a6694bfce7 | |||
| a242458d2f | |||
| 94123cd1be | |||
| 080c2ac2a0 | |||
| 97122d0bbd | |||
| e9d1586266 | |||
| 2804993ea6 | |||
| 06a9dc7385 | |||
| 4b5c763f24 | |||
| 559c726118 | |||
| 1106a86ec3 | |||
| 89436406ce | |||
| c96cbbc8d3 | |||
| 9b85e55cd4 | |||
| 536413bafe | |||
| b37ccc8538 | |||
| d0606f3605 | |||
| c037ad8446 | |||
| e2bec710e0 | |||
| 61b11fc216 | |||
| c4ec0c2b48 | |||
| eed9d46e19 | |||
| c1bb3abeef | |||
| d72d41ec2d | |||
| d4107f6f89 | |||
| 22ac2889af | |||
| a23c78ec3a | |||
| 41f97ce533 | |||
| 25fbea205f | |||
| e95f070b9b | |||
| aafe46a738 | |||
| 0bec759396 | |||
| 5c3fafff1b | |||
| bc273c7f4f | |||
| e12b64a517 | |||
| 3598c5f9c6 | |||
| 522de8a863 | |||
| cb552e54e7 | |||
| fa4e55242d | |||
| e14044c48a | |||
| 34e38f19e5 | |||
| 61b1595258 | |||
| 26458a4017 | |||
| 2c673ea98e | |||
| 1989ca7ef7 | |||
| 0f4d860176 | |||
| 645dfceafa | |||
| 07be9b9f02 | |||
| a1e8575018 | |||
| b4befde418 | |||
| 1af158840e | |||
| 5dc2e38507 | |||
| c93488c29f | |||
| 251ecc34d9 | |||
| 9db15f7025 | |||
| 364b755f95 | |||
| f2e6ef0260 | |||
| 8aff3138ff | |||
| 58f9b07af3 | |||
| 468dca46d4 | |||
| e13e85182a | |||
| 1de781748b | |||
| 73a7afe257 | |||
| d123bc996e | |||
| 4085a88485 | |||
| 88984c8887 | |||
| a315fbf890 | |||
| 1a89887056 | |||
| a729df6fda | |||
| 88bde13422 | |||
| 889144f144 | |||
| 35016f02e1 | |||
| e321963487 | |||
| 711f1a2660 | |||
| 386a124a4e | |||
| f7433111a7 | |||
| cd50d45bd5 | |||
| 1ff7cbea11 | |||
| dc0af68d26 |
126
AGENTS.md
126
AGENTS.md
@@ -1,6 +1,6 @@
|
||||
# AGENTS.md
|
||||
|
||||
Agent guidance for DocumentOperator service. Read this before working on the codebase.
|
||||
Agent guidance for DocumentService service. Read this before working on the codebase.
|
||||
|
||||
---
|
||||
|
||||
@@ -23,7 +23,7 @@ Agent guidance for DocumentOperator service. Read this before working on the cod
|
||||
### Migration Required
|
||||
|
||||
**Existing code that needs replacement:**
|
||||
- `DocumentOperator.API/Endpoints/v1/DocumentEndpoints.cs` → Delete, replace with Controllers
|
||||
- `DocumentService.API/Endpoints/v1/DocumentEndpoints.cs` → Delete, replace with Controllers
|
||||
- `Program.cs` line 72: `app.MapDocumentEndpoints()` → Replace with `app.MapControllers()`
|
||||
- `Program.cs` line 44: Add `builder.Services.AddControllers()`
|
||||
- All DTOs → Support **BOTH** `IFormFile` (multipart) AND `Base64String` (JSON)
|
||||
@@ -177,7 +177,7 @@ All files for a feature live together. Do NOT create separate Commands/, Handler
|
||||
- ✅ All features currently work without authentication
|
||||
|
||||
**When to implement:**
|
||||
After completing all Phase 1-3 controllers (PdfValidation, PdfAttachment, PdfOperations, PdfRender, PdfConversion), then add multi-tenancy to ALL endpoints in one refactoring phase.
|
||||
After completing all Phase 1-3 controllers (PdfValidation, PdfAttachment, SwissQrCode, PdfOperations, PdfConversion), then add multi-tenancy to ALL endpoints in one refactoring phase.
|
||||
|
||||
---
|
||||
|
||||
@@ -188,16 +188,17 @@ After completing all Phase 1-3 controllers (PdfValidation, PdfAttachment, PdfOpe
|
||||
dotnet build
|
||||
```
|
||||
|
||||
**Run tests (19 tests as of Feature 2):**
|
||||
**Run tests (101 passed, 7 skipped as of Feature 7 - PDF Stamp):**
|
||||
```powershell
|
||||
dotnet test
|
||||
```
|
||||
|
||||
**Run API (Development):**
|
||||
```powershell
|
||||
dotnet run --project DocumentOperator.API
|
||||
dotnet run --project DocumentService.API
|
||||
```
|
||||
Swagger UI: `https://localhost:<port>/swagger`
|
||||
Swagger UI: `https://localhost:7186/swagger`
|
||||
Serilog UI: `https://localhost:7186/serilog-ui` (Web-based log viewer)
|
||||
|
||||
**Target framework:** .NET 8.0
|
||||
**SDK required:** 8.0.412 or later (repo has 8.0.412–10.0.203 available)
|
||||
@@ -214,6 +215,8 @@ Swagger UI: `https://localhost:<port>/swagger`
|
||||
| **MediatR** (14.1.0) | CQRS: 1 handler per feature | Application layer |
|
||||
| **FluentValidation** (12.1.1) | Request validation (runs via ValidationBehavior before handlers) | Application layer |
|
||||
| **Serilog.AspNetCore** (10.0.0) | Structured logging | API layer |
|
||||
| **Serilog.Sinks.SQLite** (7.0.0) | SQLite log persistence | API layer |
|
||||
| **Serilog.UI** (3.2.0) + **Serilog.UI.SqliteProvider** (1.1.0) | Web-based log viewer UI | API layer |
|
||||
|
||||
**Critical:** DevExpress requires a license. All PDF operations use `DevExpress.Pdf.PdfDocumentProcessor`.
|
||||
|
||||
@@ -245,30 +248,51 @@ Do NOT add `if (result.IsSuccess)` checks. Throw exceptions for errors. The midd
|
||||
1. `PdfValidationController` – 2 endpoints
|
||||
- `POST /api/pdf/validation/validate` (Basic PDF validation)
|
||||
- `POST /api/pdf/validation/validate-pdfa` (PDF/A conformance)
|
||||
2. `PdfAttachmentController` – 2 endpoints
|
||||
2. `PdfAttachmentController` – check endpoint
|
||||
- `POST /api/pdf/attachments/check` (Attachment detection)
|
||||
- `POST /api/pdf/attachments/extract` (Extract attachments)
|
||||
3. `PdfOperationsController` – Merge endpoint
|
||||
3. `SwissQrCodeController` – extract endpoint
|
||||
- `POST /api/swissqrcode/extract` (Swiss QR Bill extraction)
|
||||
4. `PdfAttachmentController` – extract endpoint
|
||||
- `POST /api/pdf/attachments/extract` (Extract attachments as ZIP)
|
||||
5. `PdfOperationsController` – merge endpoint
|
||||
- `POST /api/pdf/operations/merge` (Merge multiple PDFs)
|
||||
|
||||
**Phase 2:**
|
||||
4. `PdfOperationsController` – Stamp & Annotate
|
||||
5. `PdfRenderController` – Preview
|
||||
6. `PdfOperationsController` – stamp & annotate
|
||||
- `POST /api/pdf/operations/stamp` (Add stamps)
|
||||
- `POST /api/pdf/operations/annotate` (Add annotations)
|
||||
7. `PdfAttachmentController` – add attachment
|
||||
- `POST /api/pdf/attachments/add` (Embed attachments in PDF/A-3)
|
||||
|
||||
**Phase 3:**
|
||||
6. `PdfConversionController` – PDF ↔ PDF/A conversion
|
||||
8. `PdfConversionController` – PDF ↔ PDF/A conversion
|
||||
- `POST /api/pdf/conversion/to-pdfa` (Convert to PDF/A)
|
||||
- `POST /api/pdf/conversion/from-pdfa` (Convert from PDF/A)
|
||||
|
||||
**Removed:**
|
||||
- `PdfRenderController` – Moved to .NET client library (WinForms/WPF DevExpress controls)
|
||||
|
||||
### Current Status
|
||||
|
||||
| Controller | Status | Tests |
|
||||
|-----------|--------|-------|
|
||||
| **PdfValidationController** | ⏳ Pending | 0 |
|
||||
| **PdfAttachmentController** | ⏳ Pending | 0 |
|
||||
| **PdfOperationsController** | ⏳ Pending | 0 |
|
||||
| **PdfRenderController** | ⏳ Pending | 0 |
|
||||
| **PdfValidationController** | ✅ DONE | 13 (7 validate + 6 validate-pdfa) |
|
||||
| **SwissQrCodeController** | ✅ DONE | 2 |
|
||||
| **PdfAttachmentController** | ⏳ Partial (2/3 endpoints) | 10 (4 check + 6 extract) |
|
||||
| **PdfOperationsController** | ⏳ Partial (3/3 endpoints, integration tests pending) | 29 (7 merge + 22 annotate: 12 unit + 10 integration) |
|
||||
| **PdfConversionController** | ⏳ Pending | 0 |
|
||||
|
||||
**Legacy code exists** (`DocumentEndpoints.cs` with Minimal API + Base64 JSON), but **must be replaced** with Controllers + multipart/form-data.
|
||||
**PdfAttachmentController Status:**
|
||||
- ✅ `POST /api/pdf/attachments/check` - DONE (with multipart + Base64 support)
|
||||
- ✅ `POST /api/pdf/attachments/extract` - DONE (Phase 1, Priority 4) - Returns ZIP with all attachments
|
||||
- ⏳ `POST /api/pdf/attachments/add` - TODO (Phase 2, Priority 7)
|
||||
|
||||
**PdfOperationsController Status:**
|
||||
- ✅ `POST /api/pdf/operations/merge` - DONE (Phase 1, Priority 5) - Merges multiple PDFs with optional page ranges (multipart + Base64)
|
||||
- ✅ `POST /api/pdf/operations/annotate` - DONE (Phase 2, Priority 6) - Adds annotations (TextMarkup/FreeText/StickyNote/Circle/Square) with multipart + Base64 support
|
||||
- ✅ `POST /api/pdf/operations/stamp` - DONE (Phase 2, Priority 6) - Adds text/image/predefined stamps (multipart + Base64 support, origin/rotation/opacity/placement)
|
||||
|
||||
**Note:** PdfRenderController removed - moved to .NET client library.
|
||||
|
||||
---
|
||||
|
||||
@@ -315,13 +339,77 @@ Do NOT skip steps. Each feature is done when it's **testable in Swagger UI with
|
||||
**All test PDFs are EmbeddedResource.** Access via:
|
||||
```csharp
|
||||
var stream = Assembly.GetExecutingAssembly()
|
||||
.GetManifestResourceStream("DocumentOperator.Tests.TestData.Pdfs.valid.pdf");
|
||||
.GetManifestResourceStream("DocumentService.Tests.TestData.Pdfs.valid.pdf");
|
||||
```
|
||||
|
||||
**Do NOT commit new binary files** without marking them as `<EmbeddedResource>`.
|
||||
|
||||
---
|
||||
|
||||
## Test Structure & Strategy
|
||||
|
||||
**3-folder structure (CORRECT approach by previous developer):**
|
||||
|
||||
```
|
||||
DocumentService.Tests/
|
||||
├── Integration/
|
||||
│ └── API/
|
||||
│ ├── PdfValidationControllerTests.cs (13 tests)
|
||||
│ └── ExtractSwissQrCodeEndpointTests.cs (2 tests)
|
||||
├── TestData/
|
||||
│ └── Pdfs/ (EmbeddedResource PDFs)
|
||||
├── Unit/
|
||||
│ ├── Application/
|
||||
│ │ └── Features/
|
||||
│ │ ├── ValidatePdf/
|
||||
│ │ │ └── ValidatePdfHandlerTests.cs (2 tests)
|
||||
│ │ ├── ValidatePdfA/
|
||||
│ │ │ └── ValidatePdfAQueryHandlerTests.cs (4 tests)
|
||||
│ │ └── ExtractSwissQrCode/
|
||||
│ │ └── ExtractSwissQrCodeHandlerTests.cs (2 tests)
|
||||
│ └── Infrastructure/
|
||||
│ └── Services/
|
||||
│ └── PdfProcessing/
|
||||
│ └── DevExpressPdfProcessorTests.cs (7 tests)
|
||||
```
|
||||
|
||||
**✅ Why this structure is CORRECT:**
|
||||
|
||||
1. **Integration vs Unit separation:**
|
||||
- **Integration:** WebApplicationFactory → REAL API calls (HTTP, middleware, MediatR pipeline, DevExpress)
|
||||
- **Unit:** Mock-based ISOLATED tests (Handler only depends on mocked IPdfProcessor)
|
||||
|
||||
2. **TestData centralization:**
|
||||
- All 3 layers share same EmbeddedResource PDFs (no duplication)
|
||||
- Accessed via `Assembly.GetManifestResourceStream()`
|
||||
|
||||
3. **Vertical Slice compliance:**
|
||||
- `Unit/Application/Features/ValidatePdf/` → Each feature's tests co-located
|
||||
- Matches Application layer structure exactly
|
||||
|
||||
4. **Test Pyramid:**
|
||||
- **Unit tests (60+):** Fast, isolated, many scenarios
|
||||
- **Integration tests (27):** Slower, full pipeline, critical paths only
|
||||
|
||||
**Test count:** 101 passed, 7 skipped (as of Feature 6 - PDF Annotation)
|
||||
|
||||
**Test breakdown by feature:**
|
||||
- Feature 1 (PDF Validation): 13 integration tests
|
||||
- Feature 2 (Swiss QR Code): 2 integration tests
|
||||
- Feature 3 (PDF/A Validation): 6 integration tests (validate-pdfa) + 4 unit tests (handler)
|
||||
- Feature 4 (PDF Attachments): 10 tests (4 check + 6 extract integration)
|
||||
- Feature 5 (PDF Merge): 7 integration + 10 unit tests (DevExpressPdfProcessor)
|
||||
- Feature 6 (PDF Annotation): 10 integration + 12 unit tests (DevExpressPdfProcessor)
|
||||
- Infrastructure: 37 unit tests (DevExpressPdfProcessor for validation, attachments, merge, annotation)
|
||||
|
||||
**FluentValidation in tests:**
|
||||
- Base64 format validation happens in `ValidatePdfQueryValidator` and `ValidatePdfAQueryValidator`
|
||||
- Prevents `FormatException` from reaching handler (caught as 400 Bad Request, not 500)
|
||||
- Unit tests verify handler behavior with valid inputs only
|
||||
- Integration tests verify full validation pipeline (including FluentValidation)
|
||||
|
||||
---
|
||||
|
||||
## Swiss QR Code Feature (Feature 2)
|
||||
|
||||
**Swiss QR Bill Standard 2.0** requires:
|
||||
@@ -432,7 +520,7 @@ public async Task<IActionResult> Validate([FromForm] PdfInputModel input, Cancel
|
||||
## Configuration
|
||||
|
||||
**appsettings.json sections:**
|
||||
- `DocumentOperatorSettings` (future: file size limits, temp paths)
|
||||
- `DocumentServiceSettings` (future: file size limits, temp paths)
|
||||
- `RedisSettings` (future: multi-tenancy caching)
|
||||
- `ApiKeySettings` (future: authentication)
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# DocumentOperator - Controller & Endpoint Specification
|
||||
# DocumentService - Controller & Endpoint Specification
|
||||
|
||||
**Project:** DocumentService (DOC)
|
||||
**Ticket:** DOC-1 - GDPicture and Nutrient Replacing
|
||||
@@ -9,7 +9,7 @@
|
||||
|
||||
## Overview
|
||||
|
||||
This specification defines the controller structure and REST API endpoints for the DocumentOperator service.
|
||||
This specification defines the controller structure and REST API endpoints for the DocumentService service.
|
||||
|
||||
---
|
||||
|
||||
@@ -90,34 +90,37 @@ This specification defines the controller structure and REST API endpoints for t
|
||||
|
||||
### Endpoint: Attachment Extraction
|
||||
**Route:** `POST /api/pdf/attachments/extract`
|
||||
**Function:** Extracts all embedded files from the PDF and saves them to the specified path
|
||||
**Function:** Extracts all embedded files from the PDF and returns them as a ZIP archive
|
||||
|
||||
**Input:**
|
||||
```json
|
||||
{
|
||||
"file": "PDF (multipart/form-data)",
|
||||
"outputPath": string
|
||||
}
|
||||
```
|
||||
- PDF file (multipart/form-data)
|
||||
|
||||
**Output:**
|
||||
```json
|
||||
{
|
||||
"success": bool,
|
||||
"extractedFiles": [
|
||||
{
|
||||
"fileName": string,
|
||||
"savedPath": string,
|
||||
"size": long
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
- Binary stream (application/zip)
|
||||
- Content-Disposition: attachment; filename="attachments.zip"
|
||||
- ZIP archive containing all extracted files
|
||||
|
||||
**Usage:** eParser (ZUGFeRD XML extraction)
|
||||
|
||||
---
|
||||
|
||||
### Endpoint: Add Attachment
|
||||
**Route:** `POST /api/pdf/attachments/add`
|
||||
**Function:** Embeds one or more files as attachments in a PDF (supports PDF/A-3)
|
||||
|
||||
**Input:**
|
||||
- PDF file (multipart/form-data)
|
||||
- Attachment files (multipart/form-data, multiple)
|
||||
|
||||
**Output:**
|
||||
- Binary stream (application/pdf)
|
||||
- Content-Disposition: attachment; filename="with-attachments.pdf"
|
||||
- PDF with embedded attachments
|
||||
|
||||
**Usage:** eParser (ZUGFeRD XML embedding), PDF/A-3 archiving
|
||||
|
||||
---
|
||||
|
||||
## PdfOperationsController
|
||||
|
||||
### Endpoint: PDF Merge
|
||||
@@ -125,22 +128,13 @@ This specification defines the controller structure and REST API endpoints for t
|
||||
**Function:** Merges multiple PDFs into a single file
|
||||
|
||||
**Input:**
|
||||
```json
|
||||
{
|
||||
"sourceFiles": string[],
|
||||
"outputPath": string
|
||||
}
|
||||
```
|
||||
- Multiple PDF files (multipart/form-data)
|
||||
- Field name: "files" (array of IFormFile)
|
||||
|
||||
**Output:**
|
||||
```json
|
||||
{
|
||||
"success": bool,
|
||||
"outputPath": string,
|
||||
"pageCount": int,
|
||||
"fileSize": long
|
||||
}
|
||||
```
|
||||
- Binary stream (application/pdf)
|
||||
- Content-Disposition: attachment; filename="merged.pdf"
|
||||
- Merged PDF document
|
||||
|
||||
**Usage:** signFLOW (Envelope Generator), ErgebnisberichtCreator, ResultHandler (windream)
|
||||
|
||||
@@ -151,26 +145,22 @@ This specification defines the controller structure and REST API endpoints for t
|
||||
**Function:** Adds stamps to PDF pages (APPROVED, CONFIDENTIAL, etc.)
|
||||
|
||||
**Input:**
|
||||
```json
|
||||
{
|
||||
"file": "PDF (multipart/form-data)",
|
||||
"stamp": {
|
||||
- PDF file (multipart/form-data)
|
||||
- Stamp configuration (JSON):
|
||||
```json
|
||||
{
|
||||
"text": string,
|
||||
"position": string,
|
||||
"pages": string,
|
||||
"color": string,
|
||||
"opacity": float
|
||||
}
|
||||
}
|
||||
```
|
||||
```
|
||||
|
||||
**Output:**
|
||||
```json
|
||||
{
|
||||
"success": bool,
|
||||
"outputPath": string
|
||||
}
|
||||
```
|
||||
- Binary stream (application/pdf)
|
||||
- Content-Disposition: attachment; filename="stamped.pdf"
|
||||
- PDF with applied stamps
|
||||
|
||||
**Usage:** ErgebnisberichtCreator
|
||||
|
||||
@@ -181,63 +171,75 @@ This specification defines the controller structure and REST API endpoints for t
|
||||
**Function:** Adds comments, highlights, and markings to the PDF
|
||||
|
||||
**Input:**
|
||||
```json
|
||||
{
|
||||
"file": "PDF (multipart/form-data)",
|
||||
"annotations": [
|
||||
{
|
||||
"type": string,
|
||||
"page": int,
|
||||
"position": object,
|
||||
"text": string
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
- PDF file (multipart/form-data)
|
||||
- Annotations (JSON):
|
||||
```json
|
||||
{
|
||||
"annotations": [
|
||||
{
|
||||
"type": string,
|
||||
"page": int,
|
||||
"position": object,
|
||||
"text": string
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Output:**
|
||||
```json
|
||||
{
|
||||
"success": bool,
|
||||
"outputPath": string
|
||||
}
|
||||
```
|
||||
- Binary stream (application/pdf)
|
||||
- Content-Disposition: attachment; filename="annotated.pdf"
|
||||
- PDF with applied annotations
|
||||
|
||||
**Usage:** signFLOW (Envelope Generator)
|
||||
|
||||
---
|
||||
|
||||
## PdfRenderController
|
||||
## SwissQrCodeController
|
||||
|
||||
### Endpoint: PDF Preview
|
||||
**Route:** `POST /api/pdf/render/preview`
|
||||
**Function:** Renders PDF pages as PNG/JPEG for preview
|
||||
### Endpoint: Swiss QR Code Extraction
|
||||
**Route:** `POST /api/swissqrcode/extract`
|
||||
**Function:** Extracts and parses Swiss QR Bill (Swiss QR Code) from PDF
|
||||
|
||||
**Input:**
|
||||
```json
|
||||
{
|
||||
"file": "PDF (multipart/form-data)",
|
||||
"page": int,
|
||||
"format": string,
|
||||
"dpi": int
|
||||
}
|
||||
```
|
||||
- PDF file (multipart/form-data)
|
||||
|
||||
**Output:**
|
||||
```json
|
||||
{
|
||||
"images": [
|
||||
{
|
||||
"page": int,
|
||||
"base64": string,
|
||||
"width": int,
|
||||
"height": int
|
||||
}
|
||||
]
|
||||
"qrType": "SwissQrBill",
|
||||
"version": "0200",
|
||||
"creditorIban": "CH4431999123000889012",
|
||||
"creditorName": "Example AG",
|
||||
"creditorAddress": {
|
||||
"addressType": "Structured",
|
||||
"street": "Musterstrasse",
|
||||
"houseNumber": "1",
|
||||
"postalCode": "8000",
|
||||
"city": "Zürich",
|
||||
"country": "CH"
|
||||
},
|
||||
"amount": 1234.56,
|
||||
"currency": "CHF",
|
||||
"debtorName": "Max Mustermann",
|
||||
"debtorAddress": { ... },
|
||||
"referenceType": "QRR",
|
||||
"reference": "210000000003139471430009017",
|
||||
"unstructuredMessage": "Invoice #12345",
|
||||
"billInformation": "//S1/10/12345",
|
||||
"alternativeProcedures": ["UV1", "UV2"]
|
||||
}
|
||||
```
|
||||
|
||||
**Usage:** taskFLOW, fileFLOW, easyFLOW, orgFLOW - PDF preview
|
||||
**Usage:** eParser (Swiss QR Bill processing), signFLOW (payment reference extraction)
|
||||
|
||||
**Note:** Supports only Structured Address (S-Type) as per Swiss QR Bill Standard 2.0. Combined Address (K-Type) deprecated November 21, 2025.
|
||||
|
||||
---
|
||||
|
||||
## PdfRenderController
|
||||
|
||||
**Status:** REMOVED - PDF preview functionality will be implemented in .NET client library using DevExpress WinForms/WPF controls. Server-side rendering is unnecessary CPU/memory overhead.
|
||||
|
||||
---
|
||||
|
||||
@@ -248,21 +250,13 @@ This specification defines the controller structure and REST API endpoints for t
|
||||
**Function:** Converts a standard PDF to PDF/A
|
||||
|
||||
**Input:**
|
||||
```json
|
||||
{
|
||||
"file": "PDF (multipart/form-data)",
|
||||
"pdfaLevel": string
|
||||
}
|
||||
```
|
||||
- PDF file (multipart/form-data)
|
||||
- PDF/A level (query parameter): "PDF/A-1b", "PDF/A-2b", "PDF/A-3b"
|
||||
|
||||
**Output:**
|
||||
```json
|
||||
{
|
||||
"success": bool,
|
||||
"outputPath": string,
|
||||
"pdfaVersion": string
|
||||
}
|
||||
```
|
||||
- Binary stream (application/pdf)
|
||||
- Content-Disposition: attachment; filename="converted-pdfa.pdf"
|
||||
- PDF/A compliant document
|
||||
|
||||
**Usage:** taskFLOW (optional conversion)
|
||||
|
||||
@@ -273,19 +267,12 @@ This specification defines the controller structure and REST API endpoints for t
|
||||
**Function:** Converts PDF/A to a standard PDF
|
||||
|
||||
**Input:**
|
||||
```json
|
||||
{
|
||||
"file": "PDF (multipart/form-data)"
|
||||
}
|
||||
```
|
||||
- PDF/A file (multipart/form-data)
|
||||
|
||||
**Output:**
|
||||
```json
|
||||
{
|
||||
"success": bool,
|
||||
"outputPath": string
|
||||
}
|
||||
```
|
||||
- Binary stream (application/pdf)
|
||||
- Content-Disposition: attachment; filename="converted-pdf.pdf"
|
||||
- Standard PDF document
|
||||
|
||||
**Usage:** taskFLOW (optional conversion)
|
||||
|
||||
@@ -306,15 +293,27 @@ The service can be used on the client side **without manual HTTP response handli
|
||||
|
||||
**Example Client SDK:**
|
||||
```csharp
|
||||
var client = new DocumentOperatorClient("https://api.example.com");
|
||||
var client = new DocumentServiceClient("https://api.example.com");
|
||||
var result = await client.Pdf.Validation.ValidateAsync(pdfFile);
|
||||
if (result.IsValid) { ... }
|
||||
```
|
||||
|
||||
### Response Format
|
||||
- Default: JSON
|
||||
- Default: JSON (for metadata endpoints like validation, check)
|
||||
- Binary streams: application/pdf, application/zip (for operations, conversion, extraction)
|
||||
- Content-Disposition header: attachment; filename="<output-filename>"
|
||||
- Errors: HTTP Status Codes (400, 404, 500) + JSON error object
|
||||
- Success: HTTP 200 + JSON response
|
||||
- Success: HTTP 200 + JSON/Binary response
|
||||
|
||||
**Binary Stream Endpoints:**
|
||||
- PDF Operations: merge, stamp, annotate
|
||||
- PDF Conversion: to-pdfa, from-pdfa
|
||||
- Attachment Operations: extract (ZIP), add (PDF)
|
||||
|
||||
**JSON Response Endpoints:**
|
||||
- PDF Validation: validate, validate-pdfa
|
||||
- Attachment Check: check
|
||||
- Swiss QR Code: extract
|
||||
|
||||
### Authentication
|
||||
- API Key (Header: `X-API-Key`)
|
||||
@@ -330,16 +329,21 @@ if (result.IsValid) { ... }
|
||||
## Prioritization
|
||||
|
||||
### Phase 1 (Priority)
|
||||
1. PdfValidationController - both endpoints
|
||||
2. PdfAttachmentController - both endpoints
|
||||
3. PdfOperationsController - Merge endpoint
|
||||
1. PdfValidationController - both endpoints (validate, validate-pdfa)
|
||||
2. PdfAttachmentController - check endpoint
|
||||
3. SwissQrCodeController - extract endpoint (already implemented)
|
||||
4. PdfAttachmentController - extract endpoint
|
||||
5. PdfOperationsController - merge endpoint
|
||||
|
||||
### Phase 2
|
||||
4. PdfOperationsController - Stamp & Annotate
|
||||
5. PdfRenderController - Preview
|
||||
6. PdfOperationsController - stamp & annotate endpoints
|
||||
7. PdfAttachmentController - add attachment endpoint
|
||||
|
||||
### Phase 3
|
||||
6. PdfConversionController - both endpoints
|
||||
8. PdfConversionController - both endpoints (to-pdfa, from-pdfa)
|
||||
|
||||
### Removed
|
||||
- PdfRenderController - moved to .NET client library (WinForms/WPF DevExpress controls)
|
||||
|
||||
---
|
||||
|
||||
|
||||
125
DocumentOperator.API/Configuration/DualInputDocumentFilter.cs
Normal file
125
DocumentOperator.API/Configuration/DualInputDocumentFilter.cs
Normal file
@@ -0,0 +1,125 @@
|
||||
using Microsoft.AspNetCore.Mvc.ApiExplorer;
|
||||
using Microsoft.OpenApi.Models;
|
||||
using Swashbuckle.AspNetCore.SwaggerGen;
|
||||
|
||||
namespace DocumentService.API.Configuration
|
||||
{
|
||||
/// <summary>
|
||||
/// Swagger document filter that merges operations with same path but different [Consumes] attributes.
|
||||
/// Ensures both multipart/form-data and application/json variants are visible in Swagger UI.
|
||||
/// </summary>
|
||||
public class DualInputDocumentFilter : IDocumentFilter
|
||||
{
|
||||
private readonly IApiDescriptionGroupCollectionProvider _apiDescriptionProvider;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="DualInputDocumentFilter"/> class.
|
||||
/// </summary>
|
||||
/// <param name="apiDescriptionProvider">API description provider to access all endpoints</param>
|
||||
public DualInputDocumentFilter(IApiDescriptionGroupCollectionProvider apiDescriptionProvider)
|
||||
{
|
||||
_apiDescriptionProvider = apiDescriptionProvider;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Applies the filter to merge operations with different content types.
|
||||
/// </summary>
|
||||
public void Apply(OpenApiDocument swaggerDoc, DocumentFilterContext context)
|
||||
{
|
||||
var allApiDescriptions = _apiDescriptionProvider.ApiDescriptionGroups.Items
|
||||
.SelectMany(g => g.Items)
|
||||
.ToList();
|
||||
|
||||
// Group by path
|
||||
var groupedByPath = allApiDescriptions
|
||||
.GroupBy(x => "/" + x.RelativePath)
|
||||
.ToList();
|
||||
|
||||
foreach (var group in groupedByPath)
|
||||
{
|
||||
var path = group.Key;
|
||||
|
||||
if (!swaggerDoc.Paths.ContainsKey(path))
|
||||
continue;
|
||||
|
||||
var pathItem = swaggerDoc.Paths[path];
|
||||
|
||||
// Find multipart and JSON variants
|
||||
var multipartDesc = group.FirstOrDefault(x =>
|
||||
x.SupportedRequestFormats.Any(f => f.MediaType == "multipart/form-data"));
|
||||
|
||||
var jsonDesc = group.FirstOrDefault(x =>
|
||||
x.SupportedRequestFormats.Any(f => f.MediaType == "application/json"));
|
||||
|
||||
// If we have both variants, merge them into single operation
|
||||
if (multipartDesc != null && jsonDesc != null)
|
||||
{
|
||||
var httpMethod = multipartDesc.HttpMethod?.ToLowerInvariant();
|
||||
OperationType operationType;
|
||||
|
||||
if (!Enum.TryParse<OperationType>(httpMethod, true, out operationType))
|
||||
continue;
|
||||
|
||||
if (!pathItem.Operations.ContainsKey(operationType))
|
||||
continue;
|
||||
|
||||
var operation = pathItem.Operations[operationType];
|
||||
|
||||
// Ensure RequestBody exists
|
||||
if (operation.RequestBody == null)
|
||||
{
|
||||
operation.RequestBody = new OpenApiRequestBody
|
||||
{
|
||||
Required = true,
|
||||
Content = new Dictionary<string, OpenApiMediaType>()
|
||||
};
|
||||
}
|
||||
|
||||
// Add multipart/form-data if missing
|
||||
if (!operation.RequestBody.Content.ContainsKey("multipart/form-data"))
|
||||
{
|
||||
operation.RequestBody.Content.Add("multipart/form-data", new OpenApiMediaType
|
||||
{
|
||||
Schema = new OpenApiSchema
|
||||
{
|
||||
Type = "object",
|
||||
Properties = new Dictionary<string, OpenApiSchema>
|
||||
{
|
||||
["file"] = new OpenApiSchema
|
||||
{
|
||||
Type = "string",
|
||||
Format = "binary",
|
||||
Description = "PDF file to upload"
|
||||
}
|
||||
},
|
||||
Required = new HashSet<string> { "file" }
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Add application/json if missing
|
||||
if (!operation.RequestBody.Content.ContainsKey("application/json"))
|
||||
{
|
||||
operation.RequestBody.Content.Add("application/json", new OpenApiMediaType
|
||||
{
|
||||
Schema = new OpenApiSchema
|
||||
{
|
||||
Type = "object",
|
||||
Properties = new Dictionary<string, OpenApiSchema>
|
||||
{
|
||||
["base64Pdf"] = new OpenApiSchema
|
||||
{
|
||||
Type = "string",
|
||||
Format = "byte",
|
||||
Description = "Base64-encoded PDF file content"
|
||||
}
|
||||
},
|
||||
Required = new HashSet<string> { "base64Pdf" }
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace DocumentOperator.API.Configuration
|
||||
namespace DocumentService.API.Configuration
|
||||
{
|
||||
/// <summary>
|
||||
/// Placeholder class for Serilog configuration extensions.
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
using Microsoft.OpenApi.Models;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.OpenApi.Models;
|
||||
using System.Reflection;
|
||||
|
||||
namespace DocumentOperator.API.Configuration
|
||||
namespace DocumentService.API.Configuration
|
||||
{
|
||||
/// <summary>
|
||||
/// Provides extension methods for configuring Swagger/OpenAPI documentation.
|
||||
@@ -12,18 +13,31 @@ namespace DocumentOperator.API.Configuration
|
||||
/// Adds Swagger documentation generation to the service collection.
|
||||
/// </summary>
|
||||
/// <param name="services">The service collection to add Swagger to.</param>
|
||||
/// <param name="configuration">Configuration to read SwaggerSettings from.</param>
|
||||
/// <returns>The modified service collection.</returns>
|
||||
public static IServiceCollection AddSwaggerDocumentation(this IServiceCollection services)
|
||||
public static IServiceCollection AddSwaggerDocumentation(
|
||||
this IServiceCollection services,
|
||||
IConfiguration configuration)
|
||||
{
|
||||
var swaggerSettings = configuration.GetSection(SwaggerSettings.SectionName).Get<SwaggerSettings>()
|
||||
?? new SwaggerSettings();
|
||||
|
||||
services.AddSwaggerGen(options =>
|
||||
{
|
||||
options.SwaggerDoc("v1", new OpenApiInfo
|
||||
options.SwaggerDoc(swaggerSettings.Version, new OpenApiInfo
|
||||
{
|
||||
Title = "DD Document Operator API",
|
||||
Version = "v1",
|
||||
Description = "PDF Verarbeitungs-Service für Validierung, Stempel, Zertifikate, Anhänge & Zusammenführung"
|
||||
Title = swaggerSettings.Title,
|
||||
Version = swaggerSettings.Version,
|
||||
Description = swaggerSettings.Description
|
||||
});
|
||||
|
||||
// Resolve conflicting actions: Keep first variant
|
||||
// DualInputDocumentFilter will merge both variants into single operation
|
||||
options.ResolveConflictingActions(apiDescriptions => apiDescriptions.First());
|
||||
|
||||
// Add document filter to merge operations with different content types
|
||||
options.DocumentFilter<DualInputDocumentFilter>();
|
||||
|
||||
// XML-Kommentare einbinden
|
||||
var xmlFile = $"{Assembly.GetExecutingAssembly().GetName().Name}.xml";
|
||||
var xmlPath = Path.Combine(AppContext.BaseDirectory, xmlFile);
|
||||
|
||||
33
DocumentOperator.API/Configuration/SwaggerSettings.cs
Normal file
33
DocumentOperator.API/Configuration/SwaggerSettings.cs
Normal file
@@ -0,0 +1,33 @@
|
||||
namespace DocumentService.API.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration settings for Swagger/OpenAPI documentation.
|
||||
/// </summary>
|
||||
public class SwaggerSettings
|
||||
{
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
public const string SectionName = "SwaggerSettings";
|
||||
|
||||
/// <summary>
|
||||
/// Enable Swagger UI in Production environment.
|
||||
/// Default: true (allows production testing/debugging).
|
||||
/// </summary>
|
||||
public bool EnableInProduction { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// API title displayed in Swagger UI.
|
||||
/// </summary>
|
||||
public string Title { get; set; } = "DocumentService API";
|
||||
|
||||
/// <summary>
|
||||
/// API version.
|
||||
/// </summary>
|
||||
public string Version { get; set; } = "v1";
|
||||
|
||||
/// <summary>
|
||||
/// API description displayed in Swagger UI.
|
||||
/// </summary>
|
||||
public string Description { get; set; } = "PDF document processing service";
|
||||
}
|
||||
356
DocumentOperator.API/Controllers/PdfAttachmentController.cs
Normal file
356
DocumentOperator.API/Controllers/PdfAttachmentController.cs
Normal file
@@ -0,0 +1,356 @@
|
||||
using DocumentService.Application.AddAttachments;
|
||||
using DocumentService.Application.CheckPdfAttachments.Queries;
|
||||
using DocumentService.Application.Common.DTOs;
|
||||
using DocumentService.Application.ExtractPdfAttachments;
|
||||
using DocumentService.Client.Models.Requests;
|
||||
using DocumentService.Domain.Common.Exceptions;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace DocumentService.API.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Controller for PDF attachment operations (detection, extraction, embedding)
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/pdf/attachments")]
|
||||
[Produces("application/json")]
|
||||
public class PdfAttachmentController(IMediator mediator) : ControllerBase
|
||||
{
|
||||
/// <summary>
|
||||
/// Checks if a PDF contains embedded files (attachments) and returns their metadata.
|
||||
/// Supports multipart/form-data file upload.
|
||||
/// </summary>
|
||||
/// <param name="file">The PDF file to check for attachments</param>
|
||||
/// <param name="cancellationToken">Cancellation token</param>
|
||||
/// <returns>Attachment check result with metadata for all found attachments</returns>
|
||||
/// <response code="200">PDF successfully checked - returns attachment details</response>
|
||||
/// <response code="400">Invalid input (file missing, not a PDF, or corrupted)</response>
|
||||
/// <response code="500">Internal server error during PDF processing</response>
|
||||
[HttpPost("check")]
|
||||
[Consumes("multipart/form-data")]
|
||||
[ProducesResponseType(typeof(AttachmentCheckResult), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status500InternalServerError)]
|
||||
public async Task<IActionResult> CheckAttachmentsFromFile(
|
||||
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 CheckPdfAttachmentsQuery { PdfStream = pdfStream };
|
||||
var result = await mediator.Send(query, cancellationToken);
|
||||
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if a PDF contains embedded files (attachments) and returns their metadata.
|
||||
/// Supports Base64-encoded PDF via JSON payload.
|
||||
/// </summary>
|
||||
/// <param name="request">Request containing Base64-encoded PDF</param>
|
||||
/// <param name="cancellationToken">Cancellation token</param>
|
||||
/// <returns>Attachment check result with metadata for all found attachments</returns>
|
||||
/// <response code="200">PDF successfully checked - returns attachment details</response>
|
||||
/// <response code="400">Invalid input (Base64 format error, not a PDF, or corrupted)</response>
|
||||
/// <response code="500">Internal server error during PDF processing</response>
|
||||
[HttpPost("check")]
|
||||
[Consumes("application/json")]
|
||||
[ProducesResponseType(typeof(AttachmentCheckResult), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status500InternalServerError)]
|
||||
public async Task<IActionResult> CheckAttachmentsFromBase64(
|
||||
[FromBody] CheckPdfAttachmentsRequest 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 CheckPdfAttachmentsQuery { PdfStream = pdfStream };
|
||||
var result = await mediator.Send(query, cancellationToken);
|
||||
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts all embedded files from a PDF and returns them as a ZIP archive.
|
||||
/// Supports multipart/form-data file upload.
|
||||
/// </summary>
|
||||
/// <param name="file">The PDF file to extract attachments from</param>
|
||||
/// <param name="cancellationToken">Cancellation token</param>
|
||||
/// <returns>ZIP archive containing all extracted attachments</returns>
|
||||
/// <response code="200">Attachments extracted successfully - returns ZIP file</response>
|
||||
/// <response code="400">Invalid input (file missing, not a PDF, or corrupted)</response>
|
||||
/// <response code="404">PDF contains no attachments</response>
|
||||
/// <response code="500">Internal server error during PDF processing</response>
|
||||
[HttpPost("extract")]
|
||||
[Consumes("multipart/form-data")]
|
||||
[ProducesResponseType(typeof(FileContentResult), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status500InternalServerError)]
|
||||
public async Task<IActionResult> ExtractAttachmentsFromFile(
|
||||
IFormFile file,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// Use IFormFile stream directly (no intermediate byte[] conversion)
|
||||
using var pdfStream = file.OpenReadStream();
|
||||
|
||||
// Send command to MediatR
|
||||
var command = new ExtractPdfAttachmentsCommand { PdfStream = pdfStream };
|
||||
byte[] zipBytes = await mediator.Send(command, cancellationToken);
|
||||
|
||||
// Return ZIP file
|
||||
return File(zipBytes, "application/zip", "attachments.zip");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts all embedded files from a PDF and returns them as a ZIP archive.
|
||||
/// Supports Base64-encoded PDF via JSON payload.
|
||||
/// </summary>
|
||||
/// <param name="request">Request containing Base64-encoded PDF</param>
|
||||
/// <param name="cancellationToken">Cancellation token</param>
|
||||
/// <returns>ZIP archive containing all extracted attachments</returns>
|
||||
/// <response code="200">Attachments extracted successfully - returns ZIP file</response>
|
||||
/// <response code="400">Invalid input (Base64 format error, not a PDF, or corrupted)</response>
|
||||
/// <response code="404">PDF contains no attachments</response>
|
||||
/// <response code="500">Internal server error during PDF processing</response>
|
||||
[HttpPost("extract")]
|
||||
[Consumes("application/json")]
|
||||
[ProducesResponseType(typeof(FileContentResult), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status500InternalServerError)]
|
||||
public async Task<IActionResult> ExtractAttachmentsFromBase64(
|
||||
[FromBody] ExtractPdfAttachmentsRequest 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 ExtractPdfAttachmentsCommand { PdfStream = pdfStream };
|
||||
byte[] zipBytes = await mediator.Send(command, cancellationToken);
|
||||
|
||||
// Return ZIP file
|
||||
return File(zipBytes, "application/zip", "attachments.zip");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Embeds one or more files as attachments in a PDF document (supports PDF/A-3).
|
||||
/// Supports multipart/form-data file upload.
|
||||
/// </summary>
|
||||
/// <param name="pdfFile">The PDF file to add attachments to</param>
|
||||
/// <param name="attachmentFiles">Files to embed as attachments (one or more)</param>
|
||||
/// <param name="cancellationToken">Cancellation token</param>
|
||||
/// <returns>PDF with embedded attachments</returns>
|
||||
/// <response code="200">Attachments added successfully - returns PDF</response>
|
||||
/// <response code="400">Invalid input (file missing, not a PDF, or no attachments provided)</response>
|
||||
/// <response code="500">Internal server error during PDF processing</response>
|
||||
[Obsolete("This endpoint is not implemented yet.")]
|
||||
[HttpPost("add")]
|
||||
[Consumes("multipart/form-data")]
|
||||
[ProducesResponseType(typeof(FileContentResult), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status500InternalServerError)]
|
||||
public async Task<IActionResult> AddAttachmentsFromFile(
|
||||
IFormFile pdfFile,
|
||||
List<IFormFile> attachmentFiles,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (pdfFile == null || pdfFile.Length == 0)
|
||||
{
|
||||
throw new BadRequestException("PDF file is required");
|
||||
}
|
||||
|
||||
if (attachmentFiles == null || attachmentFiles.Count == 0)
|
||||
{
|
||||
throw new BadRequestException("At least one attachment file is required");
|
||||
}
|
||||
|
||||
// Use IFormFile stream directly (no intermediate byte[] conversion)
|
||||
using var pdfStream = pdfFile.OpenReadStream();
|
||||
|
||||
// Convert attachment files to AttachmentFile records
|
||||
var attachments = new List<AttachmentFile>();
|
||||
foreach (var file in attachmentFiles)
|
||||
{
|
||||
using var ms = new MemoryStream();
|
||||
await file.CopyToAsync(ms, cancellationToken);
|
||||
|
||||
attachments.Add(new AttachmentFile
|
||||
{
|
||||
FileName = file.FileName,
|
||||
Content = ms.ToArray(),
|
||||
MimeType = file.ContentType
|
||||
});
|
||||
}
|
||||
|
||||
// Send command to MediatR
|
||||
var command = new AddAttachmentsCommand
|
||||
{
|
||||
PdfStream = pdfStream,
|
||||
Attachments = attachments
|
||||
};
|
||||
byte[] resultPdf = await mediator.Send(command, cancellationToken);
|
||||
|
||||
// Return PDF with attachments
|
||||
return File(resultPdf, "application/pdf", "with-attachments.pdf");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Embeds one or more files as attachments in a PDF document (supports PDF/A-3).
|
||||
/// Supports Base64-encoded PDF and attachments via JSON payload.
|
||||
/// </summary>
|
||||
/// <param name="request">Request containing Base64-encoded PDF and attachments</param>
|
||||
/// <param name="cancellationToken">Cancellation token</param>
|
||||
/// <returns>PDF with embedded attachments</returns>
|
||||
/// <response code="200">Attachments added successfully - returns PDF</response>
|
||||
/// <response code="400">Invalid input (Base64 format error, not a PDF, or no attachments provided)</response>
|
||||
/// <response code="500">Internal server error during PDF processing</response>
|
||||
[Obsolete("This endpoint is not implemented yet.")]
|
||||
[HttpPost("add")]
|
||||
[Consumes("application/json")]
|
||||
[ProducesResponseType(typeof(FileContentResult), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status500InternalServerError)]
|
||||
public async Task<IActionResult> AddAttachmentsFromBase64(
|
||||
[FromBody] AddAttachmentsRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// Convert Base64 PDF to stream
|
||||
byte[] pdfBytes;
|
||||
try
|
||||
{
|
||||
pdfBytes = Convert.FromBase64String(request.Base64Pdf);
|
||||
}
|
||||
catch (FormatException ex)
|
||||
{
|
||||
throw new BadRequestException("Invalid Base64 PDF format: " + ex.Message);
|
||||
}
|
||||
|
||||
using var pdfStream = new MemoryStream(pdfBytes);
|
||||
|
||||
// Convert Base64 attachments to AttachmentFile records
|
||||
var attachments = new List<AttachmentFile>();
|
||||
foreach (var att in request.Attachments)
|
||||
{
|
||||
byte[] attBytes;
|
||||
try
|
||||
{
|
||||
attBytes = Convert.FromBase64String(att.Base64Content);
|
||||
}
|
||||
catch (FormatException ex)
|
||||
{
|
||||
throw new BadRequestException($"Invalid Base64 format for attachment '{att.FileName}': " + ex.Message);
|
||||
}
|
||||
|
||||
attachments.Add(new AttachmentFile
|
||||
{
|
||||
FileName = att.FileName,
|
||||
Content = attBytes,
|
||||
MimeType = att.MimeType
|
||||
});
|
||||
}
|
||||
|
||||
// Send command to MediatR
|
||||
var command = new AddAttachmentsCommand
|
||||
{
|
||||
PdfStream = pdfStream,
|
||||
Attachments = attachments
|
||||
};
|
||||
byte[] resultPdf = await mediator.Send(command, cancellationToken);
|
||||
|
||||
// Return PDF with attachments
|
||||
return File(resultPdf, "application/pdf", "with-attachments.pdf");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request DTO for Base64-encoded PDF attachment check
|
||||
/// </summary>
|
||||
public record CheckPdfAttachmentsRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// PDF document encoded as Base64 string
|
||||
/// </summary>
|
||||
/// <example>JVBERi0xLjQKJeLjz9MKMSAwIG9iago8PC9UeXBlL0NhdGFsb2c...</example>
|
||||
public string Base64Pdf { get; init; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request DTO for Base64-encoded PDF attachment extraction
|
||||
/// </summary>
|
||||
public record ExtractPdfAttachmentsRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// PDF document encoded as Base64 string
|
||||
/// </summary>
|
||||
/// <example>JVBERi0xLjQKJeLjz9MKMSAwIG9iago8PC9UeXBlL0NhdGFsb2c...</example>
|
||||
public required string Base64Pdf { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request DTO for Base64-encoded PDF with attachments to add
|
||||
/// </summary>
|
||||
public record AddAttachmentsRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// PDF document encoded as Base64 string
|
||||
/// </summary>
|
||||
/// <example>JVBERi0xLjQKJeLjz9MKMSAwIG9iago8PC9UeXBlL0NhdGFsb2c...</example>
|
||||
public required string Base64Pdf { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// List of attachments to embed
|
||||
/// </summary>
|
||||
public required List<AttachmentRequestDto> Attachments { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// DTO for attachment file in request
|
||||
/// </summary>
|
||||
public record AttachmentRequestDto
|
||||
{
|
||||
/// <summary>
|
||||
/// File name (e.g., "invoice.xml", "document.pdf")
|
||||
/// </summary>
|
||||
/// <example>factur-x.xml</example>
|
||||
public required string FileName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// File content encoded as Base64 string
|
||||
/// </summary>
|
||||
/// <example>PD94bWwgdmVyc2lvbj0iMS4wIj8+...</example>
|
||||
public required string Base64Content { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// MIME type (optional, e.g., "application/xml")
|
||||
/// </summary>
|
||||
/// <example>application/xml</example>
|
||||
public string? MimeType { get; init; }
|
||||
}
|
||||
182
DocumentOperator.API/Controllers/PdfConversionController.cs
Normal file
182
DocumentOperator.API/Controllers/PdfConversionController.cs
Normal file
@@ -0,0 +1,182 @@
|
||||
using DocumentService.Application.ConvertFromPdfA;
|
||||
using DocumentService.Application.ConvertToPdfA;
|
||||
using DocumentService.Client.Models.Requests;
|
||||
using DocumentService.Domain.Common.Exceptions;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace DocumentService.API.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Controller for PDF conversion operations (PDF ? PDF/A)
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/pdf/conversion")]
|
||||
[Obsolete("This endpoint is not implemented yet.")]
|
||||
public class PdfConversionController(IMediator mediator) : ControllerBase
|
||||
{
|
||||
/// <summary>
|
||||
/// Converts a standard PDF to PDF/A format.
|
||||
/// Supports multipart/form-data file upload.
|
||||
/// </summary>
|
||||
/// <param name="file">The PDF file to convert</param>
|
||||
/// <param name="pdfALevel">Target PDF/A level (e.g., "PDF/A-1b", "PDF/A-2b", "PDF/A-3b")</param>
|
||||
/// <param name="cancellationToken">Cancellation token</param>
|
||||
/// <returns>PDF/A compliant document</returns>
|
||||
/// <response code="200">PDF converted to PDF/A successfully</response>
|
||||
/// <response code="400">Invalid input (file missing, not a PDF, or invalid PDF/A level)</response>
|
||||
/// <response code="500">Internal server error during PDF processing</response>
|
||||
[Obsolete("This endpoint is not implemented yet.")]
|
||||
[HttpPost("to-pdfa")]
|
||||
[Consumes("multipart/form-data")]
|
||||
[ProducesResponseType(typeof(FileContentResult), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status500InternalServerError)]
|
||||
public async Task<IActionResult> ConvertToPdfAFromFile(
|
||||
IFormFile file,
|
||||
[FromQuery] string pdfALevel = "PDF/A-3b",
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (file == null || file.Length == 0)
|
||||
{
|
||||
throw new BadRequestException("PDF file is required");
|
||||
}
|
||||
|
||||
// Use IFormFile stream directly (no intermediate byte[] conversion)
|
||||
using var pdfStream = file.OpenReadStream();
|
||||
|
||||
// Send command to MediatR
|
||||
var command = new ConvertToPdfACommand
|
||||
{
|
||||
PdfStream = pdfStream,
|
||||
PdfALevel = pdfALevel
|
||||
};
|
||||
byte[] resultPdf = await mediator.Send(command, cancellationToken);
|
||||
|
||||
// Return PDF/A file
|
||||
return File(resultPdf, "application/pdf", "converted-pdfa.pdf");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts a standard PDF to PDF/A format.
|
||||
/// Supports Base64-encoded PDF via JSON payload.
|
||||
/// </summary>
|
||||
/// <param name="request">Request containing Base64-encoded PDF and PDF/A level</param>
|
||||
/// <param name="cancellationToken">Cancellation token</param>
|
||||
/// <returns>PDF/A compliant document</returns>
|
||||
/// <response code="200">PDF converted to PDF/A successfully</response>
|
||||
/// <response code="400">Invalid input (Base64 format error, not a PDF, or invalid PDF/A level)</response>
|
||||
/// <response code="500">Internal server error during PDF processing</response>
|
||||
[Obsolete("This endpoint is not implemented yet.")]
|
||||
[HttpPost("to-pdfa")]
|
||||
[Consumes("application/json")]
|
||||
[ProducesResponseType(typeof(FileContentResult), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status500InternalServerError)]
|
||||
public async Task<IActionResult> ConvertToPdfAFromBase64(
|
||||
[FromBody] ConvertToPdfARequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Convert Base64 PDF to stream
|
||||
byte[] pdfBytes;
|
||||
try
|
||||
{
|
||||
pdfBytes = Convert.FromBase64String(request.Base64Pdf);
|
||||
}
|
||||
catch (FormatException ex)
|
||||
{
|
||||
throw new BadRequestException("Invalid Base64 PDF format: " + ex.Message);
|
||||
}
|
||||
|
||||
using var pdfStream = new MemoryStream(pdfBytes);
|
||||
|
||||
// Send command to MediatR
|
||||
var command = new ConvertToPdfACommand
|
||||
{
|
||||
PdfStream = pdfStream,
|
||||
PdfALevel = request.PdfALevel ?? "PDF/A-3b"
|
||||
};
|
||||
byte[] resultPdf = await mediator.Send(command, cancellationToken);
|
||||
|
||||
// Return PDF/A file
|
||||
return File(resultPdf, "application/pdf", "converted-pdfa.pdf");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts a PDF/A document to a standard PDF (removes PDF/A restrictions).
|
||||
/// Supports multipart/form-data file upload.
|
||||
/// </summary>
|
||||
/// <param name="file">The PDF/A file to convert</param>
|
||||
/// <param name="cancellationToken">Cancellation token</param>
|
||||
/// <returns>Standard PDF document</returns>
|
||||
/// <response code="200">PDF/A converted to standard PDF successfully</response>
|
||||
/// <response code="400">Invalid input (file missing, not a PDF)</response>
|
||||
/// <response code="500">Internal server error during PDF processing</response>
|
||||
[Obsolete("This endpoint is not implemented yet.")]
|
||||
[HttpPost("from-pdfa")]
|
||||
[Consumes("multipart/form-data")]
|
||||
[ProducesResponseType(typeof(FileContentResult), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status500InternalServerError)]
|
||||
public async Task<IActionResult> ConvertFromPdfAFromFile(
|
||||
IFormFile file,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (file == null || file.Length == 0)
|
||||
{
|
||||
throw new BadRequestException("PDF/A file is required");
|
||||
}
|
||||
|
||||
// Use IFormFile stream directly (no intermediate byte[] conversion)
|
||||
using var pdfStream = file.OpenReadStream();
|
||||
|
||||
// Send command to MediatR
|
||||
var command = new ConvertFromPdfACommand { PdfStream = pdfStream };
|
||||
byte[] resultPdf = await mediator.Send(command, cancellationToken);
|
||||
|
||||
// Return standard PDF file
|
||||
return File(resultPdf, "application/pdf", "converted-pdf.pdf");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts a PDF/A document to a standard PDF (removes PDF/A restrictions).
|
||||
/// Supports Base64-encoded PDF via JSON payload.
|
||||
/// </summary>
|
||||
/// <param name="request">Request containing Base64-encoded PDF/A</param>
|
||||
/// <param name="cancellationToken">Cancellation token</param>
|
||||
/// <returns>Standard PDF document</returns>
|
||||
/// <response code="200">PDF/A converted to standard PDF successfully</response>
|
||||
/// <response code="400">Invalid input (Base64 format error, not a PDF)</response>
|
||||
/// <response code="500">Internal server error during PDF processing</response>
|
||||
[Obsolete("This endpoint is not implemented yet.")]
|
||||
[HttpPost("from-pdfa")]
|
||||
[Consumes("application/json")]
|
||||
[ProducesResponseType(typeof(FileContentResult), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status500InternalServerError)]
|
||||
public async Task<IActionResult> ConvertFromPdfAFromBase64(
|
||||
[FromBody] ConvertFromPdfARequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Convert Base64 PDF to stream
|
||||
byte[] pdfBytes;
|
||||
try
|
||||
{
|
||||
pdfBytes = Convert.FromBase64String(request.Base64Pdf);
|
||||
}
|
||||
catch (FormatException ex)
|
||||
{
|
||||
throw new BadRequestException("Invalid Base64 PDF format: " + ex.Message);
|
||||
}
|
||||
|
||||
using var pdfStream = new MemoryStream(pdfBytes);
|
||||
|
||||
// Send command to MediatR
|
||||
var command = new ConvertFromPdfACommand { PdfStream = pdfStream };
|
||||
byte[] resultPdf = await mediator.Send(command, cancellationToken);
|
||||
|
||||
// Return standard PDF file
|
||||
return File(resultPdf, "application/pdf", "converted-pdf.pdf");
|
||||
}
|
||||
}
|
||||
728
DocumentOperator.API/Controllers/PdfOperationsController.cs
Normal file
728
DocumentOperator.API/Controllers/PdfOperationsController.cs
Normal file
@@ -0,0 +1,728 @@
|
||||
using DocumentService.Application.AddAnnotation;
|
||||
using DocumentService.Application.AddStamp;
|
||||
using DocumentService.Application.MergePdfs;
|
||||
using DocumentService.Domain.Common.Exceptions;
|
||||
using DocumentService.Domain.Models.ValueObjects;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace DocumentService.API.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Controller for PDF operations (merge, stamp, annotate).
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/pdf/operations")]
|
||||
public class PdfOperationsController(IMediator mediator) : ControllerBase
|
||||
{
|
||||
/// <summary>
|
||||
/// Merges multiple PDF files into a single PDF.
|
||||
/// Supports multipart/form-data file upload.
|
||||
/// </summary>
|
||||
/// <param name="files">PDF files to merge (minimum 2 required)</param>
|
||||
/// <param name="cancellationToken">Cancellation token</param>
|
||||
/// <returns>Merged PDF file</returns>
|
||||
/// <response code="200">PDFs merged successfully - returns merged PDF</response>
|
||||
/// <response code="400">Invalid input (fewer than 2 files, corrupted PDF)</response>
|
||||
/// <response code="500">Internal server error during PDF processing</response>
|
||||
[HttpPost("merge", Name = "MergeFromFiles")]
|
||||
[Consumes("multipart/form-data")]
|
||||
[ProducesResponseType(typeof(FileContentResult), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status500InternalServerError)]
|
||||
public async Task<IActionResult> MergeFromFiles(
|
||||
[FromForm] List<IFormFile> files,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// Convert IFormFile[] to Stream[] (use OpenReadStream directly - no buffering)
|
||||
var streams = files.Select(f => f.OpenReadStream()).ToList();
|
||||
|
||||
// Send command to MediatR (no page ranges for now - multipart binding is complex)
|
||||
var command = new MergePdfsCommand
|
||||
{
|
||||
PdfStreams = streams,
|
||||
PageRanges = null
|
||||
};
|
||||
|
||||
byte[] mergedPdf = await mediator.Send(command, cancellationToken);
|
||||
|
||||
// Return merged PDF
|
||||
return File(mergedPdf, "application/pdf", "merged.pdf");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Merges multiple PDF files into a single PDF.
|
||||
/// Supports Base64-encoded PDFs via JSON payload.
|
||||
/// </summary>
|
||||
/// <param name="request">Request containing Base64-encoded PDFs and optional page ranges</param>
|
||||
/// <param name="cancellationToken">Cancellation token</param>
|
||||
/// <returns>Merged PDF file</returns>
|
||||
/// <response code="200">PDFs merged successfully - returns merged PDF</response>
|
||||
/// <response code="400">Invalid input (Base64 format error, fewer than 2 files, corrupted PDF, invalid page range)</response>
|
||||
/// <response code="500">Internal server error during PDF processing</response>
|
||||
[HttpPost("merge", Name = "MergeFromBase64")]
|
||||
[Consumes("application/json")]
|
||||
[ProducesResponseType(typeof(FileContentResult), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status500InternalServerError)]
|
||||
public async Task<IActionResult> MergeFromBase64(
|
||||
[FromBody] MergePdfsBase64Request request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// Convert Base64[] to MemoryStream[]
|
||||
List<Stream> streams = [];
|
||||
try
|
||||
{
|
||||
foreach (var base64Pdf in request.Base64Pdfs)
|
||||
{
|
||||
byte[] pdfBytes = Convert.FromBase64String(base64Pdf);
|
||||
streams.Add(new MemoryStream(pdfBytes));
|
||||
}
|
||||
}
|
||||
catch (FormatException ex)
|
||||
{
|
||||
// Dispose opened streams on error
|
||||
foreach (var stream in streams) stream.Dispose();
|
||||
throw new BadRequestException("Invalid Base64 format: " + ex.Message);
|
||||
}
|
||||
|
||||
var command = new MergePdfsCommand
|
||||
{
|
||||
PdfStreams = streams,
|
||||
PageRanges = request.PageRanges
|
||||
};
|
||||
|
||||
byte[] mergedPdf = await mediator.Send(command, cancellationToken);
|
||||
|
||||
// Cleanup streams (important for MemoryStreams we created)
|
||||
foreach (var stream in streams) stream.Dispose();
|
||||
|
||||
return File(mergedPdf, "application/pdf", "merged.pdf");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds an annotation to a PDF document.
|
||||
/// Supports multipart/form-data file upload.
|
||||
/// </summary>
|
||||
/// <param name="request">Multipart form data containing PDF file and annotation parameters</param>
|
||||
/// <param name="cancellationToken">Cancellation token</param>
|
||||
/// <returns>Annotated PDF file</returns>
|
||||
/// <response code="200">Annotation added successfully - returns annotated PDF</response>
|
||||
/// <response code="400">Invalid input (invalid page number, missing required parameters)</response>
|
||||
/// <response code="500">Internal server error during PDF processing</response>
|
||||
[HttpPost("annotate", Name = "AnnotateFromFile")]
|
||||
[Consumes("multipart/form-data")]
|
||||
[ProducesResponseType(typeof(FileContentResult), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status500InternalServerError)]
|
||||
public async Task<IActionResult> AnnotateFromFile(
|
||||
[FromForm] AddAnnotationMultipartRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// Calculate X2, Y2 from Width/Height if provided
|
||||
double x2 = request.X2 ?? request.X1 + (request.Width ?? throw new BadRequestException("Either X2 or Width must be provided"));
|
||||
double y2 = request.Y2 ?? request.Y1 + (request.Height ?? throw new BadRequestException("Either Y2 or Height must be provided"));
|
||||
|
||||
var command = new AddAnnotationCommand
|
||||
{
|
||||
PdfStream = request.File.OpenReadStream(),
|
||||
AnnotationType = request.AnnotationType,
|
||||
PageNumber = request.PageNumber,
|
||||
Rectangle = (request.X1, request.Y1, x2, y2),
|
||||
Content = request.Content,
|
||||
Author = request.Author,
|
||||
Color = request.Color,
|
||||
TextMarkupStyle = request.TextMarkupStyle,
|
||||
Origin = request.Origin
|
||||
};
|
||||
|
||||
byte[] annotatedPdf = await mediator.Send(command, cancellationToken);
|
||||
|
||||
return File(annotatedPdf, "application/pdf", "annotated.pdf");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds an annotation to a PDF document.
|
||||
/// Supports Base64-encoded PDF via JSON payload.
|
||||
/// </summary>
|
||||
/// <param name="command">Command containing all annotation parameters (including Base64 PDF)</param>
|
||||
/// <param name="cancellationToken">Cancellation token</param>
|
||||
/// <returns>Annotated PDF file</returns>
|
||||
/// <response code="200">Annotation added successfully - returns annotated PDF</response>
|
||||
/// <response code="400">Invalid input (Base64 format error, invalid page number, missing required parameters)</response>
|
||||
/// <response code="500">Internal server error during PDF processing</response>
|
||||
[HttpPost("annotate", Name = "AnnotateFromBase64")]
|
||||
[Consumes("application/json")]
|
||||
[ProducesResponseType(typeof(FileContentResult), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status500InternalServerError)]
|
||||
public async Task<IActionResult> AnnotateFromBase64(
|
||||
[FromBody] AddAnnotationBase64Command command,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// Convert Base64 to MemoryStream
|
||||
Stream pdfStream;
|
||||
try
|
||||
{
|
||||
byte[] pdfBytes = Convert.FromBase64String(command.Base64Pdf);
|
||||
pdfStream = new MemoryStream(pdfBytes);
|
||||
}
|
||||
catch (FormatException ex)
|
||||
{
|
||||
throw new BadRequestException("Invalid Base64 format: " + ex.Message);
|
||||
}
|
||||
|
||||
// Calculate X2, Y2 from Width/Height if provided
|
||||
double x2 = command.X2 ?? command.X1 + (command.Width ?? throw new BadRequestException("Either X2 or Width must be provided"));
|
||||
double y2 = command.Y2 ?? command.Y1 + (command.Height ?? throw new BadRequestException("Either Y2 or Height must be provided"));
|
||||
|
||||
var annotationCommand = new AddAnnotationCommand
|
||||
{
|
||||
PdfStream = pdfStream,
|
||||
AnnotationType = command.AnnotationType,
|
||||
PageNumber = command.PageNumber,
|
||||
Rectangle = (command.X1, command.Y1, x2, y2),
|
||||
Content = command.Content,
|
||||
Author = command.Author,
|
||||
Color = command.Color,
|
||||
TextMarkupStyle = command.TextMarkupStyle,
|
||||
Origin = command.Origin
|
||||
};
|
||||
|
||||
byte[] annotatedPdf = await mediator.Send(annotationCommand, cancellationToken);
|
||||
|
||||
// Cleanup stream
|
||||
pdfStream.Dispose();
|
||||
|
||||
return File(annotatedPdf, "application/pdf", "annotated.pdf");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a stamp (text, image, or predefined) to PDF pages.
|
||||
/// Supports multipart/form-data file upload.
|
||||
/// </summary>
|
||||
/// <param name="request">Multipart form data containing PDF file and stamp parameters</param>
|
||||
/// <param name="cancellationToken">Cancellation token</param>
|
||||
/// <returns>Stamped PDF file</returns>
|
||||
/// <response code="200">Stamp added successfully - returns stamped PDF</response>
|
||||
/// <response code="400">Invalid input (invalid page number, missing required parameters, invalid image format)</response>
|
||||
/// <response code="500">Internal server error during PDF processing</response>
|
||||
[HttpPost("stamp", Name = "AddStampFromFile")]
|
||||
[Consumes("multipart/form-data")]
|
||||
[ProducesResponseType(typeof(FileContentResult), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status500InternalServerError)]
|
||||
public async Task<IActionResult> AddStampFromFile(
|
||||
[FromForm] AddStampMultipartRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// Convert ImageFile to byte[] if provided
|
||||
byte[]? imageBytes = null;
|
||||
if (request.ImageFile != null)
|
||||
{
|
||||
using var ms = new MemoryStream();
|
||||
await request.ImageFile.CopyToAsync(ms, cancellationToken);
|
||||
imageBytes = ms.ToArray();
|
||||
}
|
||||
|
||||
var command = new AddStampCommand
|
||||
{
|
||||
PdfStream = request.File.OpenReadStream(),
|
||||
StampType = request.StampType,
|
||||
PageNumbers = request.PageNumbers,
|
||||
Position = (request.X, request.Y),
|
||||
Size = request.Width.HasValue && request.Height.HasValue
|
||||
? (request.Width.Value, request.Height.Value)
|
||||
: null,
|
||||
Origin = request.Origin,
|
||||
Text = request.Text,
|
||||
FontName = request.FontName,
|
||||
FontSize = request.FontSize,
|
||||
Color = request.Color,
|
||||
Opacity = request.Opacity,
|
||||
Rotation = request.Rotation,
|
||||
Placement = request.Placement,
|
||||
ImageBytes = imageBytes,
|
||||
PredefinedType = request.PredefinedType
|
||||
};
|
||||
|
||||
byte[] stampedPdf = await mediator.Send(command, cancellationToken);
|
||||
|
||||
return File(stampedPdf, "application/pdf", "stamped.pdf");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a stamp (text, image, or predefined) to PDF pages.
|
||||
/// Supports Base64-encoded PDF and image via JSON payload.
|
||||
/// </summary>
|
||||
/// <param name="request">Request containing Base64-encoded PDF, stamp parameters, and optional Base64 image</param>
|
||||
/// <param name="cancellationToken">Cancellation token</param>
|
||||
/// <returns>Stamped PDF file</returns>
|
||||
/// <response code="200">Stamp added successfully - returns stamped PDF</response>
|
||||
/// <response code="400">Invalid input (Base64 format error, invalid page number, missing required parameters)</response>
|
||||
/// <response code="500">Internal server error during PDF processing</response>
|
||||
[HttpPost("stamp", Name = "AddStampFromBase64")]
|
||||
[Consumes("application/json")]
|
||||
[ProducesResponseType(typeof(FileContentResult), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status500InternalServerError)]
|
||||
public async Task<IActionResult> AddStampFromBase64(
|
||||
[FromBody] AddStampBase64Request request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// Convert Base64 PDF to MemoryStream
|
||||
Stream pdfStream;
|
||||
try
|
||||
{
|
||||
byte[] pdfBytes = Convert.FromBase64String(request.Base64Pdf);
|
||||
pdfStream = new MemoryStream(pdfBytes);
|
||||
}
|
||||
catch (FormatException ex)
|
||||
{
|
||||
throw new BadRequestException("Invalid Base64 PDF format: " + ex.Message);
|
||||
}
|
||||
|
||||
// Convert Base64 image to byte[] if provided
|
||||
byte[]? imageBytes = null;
|
||||
if (!string.IsNullOrWhiteSpace(request.Base64Image))
|
||||
{
|
||||
try
|
||||
{
|
||||
imageBytes = Convert.FromBase64String(request.Base64Image);
|
||||
}
|
||||
catch (FormatException ex)
|
||||
{
|
||||
pdfStream.Dispose();
|
||||
throw new BadRequestException("Invalid Base64 image format: " + ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
var command = new AddStampCommand
|
||||
{
|
||||
PdfStream = pdfStream,
|
||||
StampType = request.StampType,
|
||||
PageNumbers = request.PageNumbers,
|
||||
Position = (request.X, request.Y),
|
||||
Size = request.Width.HasValue && request.Height.HasValue
|
||||
? (request.Width.Value, request.Height.Value)
|
||||
: null,
|
||||
Origin = request.Origin,
|
||||
Text = request.Text,
|
||||
FontName = request.FontName,
|
||||
FontSize = request.FontSize,
|
||||
Color = request.Color,
|
||||
Opacity = request.Opacity,
|
||||
Rotation = request.Rotation,
|
||||
Placement = request.Placement,
|
||||
ImageBytes = imageBytes,
|
||||
PredefinedType = request.PredefinedType
|
||||
};
|
||||
|
||||
byte[] stampedPdf = await mediator.Send(command, cancellationToken);
|
||||
|
||||
// Cleanup stream
|
||||
pdfStream.Dispose();
|
||||
|
||||
return File(stampedPdf, "application/pdf", "stamped.pdf");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request DTO for multipart/form-data annotation operation
|
||||
/// </summary>
|
||||
public class AddAnnotationMultipartRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// PDF file to annotate
|
||||
/// </summary>
|
||||
public required IFormFile File { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Type of annotation (TextMarkup, FreeText, StickyNote, Circle, Square)
|
||||
/// </summary>
|
||||
public required AnnotationType AnnotationType { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Target page number (1-indexed)
|
||||
/// </summary>
|
||||
public required int PageNumber { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Rectangle X1 coordinate (left)
|
||||
/// </summary>
|
||||
public required double X1 { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Rectangle Y1 coordinate (top or bottom depending on Origin)
|
||||
/// </summary>
|
||||
public required double Y1 { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Rectangle X2 coordinate (right). Optional if Width is provided.
|
||||
/// </summary>
|
||||
public double? X2 { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Rectangle Y2 coordinate (bottom or top depending on Origin). Optional if Height is provided.
|
||||
/// </summary>
|
||||
public double? Y2 { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Rectangle width. Alternative to X2 (X2 = X1 + Width). Optional if X2 is provided.
|
||||
/// </summary>
|
||||
public double? Width { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Rectangle height. Alternative to Y2 (Y2 = Y1 + Height). Optional if Y2 is provided.
|
||||
/// </summary>
|
||||
public double? Height { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Annotation content (required for FreeText/StickyNote)
|
||||
/// </summary>
|
||||
public string? Content { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Author name (optional)
|
||||
/// </summary>
|
||||
public string? Author { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Hex color (6 digits, e.g., "FF0000" for red)
|
||||
/// </summary>
|
||||
public string? Color { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Markup style (Highlight/Underline/Strikeout, required for TextMarkup)
|
||||
/// </summary>
|
||||
public TextMarkupStyle? TextMarkupStyle { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Coordinate origin (BottomLeft = PDF native, TopLeft = UI-friendly). Default: BottomLeft
|
||||
/// </summary>
|
||||
public AnnotationOrigin Origin { get; set; } = AnnotationOrigin.BottomLeft;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request DTO for Base64-encoded PDF merge operation (API layer only - converts to MergePdfsCommand)
|
||||
/// </summary>
|
||||
public record MergePdfsBase64Request
|
||||
{
|
||||
/// <summary>
|
||||
/// Array of Base64-encoded PDF files (minimum 2 required)
|
||||
/// </summary>
|
||||
/// <example>["JVBERi0xLjQK...", "JVBERi0xLjQK..."]</example>
|
||||
public required List<string> Base64Pdfs { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional page ranges per PDF (null = all pages).
|
||||
/// Format: "1-3,5" means pages 1, 2, 3, and 5.
|
||||
/// If provided, array length must match Base64Pdfs length.
|
||||
/// </summary>
|
||||
/// <example>["1-2", "1,3,5", null]</example>
|
||||
public List<string?>? PageRanges { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request DTO for Base64-encoded PDF annotation (API layer only - converts to AddAnnotationCommand)
|
||||
/// </summary>
|
||||
public record AddAnnotationBase64Command
|
||||
{
|
||||
/// <summary>
|
||||
/// Base64-encoded PDF file
|
||||
/// </summary>
|
||||
/// <example>"JVBERi0xLjQK..."</example>
|
||||
public required string Base64Pdf { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Type of annotation to add
|
||||
/// </summary>
|
||||
/// <example>TextMarkup</example>
|
||||
public required AnnotationType AnnotationType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Target page number (1-indexed)
|
||||
/// </summary>
|
||||
/// <example>1</example>
|
||||
public required int PageNumber { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Rectangle X1 coordinate (left)
|
||||
/// </summary>
|
||||
/// <example>100.0</example>
|
||||
public required double X1 { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Rectangle Y1 coordinate (top or bottom depending on Origin)
|
||||
/// </summary>
|
||||
/// <example>100.0</example>
|
||||
public required double Y1 { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Rectangle X2 coordinate (right). Optional if Width is provided.
|
||||
/// </summary>
|
||||
/// <example>200.0</example>
|
||||
public double? X2 { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Rectangle Y2 coordinate (bottom or top depending on Origin). Optional if Height is provided.
|
||||
/// </summary>
|
||||
/// <example>120.0</example>
|
||||
public double? Y2 { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Rectangle width. Alternative to X2 (X2 = X1 + Width). Optional if X2 is provided.
|
||||
/// </summary>
|
||||
/// <example>100.0</example>
|
||||
public double? Width { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Rectangle height. Alternative to Y2 (Y2 = Y1 + Height). Optional if Y2 is provided.
|
||||
/// </summary>
|
||||
/// <example>20.0</example>
|
||||
public double? Height { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Annotation content (required for FreeText and StickyNote)
|
||||
/// </summary>
|
||||
/// <example>"Important text to highlight"</example>
|
||||
public string? Content { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Author name (optional)
|
||||
/// </summary>
|
||||
/// <example>"John Doe"</example>
|
||||
public string? Author { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Hex color (6 digits, e.g., "FF0000" for red). Optional - defaults vary by annotation type.
|
||||
/// </summary>
|
||||
/// <example>"FFFF00"</example>
|
||||
public string? Color { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Text markup style (Highlight, Underline, or Strikeout). Required for TextMarkup annotations.
|
||||
/// </summary>
|
||||
/// <example>Highlight</example>
|
||||
public TextMarkupStyle? TextMarkupStyle { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Coordinate origin (BottomLeft = PDF native, TopLeft = UI-friendly). Default: BottomLeft
|
||||
/// </summary>
|
||||
/// <example>BottomLeft</example>
|
||||
public AnnotationOrigin Origin { get; init; } = AnnotationOrigin.BottomLeft;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request DTO for multipart/form-data stamp operation
|
||||
/// </summary>
|
||||
public class AddStampMultipartRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// PDF file to stamp
|
||||
/// </summary>
|
||||
public required IFormFile File { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Type of stamp (Text, Image, or Predefined)
|
||||
/// </summary>
|
||||
public required StampType StampType { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Target page numbers (1-indexed). Null or empty = all pages.
|
||||
/// </summary>
|
||||
/// <example>[1, 3, 5]</example>
|
||||
public int[]? PageNumbers { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Stamp position X coordinate
|
||||
/// </summary>
|
||||
/// <example>100.0</example>
|
||||
public required double X { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Stamp position Y coordinate
|
||||
/// </summary>
|
||||
/// <example>100.0</example>
|
||||
public required double Y { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Stamp width (optional, auto-size for images if not specified)
|
||||
/// </summary>
|
||||
/// <example>200.0</example>
|
||||
public double? Width { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Stamp height (optional, auto-size for images if not specified)
|
||||
/// </summary>
|
||||
/// <example>50.0</example>
|
||||
public double? Height { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Coordinate origin (BottomLeft = PDF native, TopLeft = UI-friendly). Default: BottomLeft
|
||||
/// </summary>
|
||||
/// <example>BottomLeft</example>
|
||||
public AnnotationOrigin Origin { get; set; } = AnnotationOrigin.BottomLeft;
|
||||
|
||||
/// <summary>
|
||||
/// Text content (required for Text stamps)
|
||||
/// </summary>
|
||||
/// <example>"CONFIDENTIAL"</example>
|
||||
public string? Text { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Font name (default: Arial)
|
||||
/// </summary>
|
||||
/// <example>"Arial"</example>
|
||||
public string? FontName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Font size in points (default: 12)
|
||||
/// </summary>
|
||||
/// <example>24.0</example>
|
||||
public double? FontSize { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Hex color (6 digits, e.g., "FF0000" for red, default: "000000")
|
||||
/// </summary>
|
||||
/// <example>"FF0000"</example>
|
||||
public string? Color { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Opacity (0.0 = transparent, 1.0 = opaque, default: 0.5)
|
||||
/// </summary>
|
||||
/// <example>0.5</example>
|
||||
public double? Opacity { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Rotation angle in degrees (0-360, default: 0)
|
||||
/// </summary>
|
||||
/// <example>45.0</example>
|
||||
public double? Rotation { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Stamp placement (Foreground = on top, Background = watermark effect, default: Foreground)
|
||||
/// </summary>
|
||||
/// <example>Foreground</example>
|
||||
public StampPlacement Placement { get; set; } = StampPlacement.Foreground;
|
||||
|
||||
/// <summary>
|
||||
/// Image file (required for Image stamps, PNG/JPEG)
|
||||
/// </summary>
|
||||
public IFormFile? ImageFile { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Predefined stamp type (required for Predefined stamps)
|
||||
/// </summary>
|
||||
/// <example>Confidential</example>
|
||||
public PredefinedStampType? PredefinedType { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request DTO for Base64-encoded PDF stamp operation (API layer only - converts to AddStampCommand)
|
||||
/// </summary>
|
||||
public record AddStampBase64Request
|
||||
{
|
||||
/// <summary>
|
||||
/// Base64-encoded PDF file
|
||||
/// </summary>
|
||||
/// <example>"JVBERi0xLjQK..."</example>
|
||||
public required string Base64Pdf { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Type of stamp (Text, Image, or Predefined)
|
||||
/// </summary>
|
||||
/// <example>Text</example>
|
||||
public required StampType StampType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Target page numbers (1-indexed). Null or empty = all pages.
|
||||
/// </summary>
|
||||
/// <example>[1, 3, 5]</example>
|
||||
public int[]? PageNumbers { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Stamp position X coordinate
|
||||
/// </summary>
|
||||
/// <example>100.0</example>
|
||||
public required double X { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Stamp position Y coordinate
|
||||
/// </summary>
|
||||
/// <example>100.0</example>
|
||||
public required double Y { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Stamp width (optional, auto-size for images if not specified)
|
||||
/// </summary>
|
||||
/// <example>200.0</example>
|
||||
public double? Width { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Stamp height (optional, auto-size for images if not specified)
|
||||
/// </summary>
|
||||
/// <example>50.0</example>
|
||||
public double? Height { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Coordinate origin (BottomLeft = PDF native, TopLeft = UI-friendly). Default: BottomLeft
|
||||
/// </summary>
|
||||
/// <example>BottomLeft</example>
|
||||
public AnnotationOrigin Origin { get; init; } = AnnotationOrigin.BottomLeft;
|
||||
|
||||
/// <summary>
|
||||
/// Text content (required for Text stamps)
|
||||
/// </summary>
|
||||
/// <example>"CONFIDENTIAL"</example>
|
||||
public string? Text { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Font name (default: Arial)
|
||||
/// </summary>
|
||||
/// <example>"Arial"</example>
|
||||
public string? FontName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Font size in points (default: 12)
|
||||
/// </summary>
|
||||
/// <example>24.0</example>
|
||||
public double? FontSize { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Hex color (6 digits, e.g., "FF0000" for red, default: "000000")
|
||||
/// </summary>
|
||||
/// <example>"FF0000"</example>
|
||||
public string? Color { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Opacity (0.0 = transparent, 1.0 = opaque, default: 0.5)
|
||||
/// </summary>
|
||||
/// <example>0.5</example>
|
||||
public double? Opacity { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Rotation angle in degrees (0-360, default: 0)
|
||||
/// </summary>
|
||||
/// <example>45.0</example>
|
||||
public double? Rotation { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Stamp placement (Foreground = on top, Background = watermark effect, default: Foreground)
|
||||
/// </summary>
|
||||
/// <example>Foreground</example>
|
||||
public StampPlacement Placement { get; init; } = StampPlacement.Foreground;
|
||||
|
||||
/// <summary>
|
||||
/// Base64-encoded image (required for Image stamps, PNG/JPEG)
|
||||
/// </summary>
|
||||
/// <example>"iVBORw0KGgoAAAANSUhEUgAA..."</example>
|
||||
public string? Base64Image { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Predefined stamp type (required for Predefined stamps)
|
||||
/// </summary>
|
||||
/// <example>Confidential</example>
|
||||
public PredefinedStampType? PredefinedType { get; init; }
|
||||
}
|
||||
@@ -1,9 +1,12 @@
|
||||
using DocumentOperator.Application.Common.DTOs;
|
||||
using DocumentOperator.Application.ValidatePdf.Queries;
|
||||
using DocumentService.Application.Common.DTOs;
|
||||
using DocumentService.Application.ValidatePdf.Queries;
|
||||
using DocumentService.Application.ValidatePdfA.Queries;
|
||||
using DocumentService.Client.Models.Requests;
|
||||
using DocumentService.Domain.Common.Exceptions;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace DocumentOperator.API.Controllers;
|
||||
namespace DocumentService.API.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// PDF validation operations
|
||||
@@ -41,13 +44,11 @@ public class PdfValidationController(IMediator Mediator) : ControllerBase
|
||||
});
|
||||
}
|
||||
|
||||
// Convert IFormFile to byte array
|
||||
using var memoryStream = new MemoryStream();
|
||||
await file.CopyToAsync(memoryStream, cancellationToken);
|
||||
byte[] pdfBytes = memoryStream.ToArray();
|
||||
|
||||
// Use IFormFile stream directly (no intermediate byte[] conversion)
|
||||
using var pdfStream = file.OpenReadStream();
|
||||
|
||||
// Direct pass-through to MediatR
|
||||
var query = new ValidatePdfQuery { PdfBytes = pdfBytes };
|
||||
var query = new ValidatePdfQuery { PdfStream = pdfStream };
|
||||
var result = await Mediator.Send(query, cancellationToken);
|
||||
|
||||
return Ok(result);
|
||||
@@ -56,7 +57,7 @@ public class PdfValidationController(IMediator Mediator) : ControllerBase
|
||||
/// <summary>
|
||||
/// Validates a PDF document and returns metadata (Base64 JSON)
|
||||
/// </summary>
|
||||
/// <param name="query">PDF as Base64 string</param>
|
||||
/// <param name="request">Request containing Base64-encoded PDF</param>
|
||||
/// <param name="cancellationToken">Cancellation token</param>
|
||||
/// <returns>PDF metadata (page count, file size, PDF version, attachments)</returns>
|
||||
/// <response code="200">PDF is valid, metadata returned</response>
|
||||
@@ -68,10 +69,100 @@ public class PdfValidationController(IMediator Mediator) : ControllerBase
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status500InternalServerError)]
|
||||
public async Task<IActionResult> ValidateFromBase64(
|
||||
[FromBody] ValidatePdfQuery query,
|
||||
[FromBody] ValidatePdfBase64Request 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);
|
||||
|
||||
// Direct pass-through to MediatR
|
||||
var query = new ValidatePdfQuery { PdfStream = pdfStream };
|
||||
var result = await Mediator.Send(query, cancellationToken);
|
||||
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates a PDF/A document and checks conformance level (multipart/form-data)
|
||||
/// </summary>
|
||||
/// <param name="file">PDF file to validate</param>
|
||||
/// <param name="cancellationToken">Cancellation token</param>
|
||||
/// <returns>PDF/A metadata (conformance level, errors, warnings)</returns>
|
||||
/// <response code="200">PDF/A validation completed, results returned</response>
|
||||
/// <response code="400">Invalid PDF or file format</response>
|
||||
/// <response code="500">Internal server error during validation</response>
|
||||
[HttpPost("validate-pdfa")]
|
||||
[Consumes("multipart/form-data")]
|
||||
[ProducesResponseType(typeof(PdfAValidationResult), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status500InternalServerError)]
|
||||
public async Task<IActionResult> ValidatePdfAFromFile(
|
||||
IFormFile file,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (file == null || file.Length == 0)
|
||||
{
|
||||
return BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Invalid file",
|
||||
Detail = "File is required and cannot be empty",
|
||||
Status = StatusCodes.Status400BadRequest
|
||||
});
|
||||
}
|
||||
|
||||
// Use IFormFile stream directly (no intermediate byte[] conversion)
|
||||
using var pdfStream = file.OpenReadStream();
|
||||
|
||||
// Direct pass-through to MediatR
|
||||
var query = new ValidatePdfAQuery { PdfStream = pdfStream };
|
||||
var result = await Mediator.Send(query, cancellationToken);
|
||||
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates a PDF/A document and checks conformance level (Base64 JSON)
|
||||
/// </summary>
|
||||
/// <param name="request">Request containing Base64-encoded PDF</param>
|
||||
/// <param name="cancellationToken">Cancellation token</param>
|
||||
/// <returns>PDF/A metadata (conformance level, errors, warnings)</returns>
|
||||
/// <response code="200">PDF/A validation completed, results returned</response>
|
||||
/// <response code="400">Invalid PDF or Base64 format</response>
|
||||
/// <response code="500">Internal server error during validation</response>
|
||||
[HttpPost("validate-pdfa")]
|
||||
[Consumes("application/json")]
|
||||
[ProducesResponseType(typeof(PdfAValidationResult), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status500InternalServerError)]
|
||||
public async Task<IActionResult> ValidatePdfAFromBase64(
|
||||
[FromBody] ValidatePdfABase64Request 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);
|
||||
|
||||
// Direct pass-through to MediatR
|
||||
var query = new ValidatePdfAQuery { PdfStream = pdfStream };
|
||||
var result = await Mediator.Send(query, cancellationToken);
|
||||
|
||||
return Ok(result);
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
using DocumentOperator.Application.Common.DTOs;
|
||||
using DocumentOperator.Application.SwissQrCode.Queries;
|
||||
using DocumentService.Application.Common.DTOs;
|
||||
using DocumentService.Application.SwissQrCode.Queries;
|
||||
using DocumentService.Domain.Common.Exceptions;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace DocumentOperator.API.Controllers;
|
||||
namespace DocumentService.API.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Swiss QR Code extraction operations
|
||||
@@ -17,7 +18,7 @@ public class SwissQrCodeController(IMediator Mediator) : ControllerBase
|
||||
/// Extracts Swiss QR Code from the last page of a PDF document (multipart/form-data)
|
||||
/// </summary>
|
||||
/// <param name="file">PDF file containing Swiss QR Code</param>
|
||||
/// <param name="references">Optional references (comma-separated)</param>
|
||||
/// <param name="raw">If true, returns raw QR text lines instead of parsed Bill object</param>
|
||||
/// <param name="cancellationToken">Cancellation token</param>
|
||||
/// <returns>Swiss QR Code data (IBAN, amount, creditor, debtor, reference, etc.)</returns>
|
||||
/// <response code="200">Swiss QR Code extracted successfully</response>
|
||||
@@ -32,44 +33,35 @@ public class SwissQrCodeController(IMediator Mediator) : ControllerBase
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status500InternalServerError)]
|
||||
public async Task<IActionResult> ExtractFromFile(
|
||||
IFormFile file,
|
||||
[FromForm] string? references,
|
||||
CancellationToken cancellationToken)
|
||||
[FromQuery] bool raw = false,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (file == null || file.Length == 0)
|
||||
{
|
||||
if (file.Length == 0)
|
||||
return BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Invalid file",
|
||||
Detail = "File is required and cannot be empty",
|
||||
Status = StatusCodes.Status400BadRequest
|
||||
});
|
||||
}
|
||||
|
||||
// Convert IFormFile to byte array
|
||||
using var memoryStream = new MemoryStream();
|
||||
await file.CopyToAsync(memoryStream, cancellationToken);
|
||||
byte[] pdfBytes = memoryStream.ToArray();
|
||||
|
||||
// Parse references (comma-separated or empty)
|
||||
var referencesList = string.IsNullOrWhiteSpace(references)
|
||||
? new List<string>()
|
||||
: references.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList();
|
||||
// Use IFormFile stream directly (no intermediate byte[] conversion)
|
||||
using var pdfStream = file.OpenReadStream();
|
||||
|
||||
// Direct pass-through to MediatR
|
||||
var query = new ExtractSwissQrCodeQuery
|
||||
{
|
||||
PdfBytes = pdfBytes,
|
||||
References = referencesList
|
||||
PdfStream = pdfStream
|
||||
};
|
||||
var result = await Mediator.Send(query, cancellationToken);
|
||||
|
||||
return Ok(result);
|
||||
return Ok(raw ? result.RawLines : result.Bill);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts Swiss QR Code from the last page of a PDF document (Base64 JSON)
|
||||
/// </summary>
|
||||
/// <param name="query">References array + PDF as Base64 string</param>
|
||||
/// <param name="request">Request containing Base64-encoded PDF</param>
|
||||
/// <param name="raw">If true, returns raw QR text lines instead of parsed Bill object</param>
|
||||
/// <param name="cancellationToken">Cancellation token</param>
|
||||
/// <returns>Swiss QR Code data (IBAN, amount, creditor, debtor, reference, etc.)</returns>
|
||||
/// <response code="200">Swiss QR Code extracted successfully</response>
|
||||
@@ -83,12 +75,39 @@ public class SwissQrCodeController(IMediator Mediator) : ControllerBase
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status500InternalServerError)]
|
||||
public async Task<IActionResult> ExtractFromBase64(
|
||||
[FromBody] ExtractSwissQrCodeQuery query,
|
||||
CancellationToken cancellationToken)
|
||||
[FromBody] ExtractSwissQrCodeBase64Request request,
|
||||
[FromQuery] bool raw = false,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 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);
|
||||
|
||||
// Direct pass-through to MediatR
|
||||
var query = new ExtractSwissQrCodeQuery { PdfStream = pdfStream };
|
||||
var result = await Mediator.Send(query, cancellationToken);
|
||||
|
||||
return Ok(result);
|
||||
return Ok(raw ? result.RawLines : result.Bill);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request DTO for Base64-encoded Swiss QR Code extraction
|
||||
/// </summary>
|
||||
public record ExtractSwissQrCodeBase64Request
|
||||
{
|
||||
/// <summary>
|
||||
/// PDF document encoded as Base64 string
|
||||
/// </summary>
|
||||
/// <example>JVBERi0xLjQKJeLjz9MKMSAwIG9iago8PC9UeXBlL0NhdGFsb2c...</example>
|
||||
public required string Base64Pdf { get; init; }
|
||||
}
|
||||
|
||||
179
DocumentOperator.API/Controllers/ZugferdController.cs
Normal file
179
DocumentOperator.API/Controllers/ZugferdController.cs
Normal file
@@ -0,0 +1,179 @@
|
||||
using DocumentService.Application.Common.DTOs;
|
||||
using DocumentService.Application.ExtractZugferd;
|
||||
using DocumentService.Application.HasZugferd.Queries;
|
||||
using DocumentService.Client.Models.Requests;
|
||||
using DocumentService.Domain.Common.Exceptions;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace DocumentService.API.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Controller for ZUGFeRD operations (detection, extraction)
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/pdf/zugferd")]
|
||||
[Produces("application/json")]
|
||||
public class ZugferdController(IMediator mediator) : ControllerBase
|
||||
{
|
||||
/// <summary>
|
||||
/// Checks if a PDF contains ZUGFeRD XML attachment.
|
||||
/// Supports multipart/form-data file upload.
|
||||
/// </summary>
|
||||
/// <param name="file">The PDF file to check for ZUGFeRD</param>
|
||||
/// <param name="cancellationToken">Cancellation token</param>
|
||||
/// <returns>ZUGFeRD check result with metadata</returns>
|
||||
/// <response code="200">PDF successfully checked - returns ZUGFeRD status</response>
|
||||
/// <response code="400">Invalid input (file missing, not a PDF, or corrupted)</response>
|
||||
/// <response code="500">Internal server error during PDF processing</response>
|
||||
[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<IActionResult> 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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if a PDF contains ZUGFeRD XML attachment.
|
||||
/// Supports Base64-encoded PDF via JSON payload.
|
||||
/// </summary>
|
||||
/// <param name="request">Request containing Base64-encoded PDF</param>
|
||||
/// <param name="cancellationToken">Cancellation token</param>
|
||||
/// <returns>ZUGFeRD check result with metadata</returns>
|
||||
/// <response code="200">PDF successfully checked - returns ZUGFeRD status</response>
|
||||
/// <response code="400">Invalid input (Base64 format error, not a PDF, or corrupted)</response>
|
||||
/// <response code="500">Internal server error during PDF processing</response>
|
||||
[HttpPost("has-zugferd")]
|
||||
[Consumes("application/json")]
|
||||
[ProducesResponseType(typeof(ZugferdCheckResult), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status500InternalServerError)]
|
||||
public async Task<IActionResult> 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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts ZUGFeRD XML from a PDF document.
|
||||
/// Supports multipart/form-data file upload.
|
||||
/// </summary>
|
||||
/// <param name="file">The PDF file to extract ZUGFeRD from</param>
|
||||
/// <param name="asFile">if true, 'file' (returns XML file directly); otherwise output format: 'json' (default, returns metadata + XML content)</param>
|
||||
/// <param name="cancellationToken">Cancellation token</param>
|
||||
/// <returns>ZUGFeRD XML content and metadata (JSON) or XML file (application/xml)</returns>
|
||||
/// <response code="200">ZUGFeRD XML extracted successfully</response>
|
||||
/// <response code="400">Invalid input (file missing, not a PDF, or corrupted)</response>
|
||||
/// <response code="404">PDF contains no ZUGFeRD XML</response>
|
||||
/// <response code="500">Internal server error during PDF processing</response>
|
||||
[HttpPost("extract")]
|
||||
[Consumes("multipart/form-data")]
|
||||
[ProducesResponseType(typeof(ZugferdExtractionResult), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(FileContentResult), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status500InternalServerError)]
|
||||
public async Task<IActionResult> ExtractZugferdFromFile(
|
||||
IFormFile file,
|
||||
[FromQuery] bool asFile = true,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 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 as file or JSON based on format parameter
|
||||
if (asFile)
|
||||
{
|
||||
byte[] xmlBytes = System.Text.Encoding.UTF8.GetBytes(result.XmlContent);
|
||||
return File(xmlBytes, "application/xml", result.FileName);
|
||||
}
|
||||
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts ZUGFeRD XML from a PDF document.
|
||||
/// Supports Base64-encoded PDF via JSON payload.
|
||||
/// </summary>
|
||||
/// <param name="request">Request containing Base64-encoded PDF</param>
|
||||
/// <param name="format">Output format: 'json' (default, returns metadata + XML content) or 'file' (returns XML file directly)</param>
|
||||
/// <param name="cancellationToken">Cancellation token</param>
|
||||
/// <returns>ZUGFeRD XML content and metadata (JSON) or XML file (application/xml)</returns>
|
||||
/// <response code="200">ZUGFeRD XML extracted successfully</response>
|
||||
/// <response code="400">Invalid input (Base64 format error, not a PDF, or corrupted)</response>
|
||||
/// <response code="404">PDF contains no ZUGFeRD XML</response>
|
||||
/// <response code="500">Internal server error during PDF processing</response>
|
||||
[HttpPost("extract")]
|
||||
[Consumes("application/json")]
|
||||
[ProducesResponseType(typeof(ZugferdExtractionResult), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(FileContentResult), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status500InternalServerError)]
|
||||
public async Task<IActionResult> ExtractZugferdFromBase64(
|
||||
[FromBody] ExtractZugferdRequest request,
|
||||
[FromQuery] string format = "json",
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 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 as file or JSON based on format parameter
|
||||
if (format.Equals("file", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
byte[] xmlBytes = System.Text.Encoding.UTF8.GetBytes(result.XmlContent);
|
||||
return File(xmlBytes, "application/xml", result.FileName);
|
||||
}
|
||||
|
||||
return Ok(result);
|
||||
}
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Asp.Versioning.Http" Version="8.1.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.StackExchangeRedis" Version="8.0.28" />
|
||||
<PackageReference Include="Serilog.AspNetCore" Version="10.0.0" />
|
||||
<PackageReference Include="Serilog.Enrichers.Environment" Version="3.0.1" />
|
||||
<PackageReference Include="Serilog.Sinks.File" Version="7.0.0" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\DocumentOperator.Application\DocumentOperator.Application.csproj" />
|
||||
<ProjectReference Include="..\DocumentOperator.Domain\DocumentOperator.Domain.csproj" />
|
||||
<ProjectReference Include="..\DocumentOperator.Infrastructure\DocumentOperator.Infrastructure.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -1,6 +0,0 @@
|
||||
@DocumentOperator.API_HostAddress = http://localhost:5028
|
||||
|
||||
GET {{DocumentOperator.API_HostAddress}}/weatherforecast/
|
||||
Accept: application/json
|
||||
|
||||
###
|
||||
41
DocumentOperator.API/DocumentService.API.csproj
Normal file
41
DocumentOperator.API/DocumentService.API.csproj
Normal file
@@ -0,0 +1,41 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
<PackageId>DocumentOperator.API</PackageId>
|
||||
<Authors>Digital Data GmbH</Authors>
|
||||
<Company>Digital Data GmbH</Company>
|
||||
<Product>DocumentOperator.API</Product>
|
||||
<Version>1.0.0</Version>
|
||||
<FileVersion>1.0.0.0</FileVersion>
|
||||
<AssemblyVersion>1.0.0.0</AssemblyVersion>
|
||||
<InformationalVersion>1.0.0</InformationalVersion>
|
||||
<Copyright>Copyright © 2026 Digital Data GmbH. All rights reserved.</Copyright>
|
||||
<Description>PDF Document Operations REST API - Validation, Swiss QR Code extraction, attachments, merge, annotation, stamp operations powered by DevExpress Office File API</Description>
|
||||
<PackageTags>pdf document operator validation swiss-qr-code annotations stamp devexpress</PackageTags>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Asp.Versioning.Http" Version="8.1.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.StackExchangeRedis" Version="8.0.28" />
|
||||
<PackageReference Include="Scalar.AspNetCore" Version="1.2.58" />
|
||||
<PackageReference Include="Serilog.AspNetCore" Version="10.0.0" />
|
||||
<PackageReference Include="Serilog.Enrichers.Environment" Version="3.0.1" />
|
||||
<PackageReference Include="Serilog.Sinks.File" Version="7.0.0" />
|
||||
<PackageReference Include="Serilog.Sinks.SQLite" Version="7.0.0" />
|
||||
<PackageReference Include="Serilog.UI" Version="3.2.0" />
|
||||
<PackageReference Include="Serilog.UI.SqliteProvider" Version="1.1.0" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\DocumentOperator.Application\DocumentService.Application.csproj" />
|
||||
<ProjectReference Include="..\DocumentOperator.Domain\DocumentService.Domain.csproj" />
|
||||
<ProjectReference Include="..\DocumentOperator.Infrastructure\DocumentService.Infrastructure.csproj" />
|
||||
<ProjectReference Include="..\DocumentService.Client\DocumentService.Client.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
6
DocumentOperator.API/DocumentService.API.http
Normal file
6
DocumentOperator.API/DocumentService.API.http
Normal file
@@ -0,0 +1,6 @@
|
||||
@DocumentService.API_HostAddress = http://localhost:5028
|
||||
|
||||
GET {{DocumentService.API_HostAddress}}/weatherforecast/
|
||||
Accept: application/json
|
||||
|
||||
###
|
||||
@@ -1,31 +1,25 @@
|
||||
using DocumentOperator.Domain.Common.Exceptions;
|
||||
using DocumentOperator.Domain.Exceptions;
|
||||
using DocumentService.Domain.Common.Exceptions;
|
||||
using FluentValidation;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using System.Net;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace DocumentOperator.API.Middleware;
|
||||
namespace DocumentService.API.Middleware;
|
||||
|
||||
/// <summary>
|
||||
/// Central exception handling middleware
|
||||
/// Maps exceptions to HTTP status codes and RFC 7807 Problem Details
|
||||
/// </summary>
|
||||
public class ExceptionHandlingMiddleware
|
||||
/// <remarks>
|
||||
/// Initializes a new instance of the <see cref="ExceptionHandlingMiddleware"/> class.
|
||||
/// </remarks>
|
||||
/// <param name="Next">The next middleware in the pipeline.</param>
|
||||
public class ExceptionHandlingMiddleware(RequestDelegate Next)
|
||||
{
|
||||
private readonly RequestDelegate _next;
|
||||
private readonly ILogger<ExceptionHandlingMiddleware> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ExceptionHandlingMiddleware"/> class.
|
||||
/// </summary>
|
||||
/// <param name="next">The next middleware in the pipeline.</param>
|
||||
/// <param name="logger">The logger instance for exception logging.</param>
|
||||
public ExceptionHandlingMiddleware(RequestDelegate next, ILogger<ExceptionHandlingMiddleware> logger)
|
||||
private static readonly JsonSerializerOptions ProbDetailsJsonOpt = new()
|
||||
{
|
||||
_next = next;
|
||||
_logger = logger;
|
||||
}
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Invokes the middleware to handle incoming HTTP requests and catch exceptions.
|
||||
@@ -35,11 +29,10 @@ public class ExceptionHandlingMiddleware
|
||||
{
|
||||
try
|
||||
{
|
||||
await _next(context);
|
||||
await Next(context);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Unhandled exception: {ExceptionMessage}", ex.Message);
|
||||
await HandleExceptionAsync(context, ex);
|
||||
}
|
||||
}
|
||||
@@ -51,12 +44,7 @@ public class ExceptionHandlingMiddleware
|
||||
context.Response.StatusCode = (int)statusCode;
|
||||
context.Response.ContentType = "application/problem+json";
|
||||
|
||||
var options = new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
};
|
||||
|
||||
await context.Response.WriteAsync(JsonSerializer.Serialize(problemDetails, options));
|
||||
await context.Response.WriteAsync(JsonSerializer.Serialize(problemDetails, ProbDetailsJsonOpt));
|
||||
}
|
||||
|
||||
private static (HttpStatusCode StatusCode, ProblemDetails ProblemDetails) MapExceptionToProblemDetails(
|
||||
@@ -78,15 +66,15 @@ public class ExceptionHandlingMiddleware
|
||||
}
|
||||
),
|
||||
|
||||
// Domain Validation Exception (400 Bad Request)
|
||||
DomainValidationException domainEx => (
|
||||
// Bad Request Exception (400 Bad Request)
|
||||
BadRequestException badReqEx => (
|
||||
HttpStatusCode.BadRequest,
|
||||
new ProblemDetails
|
||||
{
|
||||
Type = "https://datatracker.ietf.org/doc/html/rfc7231#section-6.5.1",
|
||||
Title = "Domain Validation Error",
|
||||
Type = "https://datatracker.ietf.org/doc/html/rfc7231#section-6.5.4",
|
||||
Title = "Bad Request",
|
||||
Status = (int)HttpStatusCode.BadRequest,
|
||||
Detail = domainEx.Message,
|
||||
Detail = badReqEx.Message,
|
||||
Instance = context.Request.Path
|
||||
}
|
||||
),
|
||||
@@ -104,32 +92,6 @@ public class ExceptionHandlingMiddleware
|
||||
}
|
||||
),
|
||||
|
||||
// Swiss QR Code Not Found Exception (404 Not Found)
|
||||
SwissQrCodeNotFoundException qrNotFoundEx => (
|
||||
HttpStatusCode.NotFound,
|
||||
new ProblemDetails
|
||||
{
|
||||
Type = "https://datatracker.ietf.org/doc/html/rfc7231#section-6.5.4",
|
||||
Title = "Swiss QR Code Not Found",
|
||||
Status = (int)HttpStatusCode.NotFound,
|
||||
Detail = qrNotFoundEx.Message,
|
||||
Instance = context.Request.Path
|
||||
}
|
||||
),
|
||||
|
||||
// PDF Processing Exception (500 Internal Server Error)
|
||||
PdfProcessingException pdfEx => (
|
||||
HttpStatusCode.InternalServerError,
|
||||
new ProblemDetails
|
||||
{
|
||||
Type = "https://datatracker.ietf.org/doc/html/rfc7231#section-6.6.1",
|
||||
Title = "PDF Processing Error",
|
||||
Status = (int)HttpStatusCode.InternalServerError,
|
||||
Detail = pdfEx.Message,
|
||||
Instance = context.Request.Path
|
||||
}
|
||||
),
|
||||
|
||||
// Generic Exception (500 Internal Server Error)
|
||||
_ => (
|
||||
HttpStatusCode.InternalServerError,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace DocumentOperator.API.Middleware
|
||||
namespace DocumentService.API.Middleware
|
||||
{
|
||||
/// <summary>
|
||||
/// Placeholder middleware for HTTP request/response logging.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace DocumentOperator.API.Middleware
|
||||
namespace DocumentService.API.Middleware
|
||||
{
|
||||
/// <summary>
|
||||
/// Placeholder middleware for multi-tenancy resolution via X-API-Key header.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# ?? DocumentOperator - Phasenplan (Feature-Driven Development)
|
||||
# ?? DocumentService - Phasenplan (Feature-Driven Development)
|
||||
|
||||
> **Stand:** 17.01.2025 | **Aktuell:** Feature 3 - ExtractAttachments ? NEXT | **Projektdauer:** 6 Wochen
|
||||
|
||||
@@ -132,7 +132,7 @@
|
||||
- ? XML Comments aktiviert
|
||||
|
||||
2. **XML-Dokumentation aktiviert**
|
||||
- ? `API/DocumentOperator.API.csproj`
|
||||
- ? `API/DocumentService.API.csproj`
|
||||
- ? `<GenerateDocumentationFile>true</GenerateDocumentationFile>`
|
||||
|
||||
3. **Endpoint Dokumentation**
|
||||
@@ -146,12 +146,12 @@
|
||||
|
||||
5. **Program.cs Updates**
|
||||
- ? `builder.Services.AddSwaggerDocumentation()` statt `AddSwaggerGen()`
|
||||
- ? `using DocumentOperator.API.Configuration;` hinzugefügt
|
||||
- ? `using DocumentService.API.Configuration;` hinzugefügt
|
||||
|
||||
**Akzeptanzkriterien:**
|
||||
- ? Build erfolgreich
|
||||
- ? Alle Tests grün (11/11)
|
||||
- ? XML-Dokumentation wird generiert (`DocumentOperator.API.xml`)
|
||||
- ? XML-Dokumentation wird generiert (`DocumentService.API.xml`)
|
||||
- ? Swagger UI zeigt Endpoint `/api/v1/documents/validate` mit Dokumentation
|
||||
- ? Request/Response-Schemas sind dokumentiert
|
||||
- ? Endpoint ist im Swagger UI testbar
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
using Serilog;
|
||||
using DocumentOperator.Infrastructure.Configuration;
|
||||
using DocumentOperator.Application;
|
||||
using DocumentOperator.Infrastructure;
|
||||
using DocumentOperator.API.Middleware;
|
||||
using DocumentOperator.API.Configuration;
|
||||
using Serilog.Ui.Core.Extensions;
|
||||
using Serilog.Ui.SqliteDataProvider.Extensions;
|
||||
using Serilog.Ui.Web.Extensions;
|
||||
using Scalar.AspNetCore;
|
||||
using DocumentService.Infrastructure.Configuration;
|
||||
using DocumentService.Application;
|
||||
using DocumentService.Application.Common.Configuration;
|
||||
using DocumentService.Infrastructure;
|
||||
using DocumentService.API.Middleware;
|
||||
using DocumentService.API.Configuration;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
@@ -13,20 +18,23 @@ var builder = WebApplication.CreateBuilder(args);
|
||||
Log.Logger = new LoggerConfiguration()
|
||||
.ReadFrom.Configuration(builder.Configuration)
|
||||
.Enrich.FromLogContext()
|
||||
.Enrich.WithProperty("Application", "DocumentOperator")
|
||||
.Enrich.WithProperty("Application", "DocumentService")
|
||||
.CreateLogger();
|
||||
|
||||
builder.Host.UseSerilog();
|
||||
|
||||
Log.Information("Starting DocumentOperator API...");
|
||||
Log.Information("Starting DocumentService API...");
|
||||
|
||||
try
|
||||
{
|
||||
// ========================================
|
||||
// 2. Options Pattern Configuration
|
||||
// ========================================
|
||||
builder.Services.Configure<DocumentOperatorSettings>(
|
||||
builder.Configuration.GetSection(DocumentOperatorSettings.SectionName));
|
||||
builder.Services.Configure<DocumentServiceSettings>(
|
||||
builder.Configuration.GetSection(DocumentServiceSettings.SectionName));
|
||||
|
||||
builder.Services.Configure<ZugferdSettings>(
|
||||
builder.Configuration.GetSection("ZugferdSettings"));
|
||||
|
||||
builder.Services.Configure<RedisSettings>(
|
||||
builder.Configuration.GetSection(RedisSettings.SectionName));
|
||||
@@ -34,18 +42,37 @@ try
|
||||
builder.Services.Configure<ApiKeySettings>(
|
||||
builder.Configuration.GetSection(ApiKeySettings.SectionName));
|
||||
|
||||
builder.Services.Configure<SwaggerSettings>(
|
||||
builder.Configuration.GetSection(SwaggerSettings.SectionName));
|
||||
|
||||
// ========================================
|
||||
// 3. Services (Clean Architecture Layers)
|
||||
// ========================================
|
||||
builder.Services.AddApplication(); // Application Layer (MediatR, FluentValidation, Behaviors)
|
||||
builder.Services.AddApplication(builder.Configuration); // Application Layer (MediatR, FluentValidation, Behaviors)
|
||||
builder.Services.AddInfrastructure(); // Infrastructure Layer (DevExpress, Services)
|
||||
|
||||
builder.Services.AddControllers(); // Controllers (Controller-based API)
|
||||
builder.Services.AddEndpointsApiExplorer();
|
||||
builder.Services.AddSwaggerDocumentation();
|
||||
builder.Services.AddSwaggerDocumentation(builder.Configuration);
|
||||
|
||||
// ========================================
|
||||
// 4. Build App
|
||||
// 4. Serilog.UI Configuration
|
||||
// ========================================
|
||||
var logDirectory = builder.Configuration.GetValue<string>("Application:LogDirectory")
|
||||
?? throw new InvalidOperationException("Application:LogDirectory not found in configuration.");
|
||||
var sqliteDbPath = Path.Combine(logDirectory, "logs.db");
|
||||
|
||||
builder.Services.AddSerilogUi(logUIOpt =>
|
||||
{
|
||||
logUIOpt.UseSqliteServer(dbOpt =>
|
||||
{
|
||||
dbOpt.WithConnectionString($"Data Source={sqliteDbPath}");
|
||||
dbOpt.WithTable("Logs");
|
||||
});
|
||||
});
|
||||
|
||||
// ========================================
|
||||
// 5. Build App
|
||||
// ========================================
|
||||
var app = builder.Build();
|
||||
|
||||
@@ -56,10 +83,32 @@ try
|
||||
// Exception Handling FIRST (catches all exceptions from subsequent middleware)
|
||||
app.UseMiddleware<ExceptionHandlingMiddleware>();
|
||||
|
||||
if (app.Environment.IsDevelopment())
|
||||
// ========================================
|
||||
// Swagger/OpenAPI (Conditional based on settings)
|
||||
// ========================================
|
||||
var swaggerSettings = builder.Configuration.GetSection(SwaggerSettings.SectionName).Get<SwaggerSettings>()
|
||||
?? new SwaggerSettings();
|
||||
|
||||
if (app.Environment.IsDevelopment() || swaggerSettings.EnableInProduction)
|
||||
{
|
||||
app.UseSwagger();
|
||||
app.UseSwaggerUI();
|
||||
|
||||
// Swagger UI (classic)
|
||||
app.UseSwaggerUI(options =>
|
||||
{
|
||||
options.SwaggerEndpoint($"/swagger/{swaggerSettings.Version}/swagger.json",
|
||||
$"{swaggerSettings.Title} {swaggerSettings.Version}");
|
||||
options.RoutePrefix = "swagger"; // /swagger
|
||||
});
|
||||
|
||||
// Scalar UI (modern alternative)
|
||||
app.MapScalarApiReference(options =>
|
||||
{
|
||||
options
|
||||
.WithTitle(swaggerSettings.Title)
|
||||
.WithTheme(ScalarTheme.DeepSpace)
|
||||
.WithDefaultHttpClient(ScalarTarget.CSharp, ScalarClient.HttpClient);
|
||||
});
|
||||
}
|
||||
|
||||
app.UseSerilogRequestLogging(); // Log HTTP Requests
|
||||
@@ -67,11 +116,16 @@ try
|
||||
app.UseHttpsRedirection();
|
||||
|
||||
// ========================================
|
||||
// 6. Endpoints (Controller-based API)
|
||||
// 6. Serilog.UI Dashboard
|
||||
// ========================================
|
||||
app.UseSerilogUi(); // Accessible at /serilog-ui
|
||||
|
||||
// ========================================
|
||||
// 7. Endpoints (Controller-based API)
|
||||
// ========================================
|
||||
app.MapControllers(); // Maps all [ApiController] controllers
|
||||
|
||||
Log.Information("DocumentOperator API started successfully");
|
||||
Log.Information("DocumentService API started successfully");
|
||||
|
||||
app.Run();
|
||||
}
|
||||
@@ -87,7 +141,7 @@ finally
|
||||
|
||||
// Make Program class accessible for Integration Tests
|
||||
/// <summary>
|
||||
/// Entry point class for the DocumentOperator API.
|
||||
/// Entry point class for the DocumentService API.
|
||||
/// Made partial and public for integration test access.
|
||||
/// </summary>
|
||||
public partial class Program { }
|
||||
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- https://go.microsoft.com/fwlink/?LinkID=208121. -->
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<WebPublishMethod>Package</WebPublishMethod>
|
||||
<LastUsedBuildConfiguration>Release</LastUsedBuildConfiguration>
|
||||
<LastUsedPlatform>Any CPU</LastUsedPlatform>
|
||||
<SiteUrlToLaunchAfterPublish />
|
||||
<LaunchSiteAfterPublish>true</LaunchSiteAfterPublish>
|
||||
<ExcludeApp_Data>false</ExcludeApp_Data>
|
||||
<ProjectGuid>c60bc965-d293-ea64-b153-1941f0648df4</ProjectGuid>
|
||||
<DesktopBuildPackageLocation>M:\App&Service\0 DD - Smart UP\DocumentService\PreRelease\API\net8\$(Version)\DocumentService.API.zip</DesktopBuildPackageLocation>
|
||||
<PackageAsSingleFile>true</PackageAsSingleFile>
|
||||
<DeployIisAppPath>DocumentService.API</DeployIisAppPath>
|
||||
<_TargetId>IISWebDeployPackage</_TargetId>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
387
DocumentOperator.API/README.md
Normal file
387
DocumentOperator.API/README.md
Normal file
@@ -0,0 +1,387 @@
|
||||
# DocumentService API - Manual Testing Guide
|
||||
|
||||
This guide contains manual test scenarios for validating the DocumentService API endpoints using Swagger UI or tools like Postman.
|
||||
|
||||
---
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. **Start the API:**
|
||||
```powershell
|
||||
dotnet run --project DocumentService.API
|
||||
```
|
||||
Default URL: `https://localhost:5001` (check console output for actual port)
|
||||
|
||||
2. **Open Swagger UI:**
|
||||
Navigate to `https://localhost:<port>/swagger`
|
||||
|
||||
3. **Test PDFs:**
|
||||
- Use PDFs from `fake-pdf/` folder (form.pdf, multi-page.pdf, one-page.pdf, with-image.pdf)
|
||||
- Or use your own PDF files
|
||||
|
||||
---
|
||||
|
||||
## Feature 1: Basic PDF Validation
|
||||
|
||||
### Endpoint: `POST /api/pdf/validation/validate`
|
||||
|
||||
#### Test Case 1.1: Valid PDF (Multipart Upload)
|
||||
**Objective:** Verify basic PDF validation works with file upload
|
||||
|
||||
**Steps:**
|
||||
1. Open Swagger UI → `/api/pdf/validation/validate`
|
||||
2. Click "Try it out"
|
||||
3. Select **multipart/form-data** from dropdown
|
||||
4. Click "Choose File" and select `fake-pdf/one-page.pdf`
|
||||
5. Click "Execute"
|
||||
|
||||
**Expected Result:**
|
||||
- **Status Code:** 200 OK
|
||||
- **Response Body:**
|
||||
```json
|
||||
{
|
||||
"pageCount": 1,
|
||||
"fileSizeBytes": 7168,
|
||||
"fileSizeMB": 0.01,
|
||||
"pdfVersion": "1.4",
|
||||
"hasAttachments": false,
|
||||
"attachmentCount": 0
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### Test Case 1.2: Valid PDF (Base64 JSON)
|
||||
**Objective:** Verify Base64 input works
|
||||
|
||||
**Steps:**
|
||||
1. Convert a PDF to Base64:
|
||||
```powershell
|
||||
$bytes = [System.IO.File]::ReadAllBytes("fake-pdf/one-page.pdf")
|
||||
$base64 = [Convert]::ToBase64String($bytes)
|
||||
Write-Output $base64
|
||||
```
|
||||
2. Open Swagger UI → `/api/pdf/validation/validate`
|
||||
3. Click "Try it out"
|
||||
4. Select **application/json** from dropdown
|
||||
5. Paste into Request Body:
|
||||
```json
|
||||
{
|
||||
"base64Pdf": "<paste-your-base64-here>"
|
||||
}
|
||||
```
|
||||
6. Click "Execute"
|
||||
|
||||
**Expected Result:**
|
||||
- **Status Code:** 200 OK
|
||||
- Same response as Test 1.1
|
||||
|
||||
---
|
||||
|
||||
#### Test Case 1.3: Invalid Base64 String
|
||||
**Objective:** Verify validation rejects malformed Base64
|
||||
|
||||
**Steps:**
|
||||
1. Open Swagger UI → `/api/pdf/validation/validate`
|
||||
2. Select **application/json**
|
||||
3. Paste into Request Body:
|
||||
```json
|
||||
{
|
||||
"base64Pdf": "invalid-base64!!!"
|
||||
}
|
||||
```
|
||||
4. Click "Execute"
|
||||
|
||||
**Expected Result:**
|
||||
- **Status Code:** 400 Bad Request
|
||||
- **Error Message:** Contains "Base64"
|
||||
|
||||
---
|
||||
|
||||
#### Test Case 1.4: Empty File Upload
|
||||
**Objective:** Verify empty files are rejected
|
||||
|
||||
**Steps:**
|
||||
1. Create an empty file (`empty.pdf`)
|
||||
2. Upload via multipart/form-data
|
||||
|
||||
**Expected Result:**
|
||||
- **Status Code:** 400 Bad Request
|
||||
- **Error Message:** Contains "cannot be empty"
|
||||
|
||||
---
|
||||
|
||||
#### Test Case 1.5: Large Multi-Page PDF
|
||||
**Objective:** Verify handling of larger PDFs
|
||||
|
||||
**Steps:**
|
||||
1. Upload `fake-pdf/multi-page.pdf` (49 KB)
|
||||
|
||||
**Expected Result:**
|
||||
- **Status Code:** 200 OK
|
||||
- **Response:**
|
||||
```json
|
||||
{
|
||||
"pageCount": 3,
|
||||
"fileSizeBytes": 49152,
|
||||
"fileSizeMB": 0.05,
|
||||
"pdfVersion": "1.7",
|
||||
"hasAttachments": false,
|
||||
"attachmentCount": 0
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### Test Case 1.6: PDF with Images
|
||||
**Objective:** Verify image-heavy PDFs are processed
|
||||
|
||||
**Steps:**
|
||||
1. Upload `fake-pdf/with-image.pdf` (256 KB)
|
||||
|
||||
**Expected Result:**
|
||||
- **Status Code:** 200 OK
|
||||
- **Response:**
|
||||
```json
|
||||
{
|
||||
"pageCount": 1,
|
||||
"fileSizeBytes": 262144,
|
||||
"fileSizeMB": 0.25,
|
||||
"pdfVersion": "1.6",
|
||||
"hasAttachments": false,
|
||||
"attachmentCount": 0
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Feature 3: PDF/A Validation
|
||||
|
||||
### Endpoint: `POST /api/pdf/validation/validate-pdfa`
|
||||
|
||||
#### Test Case 3.1: PDF/A Compliant Document (Multipart)
|
||||
**Objective:** Verify PDF/A validation detects conformance
|
||||
|
||||
**Steps:**
|
||||
1. Open Swagger UI → `/api/pdf/validation/validate-pdfa`
|
||||
2. Select **multipart/form-data**
|
||||
3. Upload a PDF/A-compliant PDF (if available)
|
||||
4. Click "Execute"
|
||||
|
||||
**Expected Result (if PDF/A compliant):**
|
||||
- **Status Code:** 200 OK
|
||||
- **Response:**
|
||||
```json
|
||||
{
|
||||
"isValid": true,
|
||||
"pdfVersion": "1.7",
|
||||
"pageCount": 1,
|
||||
"fileSize": 12345,
|
||||
"encrypted": false,
|
||||
"pdfAVersion": "PDF/A-3b",
|
||||
"pdfACompliant": true,
|
||||
"errors": [],
|
||||
"warnings": []
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### Test Case 3.2: Non-PDF/A Document
|
||||
**Objective:** Verify regular PDFs are detected as non-compliant
|
||||
|
||||
**Steps:**
|
||||
1. Upload `fake-pdf/one-page.pdf` (regular PDF, NOT PDF/A)
|
||||
|
||||
**Expected Result:**
|
||||
- **Status Code:** 200 OK
|
||||
- **Response:**
|
||||
```json
|
||||
{
|
||||
"isValid": true,
|
||||
"pdfVersion": "1.4",
|
||||
"pageCount": 1,
|
||||
"fileSize": 7168,
|
||||
"encrypted": false,
|
||||
"pdfAVersion": null,
|
||||
"pdfACompliant": false,
|
||||
"errors": [],
|
||||
"warnings": ["Manual verification recommended: PDF/A compliance requires all fonts to be embedded"]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### Test Case 3.3: Encrypted PDF
|
||||
**Objective:** Verify encrypted PDFs are flagged
|
||||
|
||||
**Steps:**
|
||||
1. Create or obtain a password-protected PDF
|
||||
2. Upload via multipart/form-data
|
||||
|
||||
**Expected Result:**
|
||||
- **Status Code:** 200 OK
|
||||
- **Response:**
|
||||
```json
|
||||
{
|
||||
"isValid": true,
|
||||
"pdfVersion": "1.7",
|
||||
"pageCount": 1,
|
||||
"fileSize": 12345,
|
||||
"encrypted": true,
|
||||
"pdfAVersion": null,
|
||||
"pdfACompliant": false,
|
||||
"errors": ["Encrypted PDFs cannot be PDF/A compliant"],
|
||||
"warnings": []
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### Test Case 3.4: Invalid Base64 (PDF/A Endpoint)
|
||||
**Objective:** Verify validation works on PDF/A endpoint
|
||||
|
||||
**Steps:**
|
||||
1. Select **application/json**
|
||||
2. Paste:
|
||||
```json
|
||||
{
|
||||
"base64Pdf": "not-base64!!!"
|
||||
}
|
||||
```
|
||||
|
||||
**Expected Result:**
|
||||
- **Status Code:** 400 Bad Request
|
||||
- **Error Message:** Contains "Base64"
|
||||
|
||||
---
|
||||
|
||||
#### Test Case 3.5: Empty Request
|
||||
**Objective:** Verify both inputs missing is rejected
|
||||
|
||||
**Steps:**
|
||||
1. Select **application/json**
|
||||
2. Paste:
|
||||
```json
|
||||
{
|
||||
"pdfBytes": null,
|
||||
"base64Pdf": ""
|
||||
}
|
||||
```
|
||||
|
||||
**Expected Result:**
|
||||
- **Status Code:** 400 Bad Request
|
||||
- **Error Message:** "Either PdfBytes or Base64Pdf must be provided, but not both"
|
||||
|
||||
---
|
||||
|
||||
## Feature 2: Swiss QR Code Extraction
|
||||
|
||||
### Endpoint: `POST /api/swissqrcode/extract`
|
||||
|
||||
#### Test Case 2.1: PDF with Swiss QR Code
|
||||
**Objective:** Extract Swiss QR Bill from PDF
|
||||
|
||||
**Steps:**
|
||||
1. Open Swagger UI → `/api/swissqrcode/extract`
|
||||
2. Select **multipart/form-data**
|
||||
3. Upload a PDF containing Swiss QR Code on the **last page**
|
||||
4. Click "Execute"
|
||||
|
||||
**Expected Result (if QR code present):**
|
||||
- **Status Code:** 200 OK
|
||||
- **Response:** Contains Swiss QR Bill details (IBAN, amount, creditor, debtor, reference)
|
||||
|
||||
---
|
||||
|
||||
#### Test Case 2.2: PDF without QR Code
|
||||
**Objective:** Verify graceful handling when no QR code exists
|
||||
|
||||
**Steps:**
|
||||
1. Upload `fake-pdf/one-page.pdf` (no QR code)
|
||||
|
||||
**Expected Result:**
|
||||
- **Status Code:** 404 Not Found
|
||||
- **Error Message:** "Swiss QR Code not found in PDF"
|
||||
|
||||
---
|
||||
|
||||
## Common Error Scenarios
|
||||
|
||||
### Test Case E1: Missing File in Multipart Request
|
||||
**Steps:**
|
||||
1. Any multipart endpoint
|
||||
2. Don't select a file, click "Execute"
|
||||
|
||||
**Expected Result:**
|
||||
- **Status Code:** 400 Bad Request
|
||||
|
||||
---
|
||||
|
||||
### Test Case E2: Both PdfBytes AND Base64Pdf Provided
|
||||
**Steps:**
|
||||
1. Attempt to send JSON with both fields populated
|
||||
```json
|
||||
{
|
||||
"pdfBytes": [1,2,3],
|
||||
"base64Pdf": "dGVzdA=="
|
||||
}
|
||||
```
|
||||
|
||||
**Expected Result:**
|
||||
- **Status Code:** 400 Bad Request
|
||||
- **Error Message:** "Either PdfBytes or Base64Pdf must be provided, but not both"
|
||||
|
||||
---
|
||||
|
||||
### Test Case E3: Corrupted PDF File
|
||||
**Steps:**
|
||||
1. Create a text file with `.pdf` extension containing "FAKE PDF CONTENT"
|
||||
2. Upload it
|
||||
|
||||
**Expected Result:**
|
||||
- **Status Code:** 500 Internal Server Error
|
||||
- **Error Message:** Contains "PDF processing error"
|
||||
|
||||
---
|
||||
|
||||
## Test Coverage Summary
|
||||
|
||||
| Feature | Endpoint | Test Cases |
|
||||
|---------|----------|------------|
|
||||
| Basic PDF Validation | `POST /api/pdf/validation/validate` | 6 |
|
||||
| PDF/A Validation | `POST /api/pdf/validation/validate-pdfa` | 5 |
|
||||
| Swiss QR Code | `POST /api/swissqrcode/extract` | 2 |
|
||||
| Error Handling | All endpoints | 3 |
|
||||
| **TOTAL** | | **16 Manual Test Cases** |
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- All endpoints support **BOTH** `multipart/form-data` (file upload) AND `application/json` (Base64)
|
||||
- FluentValidation runs before handlers (400 errors indicate validation failures)
|
||||
- DevExpress evaluation warnings (DX1000/DX1001) are expected and can be ignored
|
||||
- Test PDFs in `fake-pdf/` folder are small samples; use real-world PDFs for comprehensive testing
|
||||
|
||||
---
|
||||
|
||||
## Quick PowerShell Helpers
|
||||
|
||||
**Convert PDF to Base64:**
|
||||
```powershell
|
||||
$bytes = [System.IO.File]::ReadAllBytes("path\to\file.pdf")
|
||||
$base64 = [Convert]::ToBase64String($bytes)
|
||||
$base64 | Set-Clipboard # Copies to clipboard
|
||||
```
|
||||
|
||||
**Create empty PDF for testing:**
|
||||
```powershell
|
||||
New-Item -Path "empty.pdf" -ItemType File -Force
|
||||
```
|
||||
|
||||
**Check if file is valid PDF:**
|
||||
```powershell
|
||||
$header = Get-Content -Path "file.pdf" -TotalCount 1 -Encoding Byte
|
||||
# Should start with: 0x25 0x50 0x44 0x46 (%PDF)
|
||||
```
|
||||
@@ -5,8 +5,8 @@
|
||||
}
|
||||
},
|
||||
|
||||
"DocumentOperatorSettings": {
|
||||
"TempFolderPath": "C:\\Temp\\DocumentOperator\\Dev",
|
||||
"DocumentServiceSettings": {
|
||||
"TempFolderPath": "C:\\Temp\\DocumentService\\Dev",
|
||||
"EnableDetailedLogging": true
|
||||
},
|
||||
|
||||
|
||||
@@ -7,6 +7,17 @@
|
||||
},
|
||||
"AllowedHosts": "*",
|
||||
|
||||
"Application": {
|
||||
"LogDirectory": "E:\\LogFiles\\Digital Data\\DocumentService.API"
|
||||
},
|
||||
|
||||
"SwaggerSettings": {
|
||||
"EnableInProduction": true,
|
||||
"Title": "DocumentService API",
|
||||
"Version": "v1",
|
||||
"Description": "PDF document processing service using DevExpress"
|
||||
},
|
||||
|
||||
"Serilog": {
|
||||
"MinimumLevel": {
|
||||
"Default": "Information",
|
||||
@@ -30,21 +41,45 @@
|
||||
"rollingInterval": "Day",
|
||||
"outputTemplate": "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {Message:lj} {Properties:j}{NewLine}{Exception}"
|
||||
}
|
||||
},
|
||||
{
|
||||
"Name": "SQLite",
|
||||
"Args": {
|
||||
"sqliteDbPath": "E:\\LogFiles\\Digital Data\\DocumentService.API\\logs.db",
|
||||
"tableName": "Logs",
|
||||
"storeTimestampInUtc": true
|
||||
}
|
||||
}
|
||||
],
|
||||
"Enrich": [ "FromLogContext", "WithMachineName", "WithThreadId" ]
|
||||
},
|
||||
|
||||
"DocumentOperatorSettings": {
|
||||
"TempFolderPath": "C:\\Temp\\DocumentOperator",
|
||||
"DocumentServiceSettings": {
|
||||
"TempFolderPath": "C:\\Temp\\DocumentService",
|
||||
"TempFileRetentionHours": 24,
|
||||
"MaxPdfSizeMB": 50,
|
||||
"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:",
|
||||
"InstanceName": "DocumentService:",
|
||||
"CacheExpirationMinutes": 60
|
||||
},
|
||||
|
||||
@@ -62,5 +97,6 @@
|
||||
"IsActive": true
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"LuckyPennySoftLicenseKey": "eyJhbGciOiJSUzI1NiIsImtpZCI6Ikx1Y2t5UGVubnlTb2Z0d2FyZUxpY2Vuc2VLZXkvYmJiMTNhY2I1OTkwNGQ4OWI0Y2IxYzg1ZjA4OGNjZjkiLCJ0eXAiOiJKV1QifQ.eyJpc3MiOiJodHRwczovL2x1Y2t5cGVubnlzb2Z0d2FyZS5jb20iLCJhdWQiOiJMdWNreVBlbm55U29mdHdhcmUiLCJleHAiOiIxODE2MTI4MDAwIiwiaWF0IjoiMTc4NDYyNDU1NyIsImFjY291bnRfaWQiOiIwMTk4M2M1OWU0YjM3MjhlYmZkMzEwM2MyYTQ4NmU4NSIsImN1c3RvbWVyX2lkIjoiMDE5ODNjNTllNGIzNzI4ZWJmZDMxMDNjMmE0ODZlODUiLCJzdWJfaWQiOiItIiwiZWRpdGlvbiI6IjAiLCJ0eXBlIjoiMiJ9.IUUO926m9crYGYxMjjKD_n9BnUm-EDyjFIn0YmMUCo7C-QTwvB8WhXP8veTSFsBq-leIIDJ4jyl7Pgc_7ciwg1XhUSIs4mkQroEUaSFCGOxw7Pi41WM8MK5YFSaqLTYYXec9zxgiJbGzABbh3CHTSup3okGnVm_CMoPEs91l2c0A6N1JyZy74urd_tF0KGVKf0MOvzdlQIWLQ8o73S4pTv2N-F6UlzI0fdMtTHMLNNQyr0NdWdnuBk_jMBXO-gy5RE_oCRfMTTYRX2n3XLK6pTfXE0Ct338o9F5sH8Ph2lTXSu56cpdsfZOQZGqCH0LoFp1Dd7RJgIgNmBiTGfvDnA"
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
using DocumentService.Application.Common.Interfaces;
|
||||
using DocumentService.Domain.Models.ValueObjects;
|
||||
using FluentValidation;
|
||||
using MediatR;
|
||||
|
||||
namespace DocumentService.Application.AddAnnotation;
|
||||
|
||||
/// <summary>
|
||||
/// Command to add an annotation to a PDF document.
|
||||
/// </summary>
|
||||
public record AddAnnotationCommand : IRequest<byte[]>
|
||||
{
|
||||
public required Stream PdfStream { get; init; }
|
||||
public required AnnotationType AnnotationType { get; init; }
|
||||
public required int PageNumber { get; init; }
|
||||
public required (double X1, double Y1, double X2, double Y2) Rectangle { get; init; }
|
||||
public string? Content { get; init; }
|
||||
public string? Author { get; init; }
|
||||
public string? Color { get; init; }
|
||||
public TextMarkupStyle? TextMarkupStyle { get; init; }
|
||||
public AnnotationOrigin Origin { get; init; } = AnnotationOrigin.BottomLeft;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handler for AddAnnotationCommand.
|
||||
/// </summary>
|
||||
public class AddAnnotationHandler(IPdfProcessor pdfProcessor) : IRequestHandler<AddAnnotationCommand, byte[]>
|
||||
{
|
||||
public async Task<byte[]> Handle(AddAnnotationCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
return await pdfProcessor.AddAnnotationAsync(
|
||||
request.PdfStream,
|
||||
request.AnnotationType,
|
||||
request.PageNumber,
|
||||
request.Rectangle,
|
||||
request.Content,
|
||||
request.Author,
|
||||
request.Color,
|
||||
request.TextMarkupStyle,
|
||||
request.Origin);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validator for AddAnnotationCommand.
|
||||
/// </summary>
|
||||
public class AddAnnotationValidator : AbstractValidator<AddAnnotationCommand>
|
||||
{
|
||||
public AddAnnotationValidator()
|
||||
{
|
||||
RuleFor(x => x.PdfStream)
|
||||
.NotNull()
|
||||
.WithMessage("PDF stream is required");
|
||||
|
||||
RuleFor(x => x.PageNumber)
|
||||
.GreaterThan(0)
|
||||
.WithMessage("Page number must be greater than 0");
|
||||
|
||||
RuleFor(x => x.Content)
|
||||
.NotEmpty()
|
||||
.When(x => x.AnnotationType == AnnotationType.FreeText || x.AnnotationType == AnnotationType.StickyNote)
|
||||
.WithMessage("Content is required for FreeText and StickyNote annotations");
|
||||
|
||||
RuleFor(x => x.TextMarkupStyle)
|
||||
.NotNull()
|
||||
.When(x => x.AnnotationType == AnnotationType.TextMarkup)
|
||||
.WithMessage("TextMarkupStyle is required for TextMarkup annotations");
|
||||
|
||||
RuleFor(x => x.Color)
|
||||
.Matches("^[0-9A-Fa-f]{6}$")
|
||||
.When(x => !string.IsNullOrWhiteSpace(x.Color))
|
||||
.WithMessage("Color must be a 6-digit hex value (e.g., 'FF0000' for red)");
|
||||
|
||||
RuleFor(x => x.Rectangle)
|
||||
.Must(r => r.X2 > r.X1 && r.Y2 > r.Y1)
|
||||
.WithMessage("Rectangle coordinates must define a valid area (X2 > X1 and Y2 > Y1)");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
using DocumentService.Application.Common.Interfaces;
|
||||
using FluentValidation;
|
||||
using MediatR;
|
||||
|
||||
namespace DocumentService.Application.AddAttachments;
|
||||
|
||||
/// <summary>
|
||||
/// Command to add one or more attachments to a PDF document (supports PDF/A-3)
|
||||
/// </summary>
|
||||
public record AddAttachmentsCommand : IRequest<byte[]>
|
||||
{
|
||||
/// <summary>
|
||||
/// PDF document stream. Must be positioned at the beginning (Position = 0).
|
||||
/// </summary>
|
||||
public required Stream PdfStream { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// List of attachments to embed (filename, content, optional MIME type)
|
||||
/// </summary>
|
||||
public required IReadOnlyList<AttachmentFile> Attachments { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a file to be attached to a PDF
|
||||
/// </summary>
|
||||
public record AttachmentFile
|
||||
{
|
||||
/// <summary>
|
||||
/// File name (e.g., "invoice.xml", "document.pdf")
|
||||
/// </summary>
|
||||
public required string FileName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// File content as byte array
|
||||
/// </summary>
|
||||
public required byte[] Content { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// MIME type (optional, e.g., "application/xml", "application/pdf")
|
||||
/// If not provided, will be inferred from file extension
|
||||
/// </summary>
|
||||
public string? MimeType { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handler for AddAttachmentsCommand
|
||||
/// </summary>
|
||||
public class AddAttachmentsCommandHandler(IPdfProcessor pdfProcessor)
|
||||
: IRequestHandler<AddAttachmentsCommand, byte[]>
|
||||
{
|
||||
public async Task<byte[]> Handle(AddAttachmentsCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
// Convert to tuple list for IPdfProcessor
|
||||
var attachmentTuples = request.Attachments
|
||||
.Select(a => (a.FileName, a.Content, a.MimeType))
|
||||
.ToList();
|
||||
|
||||
return await pdfProcessor.AddAttachmentsAsync(request.PdfStream, attachmentTuples);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validator for AddAttachmentsCommand
|
||||
/// </summary>
|
||||
public class AddAttachmentsCommandValidator : AbstractValidator<AddAttachmentsCommand>
|
||||
{
|
||||
public AddAttachmentsCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.PdfStream)
|
||||
.NotNull()
|
||||
.WithMessage("PDF stream is required");
|
||||
|
||||
RuleFor(x => x.Attachments)
|
||||
.NotNull()
|
||||
.NotEmpty()
|
||||
.WithMessage("At least one attachment is required");
|
||||
|
||||
RuleForEach(x => x.Attachments).ChildRules(attachment =>
|
||||
{
|
||||
attachment.RuleFor(a => a.FileName)
|
||||
.NotEmpty()
|
||||
.WithMessage("Attachment file name is required");
|
||||
|
||||
attachment.RuleFor(a => a.Content)
|
||||
.NotNull()
|
||||
.NotEmpty()
|
||||
.WithMessage("Attachment content is required");
|
||||
});
|
||||
}
|
||||
}
|
||||
125
DocumentOperator.Application/AddStamp/AddStampCommand.cs
Normal file
125
DocumentOperator.Application/AddStamp/AddStampCommand.cs
Normal file
@@ -0,0 +1,125 @@
|
||||
using FluentValidation;
|
||||
using MediatR;
|
||||
|
||||
namespace DocumentService.Application.AddStamp;
|
||||
|
||||
/// <summary>
|
||||
/// Command to add a stamp (text, image, or predefined) to PDF pages.
|
||||
/// Combines Command, Handler, and Validator in a single file (Vertical Slice pattern).
|
||||
/// </summary>
|
||||
public record AddStampCommand : IRequest<byte[]>
|
||||
{
|
||||
public required Stream PdfStream { get; init; }
|
||||
public required Domain.Models.ValueObjects.StampType StampType { get; init; }
|
||||
public int[]? PageNumbers { get; init; } // null = all pages
|
||||
public required (double X, double Y) Position { get; init; }
|
||||
public (double Width, double Height)? Size { get; init; }
|
||||
public Domain.Models.ValueObjects.AnnotationOrigin Origin { get; init; } = Domain.Models.ValueObjects.AnnotationOrigin.BottomLeft;
|
||||
public string? Text { get; init; }
|
||||
public string? FontName { get; init; }
|
||||
public double? FontSize { get; init; }
|
||||
public string? Color { get; init; }
|
||||
public double? Opacity { get; init; }
|
||||
public double? Rotation { get; init; }
|
||||
public Domain.Models.ValueObjects.StampPlacement Placement { get; init; } = Domain.Models.ValueObjects.StampPlacement.Foreground;
|
||||
public byte[]? ImageBytes { get; init; }
|
||||
public Domain.Models.ValueObjects.PredefinedStampType? PredefinedType { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handler for AddStampCommand.
|
||||
/// </summary>
|
||||
public class AddStampHandler(Common.Interfaces.IPdfProcessor pdfProcessor) : IRequestHandler<AddStampCommand, byte[]>
|
||||
{
|
||||
public async Task<byte[]> Handle(AddStampCommand command, CancellationToken cancellationToken)
|
||||
{
|
||||
return await pdfProcessor.AddStampAsync(
|
||||
command.PdfStream,
|
||||
command.StampType,
|
||||
command.PageNumbers,
|
||||
command.Position,
|
||||
command.Size,
|
||||
command.Origin,
|
||||
command.Text,
|
||||
command.FontName,
|
||||
command.FontSize,
|
||||
command.Color,
|
||||
command.Opacity,
|
||||
command.Rotation,
|
||||
command.Placement,
|
||||
command.ImageBytes,
|
||||
command.PredefinedType
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validator for AddStampCommand.
|
||||
/// </summary>
|
||||
public class AddStampValidator : AbstractValidator<AddStampCommand>
|
||||
{
|
||||
public AddStampValidator()
|
||||
{
|
||||
RuleFor(x => x.PdfStream)
|
||||
.NotNull().WithMessage("PDF stream is required");
|
||||
|
||||
RuleFor(x => x.Position.X)
|
||||
.GreaterThanOrEqualTo(0).WithMessage("Position X must be >= 0");
|
||||
|
||||
RuleFor(x => x.Position.Y)
|
||||
.GreaterThanOrEqualTo(0).WithMessage("Position Y must be >= 0");
|
||||
|
||||
// Text stamp validation
|
||||
When(x => x.StampType == Domain.Models.ValueObjects.StampType.Text, () =>
|
||||
{
|
||||
RuleFor(x => x.Text)
|
||||
.NotEmpty().WithMessage("Text is required for Text stamp type");
|
||||
});
|
||||
|
||||
// Image stamp validation
|
||||
When(x => x.StampType == Domain.Models.ValueObjects.StampType.Image, () =>
|
||||
{
|
||||
RuleFor(x => x.ImageBytes)
|
||||
.NotNull().WithMessage("Image bytes are required for Image stamp type")
|
||||
.Must(bytes => bytes != null && bytes.Length > 0).WithMessage("Image bytes cannot be empty");
|
||||
});
|
||||
|
||||
// Predefined stamp validation
|
||||
When(x => x.StampType == Domain.Models.ValueObjects.StampType.Predefined, () =>
|
||||
{
|
||||
RuleFor(x => x.PredefinedType)
|
||||
.NotNull().WithMessage("Predefined type is required for Predefined stamp type");
|
||||
});
|
||||
|
||||
// Optional parameter validations
|
||||
When(x => x.Opacity.HasValue, () =>
|
||||
{
|
||||
RuleFor(x => x.Opacity!.Value)
|
||||
.InclusiveBetween(0.0, 1.0).WithMessage("Opacity must be between 0.0 and 1.0");
|
||||
});
|
||||
|
||||
When(x => x.Rotation.HasValue, () =>
|
||||
{
|
||||
RuleFor(x => x.Rotation!.Value)
|
||||
.InclusiveBetween(0.0, 360.0).WithMessage("Rotation must be between 0 and 360 degrees");
|
||||
});
|
||||
|
||||
When(x => x.FontSize.HasValue, () =>
|
||||
{
|
||||
RuleFor(x => x.FontSize!.Value)
|
||||
.GreaterThan(0).WithMessage("Font size must be positive");
|
||||
});
|
||||
|
||||
When(x => !string.IsNullOrWhiteSpace(x.Color), () =>
|
||||
{
|
||||
RuleFor(x => x.Color!)
|
||||
.Matches(@"^[0-9A-Fa-f]{6}$").WithMessage("Color must be 6-digit hex (e.g., 'FF0000')");
|
||||
});
|
||||
|
||||
When(x => x.PageNumbers != null, () =>
|
||||
{
|
||||
RuleFor(x => x.PageNumbers!)
|
||||
.Must(pages => pages.All(p => p > 0)).WithMessage("All page numbers must be >= 1");
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
using AutoMapper;
|
||||
using DocumentService.Application.Common.DTOs;
|
||||
using DocumentService.Application.Common.Interfaces;
|
||||
using MediatR;
|
||||
|
||||
namespace DocumentService.Application.CheckPdfAttachments.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// Query for checking PDF attachments (Stream-based)
|
||||
/// </summary>
|
||||
public record CheckPdfAttachmentsQuery : IRequest<AttachmentCheckResult>
|
||||
{
|
||||
/// <summary>
|
||||
/// PDF as stream (caller is responsible for disposal)
|
||||
/// </summary>
|
||||
public required Stream PdfStream { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handler for CheckPdfAttachmentsQuery
|
||||
/// Orchestrates PDF attachment checking using IPdfProcessor and AutoMapper
|
||||
/// </summary>
|
||||
public class CheckPdfAttachmentsQueryHandler(IPdfProcessor PdfProcessor, IMapper Mapper)
|
||||
: IRequestHandler<CheckPdfAttachmentsQuery, AttachmentCheckResult>
|
||||
{
|
||||
/// <summary>
|
||||
/// Checks PDF attachments and returns detailed metadata
|
||||
/// </summary>
|
||||
public async Task<AttachmentCheckResult> Handle(CheckPdfAttachmentsQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
// Call DevExpress service directly with stream (exceptions propagate naturally)
|
||||
var attachmentInfo = await PdfProcessor.CheckAttachmentsAsync(request.PdfStream);
|
||||
|
||||
// Map DTO to response DTO using AutoMapper
|
||||
return Mapper.Map<AttachmentCheckResult>(attachmentInfo);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
using FluentValidation;
|
||||
|
||||
namespace DocumentService.Application.CheckPdfAttachments.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// Validator for CheckPdfAttachmentsQuery
|
||||
/// Ensures PdfStream is not null
|
||||
/// </summary>
|
||||
public class CheckPdfAttachmentsQueryValidator : AbstractValidator<CheckPdfAttachmentsQuery>
|
||||
{
|
||||
public CheckPdfAttachmentsQueryValidator()
|
||||
{
|
||||
// Rule: PdfStream must be provided and non-empty
|
||||
RuleFor(x => x.PdfStream)
|
||||
.NotNull()
|
||||
.WithMessage("PdfStream is required");
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@ using MediatR;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using System.Diagnostics;
|
||||
|
||||
namespace DocumentOperator.Application.Common.Behaviors;
|
||||
namespace DocumentService.Application.Common.Behaviors;
|
||||
|
||||
/// <summary>
|
||||
/// MediatR Pipeline Behavior that logs requests and tracks performance
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
using FluentValidation;
|
||||
using MediatR;
|
||||
|
||||
namespace DocumentOperator.Application.Common.Behaviors;
|
||||
namespace DocumentService.Application.Common.Behaviors;
|
||||
|
||||
/// <summary>
|
||||
/// MediatR Pipeline Behavior that validates requests using FluentValidation
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
namespace DocumentService.Application.Common.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration settings for ZUGFeRD/Factur-X/XRechnung detection
|
||||
/// </summary>
|
||||
public class ZugferdSettings
|
||||
{
|
||||
/// <summary>
|
||||
/// Exact ZUGFeRD/Factur-X/XRechnung file names to check (case-insensitive)
|
||||
/// </summary>
|
||||
public List<string> ZugferdFileNames { get; set; } = new()
|
||||
{
|
||||
"factur-x.xml",
|
||||
"zugferd-invoice.xml",
|
||||
"ZUGFeRD-invoice.xml",
|
||||
"xrechnung.xml"
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Partial filename patterns for ZUGFeRD detection (case-insensitive)
|
||||
/// </summary>
|
||||
public List<string> ZugferdFileNamePatterns { get; set; } = new()
|
||||
{
|
||||
"factur",
|
||||
"zugferd",
|
||||
"xrechnung",
|
||||
"peppol"
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
namespace DocumentService.Application.Common.DTOs;
|
||||
|
||||
/// <summary>
|
||||
/// DTO for attachment check result returned to API layer
|
||||
/// </summary>
|
||||
public record AttachmentCheckResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Indicates whether the PDF contains any attachments
|
||||
/// </summary>
|
||||
public bool HasAttachments { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Total number of attachments in the PDF
|
||||
/// </summary>
|
||||
public int AttachmentCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// List of attachment metadata (file details)
|
||||
/// </summary>
|
||||
public List<AttachmentDto> Attachments { get; init; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// DTO for individual attachment metadata
|
||||
/// </summary>
|
||||
public record AttachmentDto
|
||||
{
|
||||
/// <summary>
|
||||
/// Attachment file name (e.g., "invoice.xml", "document.pdf")
|
||||
/// </summary>
|
||||
public string FileName { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// MIME type of the attachment (e.g., "text/xml", "application/pdf")
|
||||
/// </summary>
|
||||
public string MimeType { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Attachment file size in bytes
|
||||
/// </summary>
|
||||
public long Size { get; init; }
|
||||
}
|
||||
60
DocumentOperator.Application/Common/DTOs/AttachmentInfo.cs
Normal file
60
DocumentOperator.Application/Common/DTOs/AttachmentInfo.cs
Normal file
@@ -0,0 +1,60 @@
|
||||
namespace DocumentService.Application.Common.DTOs;
|
||||
|
||||
/// <summary>
|
||||
/// Represents complete attachment information for a PDF document.
|
||||
/// Immutable value object containing attachment presence flag, count, and detailed metadata.
|
||||
/// </summary>
|
||||
public sealed class AttachmentInfo
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether the PDF contains any attachments
|
||||
/// </summary>
|
||||
public bool HasAttachments { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the total number of attachments in the PDF
|
||||
/// </summary>
|
||||
public int AttachmentCount { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the collection of attachment metadata (file details)
|
||||
/// </summary>
|
||||
public IReadOnlyList<AttachmentMetadata> Attachments { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the AttachmentInfo class.
|
||||
/// </summary>
|
||||
/// <param name="hasAttachments">Whether PDF has attachments</param>
|
||||
/// <param name="attachmentCount">Total number of attachments</param>
|
||||
/// <param name="attachments">List of attachment metadata (can be empty)</param>
|
||||
public AttachmentInfo(bool hasAttachments, int attachmentCount, IReadOnlyList<AttachmentMetadata> attachments)
|
||||
{
|
||||
HasAttachments = hasAttachments;
|
||||
AttachmentCount = attachmentCount;
|
||||
Attachments = attachments ?? [];
|
||||
|
||||
// Defensive Programming: Ensure count matches list length
|
||||
if (Attachments.Count != attachmentCount)
|
||||
{
|
||||
throw new ArgumentException(
|
||||
$"Attachment count mismatch: expected {attachmentCount}, got {Attachments.Count}",
|
||||
nameof(attachments));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates an AttachmentInfo instance for a PDF with no attachments.
|
||||
/// </summary>
|
||||
public static AttachmentInfo Empty =>
|
||||
new(
|
||||
hasAttachments: false,
|
||||
attachmentCount: 0,
|
||||
attachments: []);
|
||||
|
||||
public override string ToString()
|
||||
{
|
||||
return HasAttachments
|
||||
? $"PDF has {AttachmentCount} attachment(s): {string.Join(", ", Attachments.Select(a => a.FileName))}"
|
||||
: "PDF has no attachments";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
namespace DocumentService.Application.Common.DTOs;
|
||||
|
||||
/// <summary>
|
||||
/// Represents metadata of a single PDF attachment (embedded file).
|
||||
/// Immutable value object containing file information without the actual binary data.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Initializes a new instance of the AttachmentMetadata class.
|
||||
/// </remarks>
|
||||
/// <param name="fileName">Attachment file name</param>
|
||||
/// <param name="mimeType">MIME type (e.g., "text/xml")</param>
|
||||
/// <param name="sizeBytes">File size in bytes</param>
|
||||
public sealed class AttachmentMetadata(string fileName, string mimeType, long sizeBytes)
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the attachment file name (e.g., "invoice.xml", "document.pdf")
|
||||
/// </summary>
|
||||
public string FileName { get; } = fileName ?? string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the MIME type of the attachment (e.g., "text/xml", "application/pdf")
|
||||
/// </summary>
|
||||
public string MimeType { get; } = mimeType ?? "application/octet-stream"; // Default MIME type if unknown
|
||||
|
||||
/// <summary>
|
||||
/// Gets the attachment file size in bytes
|
||||
/// </summary>
|
||||
public long SizeBytes { get; } = sizeBytes;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the attachment file size in kilobytes (computed property)
|
||||
/// </summary>
|
||||
public double SizeKB => SizeBytes / 1024.0;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the attachment file size in megabytes (computed property)
|
||||
/// </summary>
|
||||
public double SizeMB => SizeBytes / 1024.0 / 1024.0;
|
||||
|
||||
public override string ToString()
|
||||
{
|
||||
return $"{FileName} ({MimeType}, {SizeKB:F2} KB)";
|
||||
}
|
||||
}
|
||||
34
DocumentOperator.Application/Common/DTOs/PdfAMetadata.cs
Normal file
34
DocumentOperator.Application/Common/DTOs/PdfAMetadata.cs
Normal file
@@ -0,0 +1,34 @@
|
||||
namespace DocumentService.Application.Common.DTOs;
|
||||
|
||||
/// <summary>
|
||||
/// PDF/A validation metadata including conformance level and validation errors/warnings
|
||||
/// </summary>
|
||||
public sealed class PdfAMetadata(
|
||||
bool isValid,
|
||||
string pdfVersion,
|
||||
int pageCount,
|
||||
long fileSizeBytes,
|
||||
bool encrypted,
|
||||
string? pdfaVersion,
|
||||
bool pdfaCompliant,
|
||||
IReadOnlyList<string> errors,
|
||||
IReadOnlyList<string> warnings)
|
||||
{
|
||||
public bool IsValid { get; } = isValid;
|
||||
public string PdfVersion { get; } = pdfVersion;
|
||||
public int PageCount { get; } = pageCount;
|
||||
public long FileSizeBytes { get; } = fileSizeBytes;
|
||||
public bool Encrypted { get; } = encrypted;
|
||||
public string? PdfAVersion { get; } = pdfaVersion;
|
||||
public bool PdfACompliant { get; } = pdfaCompliant;
|
||||
public IReadOnlyList<string> Errors { get; } = errors;
|
||||
public IReadOnlyList<string> Warnings { get; } = warnings;
|
||||
|
||||
// Computed property
|
||||
public double FileSizeMB => FileSizeBytes / 1024.0 / 1024.0;
|
||||
|
||||
public override string ToString()
|
||||
{
|
||||
return $"PDF/A: {PdfAVersion ?? "None"}, {PageCount} pages, {FileSizeMB:F2} MB, Compliant: {PdfACompliant}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
namespace DocumentService.Application.Common.DTOs;
|
||||
|
||||
/// <summary>
|
||||
/// PDF/A validation result including conformance level and validation errors/warnings
|
||||
/// </summary>
|
||||
public record PdfAValidationResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether the PDF is valid (no errors)
|
||||
/// </summary>
|
||||
public bool IsValid { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// PDF version (e.g., "1.4", "1.7")
|
||||
/// </summary>
|
||||
public string PdfVersion { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Number of pages in the PDF
|
||||
/// </summary>
|
||||
public int PageCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// File size in bytes
|
||||
/// </summary>
|
||||
public long FileSize { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the PDF is encrypted
|
||||
/// </summary>
|
||||
public bool Encrypted { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// PDF/A version (e.g., "PDF/A-1b", "PDF/A-2a", "PDF/A-3u") or null if not PDF/A compliant
|
||||
/// </summary>
|
||||
public string? PdfAVersion { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the PDF conforms to PDF/A standard
|
||||
/// </summary>
|
||||
public bool PdfACompliant { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Validation errors (e.g., "PDF/A documents cannot be encrypted")
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> Errors { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Validation warnings (e.g., "Manual verification recommended: All fonts must be embedded")
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> Warnings { get; init; } = [];
|
||||
}
|
||||
23
DocumentOperator.Application/Common/DTOs/PdfMetadata.cs
Normal file
23
DocumentOperator.Application/Common/DTOs/PdfMetadata.cs
Normal file
@@ -0,0 +1,23 @@
|
||||
namespace DocumentService.Application.Common.DTOs;
|
||||
|
||||
public sealed class PdfMetadata(
|
||||
int pageCount,
|
||||
long fileSizeBytes,
|
||||
string pdfVersion,
|
||||
bool hasAttachments,
|
||||
int attachmentCount)
|
||||
{
|
||||
public int PageCount { get; } = pageCount;
|
||||
public long FileSizeBytes { get; } = fileSizeBytes;
|
||||
public string PdfVersion { get; } = pdfVersion;
|
||||
public bool HasAttachments { get; } = hasAttachments;
|
||||
public int AttachmentCount { get; } = attachmentCount;
|
||||
|
||||
// Computed Property (berechnet aus FileSizeBytes)
|
||||
public double FileSizeMB => FileSizeBytes / 1024.0 / 1024.0;
|
||||
|
||||
public override string ToString()
|
||||
{
|
||||
return $"PDF: {PageCount} pages, {FileSizeMB:F2} MB, Version {PdfVersion}, Attachments: {AttachmentCount}";
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace DocumentOperator.Application.Common.DTOs;
|
||||
namespace DocumentService.Application.Common.DTOs;
|
||||
|
||||
/// <summary>
|
||||
/// Response mit PDF-Metadaten
|
||||
|
||||
93
DocumentOperator.Application/Common/DTOs/SwissQrBillDto.cs
Normal file
93
DocumentOperator.Application/Common/DTOs/SwissQrBillDto.cs
Normal file
@@ -0,0 +1,93 @@
|
||||
namespace DocumentService.Application.Common.DTOs;
|
||||
|
||||
/// <summary>
|
||||
/// Swiss QR Bill data transfer object (mapped from Codecrete Bill)
|
||||
/// </summary>
|
||||
public record SwissQrBillDto
|
||||
{
|
||||
/// <summary>QR Bill standard version (e.g., "V2_0")</summary>
|
||||
public required string Version { get; init; }
|
||||
|
||||
/// <summary>Payment amount (null if not specified)</summary>
|
||||
public decimal? Amount { get; init; }
|
||||
|
||||
/// <summary>Payment currency (CHF or EUR)</summary>
|
||||
public required string Currency { get; init; }
|
||||
|
||||
/// <summary>Creditor's IBAN account number</summary>
|
||||
public required string Account { get; init; }
|
||||
|
||||
/// <summary>Creditor address</summary>
|
||||
public required AddressDto Creditor { get; init; }
|
||||
|
||||
/// <summary>Payment reference type: "QRR", "SCOR", or "NON"</summary>
|
||||
public required string ReferenceType { get; init; }
|
||||
|
||||
/// <summary>Payment reference (format depends on ReferenceType)</summary>
|
||||
public string? Reference { get; init; }
|
||||
|
||||
/// <summary>Debtor address (optional)</summary>
|
||||
public AddressDto? Debtor { get; init; }
|
||||
|
||||
/// <summary>Additional unstructured message (max 140 chars)</summary>
|
||||
public string? UnstructuredMessage { get; init; }
|
||||
|
||||
/// <summary>Additional structured bill information</summary>
|
||||
public string? BillInformation { get; init; }
|
||||
|
||||
/// <summary>Alternative payment schemes (max 2)</summary>
|
||||
public IReadOnlyList<AlternativeSchemeDto>? AlternativeSchemes { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Address data transfer object (mapped from Codecrete Address)
|
||||
/// </summary>
|
||||
public record AddressDto
|
||||
{
|
||||
/// <summary>Address type: "Structured" or "CombinedElements"</summary>
|
||||
public required string Type { get; init; }
|
||||
|
||||
/// <summary>Name of person or company</summary>
|
||||
public required string Name { get; init; }
|
||||
|
||||
/// <summary>Street name (structured address only)</summary>
|
||||
public string? Street { get; init; }
|
||||
|
||||
/// <summary>House number (structured address only)</summary>
|
||||
public string? HouseNo { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Address line 1 (combined address only).
|
||||
/// OBSOLETE: Use structured address instead. Will be removed when Codecrete v4 is adopted.
|
||||
/// </summary>
|
||||
[Obsolete("Use structured address (Street + HouseNo) instead. This field will be removed when upgrading to Codecrete.SwissQRBill.Generator v4.x")]
|
||||
public string? AddressLine1 { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Address line 2 (combined address only).
|
||||
/// OBSOLETE: Use structured address instead. Will be removed when Codecrete v4 is adopted.
|
||||
/// </summary>
|
||||
[Obsolete("Use structured address (Street + HouseNo) instead. This field will be removed when upgrading to Codecrete.SwissQRBill.Generator v4.x")]
|
||||
public string? AddressLine2 { get; init; }
|
||||
|
||||
/// <summary>Postal code</summary>
|
||||
public required string PostalCode { get; init; }
|
||||
|
||||
/// <summary>Town/city name</summary>
|
||||
public required string Town { get; init; }
|
||||
|
||||
/// <summary>Two-letter country code (ISO 3166-1 alpha-2)</summary>
|
||||
public required string CountryCode { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Alternative payment scheme (mapped from Codecrete AlternativeScheme)
|
||||
/// </summary>
|
||||
public record AlternativeSchemeDto
|
||||
{
|
||||
/// <summary>Scheme name (e.g., "AV1", "AV2")</summary>
|
||||
public required string Name { get; init; }
|
||||
|
||||
/// <summary>Scheme instruction/parameter</summary>
|
||||
public required string Instruction { get; init; }
|
||||
}
|
||||
@@ -1,116 +1,37 @@
|
||||
namespace DocumentOperator.Application.Common.DTOs;
|
||||
namespace DocumentService.Application.Common.DTOs;
|
||||
|
||||
/// <summary>
|
||||
/// Response containing extracted Swiss QR Code data and passed-through references.
|
||||
/// Response containing extracted Swiss QR Code in dual format.
|
||||
/// </summary>
|
||||
/// <param name="References">Reference strings passed through from the request</param>
|
||||
/// <param name="QrCodeData">Parsed Swiss QR Code data from the last page of the PDF</param>
|
||||
/// <param name="Bill">Parsed Swiss QR Bill (structured format)</param>
|
||||
/// <param name="RawLines">Raw Swiss QR Code lines (original newline-separated format)</param>
|
||||
/// <example>
|
||||
/// {
|
||||
/// "references": ["REF-001", "REF-002"],
|
||||
/// "qrCodeData": {
|
||||
/// "qrType": "SPC",
|
||||
/// "version": "0200",
|
||||
/// "codingType": "1",
|
||||
/// "iban": "CH4431999123000889012",
|
||||
/// "creditor": {
|
||||
/// "addressType": "S",
|
||||
/// "name": "Robert Schneider AG",
|
||||
/// "street": "Rue du Lac",
|
||||
/// "buildingNumber": "1268",
|
||||
/// "postalCode": "2501",
|
||||
/// "city": "Biel",
|
||||
/// "country": "CH"
|
||||
/// },
|
||||
/// "amount": 1949.75,
|
||||
/// "bill": {
|
||||
/// "version": "V2_0",
|
||||
/// "amount": 630.20,
|
||||
/// "currency": "CHF",
|
||||
/// "account": "CH953000520280564701R",
|
||||
/// "creditor": {
|
||||
/// "type": "Structured",
|
||||
/// "name": "ALMAT AG",
|
||||
/// "town": "Tagelswangen"
|
||||
/// },
|
||||
/// "referenceType": "QRR",
|
||||
/// "reference": "210000000003139471430009017"
|
||||
/// }
|
||||
/// "reference": "000000000000000000252080824"
|
||||
/// },
|
||||
/// "rawLines": [
|
||||
/// "SPC",
|
||||
/// "0200",
|
||||
/// "1",
|
||||
/// "CH953000520280564701R",
|
||||
/// "S",
|
||||
/// "ALMAT AG",
|
||||
/// "..."
|
||||
/// ]
|
||||
/// }
|
||||
/// </example>
|
||||
public record SwissQrCodeExtractionResult(
|
||||
IReadOnlyList<string> References,
|
||||
SwissQrCodeDataDto QrCodeData
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
/// Swiss QR Code data according to Swiss QR Bill Standard 2.0.
|
||||
/// Contains all fields defined in the Swiss Payment Standards.
|
||||
/// </summary>
|
||||
public record SwissQrCodeDataDto(
|
||||
/// <summary>QR type - always "SPC" for Swiss Payment Code</summary>
|
||||
string QrType,
|
||||
|
||||
/// <summary>Version of the Swiss QR Code standard (e.g., "0200" for version 2.0)</summary>
|
||||
string Version,
|
||||
|
||||
/// <summary>Character set code (always "1" for UTF-8)</summary>
|
||||
string CodingType,
|
||||
|
||||
/// <summary>IBAN of the creditor (payee)</summary>
|
||||
string Iban,
|
||||
|
||||
/// <summary>Creditor (payee) information</summary>
|
||||
AddressDataDto Creditor,
|
||||
|
||||
/// <summary>Ultimate creditor information (optional)</summary>
|
||||
AddressDataDto? UltimateCreditor,
|
||||
|
||||
/// <summary>Payment amount (null if not specified)</summary>
|
||||
decimal? Amount,
|
||||
|
||||
/// <summary>Currency code (CHF or EUR)</summary>
|
||||
string Currency,
|
||||
|
||||
/// <summary>Ultimate debtor (payer) information (optional)</summary>
|
||||
AddressDataDto? UltimateDebtor,
|
||||
|
||||
/// <summary>Reference type: "QRR" (QR Reference), "SCOR" (Creditor Reference ISO 11649), or "NON" (No Reference)</summary>
|
||||
string ReferenceType,
|
||||
|
||||
/// <summary>Payment reference (format depends on ReferenceType)</summary>
|
||||
string? Reference,
|
||||
|
||||
/// <summary>Unstructured message (max 140 characters)</summary>
|
||||
string? UnstructuredMessage,
|
||||
|
||||
/// <summary>Bill information (structured data for automated processing)</summary>
|
||||
string? BillInformation,
|
||||
|
||||
/// <summary>Alternative procedure parameters (up to 2 entries)</summary>
|
||||
IReadOnlyList<string>? AlternativeProcedureParameters
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
/// Address data in Swiss QR Code (creditor or debtor).
|
||||
/// Can be either structured (S) or combined (K) format.
|
||||
/// </summary>
|
||||
public record AddressDataDto(
|
||||
/// <summary>Address type: "S" for structured, "K" for combined</summary>
|
||||
string AddressType,
|
||||
|
||||
/// <summary>Name of person or company</summary>
|
||||
string Name,
|
||||
|
||||
/// <summary>Street name (structured address only)</summary>
|
||||
string? Street,
|
||||
|
||||
/// <summary>Building number (structured address only)</summary>
|
||||
string? BuildingNumber,
|
||||
|
||||
/// <summary>Address line 1 (combined address only)</summary>
|
||||
string? AddressLine1,
|
||||
|
||||
/// <summary>Address line 2 (combined address only)</summary>
|
||||
string? AddressLine2,
|
||||
|
||||
/// <summary>Postal code</summary>
|
||||
string PostalCode,
|
||||
|
||||
/// <summary>City/town name</summary>
|
||||
string City,
|
||||
|
||||
/// <summary>Two-letter country code (ISO 3166-1 alpha-2)</summary>
|
||||
string Country
|
||||
SwissQrBillDto Bill,
|
||||
IReadOnlyList<string> RawLines
|
||||
);
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
namespace DocumentService.Application.Common.DTOs;
|
||||
|
||||
/// <summary>
|
||||
/// DTO for ZUGFeRD check result
|
||||
/// </summary>
|
||||
public record ZugferdCheckResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Indicates whether the PDF contains ZUGFeRD XML attachment
|
||||
/// </summary>
|
||||
public bool HasZugferd { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// ZUGFeRD XML file name (e.g., "factur-x.xml")
|
||||
/// </summary>
|
||||
public string? ZugferdFileName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// ZUGFeRD XML file size in bytes
|
||||
/// </summary>
|
||||
public long? ZugferdFileSize { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// MIME type of the ZUGFeRD XML file
|
||||
/// </summary>
|
||||
public string? ZugferdMimeType { get; init; }
|
||||
}
|
||||
@@ -1,16 +1,191 @@
|
||||
using DocumentOperator.Domain.Models.ValueObjects;
|
||||
using DocumentService.Application.Common.DTOs;
|
||||
|
||||
namespace DocumentOperator.Application.Common.Interfaces;
|
||||
namespace DocumentService.Application.Common.Interfaces;
|
||||
|
||||
public interface IPdfProcessor
|
||||
{
|
||||
/// <summary>
|
||||
/// Validates a PDF and extracts metadata.
|
||||
/// </summary>
|
||||
/// <param name="pdfBytes">PDF content as byte array</param>
|
||||
/// <param name="pdfStream">
|
||||
/// PDF document stream. Must be readable and positioned at the beginning (Position = 0).
|
||||
/// Non-seekable streams are supported. Caller is responsible for disposal.
|
||||
/// </param>
|
||||
/// <returns>PDF metadata (page count, size, version, attachments)</returns>
|
||||
/// <exception cref="Domain.Common.Exceptions.PdfProcessingException">
|
||||
/// Thrown when PDF is corrupted or cannot be processed
|
||||
/// <exception cref="Domain.Common.Exceptions.BadRequestException">
|
||||
/// Thrown when stream is empty, invalid, or not positioned at the beginning
|
||||
/// </exception>
|
||||
Task<PdfMetadata> ValidateAsync(byte[] pdfBytes);
|
||||
Task<PdfMetadata> ValidateAsync(Stream pdfStream);
|
||||
|
||||
/// <summary>
|
||||
/// Validates a PDF/A document and checks conformance level.
|
||||
/// </summary>
|
||||
/// <param name="pdfStream">
|
||||
/// PDF document stream. Must be readable and positioned at the beginning (Position = 0).
|
||||
/// Non-seekable streams are supported. Caller is responsible for disposal.
|
||||
/// </param>
|
||||
/// <returns>PDF/A metadata including conformance level and validation errors/warnings</returns>
|
||||
/// <exception cref="Domain.Common.Exceptions.BadRequestException">
|
||||
/// Thrown when stream is empty, invalid, or not positioned at the beginning
|
||||
/// </exception>
|
||||
Task<PdfAMetadata> ValidatePdfAAsync(Stream pdfStream);
|
||||
|
||||
/// <summary>
|
||||
/// Checks for embedded files (attachments) in a PDF document and returns detailed metadata.
|
||||
/// </summary>
|
||||
/// <param name="pdfStream">
|
||||
/// PDF document stream. Must be readable and positioned at the beginning (Position = 0).
|
||||
/// Non-seekable streams are supported. Caller is responsible for disposal.
|
||||
/// </param>
|
||||
/// <returns>Attachment information (count, file names, MIME types, sizes)</returns>
|
||||
/// <exception cref="Domain.Common.Exceptions.BadRequestException">
|
||||
/// Thrown when stream is empty, invalid, or not positioned at the beginning
|
||||
/// </exception>
|
||||
Task<AttachmentInfo> CheckAttachmentsAsync(Stream pdfStream);
|
||||
|
||||
/// <summary>
|
||||
/// Extracts all embedded files from a PDF document and returns them as a ZIP archive.
|
||||
/// </summary>
|
||||
/// <param name="pdfStream">
|
||||
/// PDF document stream. Must be readable and positioned at the beginning (Position = 0).
|
||||
/// Non-seekable streams are supported. Caller is responsible for disposal.
|
||||
/// </param>
|
||||
/// <returns>ZIP archive containing all extracted attachments as byte array</returns>
|
||||
/// <exception cref="Domain.Common.Exceptions.BadRequestException">
|
||||
/// Thrown when stream is empty, invalid, or not positioned at the beginning
|
||||
/// </exception>
|
||||
/// <exception cref="Domain.Common.Exceptions.NotFoundException">
|
||||
/// Thrown when PDF contains no attachments
|
||||
/// </exception>
|
||||
Task<byte[]> ExtractAttachmentsAsync(Stream pdfStream);
|
||||
|
||||
/// <summary>
|
||||
/// Merges multiple PDF documents into a single PDF.
|
||||
/// </summary>
|
||||
/// <param name="pdfStreams">
|
||||
/// PDF streams to merge (minimum 2 required). Each stream must be readable and positioned
|
||||
/// at the beginning (Position = 0). Caller is responsible for disposal.
|
||||
/// </param>
|
||||
/// <param name="pageRanges">
|
||||
/// Optional page ranges per PDF (null = all pages). Format: "1-3,5" means pages 1, 2, 3, and 5.
|
||||
/// If null or empty for a PDF, all pages are included. Array length must match pdfStreams length if provided.
|
||||
/// </param>
|
||||
/// <returns>Merged PDF as byte array</returns>
|
||||
/// <exception cref="Domain.Common.Exceptions.BadRequestException">
|
||||
/// Thrown when fewer than 2 PDFs provided, any stream is empty/invalid/not at Position=0,
|
||||
/// or page range format is invalid
|
||||
/// </exception>
|
||||
Task<byte[]> MergePdfsAsync(IReadOnlyList<Stream> pdfStreams, IReadOnlyList<string?>? pageRanges = null);
|
||||
|
||||
/// <summary>
|
||||
/// Adds an annotation to a PDF document at the specified page and rectangle.
|
||||
/// </summary>
|
||||
/// <param name="pdfStream">
|
||||
/// PDF document stream. Must be readable and positioned at the beginning (Position = 0).
|
||||
/// Non-seekable streams are supported. Caller is responsible for disposal.
|
||||
/// </param>
|
||||
/// <param name="annotationType">Type of annotation to add (TextMarkup, FreeText, StickyNote, Circle, Square)</param>
|
||||
/// <param name="pageNumber">Page number (1-based) where annotation should be added</param>
|
||||
/// <param name="rectangle">Annotation bounding rectangle (X1, Y1, X2, Y2)</param>
|
||||
/// <param name="content">Annotation content/comment text (required for FreeText and StickyNote)</param>
|
||||
/// <param name="author">Optional author name</param>
|
||||
/// <param name="color">Optional annotation color in RGB format (hex string like "FF0000" for red)</param>
|
||||
/// <param name="textMarkupStyle">Text markup style (Highlight, Underline, Strikeout) - only for TextMarkup type</param>
|
||||
/// <param name="origin">Coordinate origin (BottomLeft = PDF native, TopLeft = UI-friendly). Default: BottomLeft</param>
|
||||
/// <returns>Annotated PDF as byte array</returns>
|
||||
/// <exception cref="Domain.Common.Exceptions.BadRequestException">
|
||||
/// Thrown when stream is empty/invalid/not at Position=0, page number out of range,
|
||||
/// rectangle invalid, or content missing for types that require it
|
||||
/// </exception>
|
||||
Task<byte[]> AddAnnotationAsync(
|
||||
Stream pdfStream,
|
||||
Domain.Models.ValueObjects.AnnotationType annotationType,
|
||||
int pageNumber,
|
||||
(double X1, double Y1, double X2, double Y2) rectangle,
|
||||
string? content = null,
|
||||
string? author = null,
|
||||
string? color = null,
|
||||
Domain.Models.ValueObjects.TextMarkupStyle? textMarkupStyle = null,
|
||||
Domain.Models.ValueObjects.AnnotationOrigin origin = Domain.Models.ValueObjects.AnnotationOrigin.BottomLeft);
|
||||
|
||||
/// <summary>
|
||||
/// Adds a stamp (text, image, or predefined) to specified pages of a PDF document.
|
||||
/// </summary>
|
||||
/// <param name="pdfStream">Input PDF stream (must support reading and seeking)</param>
|
||||
/// <param name="stampType">Type of stamp (Text, Image, or Predefined)</param>
|
||||
/// <param name="pageNumbers">Target page numbers (1-based). Null = all pages.</param>
|
||||
/// <param name="position">Stamp position (X, Y coordinates)</param>
|
||||
/// <param name="size">Stamp size (Width, Height). Null = auto-size for images.</param>
|
||||
/// <param name="origin">Coordinate origin (BottomLeft or TopLeft)</param>
|
||||
/// <param name="text">Text content (required for Text stamps)</param>
|
||||
/// <param name="fontName">Font name (default: Arial)</param>
|
||||
/// <param name="fontSize">Font size in points (default: 12)</param>
|
||||
/// <param name="color">Hex color without # (e.g., "FF0000" for red, default: "000000")</param>
|
||||
/// <param name="opacity">Opacity 0.0 (transparent) to 1.0 (opaque, default: 0.5)</param>
|
||||
/// <param name="rotation">Rotation angle in degrees 0-360 (default: 0)</param>
|
||||
/// <param name="placement">Foreground (on top) or Background (behind content)</param>
|
||||
/// <param name="imageBytes">Image data (required for Image stamps, PNG/JPEG)</param>
|
||||
/// <param name="predefinedType">Predefined stamp type (required for Predefined stamps)</param>
|
||||
/// <returns>Stamped PDF as byte array</returns>
|
||||
/// <exception cref="BadRequestException">Invalid parameters (missing text/image, invalid page numbers, invalid opacity/rotation)</exception>
|
||||
/// <exception cref="PdfProcessingException">DevExpress processing error</exception>
|
||||
Task<byte[]> AddStampAsync(
|
||||
Stream pdfStream,
|
||||
Domain.Models.ValueObjects.StampType stampType,
|
||||
int[]? pageNumbers,
|
||||
(double X, double Y) position,
|
||||
(double Width, double Height)? size = null,
|
||||
Domain.Models.ValueObjects.AnnotationOrigin origin = Domain.Models.ValueObjects.AnnotationOrigin.BottomLeft,
|
||||
string? text = null,
|
||||
string? fontName = null,
|
||||
double? fontSize = null,
|
||||
string? color = null,
|
||||
double? opacity = null,
|
||||
double? rotation = null,
|
||||
Domain.Models.ValueObjects.StampPlacement placement = Domain.Models.ValueObjects.StampPlacement.Foreground,
|
||||
byte[]? imageBytes = null,
|
||||
Domain.Models.ValueObjects.PredefinedStampType? predefinedType = null);
|
||||
|
||||
/// <summary>
|
||||
/// Embeds one or more files as attachments in a PDF document (supports PDF/A-3).
|
||||
/// </summary>
|
||||
/// <param name="pdfStream">
|
||||
/// PDF document stream. Must be readable and positioned at the beginning (Position = 0).
|
||||
/// Non-seekable streams are supported. Caller is responsible for disposal.
|
||||
/// </param>
|
||||
/// <param name="attachments">List of files to embed (filename, content, optional MIME type)</param>
|
||||
/// <returns>PDF with embedded attachments as byte array</returns>
|
||||
/// <exception cref="Domain.Common.Exceptions.BadRequestException">
|
||||
/// Thrown when stream is empty/invalid, or attachments list is empty
|
||||
/// </exception>
|
||||
Task<byte[]> AddAttachmentsAsync(
|
||||
Stream pdfStream,
|
||||
IReadOnlyList<(string FileName, byte[] Content, string? MimeType)> attachments);
|
||||
|
||||
/// <summary>
|
||||
/// Converts a standard PDF to PDF/A format.
|
||||
/// </summary>
|
||||
/// <param name="pdfStream">
|
||||
/// PDF document stream. Must be readable and positioned at the beginning (Position = 0).
|
||||
/// Non-seekable streams are supported. Caller is responsible for disposal.
|
||||
/// </param>
|
||||
/// <param name="pdfALevel">Target PDF/A level (e.g., "PDF/A-1b", "PDF/A-2b", "PDF/A-3b")</param>
|
||||
/// <returns>PDF/A compliant document as byte array</returns>
|
||||
/// <exception cref="Domain.Common.Exceptions.BadRequestException">
|
||||
/// Thrown when stream is empty/invalid or PDF/A level is unsupported
|
||||
/// </exception>
|
||||
Task<byte[]> ConvertToPdfAAsync(Stream pdfStream, string pdfALevel);
|
||||
|
||||
/// <summary>
|
||||
/// Converts a PDF/A document to a standard PDF (removes PDF/A restrictions).
|
||||
/// </summary>
|
||||
/// <param name="pdfStream">
|
||||
/// PDF/A document stream. Must be readable and positioned at the beginning (Position = 0).
|
||||
/// Non-seekable streams are supported. Caller is responsible for disposal.
|
||||
/// </param>
|
||||
/// <returns>Standard PDF document as byte array</returns>
|
||||
/// <exception cref="Domain.Common.Exceptions.BadRequestException">
|
||||
/// Thrown when stream is empty/invalid
|
||||
/// </exception>
|
||||
Task<byte[]> ConvertFromPdfAAsync(Stream pdfStream);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
using DocumentOperator.Domain.ValueObjects;
|
||||
using Codecrete.SwissQRBill.Generator;
|
||||
|
||||
namespace DocumentOperator.Application.Common.Interfaces;
|
||||
namespace DocumentService.Application.Common.Interfaces;
|
||||
|
||||
/// <summary>
|
||||
/// Interface for Swiss QR Code processing operations.
|
||||
@@ -9,16 +9,24 @@ namespace DocumentOperator.Application.Common.Interfaces;
|
||||
public interface ISwissQrCodeProcessor
|
||||
{
|
||||
/// <summary>
|
||||
/// Extracts and parses Swiss QR Code from the last page of a PDF document.
|
||||
/// Extracts and parses Swiss QR Code from a PDF document.
|
||||
/// Returns both parsed Bill object and raw QR text lines.
|
||||
/// </summary>
|
||||
/// <param name="pdfBytes">PDF document as byte array</param>
|
||||
/// <param name="pdfStream">
|
||||
/// PDF document stream. Must be readable and positioned at the beginning (Position = 0).
|
||||
/// Non-seekable streams are supported. Caller is responsible for disposal.
|
||||
/// </param>
|
||||
/// <param name="pageNumbers">Optional: Specific page numbers to scan (1-indexed). If null, scans all pages starting with last page.</param>
|
||||
/// <param name="cancellationToken">Cancellation token</param>
|
||||
/// <returns>Parsed Swiss QR Code data</returns>
|
||||
/// <exception cref="Domain.Exceptions.SwissQrCodeNotFoundException">
|
||||
/// Thrown when no Swiss QR Code is found on the last page
|
||||
/// <returns>Tuple: (Parsed Codecrete Bill, Raw QR lines as string array)</returns>
|
||||
/// <exception cref="Domain.Common.Exceptions.NotFoundException">
|
||||
/// Thrown when no Swiss QR Code is found in the specified pages
|
||||
/// </exception>
|
||||
/// <exception cref="Domain.Exceptions.PdfProcessingException">
|
||||
/// Thrown when PDF processing fails
|
||||
/// <exception cref="ArgumentException">
|
||||
/// Thrown when stream is empty or (for seekable streams) not positioned at the beginning
|
||||
/// </exception>
|
||||
Task<SwissQrCodeData> ExtractSwissQrCodeAsync(byte[] pdfBytes, CancellationToken cancellationToken = default);
|
||||
Task<(Bill Bill, string[] RawLines)> ExtractSwissQrCodeAsync(
|
||||
Stream pdfStream,
|
||||
int[]? pageNumbers = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
using AutoMapper;
|
||||
using DocumentOperator.Application.Common.DTOs;
|
||||
using DocumentOperator.Domain.Models.ValueObjects;
|
||||
using DocumentOperator.Domain.ValueObjects;
|
||||
using Codecrete.SwissQRBill.Generator;
|
||||
using DocumentService.Application.Common.DTOs;
|
||||
using DocumentService.Domain.Models.ValueObjects;
|
||||
|
||||
namespace DocumentOperator.Application.Common.Mapping;
|
||||
namespace DocumentService.Application.Common.Mapping;
|
||||
|
||||
/// <summary>
|
||||
/// AutoMapper profile for mapping domain entities to DTOs
|
||||
/// AutoMapper profile for mapping domain entities and external models to DTOs.
|
||||
/// NOTE: Always use AutoMapper for all mappings in this project.
|
||||
/// </summary>
|
||||
public class MappingProfile : Profile
|
||||
{
|
||||
@@ -15,10 +16,39 @@ public class MappingProfile : Profile
|
||||
// PdfMetadata -> PdfValidationResult
|
||||
CreateMap<PdfMetadata, PdfValidationResult>();
|
||||
|
||||
// SwissQrCodeData -> SwissQrCodeDataDto
|
||||
CreateMap<SwissQrCodeData, SwissQrCodeDataDto>();
|
||||
// PdfAMetadata -> PdfAValidationResult
|
||||
CreateMap<PdfAMetadata, PdfAValidationResult>()
|
||||
.ForMember(dest => dest.FileSize, opt => opt.MapFrom(src => src.FileSizeBytes));
|
||||
|
||||
// AddressData -> AddressDataDto
|
||||
CreateMap<AddressData, AddressDataDto>();
|
||||
// Codecrete Bill -> SwissQrBillDto
|
||||
CreateMap<Bill, SwissQrBillDto>()
|
||||
.ForMember(dest => dest.Version, opt => opt.MapFrom(src => src.Version.ToString()))
|
||||
.ForMember(dest => dest.Currency, opt => opt.MapFrom(src => src.Currency ?? "CHF"))
|
||||
.ForMember(dest => dest.Account, opt => opt.MapFrom(src => src.Account ?? string.Empty))
|
||||
.ForMember(dest => dest.ReferenceType, opt => opt.MapFrom(src => src.ReferenceType ?? Bill.ReferenceTypeNoRef));
|
||||
|
||||
// Codecrete Address -> AddressDto
|
||||
CreateMap<Address, AddressDto>()
|
||||
.ForMember(dest => dest.Type, opt => opt.MapFrom(src => src.Type.ToString()))
|
||||
.ForMember(dest => dest.Name, opt => opt.MapFrom(src => src.Name ?? string.Empty))
|
||||
.ForMember(dest => dest.PostalCode, opt => opt.MapFrom(src => src.PostalCode ?? string.Empty))
|
||||
.ForMember(dest => dest.Town, opt => opt.MapFrom(src => src.Town ?? string.Empty))
|
||||
.ForMember(dest => dest.CountryCode, opt => opt.MapFrom(src => src.CountryCode ?? string.Empty))
|
||||
#pragma warning disable CS0618 // Suppress obsolete warning for AddressLine1/2 mapping
|
||||
.ForMember(dest => dest.AddressLine1, opt => opt.MapFrom(src => src.AddressLine1))
|
||||
.ForMember(dest => dest.AddressLine2, opt => opt.MapFrom(src => src.AddressLine2));
|
||||
#pragma warning restore CS0618
|
||||
|
||||
// Codecrete AlternativeScheme -> AlternativeSchemeDto
|
||||
CreateMap<AlternativeScheme, AlternativeSchemeDto>()
|
||||
.ForMember(dest => dest.Name, opt => opt.MapFrom(src => src.Name ?? string.Empty))
|
||||
.ForMember(dest => dest.Instruction, opt => opt.MapFrom(src => src.Instruction ?? string.Empty));
|
||||
|
||||
// AttachmentInfo -> AttachmentCheckResult
|
||||
CreateMap<AttachmentInfo, AttachmentCheckResult>();
|
||||
|
||||
// AttachmentMetadata -> AttachmentDto
|
||||
CreateMap<AttachmentMetadata, AttachmentDto>()
|
||||
.ForMember(dest => dest.Size, opt => opt.MapFrom(src => src.SizeBytes));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
using DocumentService.Application.Common.Interfaces;
|
||||
using FluentValidation;
|
||||
using MediatR;
|
||||
|
||||
namespace DocumentService.Application.ConvertFromPdfA;
|
||||
|
||||
/// <summary>
|
||||
/// Command to convert a PDF/A document to a standard PDF (removes PDF/A restrictions)
|
||||
/// </summary>
|
||||
public record ConvertFromPdfACommand : IRequest<byte[]>
|
||||
{
|
||||
/// <summary>
|
||||
/// PDF/A document stream. Must be positioned at the beginning (Position = 0).
|
||||
/// </summary>
|
||||
public required Stream PdfStream { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handler for ConvertFromPdfACommand
|
||||
/// </summary>
|
||||
public class ConvertFromPdfACommandHandler(IPdfProcessor pdfProcessor)
|
||||
: IRequestHandler<ConvertFromPdfACommand, byte[]>
|
||||
{
|
||||
public async Task<byte[]> Handle(ConvertFromPdfACommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
return await pdfProcessor.ConvertFromPdfAAsync(request.PdfStream);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validator for ConvertFromPdfACommand
|
||||
/// </summary>
|
||||
public class ConvertFromPdfACommandValidator : AbstractValidator<ConvertFromPdfACommand>
|
||||
{
|
||||
public ConvertFromPdfACommandValidator()
|
||||
{
|
||||
RuleFor(x => x.PdfStream)
|
||||
.NotNull()
|
||||
.WithMessage("PDF stream is required");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
using DocumentService.Application.Common.Interfaces;
|
||||
using FluentValidation;
|
||||
using MediatR;
|
||||
|
||||
namespace DocumentService.Application.ConvertToPdfA;
|
||||
|
||||
/// <summary>
|
||||
/// Command to convert a standard PDF to PDF/A format
|
||||
/// </summary>
|
||||
public record ConvertToPdfACommand : IRequest<byte[]>
|
||||
{
|
||||
/// <summary>
|
||||
/// PDF document stream. Must be positioned at the beginning (Position = 0).
|
||||
/// </summary>
|
||||
public required Stream PdfStream { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Target PDF/A level (e.g., "PDF/A-1b", "PDF/A-2b", "PDF/A-3b")
|
||||
/// </summary>
|
||||
public required string PdfALevel { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handler for ConvertToPdfACommand
|
||||
/// </summary>
|
||||
public class ConvertToPdfACommandHandler(IPdfProcessor pdfProcessor)
|
||||
: IRequestHandler<ConvertToPdfACommand, byte[]>
|
||||
{
|
||||
public async Task<byte[]> Handle(ConvertToPdfACommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
return await pdfProcessor.ConvertToPdfAAsync(request.PdfStream, request.PdfALevel);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validator for ConvertToPdfACommand
|
||||
/// </summary>
|
||||
public class ConvertToPdfACommandValidator : AbstractValidator<ConvertToPdfACommand>
|
||||
{
|
||||
private static readonly string[] ValidPdfALevels =
|
||||
{
|
||||
"PDF/A-1b", "PDF/A-1a",
|
||||
"PDF/A-2b", "PDF/A-2u", "PDF/A-2a",
|
||||
"PDF/A-3b", "PDF/A-3u", "PDF/A-3a"
|
||||
};
|
||||
|
||||
public ConvertToPdfACommandValidator()
|
||||
{
|
||||
RuleFor(x => x.PdfStream)
|
||||
.NotNull()
|
||||
.WithMessage("PDF stream is required");
|
||||
|
||||
RuleFor(x => x.PdfALevel)
|
||||
.NotEmpty()
|
||||
.WithMessage("PDF/A level is required")
|
||||
.Must(level => ValidPdfALevels.Contains(level, StringComparer.OrdinalIgnoreCase))
|
||||
.WithMessage($"Invalid PDF/A level. Valid values: {string.Join(", ", ValidPdfALevels)}");
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
using FluentValidation;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace DocumentOperator.Application;
|
||||
namespace DocumentService.Application;
|
||||
|
||||
/// <summary>
|
||||
/// Dependency Injection configuration for Application Layer
|
||||
@@ -11,13 +12,18 @@ public static class DependencyInjection
|
||||
/// <summary>
|
||||
/// Registers Application Layer services (MediatR, FluentValidation, AutoMapper, Behaviors)
|
||||
/// </summary>
|
||||
public static IServiceCollection AddApplication(this IServiceCollection services)
|
||||
public static IServiceCollection AddApplication(this IServiceCollection services, IConfiguration configuration)
|
||||
{
|
||||
var assembly = typeof(DependencyInjection).Assembly;
|
||||
|
||||
// Read LuckyPennySoft license key from appsettings.json
|
||||
var licenseKey = configuration.GetValue<string>("LuckyPennySoftLicenseKey")
|
||||
?? throw new InvalidOperationException("LuckyPennySoftLicenseKey not found in configuration");
|
||||
|
||||
// Register MediatR (scannt Assembly nach Handlers)
|
||||
services.AddMediatR(config =>
|
||||
{
|
||||
config.LicenseKey = licenseKey;
|
||||
config.RegisterServicesFromAssembly(assembly);
|
||||
|
||||
// Pipeline Behaviors (Reihenfolge wichtig!)
|
||||
@@ -29,7 +35,9 @@ public static class DependencyInjection
|
||||
services.AddValidatorsFromAssembly(assembly);
|
||||
|
||||
// Register AutoMapper (scannt Assembly nach Profiles)
|
||||
services.AddAutoMapper(cfg => { }, typeof(Common.Mapping.MappingProfile));
|
||||
services.AddAutoMapper(cfg => {
|
||||
cfg.LicenseKey = licenseKey;
|
||||
}, typeof(Common.Mapping.MappingProfile));
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
@@ -7,23 +7,30 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="AutoMapper" Version="16.2.0" />
|
||||
<PackageReference Include="FluentValidation" Version="12.1.1" />
|
||||
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="12.1.1" />
|
||||
<PackageReference Include="MediatR" Version="14.1.0" />
|
||||
<Compile Remove="DependencyInjection\**" />
|
||||
<Compile Remove="Features\**" />
|
||||
<EmbeddedResource Remove="DependencyInjection\**" />
|
||||
<EmbeddedResource Remove="Features\**" />
|
||||
<None Remove="DependencyInjection\**" />
|
||||
<None Remove="Features\**" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\DocumentOperator.Domain\DocumentOperator.Domain.csproj" />
|
||||
<PackageReference Include="AutoMapper" Version="16.2.0" />
|
||||
<PackageReference Include="Codecrete.SwissQRBill.Generator" Version="3.4.0" />
|
||||
<PackageReference Include="FluentValidation" Version="12.1.1" />
|
||||
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="12.1.1" />
|
||||
<PackageReference Include="MediatR" Version="14.1.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.10" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.10" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\DocumentOperator.Domain\DocumentService.Domain.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Folder Include="Common\Mappings\" />
|
||||
<Folder Include="DependencyInjection\" />
|
||||
<Folder Include="Features\Documents\ExtractAttachments\" />
|
||||
<Folder Include="Features\Documents\ConcatenatePdfs\" />
|
||||
<Folder Include="Features\Documents\ApplyStamp\" />
|
||||
<Folder Include="Features\Documents\EmbedCertificate\" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,43 @@
|
||||
using DocumentService.Application.Common.Interfaces;
|
||||
using FluentValidation;
|
||||
using MediatR;
|
||||
|
||||
namespace DocumentService.Application.ExtractPdfAttachments;
|
||||
|
||||
/// <summary>
|
||||
/// Command to extract all embedded files from a PDF document and return as ZIP archive.
|
||||
/// </summary>
|
||||
public record ExtractPdfAttachmentsCommand : IRequest<byte[]>
|
||||
{
|
||||
/// <summary>
|
||||
/// PDF document stream. Must be positioned at the beginning (Position = 0).
|
||||
/// </summary>
|
||||
public required Stream PdfStream { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handler for ExtractPdfAttachmentsCommand.
|
||||
/// Extracts all embedded files from PDF and returns as ZIP archive.
|
||||
/// </summary>
|
||||
public class ExtractPdfAttachmentsCommandHandler(IPdfProcessor pdfProcessor)
|
||||
: IRequestHandler<ExtractPdfAttachmentsCommand, byte[]>
|
||||
{
|
||||
public async Task<byte[]> Handle(ExtractPdfAttachmentsCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
// Delegate to infrastructure layer
|
||||
return await pdfProcessor.ExtractAttachmentsAsync(request.PdfStream);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validator for ExtractPdfAttachmentsCommand.
|
||||
/// </summary>
|
||||
public class ExtractPdfAttachmentsCommandValidator : AbstractValidator<ExtractPdfAttachmentsCommand>
|
||||
{
|
||||
public ExtractPdfAttachmentsCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.PdfStream)
|
||||
.NotNull()
|
||||
.WithMessage("PDF stream is required");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
using DocumentService.Application.Common.Configuration;
|
||||
using DocumentService.Application.Common.Interfaces;
|
||||
using DocumentService.Domain.Common.Exceptions;
|
||||
using FluentValidation;
|
||||
using MediatR;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace DocumentService.Application.ExtractZugferd;
|
||||
|
||||
/// <summary>
|
||||
/// Command to extract ZUGFeRD XML from a PDF document
|
||||
/// </summary>
|
||||
public record ExtractZugferdCommand : IRequest<ZugferdExtractionResult>
|
||||
{
|
||||
/// <summary>
|
||||
/// PDF document stream. Must be positioned at the beginning (Position = 0).
|
||||
/// </summary>
|
||||
public required Stream PdfStream { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handler for ExtractZugferdCommand.
|
||||
/// Extracts ZUGFeRD XML from PDF and returns XML content
|
||||
/// </summary>
|
||||
public class ExtractZugferdCommandHandler(
|
||||
IPdfProcessor pdfProcessor,
|
||||
IOptions<ZugferdSettings> settings)
|
||||
: IRequestHandler<ExtractZugferdCommand, ZugferdExtractionResult>
|
||||
{
|
||||
private readonly ZugferdSettings _settings = settings.Value;
|
||||
|
||||
public async Task<ZugferdExtractionResult> 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
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validator for ExtractZugferdCommand.
|
||||
/// </summary>
|
||||
public class ExtractZugferdCommandValidator : AbstractValidator<ExtractZugferdCommand>
|
||||
{
|
||||
public ExtractZugferdCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.PdfStream)
|
||||
.NotNull()
|
||||
.WithMessage("PDF stream is required");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result DTO for ZUGFeRD extraction
|
||||
/// </summary>
|
||||
public record ZugferdExtractionResult
|
||||
{
|
||||
/// <summary>
|
||||
/// ZUGFeRD XML file name
|
||||
/// </summary>
|
||||
public required string FileName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// ZUGFeRD XML content as string
|
||||
/// </summary>
|
||||
public required string XmlContent { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// File size in bytes
|
||||
/// </summary>
|
||||
public long FileSize { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
using DocumentService.Application.Common.Configuration;
|
||||
using DocumentService.Application.Common.DTOs;
|
||||
using DocumentService.Application.Common.Interfaces;
|
||||
using MediatR;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace DocumentService.Application.HasZugferd.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// Query for checking if PDF contains ZUGFeRD XML attachment
|
||||
/// </summary>
|
||||
public record HasZugferdQuery : IRequest<ZugferdCheckResult>
|
||||
{
|
||||
/// <summary>
|
||||
/// PDF as stream (caller is responsible for disposal)
|
||||
/// </summary>
|
||||
public required Stream PdfStream { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handler for HasZugferdQuery
|
||||
/// Checks if PDF contains ZUGFeRD/Factur-X XML attachment
|
||||
/// </summary>
|
||||
public class HasZugferdQueryHandler(
|
||||
IPdfProcessor pdfProcessor,
|
||||
IOptions<ZugferdSettings> settings)
|
||||
: IRequestHandler<HasZugferdQuery, ZugferdCheckResult>
|
||||
{
|
||||
private readonly ZugferdSettings _settings = settings.Value;
|
||||
|
||||
/// <summary>
|
||||
/// Checks if PDF contains ZUGFeRD XML and returns metadata
|
||||
/// </summary>
|
||||
public async Task<ZugferdCheckResult> 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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
using FluentValidation;
|
||||
|
||||
namespace DocumentService.Application.HasZugferd.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// Validator for HasZugferdQuery
|
||||
/// </summary>
|
||||
public class HasZugferdQueryValidator : AbstractValidator<HasZugferdQuery>
|
||||
{
|
||||
public HasZugferdQueryValidator()
|
||||
{
|
||||
RuleFor(x => x.PdfStream)
|
||||
.NotNull()
|
||||
.WithMessage("PDF stream is required");
|
||||
}
|
||||
}
|
||||
53
DocumentOperator.Application/MergePdfs/MergePdfsCommand.cs
Normal file
53
DocumentOperator.Application/MergePdfs/MergePdfsCommand.cs
Normal file
@@ -0,0 +1,53 @@
|
||||
using DocumentService.Application.Common.Interfaces;
|
||||
using FluentValidation;
|
||||
using MediatR;
|
||||
|
||||
namespace DocumentService.Application.MergePdfs;
|
||||
|
||||
/// <summary>
|
||||
/// Command to merge multiple PDF documents into a single PDF.
|
||||
/// </summary>
|
||||
public record MergePdfsCommand : IRequest<byte[]>
|
||||
{
|
||||
/// <summary>
|
||||
/// PDF streams to merge. Minimum 2 required. Each must be at Position=0.
|
||||
/// </summary>
|
||||
public required IReadOnlyList<Stream> PdfStreams { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional page ranges per PDF (null = all pages).
|
||||
/// Format: "1-3,5" means pages 1, 2, 3, and 5.
|
||||
/// If provided, array length must match PdfStreams length.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string?>? PageRanges { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handler for MergePdfsCommand.
|
||||
/// Delegates PDF merge operation to infrastructure layer.
|
||||
/// </summary>
|
||||
public class MergePdfsCommandHandler(IPdfProcessor pdfProcessor)
|
||||
: IRequestHandler<MergePdfsCommand, byte[]>
|
||||
{
|
||||
public async Task<byte[]> Handle(MergePdfsCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
return await pdfProcessor.MergePdfsAsync(request.PdfStreams, request.PageRanges);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validator for MergePdfsCommand.
|
||||
/// </summary>
|
||||
public class MergePdfsCommandValidator : AbstractValidator<MergePdfsCommand>
|
||||
{
|
||||
public MergePdfsCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.PdfStreams)
|
||||
.NotNull()
|
||||
.WithMessage("PDF streams are required");
|
||||
|
||||
RuleFor(x => x.PdfStreams)
|
||||
.Must(streams => streams != null && streams.Count >= 2)
|
||||
.WithMessage("At least 2 PDF files are required for merging");
|
||||
}
|
||||
}
|
||||
@@ -1,29 +1,19 @@
|
||||
using AutoMapper;
|
||||
using DocumentOperator.Application.Common.DTOs;
|
||||
using DocumentOperator.Application.Common.Interfaces;
|
||||
using DocumentService.Application.Common.DTOs;
|
||||
using DocumentService.Application.Common.Interfaces;
|
||||
using MediatR;
|
||||
|
||||
namespace DocumentOperator.Application.SwissQrCode.Queries;
|
||||
namespace DocumentService.Application.SwissQrCode.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// Query for extracting Swiss QR Code from PDF (supports both byte array and Base64 input)
|
||||
/// Query for extracting Swiss QR Code from PDF (Stream-based)
|
||||
/// </summary>
|
||||
public record ExtractSwissQrCodeQuery : IRequest<SwissQrCodeExtractionResult>
|
||||
{
|
||||
/// <summary>
|
||||
/// PDF as byte array (direct upload)
|
||||
/// PDF as stream (caller is responsible for disposal)
|
||||
/// </summary>
|
||||
public byte[]? PdfBytes { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// PDF as Base64 string (API clients)
|
||||
/// </summary>
|
||||
public string? Base64Pdf { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional reference strings (passed through to response for external tracking)
|
||||
/// </summary>
|
||||
public IReadOnlyList<string>? References { get; init; }
|
||||
public required Stream PdfStream { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -34,23 +24,21 @@ public class ExtractSwissQrCodeQueryHandler(ISwissQrCodeProcessor qrCodeProcesso
|
||||
: IRequestHandler<ExtractSwissQrCodeQuery, SwissQrCodeExtractionResult>
|
||||
{
|
||||
/// <summary>
|
||||
/// Extracts and parses Swiss QR Code from the last page of the PDF
|
||||
/// Extracts and parses Swiss QR Code from the PDF (default: scans all pages starting with last)
|
||||
/// Returns both parsed Bill DTO and raw QR text lines
|
||||
/// </summary>
|
||||
public async Task<SwissQrCodeExtractionResult> Handle(ExtractSwissQrCodeQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
// Use byte[] if available, otherwise convert Base64
|
||||
byte[] pdfBytes = request.PdfBytes ?? Convert.FromBase64String(request.Base64Pdf!);
|
||||
// Extract: returns (Bill, RawLines) - pass stream directly
|
||||
var (bill, rawLines) = await qrCodeProcessor.ExtractSwissQrCodeAsync(request.PdfStream, pageNumbers: null, cancellationToken);
|
||||
|
||||
// Extract and parse Swiss QR Code from last page (can throw PdfProcessingException or QrCodeNotFoundException)
|
||||
var qrCodeData = await qrCodeProcessor.ExtractSwissQrCodeAsync(pdfBytes, cancellationToken);
|
||||
// Map Codecrete Bill to DTO using AutoMapper
|
||||
var billDto = mapper.Map<SwissQrBillDto>(bill);
|
||||
|
||||
// Map domain value object to DTO using AutoMapper
|
||||
var qrCodeDto = mapper.Map<SwissQrCodeDataDto>(qrCodeData);
|
||||
|
||||
// Return references (passed through) + QR code data
|
||||
// Return references (passed through) + Bill DTO + raw lines
|
||||
return new SwissQrCodeExtractionResult(
|
||||
References: request.References ?? [],
|
||||
QrCodeData: qrCodeDto
|
||||
Bill: billDto,
|
||||
RawLines: rawLines
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,59 +1,19 @@
|
||||
using DocumentOperator.Application.SwissQrCode.Queries;
|
||||
using DocumentService.Application.SwissQrCode.Queries;
|
||||
using FluentValidation;
|
||||
|
||||
namespace DocumentOperator.Application.SwissQrCode.Queries;
|
||||
namespace DocumentService.Application.SwissQrCode.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// Validates ExtractSwissQrCodeQuery before handler execution.
|
||||
/// Ensures exactly ONE input method is provided (either PdfBytes OR Base64Pdf, not both, not none).
|
||||
/// Ensures PdfStream is not null.
|
||||
/// </summary>
|
||||
public sealed class ExtractSwissQrCodeQueryValidator : AbstractValidator<ExtractSwissQrCodeQuery>
|
||||
{
|
||||
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;
|
||||
}
|
||||
// Rule: PdfStream must be provided
|
||||
RuleFor(x => x.PdfStream)
|
||||
.NotNull()
|
||||
.WithMessage("PdfStream is required");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,24 +1,19 @@
|
||||
using AutoMapper;
|
||||
using DocumentOperator.Application.Common.DTOs;
|
||||
using DocumentOperator.Application.Common.Interfaces;
|
||||
using DocumentService.Application.Common.DTOs;
|
||||
using DocumentService.Application.Common.Interfaces;
|
||||
using MediatR;
|
||||
|
||||
namespace DocumentOperator.Application.ValidatePdf.Queries;
|
||||
namespace DocumentService.Application.ValidatePdf.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// Query for PDF validation (supports both byte array and Base64 input)
|
||||
/// Query for PDF validation (Stream-based)
|
||||
/// </summary>
|
||||
public record ValidatePdfQuery : IRequest<PdfValidationResult>
|
||||
{
|
||||
/// <summary>
|
||||
/// PDF as byte array (direct upload)
|
||||
/// PDF as stream (caller is responsible for disposal)
|
||||
/// </summary>
|
||||
public byte[]? PdfBytes { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// PDF as Base64 string (API clients)
|
||||
/// </summary>
|
||||
public string? Base64Pdf { get; init; }
|
||||
public required Stream PdfStream { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -33,13 +28,10 @@ public class ValidatePdfQueryHandler(IPdfProcessor PdfProcessor, IMapper Mapper)
|
||||
/// </summary>
|
||||
public async Task<PdfValidationResult> Handle(ValidatePdfQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
// Use byte[] if available, otherwise convert Base64
|
||||
byte[] pdfBytes = request.PdfBytes ?? Convert.FromBase64String(request.Base64Pdf!);
|
||||
// Call DevExpress service directly with stream (exceptions propagate naturally)
|
||||
var metadata = await PdfProcessor.ValidateAsync(request.PdfStream);
|
||||
|
||||
// Call DevExpress service (can throw PdfProcessingException)
|
||||
var metadata = await PdfProcessor.ValidateAsync(pdfBytes);
|
||||
|
||||
// Map domain entity to DTO using AutoMapper
|
||||
// Map DTO to response DTO using AutoMapper
|
||||
return Mapper.Map<PdfValidationResult>(metadata);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,59 +1,19 @@
|
||||
using DocumentOperator.Application.ValidatePdf.Queries;
|
||||
using DocumentService.Application.ValidatePdf.Queries;
|
||||
using FluentValidation;
|
||||
|
||||
namespace DocumentOperator.Application.ValidatePdf.Queries;
|
||||
namespace DocumentService.Application.ValidatePdf.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// Validator for ValidatePdfQuery
|
||||
/// Ensures exactly ONE input method is provided (either PdfBytes OR Base64Pdf, not both, not none)
|
||||
/// Ensures PdfStream is not null
|
||||
/// </summary>
|
||||
public class ValidatePdfQueryValidator : AbstractValidator<ValidatePdfQuery>
|
||||
{
|
||||
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;
|
||||
}
|
||||
// Rule: PdfStream must be provided
|
||||
RuleFor(x => x.PdfStream)
|
||||
.NotNull()
|
||||
.WithMessage("PdfStream is required");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
using AutoMapper;
|
||||
using DocumentService.Application.Common.DTOs;
|
||||
using DocumentService.Application.Common.Interfaces;
|
||||
using MediatR;
|
||||
|
||||
namespace DocumentService.Application.ValidatePdfA.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// Query for PDF/A validation (Stream-based)
|
||||
/// </summary>
|
||||
public record ValidatePdfAQuery : IRequest<PdfAValidationResult>
|
||||
{
|
||||
/// <summary>
|
||||
/// PDF as stream (caller is responsible for disposal)
|
||||
/// </summary>
|
||||
public required Stream PdfStream { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handler for ValidatePdfAQuery
|
||||
/// Orchestrates PDF/A validation using IPdfProcessor and AutoMapper
|
||||
/// </summary>
|
||||
public class ValidatePdfAQueryHandler(IPdfProcessor PdfProcessor, IMapper Mapper)
|
||||
: IRequestHandler<ValidatePdfAQuery, PdfAValidationResult>
|
||||
{
|
||||
/// <summary>
|
||||
/// Validates PDF/A and returns metadata with conformance level
|
||||
/// </summary>
|
||||
public async Task<PdfAValidationResult> Handle(ValidatePdfAQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
// Call DevExpress service directly with stream (exceptions propagate naturally)
|
||||
var metadata = await PdfProcessor.ValidatePdfAAsync(request.PdfStream);
|
||||
|
||||
// Map DTO to response DTO using AutoMapper
|
||||
return Mapper.Map<PdfAValidationResult>(metadata);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
using FluentValidation;
|
||||
|
||||
namespace DocumentService.Application.ValidatePdfA.Validators;
|
||||
|
||||
/// <summary>
|
||||
/// Validator for ValidatePdfAQuery
|
||||
/// Ensures PdfStream is not null
|
||||
/// </summary>
|
||||
public class ValidatePdfAQueryValidator : AbstractValidator<Queries.ValidatePdfAQuery>
|
||||
{
|
||||
public ValidatePdfAQueryValidator()
|
||||
{
|
||||
// Rule: PdfStream must be provided
|
||||
RuleFor(x => x.PdfStream)
|
||||
.NotNull()
|
||||
.WithMessage("PdfStream is required");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Runtime.Serialization;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace DocumentService.Domain.Common.Exceptions;
|
||||
|
||||
public class BadRequestException : Exception
|
||||
{
|
||||
public BadRequestException()
|
||||
{
|
||||
}
|
||||
|
||||
public BadRequestException(string? message) : base(message)
|
||||
{
|
||||
}
|
||||
|
||||
public BadRequestException(string? message, Exception? innerException) : base(message, innerException)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
namespace DocumentOperator.Domain.Common.Exceptions;
|
||||
namespace DocumentService.Domain.Common.Exceptions;
|
||||
|
||||
/// <summary>
|
||||
/// Base exception for all domain-related exceptions.
|
||||
/// Caught by the Exception Handling Middleware in the API layer.
|
||||
/// </summary>
|
||||
[Obsolete("This exception is deprecated. Use more specific exceptions for domain errors.")]
|
||||
public abstract class DomainException : Exception
|
||||
{
|
||||
/// <summary>
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
namespace DocumentOperator.Domain.Common.Exceptions;
|
||||
namespace DocumentService.Domain.Common.Exceptions;
|
||||
|
||||
/// <summary>
|
||||
/// Exception thrown when domain validation fails (e.g., invalid Value Objects).
|
||||
/// Maps to HTTP 400 Bad Request in the API layer.
|
||||
/// </summary>
|
||||
[Obsolete("This exception is deprecated. Use more specific exceptions for domain validation errors.")]
|
||||
public class DomainValidationException : DomainException
|
||||
{
|
||||
public string PropertyName { get; }
|
||||
|
||||
@@ -1,25 +1,22 @@
|
||||
namespace DocumentOperator.Domain.Common.Exceptions;
|
||||
using System.Runtime.Serialization;
|
||||
|
||||
namespace DocumentService.Domain.Common.Exceptions;
|
||||
|
||||
/// <summary>
|
||||
/// Exception thrown when a requested resource is not found.
|
||||
/// Maps to HTTP 404 Not Found in the API layer.
|
||||
/// </summary>
|
||||
public class NotFoundException : DomainException
|
||||
public class NotFoundException : Exception
|
||||
{
|
||||
public string ResourceType { get; }
|
||||
public object ResourceId { get; }
|
||||
|
||||
public NotFoundException(string resourceType, object resourceId)
|
||||
: base($"{resourceType} with ID '{resourceId}' was not found.", "RESOURCE_NOT_FOUND")
|
||||
public NotFoundException()
|
||||
{
|
||||
ResourceType = resourceType;
|
||||
ResourceId = resourceId;
|
||||
}
|
||||
|
||||
public NotFoundException(string resourceType, object resourceId, string customMessage)
|
||||
: base(customMessage, "RESOURCE_NOT_FOUND")
|
||||
public NotFoundException(string? message) : base(message)
|
||||
{
|
||||
}
|
||||
|
||||
public NotFoundException(string? message, Exception? innerException) : base(message, innerException)
|
||||
{
|
||||
ResourceType = resourceType;
|
||||
ResourceId = resourceId;
|
||||
}
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
namespace DocumentOperator.Domain.Common.Exceptions;
|
||||
|
||||
/// <summary>
|
||||
/// Exception thrown when PDF processing operations fail.
|
||||
/// Maps to HTTP 500 Internal Server Error or 422 Unprocessable Entity in the API layer.
|
||||
/// </summary>
|
||||
public class PdfProcessingException : DomainException
|
||||
{
|
||||
public string Operation { get; }
|
||||
|
||||
public PdfProcessingException(string operation, string message)
|
||||
: base($"PDF processing failed during '{operation}': {message}", "PDF_PROCESSING_ERROR")
|
||||
{
|
||||
Operation = operation;
|
||||
}
|
||||
|
||||
public PdfProcessingException(string operation, string message, Exception innerException)
|
||||
: base($"PDF processing failed during '{operation}': {message}", "PDF_PROCESSING_ERROR", innerException)
|
||||
{
|
||||
Operation = operation;
|
||||
}
|
||||
|
||||
public PdfProcessingException(string message)
|
||||
: base(message, "PDF_PROCESSING_ERROR")
|
||||
{
|
||||
Operation = "Unknown";
|
||||
}
|
||||
|
||||
public PdfProcessingException(string message, Exception innerException)
|
||||
: base(message, "PDF_PROCESSING_ERROR", innerException)
|
||||
{
|
||||
Operation = "Unknown";
|
||||
}
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Folder Include="Common\Results\" />
|
||||
<Folder Include="Constants\" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
18
DocumentOperator.Domain/DocumentService.Domain.csproj
Normal file
18
DocumentOperator.Domain/DocumentService.Domain.csproj
Normal file
@@ -0,0 +1,18 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Compile Remove="Common\Results\**" />
|
||||
<Compile Remove="Constants\**" />
|
||||
<EmbeddedResource Remove="Common\Results\**" />
|
||||
<EmbeddedResource Remove="Constants\**" />
|
||||
<None Remove="Common\Results\**" />
|
||||
<None Remove="Constants\**" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -1,22 +0,0 @@
|
||||
namespace DocumentOperator.Domain.Exceptions;
|
||||
|
||||
/// <summary>
|
||||
/// Exception thrown when a Swiss QR Code cannot be found in a PDF document.
|
||||
/// </summary>
|
||||
public sealed class SwissQrCodeNotFoundException : Exception
|
||||
{
|
||||
public SwissQrCodeNotFoundException()
|
||||
: base("No Swiss QR Code found on the last page of the PDF document.")
|
||||
{
|
||||
}
|
||||
|
||||
public SwissQrCodeNotFoundException(string message)
|
||||
: base(message)
|
||||
{
|
||||
}
|
||||
|
||||
public SwissQrCodeNotFoundException(string message, Exception innerException)
|
||||
: base(message, innerException)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
namespace DocumentOperator.Domain.Models.Enums;
|
||||
|
||||
public enum DocumentOperationType
|
||||
{
|
||||
Validate,
|
||||
ExtractAttachments,
|
||||
Concatenate,
|
||||
ApplyStamp,
|
||||
EmbedCertificate
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
namespace DocumentOperator.Domain.Models.Enums;
|
||||
|
||||
public enum ProcessingStatus
|
||||
{
|
||||
Pending,
|
||||
Processing,
|
||||
Success,
|
||||
Failed
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
namespace DocumentService.Domain.Models.ValueObjects;
|
||||
|
||||
/// <summary>
|
||||
/// Coordinate origin point for PDF annotations.
|
||||
/// PDF default is BottomLeft, but users may prefer TopLeft for easier UI integration.
|
||||
/// </summary>
|
||||
public enum AnnotationOrigin
|
||||
{
|
||||
/// <summary>
|
||||
/// Bottom-left corner (PDF native coordinate system, default)
|
||||
/// </summary>
|
||||
BottomLeft,
|
||||
|
||||
/// <summary>
|
||||
/// Top-left corner (common in UI frameworks)
|
||||
/// </summary>
|
||||
TopLeft
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
namespace DocumentService.Domain.Models.ValueObjects;
|
||||
|
||||
/// <summary>
|
||||
/// Supported PDF annotation types
|
||||
/// </summary>
|
||||
public enum AnnotationType
|
||||
{
|
||||
/// <summary>
|
||||
/// Text markup annotation (highlight, underline, strikeout)
|
||||
/// </summary>
|
||||
TextMarkup,
|
||||
|
||||
/// <summary>
|
||||
/// Free text annotation (text box with visible text)
|
||||
/// </summary>
|
||||
FreeText,
|
||||
|
||||
/// <summary>
|
||||
/// Sticky note annotation (popup comment icon)
|
||||
/// </summary>
|
||||
StickyNote,
|
||||
|
||||
/// <summary>
|
||||
/// Circle shape annotation
|
||||
/// </summary>
|
||||
Circle,
|
||||
|
||||
/// <summary>
|
||||
/// Square shape annotation
|
||||
/// </summary>
|
||||
Square
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
namespace DocumentOperator.Domain.Models.ValueObjects;
|
||||
|
||||
public sealed class PdfMetadata
|
||||
{
|
||||
public int PageCount { get; }
|
||||
public long FileSizeBytes { get; }
|
||||
public string PdfVersion { get; }
|
||||
public bool HasAttachments { get; }
|
||||
public int AttachmentCount { get; }
|
||||
|
||||
// Computed Property (berechnet aus FileSizeBytes)
|
||||
public double FileSizeMB => FileSizeBytes / 1024.0 / 1024.0;
|
||||
|
||||
public PdfMetadata(
|
||||
int pageCount,
|
||||
long fileSizeBytes,
|
||||
string pdfVersion,
|
||||
bool hasAttachments,
|
||||
int attachmentCount)
|
||||
{
|
||||
PageCount = pageCount;
|
||||
FileSizeBytes = fileSizeBytes;
|
||||
PdfVersion = pdfVersion;
|
||||
HasAttachments = hasAttachments;
|
||||
AttachmentCount = attachmentCount;
|
||||
}
|
||||
|
||||
public override string ToString()
|
||||
{
|
||||
return $"PDF: {PageCount} pages, {FileSizeMB:F2} MB, Version {PdfVersion}, Attachments: {AttachmentCount}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
namespace DocumentService.Domain.Models.ValueObjects;
|
||||
|
||||
/// <summary>
|
||||
/// Predefined stamp types with standard text and styling.
|
||||
/// </summary>
|
||||
public enum PredefinedStampType
|
||||
{
|
||||
/// <summary>
|
||||
/// CONFIDENTIAL stamp (red, bold).
|
||||
/// </summary>
|
||||
Confidential,
|
||||
|
||||
/// <summary>
|
||||
/// APPROVED stamp (green, bold).
|
||||
/// </summary>
|
||||
Approved,
|
||||
|
||||
/// <summary>
|
||||
/// DRAFT stamp (gray, italic).
|
||||
/// </summary>
|
||||
Draft,
|
||||
|
||||
/// <summary>
|
||||
/// VOID stamp (red, strikethrough effect).
|
||||
/// </summary>
|
||||
Void,
|
||||
|
||||
/// <summary>
|
||||
/// FOR REVIEW stamp (orange, bold).
|
||||
/// </summary>
|
||||
ForReview
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace DocumentService.Domain.Models.ValueObjects;
|
||||
|
||||
/// <summary>
|
||||
/// Specifies whether the stamp should appear in the foreground (on top of content) or background (behind content).
|
||||
/// </summary>
|
||||
public enum StampPlacement
|
||||
{
|
||||
/// <summary>
|
||||
/// Stamp appears on top of existing page content.
|
||||
/// </summary>
|
||||
Foreground,
|
||||
|
||||
/// <summary>
|
||||
/// Stamp appears behind existing page content (watermark effect).
|
||||
/// </summary>
|
||||
Background
|
||||
}
|
||||
22
DocumentOperator.Domain/Models/ValueObjects/StampType.cs
Normal file
22
DocumentOperator.Domain/Models/ValueObjects/StampType.cs
Normal file
@@ -0,0 +1,22 @@
|
||||
namespace DocumentService.Domain.Models.ValueObjects;
|
||||
|
||||
/// <summary>
|
||||
/// Specifies the type of stamp to add to a PDF document.
|
||||
/// </summary>
|
||||
public enum StampType
|
||||
{
|
||||
/// <summary>
|
||||
/// Text-based stamp with custom text, font, and color.
|
||||
/// </summary>
|
||||
Text,
|
||||
|
||||
/// <summary>
|
||||
/// Image-based stamp (PNG/JPEG overlay).
|
||||
/// </summary>
|
||||
Image,
|
||||
|
||||
/// <summary>
|
||||
/// Predefined stamp with standard text (e.g., CONFIDENTIAL, APPROVED).
|
||||
/// </summary>
|
||||
Predefined
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace DocumentOperator.Domain.Models.ValueObjects;
|
||||
namespace DocumentService.Domain.Models.ValueObjects;
|
||||
|
||||
public sealed class TenantId
|
||||
{
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
namespace DocumentService.Domain.Models.ValueObjects;
|
||||
|
||||
/// <summary>
|
||||
/// Text markup annotation style (highlight, underline, strikeout)
|
||||
/// Maps to DevExpress PdfTextMarkupAnnotationType
|
||||
/// </summary>
|
||||
public enum TextMarkupStyle
|
||||
{
|
||||
/// <summary>
|
||||
/// Highlight text with background color
|
||||
/// </summary>
|
||||
Highlight,
|
||||
|
||||
/// <summary>
|
||||
/// Underline text
|
||||
/// </summary>
|
||||
Underline,
|
||||
|
||||
/// <summary>
|
||||
/// Strikeout text (strikethrough)
|
||||
/// </summary>
|
||||
Strikeout
|
||||
}
|
||||
@@ -1,129 +0,0 @@
|
||||
namespace DocumentOperator.Domain.ValueObjects;
|
||||
|
||||
/// <summary>
|
||||
/// Represents Swiss QR Code data according to Swiss QR Bill Standard 2.0.
|
||||
/// Immutable value object containing all fields from a Swiss QR payment part.
|
||||
/// </summary>
|
||||
public sealed record SwissQrCodeData
|
||||
{
|
||||
/// <summary>
|
||||
/// QR type - always "SPC" for Swiss Payment Code
|
||||
/// </summary>
|
||||
public required string QrType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Version of the Swiss QR Code standard (e.g., "0200" for version 2.0)
|
||||
/// </summary>
|
||||
public required string Version { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Character set code (always "1" for UTF-8)
|
||||
/// </summary>
|
||||
public required string CodingType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// IBAN of the creditor (payee)
|
||||
/// </summary>
|
||||
public required string Iban { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Creditor (payee) information
|
||||
/// </summary>
|
||||
public required AddressData Creditor { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Ultimate creditor information (optional)
|
||||
/// </summary>
|
||||
public AddressData? UltimateCreditor { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Payment amount (null if not specified)
|
||||
/// </summary>
|
||||
public decimal? Amount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Currency code (CHF or EUR)
|
||||
/// </summary>
|
||||
public required string Currency { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Ultimate debtor (payer) information (optional)
|
||||
/// </summary>
|
||||
public AddressData? UltimateDebtor { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Reference type (QRR, SCOR, or NON)
|
||||
/// </summary>
|
||||
public required string ReferenceType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Payment reference (format depends on ReferenceType)
|
||||
/// </summary>
|
||||
public string? Reference { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Unstructured message (max 140 characters)
|
||||
/// </summary>
|
||||
public string? UnstructuredMessage { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Bill information (structured data for automated processing)
|
||||
/// </summary>
|
||||
public string? BillInformation { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Alternative procedure parameters (up to 2 entries)
|
||||
/// </summary>
|
||||
public IReadOnlyList<string>? AlternativeProcedureParameters { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents address data in Swiss QR Code (creditor or debtor)
|
||||
/// </summary>
|
||||
public sealed record AddressData
|
||||
{
|
||||
/// <summary>
|
||||
/// Address type: "S" for structured, "K" for combined
|
||||
/// </summary>
|
||||
public required string AddressType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Name of person or company
|
||||
/// </summary>
|
||||
public required string Name { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Street name (structured address only)
|
||||
/// </summary>
|
||||
public string? Street { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Building number (structured address only)
|
||||
/// </summary>
|
||||
public string? BuildingNumber { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Address line 1 (combined address only)
|
||||
/// </summary>
|
||||
public string? AddressLine1 { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Address line 2 (combined address only)
|
||||
/// </summary>
|
||||
public string? AddressLine2 { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Postal code
|
||||
/// </summary>
|
||||
public required string PostalCode { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// City/town name
|
||||
/// </summary>
|
||||
public required string City { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Two-letter country code (ISO 3166-1 alpha-2)
|
||||
/// </summary>
|
||||
public required string Country { get; init; }
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace DocumentOperator.Infrastructure.Configuration;
|
||||
namespace DocumentService.Infrastructure.Configuration;
|
||||
|
||||
public class ApiKeySettings
|
||||
{
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
namespace DocumentOperator.Infrastructure.Configuration;
|
||||
namespace DocumentService.Infrastructure.Configuration;
|
||||
|
||||
public class DocumentOperatorSettings
|
||||
public class DocumentServiceSettings
|
||||
{
|
||||
public const string SectionName = "DocumentOperatorSettings";
|
||||
public const string SectionName = "DocumentServiceSettings";
|
||||
|
||||
public string TempFolderPath { get; set; } = string.Empty;
|
||||
public int TempFileRetentionHours { get; set; }
|
||||
@@ -1,10 +1,10 @@
|
||||
namespace DocumentOperator.Infrastructure.Configuration;
|
||||
namespace DocumentService.Infrastructure.Configuration;
|
||||
|
||||
public class RedisSettings
|
||||
{
|
||||
public const string SectionName = "RedisSettings";
|
||||
|
||||
public string ConnectionString { get; set; } = "localhost:6379";
|
||||
public string InstanceName { get; set; } = "DocumentOperator:";
|
||||
public string InstanceName { get; set; } = "DocumentService:";
|
||||
public int CacheExpirationMinutes { get; set; } = 60;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace DocumentOperator.Infrastructure.Configuration;
|
||||
namespace DocumentService.Infrastructure.Configuration;
|
||||
|
||||
public class TenantInfo
|
||||
{
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
using DocumentOperator.Application.Common.Interfaces;
|
||||
using DocumentOperator.Infrastructure.Services.PdfProcessing;
|
||||
using DocumentOperator.Infrastructure.Services.QrCodeProcessing;
|
||||
using DocumentService.Application.Common.Interfaces;
|
||||
using DocumentService.Infrastructure.Services.PdfProcessing;
|
||||
using DocumentService.Infrastructure.Services.QrCodeProcessing;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace DocumentOperator.Infrastructure;
|
||||
namespace DocumentService.Infrastructure;
|
||||
|
||||
/// <summary>
|
||||
/// Dependency Injection configuration for Infrastructure Layer
|
||||
|
||||
@@ -6,23 +6,29 @@
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Compile Remove="DependencyInjection\**" />
|
||||
<Compile Remove="Services\DocumentValidation\**" />
|
||||
<Compile Remove="Services\FileStorage\**" />
|
||||
<EmbeddedResource Remove="DependencyInjection\**" />
|
||||
<EmbeddedResource Remove="Services\DocumentValidation\**" />
|
||||
<EmbeddedResource Remove="Services\FileStorage\**" />
|
||||
<None Remove="DependencyInjection\**" />
|
||||
<None Remove="Services\DocumentValidation\**" />
|
||||
<None Remove="Services\FileStorage\**" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Codecrete.SwissQRBill.Generator" Version="3.4.0" />
|
||||
<PackageReference Include="DevExpress.Document.Processor" Version="26.1.3" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="8.0.0" />
|
||||
<PackageReference Include="SkiaSharp.QrCode" Version="1.0.0" />
|
||||
<PackageReference Include="System.Drawing.Common" Version="10.0.9" />
|
||||
<PackageReference Include="ZXing.Net.Bindings.Windows.Compatibility" Version="0.16.14" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\DocumentOperator.Application\DocumentOperator.Application.csproj" />
|
||||
<ProjectReference Include="..\DocumentOperator.Domain\DocumentOperator.Domain.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Folder Include="DependencyInjection\" />
|
||||
<Folder Include="Services\FileStorage\" />
|
||||
<Folder Include="Services\DocumentValidation\" />
|
||||
<ProjectReference Include="..\DocumentOperator.Application\DocumentService.Application.csproj" />
|
||||
<ProjectReference Include="..\DocumentOperator.Domain\DocumentService.Domain.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,195 +1,165 @@
|
||||
using Codecrete.SwissQRBill.Generator;
|
||||
using DevExpress.Drawing;
|
||||
using DevExpress.Pdf;
|
||||
using DocumentOperator.Application.Common.Interfaces;
|
||||
using DocumentOperator.Domain.Exceptions;
|
||||
using DocumentOperator.Domain.ValueObjects;
|
||||
using System.Drawing;
|
||||
using System.Runtime.Versioning;
|
||||
using ZXing;
|
||||
using DocumentService.Application.Common.Interfaces;
|
||||
using DocumentService.Domain.Common.Exceptions;
|
||||
using SkiaSharp;
|
||||
using SkiaSharp.QrCode;
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
namespace DocumentOperator.Infrastructure.Services.QrCodeProcessing;
|
||||
namespace DocumentService.Infrastructure.Services.QrCodeProcessing;
|
||||
|
||||
/// <summary>
|
||||
/// Swiss QR Code processor using DevExpress PDF API for PDF access
|
||||
/// and Codecrete.SwissQRBill.Generator for QR Code parsing.
|
||||
/// Swiss QR Code processor using DevExpress PDF API for image extraction,
|
||||
/// SkiaSharp.QrCode for QR decoding, and Codecrete.SwissQRBill.Generator for Swiss QR parsing.
|
||||
///
|
||||
/// Strategy:
|
||||
/// 1. Extract all embedded images from PDF using GetDXImages()
|
||||
/// 2. Try to decode QR code from each image using SkiaSharp.QrCode
|
||||
/// 3. Parse Swiss QR format with Codecrete library
|
||||
/// 4. Return both parsed Bill and raw QR text lines
|
||||
/// </summary>
|
||||
public sealed class DevExpressSwissQrCodeProcessor : ISwissQrCodeProcessor
|
||||
{
|
||||
private const int QrCodeSearchDpi = 300; // High DPI for better QR code recognition
|
||||
|
||||
/// <inheritdoc />
|
||||
[SupportedOSPlatform("windows")]
|
||||
public async Task<SwissQrCodeData> ExtractSwissQrCodeAsync(byte[] pdfBytes, CancellationToken cancellationToken = default)
|
||||
public async Task<(Bill Bill, string[] RawLines)> ExtractSwissQrCodeAsync(
|
||||
Stream pdfStream,
|
||||
int[]? pageNumbers = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(pdfBytes);
|
||||
ArgumentNullException.ThrowIfNull(pdfStream, nameof(pdfStream));
|
||||
|
||||
if (pdfStream.Length == 0)
|
||||
throw new ArgumentException("PDF stream is empty.", nameof(pdfStream));
|
||||
|
||||
try
|
||||
// Defensive validation: Seekable streams must be at Position = 0
|
||||
// Non-seekable streams (e.g., NetworkStream) are not checked
|
||||
if (pdfStream.CanSeek && pdfStream.Position != 0)
|
||||
throw new BadRequestException(null, new ArgumentException(
|
||||
"PDF stream must be positioned at the beginning (Position = 0).",
|
||||
nameof(pdfStream)));
|
||||
|
||||
using var pdfDocument = new PdfDocumentProcessor();
|
||||
pdfDocument.LoadDocument(pdfStream);
|
||||
|
||||
if (pdfDocument.Document.Pages.Count == 0)
|
||||
throw new ArgumentException("PDF document contains no pages.", nameof(pdfStream));
|
||||
|
||||
// Determine which pages to scan
|
||||
int[] pagesToScan = DeterminePageNumbers(pdfDocument.Document.Pages.Count, pageNumbers);
|
||||
|
||||
// Extract all images from specified pages (no pre-filtering)
|
||||
var allImages = new ConcurrentBag<(int pageNumber, DXBitmap image)>();
|
||||
|
||||
foreach (int pageNumber in pagesToScan)
|
||||
{
|
||||
using var pdfDocument = new PdfDocumentProcessor();
|
||||
pdfDocument.LoadDocument(new MemoryStream(pdfBytes));
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
if (pdfDocument.Document.Pages.Count == 0)
|
||||
// Define area: entire page
|
||||
var page = pdfDocument.Document.Pages[pageNumber - 1];
|
||||
var area = new PdfDocumentArea(pageNumber,
|
||||
new PdfRectangle(0, 0, page.CropBox.Width, page.CropBox.Height));
|
||||
|
||||
// Extract images from this page
|
||||
var images = pdfDocument.GetDXImages(area);
|
||||
|
||||
foreach (var image in images)
|
||||
{
|
||||
throw new ArgumentException("PDF document contains no pages.", nameof(pdfBytes));
|
||||
// Collect ALL images - QRCodeDecoder will determine if it's a QR code
|
||||
allImages.Add((pageNumber, image));
|
||||
}
|
||||
}
|
||||
|
||||
// Get last page
|
||||
int lastPageIndex = pdfDocument.Document.Pages.Count - 1;
|
||||
if (allImages.IsEmpty)
|
||||
throw new NotFoundException($"No images found in pages: {string.Join(", ", pagesToScan)}");
|
||||
|
||||
// Convert last page to image for QR code detection
|
||||
using var pageImage = RenderPageToImage(pdfDocument, lastPageIndex);
|
||||
|
||||
// Detect and decode QR code
|
||||
string? qrCodeContent = DecodeQrCodeFromImage(pageImage);
|
||||
|
||||
if (string.IsNullOrEmpty(qrCodeContent))
|
||||
// Parallel scan all images
|
||||
var qrCodeTasks = allImages.Select(imageData =>
|
||||
Task.Run(() =>
|
||||
{
|
||||
throw new SwissQrCodeNotFoundException(
|
||||
$"No QR Code found on the last page (page {lastPageIndex + 1}) of the PDF document.");
|
||||
}
|
||||
using (imageData.image)
|
||||
{
|
||||
string? qrText = DecodeQrCodeFromImage(imageData.image);
|
||||
if (!string.IsNullOrEmpty(qrText))
|
||||
{
|
||||
try
|
||||
{
|
||||
// Parse with Codecrete
|
||||
var bill = QRBill.DecodeQrCodeText(qrText);
|
||||
|
||||
// Parse Swiss QR Bill content using Codecrete library
|
||||
SwissQrCodeData qrCodeData = ParseSwissQrBillContent(qrCodeContent);
|
||||
// Split raw text into lines (handle both \r\n and \n)
|
||||
// Remove leading/trailing \r and \n from each line
|
||||
var rawLines = qrText
|
||||
.Split(["\r\n", "\n"], StringSplitOptions.None)
|
||||
.Select(line => line.Trim('\r', '\n'))
|
||||
.ToArray();
|
||||
|
||||
return await Task.FromResult(qrCodeData);
|
||||
}
|
||||
catch (SwissQrCodeNotFoundException)
|
||||
return (success: true, bill, rawLines, imageData.pageNumber);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Not a valid Swiss QR Bill, ignore
|
||||
return (success: false, bill: (Bill?)null, rawLines: (string[]?)null, pageNumber: 0);
|
||||
}
|
||||
}
|
||||
return (success: false, bill: (Bill?)null, rawLines: (string[]?)null, pageNumber: 0);
|
||||
}
|
||||
}, cancellationToken)
|
||||
).ToList();
|
||||
|
||||
// Wait for all tasks and find first valid Swiss QR
|
||||
var results = await Task.WhenAll(qrCodeTasks);
|
||||
var validResult = results.FirstOrDefault(r => r.success);
|
||||
|
||||
if (validResult.success && validResult.bill != null)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
throw new ArgumentException("Failed to extract Swiss QR Code from PDF.", nameof(pdfBytes), ex);
|
||||
return (validResult.bill, validResult.rawLines!);
|
||||
}
|
||||
|
||||
throw new NotFoundException(
|
||||
$"No valid Swiss QR Code found in {allImages.Count} images across pages: {string.Join(", ", pagesToScan)}.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Renders a PDF page to a high-resolution bitmap for QR code detection
|
||||
/// Determines which pages to scan based on optional page numbers parameter.
|
||||
/// Default: Last page first, then all pages in reverse order.
|
||||
/// </summary>
|
||||
private static DXBitmap RenderPageToImage(PdfDocumentProcessor processor, int pageIndex)
|
||||
private static int[] DeterminePageNumbers(int totalPages, int[]? pageNumbers)
|
||||
{
|
||||
// Render page at high DPI for better QR code recognition
|
||||
var pageImage = processor.CreateDXBitmap(pageIndex + 1, QrCodeSearchDpi);
|
||||
return pageImage;
|
||||
if (pageNumbers != null && pageNumbers.Length > 0)
|
||||
{
|
||||
// Validate page numbers
|
||||
foreach (int pageNum in pageNumbers)
|
||||
if (pageNum < 1 || pageNum > totalPages)
|
||||
throw new ArgumentException(
|
||||
$"Invalid page number {pageNum}. Document has {totalPages} pages.",
|
||||
nameof(pageNumbers));
|
||||
return pageNumbers;
|
||||
}
|
||||
|
||||
// Default: Scan all pages, last page first (Swiss QR standard)
|
||||
return [.. Enumerable.Range(1, totalPages).OrderByDescending(p => p)];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Decodes QR code from a DXBitmap image using ZXing library
|
||||
/// Decodes QR code from a DXBitmap image using SkiaSharp.QrCode.
|
||||
/// Converts DXBitmap to SKBitmap and attempts decoding.
|
||||
/// </summary>
|
||||
[SupportedOSPlatform("windows")]
|
||||
private static string? DecodeQrCodeFromImage(DXBitmap dxImage)
|
||||
{
|
||||
// Convert DXBitmap to System.Drawing.Bitmap via MemoryStream
|
||||
// Convert DXBitmap to SKBitmap via MemoryStream (PNG format)
|
||||
using var ms = new MemoryStream();
|
||||
dxImage.Save(ms, DXImageFormat.Png);
|
||||
ms.Position = 0;
|
||||
|
||||
using var gdiImage = Image.FromStream(ms);
|
||||
using var gdiBitmap = new Bitmap(gdiImage);
|
||||
// Decode using SkiaSharp.QrCode
|
||||
using var skBitmap = SKBitmap.Decode(ms);
|
||||
if (skBitmap == null)
|
||||
return null;
|
||||
|
||||
var reader = new ZXing.Windows.Compatibility.BarcodeReader
|
||||
{
|
||||
AutoRotate = true,
|
||||
Options = new ZXing.Common.DecodingOptions
|
||||
{
|
||||
PossibleFormats = [BarcodeFormat.QR_CODE],
|
||||
TryHarder = true,
|
||||
TryInverted = true
|
||||
}
|
||||
};
|
||||
|
||||
var result = reader.Decode(gdiBitmap);
|
||||
return result?.Text;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses Swiss QR Bill content using Codecrete library
|
||||
/// </summary>
|
||||
private static SwissQrCodeData ParseSwissQrBillContent(string qrCodeText)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Decode Swiss QR Bill using Codecrete library
|
||||
var bill = QRBill.DecodeQrCodeText(qrCodeText);
|
||||
|
||||
// Determine reference type based on presence and format of reference
|
||||
string referenceType = DetermineReferenceType(bill.Reference);
|
||||
|
||||
// Map AlternativeSchemes to string array
|
||||
var alternativeParams = bill.AlternativeSchemes?
|
||||
.Select(s => $"{s.Name}: {s.Instruction}")
|
||||
.ToArray();
|
||||
|
||||
// Map to our domain value object
|
||||
return new SwissQrCodeData
|
||||
{
|
||||
QrType = "SPC", // Always SPC for Swiss Payment Code
|
||||
Version = bill.Version.ToString("D4"), // e.g., "0200" for version 2.0
|
||||
CodingType = "1", // Always UTF-8
|
||||
Iban = bill.Account ?? string.Empty,
|
||||
Creditor = MapAddress(bill.Creditor),
|
||||
UltimateCreditor = null, // Not exposed in Codecrete Bill model
|
||||
Amount = bill.Amount,
|
||||
Currency = bill.Currency ?? "CHF",
|
||||
UltimateDebtor = bill.Debtor != null ? MapAddress(bill.Debtor) : null,
|
||||
ReferenceType = referenceType,
|
||||
Reference = bill.Reference,
|
||||
UnstructuredMessage = bill.UnstructuredMessage,
|
||||
BillInformation = bill.BillInformation,
|
||||
AlternativeProcedureParameters = alternativeParams
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
throw new ArgumentException(
|
||||
"Failed to parse Swiss QR Code content. The QR code may not be a valid Swiss QR Bill.", "qrCodeText", ex);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determines reference type based on reference string format
|
||||
/// </summary>
|
||||
private static string DetermineReferenceType(string? reference)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(reference))
|
||||
return "NON";
|
||||
|
||||
// QRR (QR Reference): 27 digits
|
||||
if (reference.Length == 27 && reference.All(char.IsDigit))
|
||||
return "QRR";
|
||||
|
||||
// SCOR (Creditor Reference ISO 11649): starts with RF and has check digits
|
||||
if (reference.StartsWith("RF", StringComparison.OrdinalIgnoreCase) && reference.Length >= 5)
|
||||
return "SCOR";
|
||||
|
||||
return "NON";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maps Codecrete Address to our AddressData value object.
|
||||
///
|
||||
/// NOTE: AddressLine1 and AddressLine2 (Combined Address / K-Type) are deprecated
|
||||
/// as of Swiss Payment Standards 2025 (effective 21 Nov 2025).
|
||||
/// The Swiss QR Bill now mandates Structured Address (S-Type) format.
|
||||
/// These fields are retained for backward compatibility with legacy QR codes
|
||||
/// generated before the deprecation date.
|
||||
/// </summary>
|
||||
private static AddressData MapAddress(Codecrete.SwissQRBill.Generator.Address address)
|
||||
{
|
||||
return new AddressData
|
||||
{
|
||||
AddressType = address.Type == Codecrete.SwissQRBill.Generator.Address.AddressType.Structured ? "S" : "K",
|
||||
Name = address.Name ?? string.Empty,
|
||||
Street = address.Street,
|
||||
BuildingNumber = address.HouseNo,
|
||||
#pragma warning disable CS0618 // AddressLine1/AddressLine2 obsolete but required for backward compatibility
|
||||
AddressLine1 = address.AddressLine1,
|
||||
AddressLine2 = address.AddressLine2,
|
||||
#pragma warning restore CS0618
|
||||
PostalCode = address.PostalCode ?? string.Empty,
|
||||
City = address.Town ?? string.Empty,
|
||||
Country = address.CountryCode ?? string.Empty
|
||||
};
|
||||
// TryDecode returns true if QR code found and decoded successfully
|
||||
bool success = QRCodeDecoder.TryDecode(skBitmap, out var text, out _);
|
||||
return success ? text : null;
|
||||
}
|
||||
}
|
||||
|
||||
16
DocumentOperator.Infrastructure/Services/StringExtensions.cs
Normal file
16
DocumentOperator.Infrastructure/Services/StringExtensions.cs
Normal file
@@ -0,0 +1,16 @@
|
||||
using System.Xml.Serialization;
|
||||
|
||||
namespace DocumentService.Infrastructure.Services;
|
||||
|
||||
public static class StringExtensions
|
||||
{
|
||||
public static T? DeserializeXml<T>(this string xmlText)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(xmlText))
|
||||
return default;
|
||||
|
||||
var serializer = new XmlSerializer(typeof(T));
|
||||
using var reader = new StringReader(xmlText);
|
||||
return (T?)serializer.Deserialize(reader);
|
||||
}
|
||||
}
|
||||
@@ -39,10 +39,11 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\DocumentOperator.API\DocumentOperator.API.csproj" />
|
||||
<ProjectReference Include="..\DocumentOperator.Domain\DocumentOperator.Domain.csproj" />
|
||||
<ProjectReference Include="..\DocumentOperator.Application\DocumentOperator.Application.csproj" />
|
||||
<ProjectReference Include="..\DocumentOperator.Infrastructure\DocumentOperator.Infrastructure.csproj" />
|
||||
<ProjectReference Include="..\DocumentOperator.API\DocumentService.API.csproj" />
|
||||
<ProjectReference Include="..\DocumentOperator.Domain\DocumentService.Domain.csproj" />
|
||||
<ProjectReference Include="..\DocumentOperator.Application\DocumentService.Application.csproj" />
|
||||
<ProjectReference Include="..\DocumentOperator.Infrastructure\DocumentService.Infrastructure.csproj" />
|
||||
<ProjectReference Include="..\DocumentService.Client\DocumentService.Client.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -1,5 +1,5 @@
|
||||
using DocumentOperator.Application.Common.DTOs;
|
||||
using DocumentOperator.Application.SwissQrCode.Queries;
|
||||
using DocumentService.API.Controllers; // For ExtractSwissQrCodeBase64Request DTO
|
||||
using DocumentService.Application.Common.DTOs;
|
||||
using FluentAssertions;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using System.Net;
|
||||
@@ -7,7 +7,7 @@ using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using Xunit;
|
||||
|
||||
namespace DocumentOperator.Tests.Integration.API;
|
||||
namespace DocumentService.Tests.Integration.API;
|
||||
|
||||
public sealed class ExtractSwissQrCodeEndpointTests : IClassFixture<WebApplicationFactory<Program>>
|
||||
{
|
||||
@@ -27,11 +27,10 @@ public sealed class ExtractSwissQrCodeEndpointTests : IClassFixture<WebApplicati
|
||||
public async Task POST_ExtractSwissQrCode_ValidRequest_Returns200()
|
||||
{
|
||||
// Arrange
|
||||
var validPdfBase64 = GetEmbeddedResourceAsBase64("DocumentOperator.Tests.TestData.Pdfs.valid.pdf");
|
||||
var validPdfBase64 = GetEmbeddedResourceAsBase64("DocumentService.Tests.TestData.Pdfs.valid.pdf");
|
||||
|
||||
var request = new ExtractSwissQrCodeQuery
|
||||
var request = new ExtractSwissQrCodeBase64Request
|
||||
{
|
||||
References = new List<string> { "REF-001", "REF-002" },
|
||||
Base64Pdf = validPdfBase64
|
||||
};
|
||||
|
||||
@@ -48,8 +47,8 @@ public sealed class ExtractSwissQrCodeEndpointTests : IClassFixture<WebApplicati
|
||||
{
|
||||
var result = await response.Content.ReadFromJsonAsync<SwissQrCodeExtractionResult>(_jsonOptions);
|
||||
result.Should().NotBeNull();
|
||||
result!.References.Should().BeEquivalentTo(new[] { "REF-001", "REF-002" });
|
||||
result.QrCodeData.Should().NotBeNull();
|
||||
result.Bill.Should().NotBeNull();
|
||||
result.RawLines.Should().NotBeEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,9 +56,8 @@ public sealed class ExtractSwissQrCodeEndpointTests : IClassFixture<WebApplicati
|
||||
public async Task POST_ExtractSwissQrCode_InvalidBase64_Returns400()
|
||||
{
|
||||
// Arrange
|
||||
var request = new ExtractSwissQrCodeQuery
|
||||
var request = new ExtractSwissQrCodeBase64Request
|
||||
{
|
||||
References = new List<string> { "REF-001" },
|
||||
Base64Pdf = "INVALID_BASE64!!!"
|
||||
};
|
||||
|
||||
@@ -68,6 +66,10 @@ public sealed class ExtractSwissQrCodeEndpointTests : IClassFixture<WebApplicati
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
|
||||
|
||||
var problemDetails = await response.Content.ReadAsStringAsync();
|
||||
// FormatException message contains "Base-64" (with hyphen)
|
||||
problemDetails.Should().MatchRegex("(?i)base.?64", "should contain Base64 validation error");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -75,7 +77,7 @@ public sealed class ExtractSwissQrCodeEndpointTests : IClassFixture<WebApplicati
|
||||
{
|
||||
// Arrange: References are OPTIONAL - null should not cause validation error (400)
|
||||
// Using pdfWithSwissQRCode.pdf which actually has a QR code, so we get 200
|
||||
var validPdfBase64 = GetEmbeddedResourceAsBase64("DocumentOperator.Tests.TestData.Pdfs.pdfWithSwissQRCode.pdf");
|
||||
var validPdfBase64 = GetEmbeddedResourceAsBase64("DocumentService.Tests.TestData.Pdfs.pdfWithSwissQRCode.pdf");
|
||||
|
||||
var request = new
|
||||
{
|
||||
@@ -95,7 +97,6 @@ public sealed class ExtractSwissQrCodeEndpointTests : IClassFixture<WebApplicati
|
||||
{
|
||||
var result = await response.Content.ReadFromJsonAsync<SwissQrCodeExtractionResult>();
|
||||
result.Should().NotBeNull();
|
||||
result!.References.Should().BeEmpty(); // Null input → empty output array
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,383 @@
|
||||
using DocumentService.Application.Common.DTOs;
|
||||
using DocumentService.Client.Models.Requests;
|
||||
using FluentAssertions;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using Xunit;
|
||||
|
||||
namespace DocumentService.Tests.Integration.API;
|
||||
|
||||
/// <summary>
|
||||
/// Integration tests for PdfAttachmentController.
|
||||
/// Tests /api/pdf/attachments/check endpoint with both multipart and Base64 input.
|
||||
/// </summary>
|
||||
public class PdfAttachmentControllerTests : IClassFixture<WebApplicationFactory<Program>>
|
||||
{
|
||||
private readonly WebApplicationFactory<Program> _factory;
|
||||
private readonly HttpClient _client;
|
||||
|
||||
public PdfAttachmentControllerTests(WebApplicationFactory<Program> factory)
|
||||
{
|
||||
_factory = factory;
|
||||
_client = _factory.CreateClient();
|
||||
}
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
/// <summary>
|
||||
/// Loads a test PDF from embedded resources.
|
||||
/// </summary>
|
||||
private static async Task<byte[]> LoadTestPdfAsync(string filename)
|
||||
{
|
||||
var assembly = typeof(PdfAttachmentControllerTests).Assembly;
|
||||
var resourceName = $"DocumentService.Tests.TestData.Pdfs.{filename}";
|
||||
|
||||
using var stream = assembly.GetManifestResourceStream(resourceName);
|
||||
if (stream == null)
|
||||
{
|
||||
throw new InvalidOperationException($"Test resource '{resourceName}' not found");
|
||||
}
|
||||
|
||||
using var ms = new MemoryStream();
|
||||
await stream.CopyToAsync(ms);
|
||||
return ms.ToArray();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Base64 JSON Tests
|
||||
|
||||
[Fact]
|
||||
public async Task POST_CheckAttachments_Base64_PdfWithoutAttachments_Returns200()
|
||||
{
|
||||
// Arrange
|
||||
byte[] pdfBytes = await LoadTestPdfAsync("pdfWithSwissQRCode.pdf");
|
||||
string base64Pdf = Convert.ToBase64String(pdfBytes);
|
||||
var request = new CheckPdfAttachmentsRequest { Base64Pdf = base64Pdf };
|
||||
|
||||
// Act
|
||||
var response = await _client.PostAsJsonAsync("/api/pdf/attachments/check", request);
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
|
||||
var result = await response.Content.ReadFromJsonAsync<AttachmentCheckResult>();
|
||||
result.Should().NotBeNull();
|
||||
result!.HasAttachments.Should().BeFalse();
|
||||
result.AttachmentCount.Should().Be(0);
|
||||
result.Attachments.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task POST_CheckAttachments_Base64_PdfWithMultipleAttachments_Returns200()
|
||||
{
|
||||
// Arrange
|
||||
byte[] pdfBytes = await LoadTestPdfAsync("pdfWithMoreThanOneAttachment.pdf");
|
||||
string base64Pdf = Convert.ToBase64String(pdfBytes);
|
||||
var request = new CheckPdfAttachmentsRequest { Base64Pdf = base64Pdf };
|
||||
|
||||
// Act
|
||||
var response = await _client.PostAsJsonAsync("/api/pdf/attachments/check", request);
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
|
||||
var result = await response.Content.ReadFromJsonAsync<AttachmentCheckResult>();
|
||||
result.Should().NotBeNull();
|
||||
result!.HasAttachments.Should().BeTrue();
|
||||
result.AttachmentCount.Should().Be(6, "PDF has exactly 6 attachments");
|
||||
result.Attachments.Should().HaveCount(6);
|
||||
|
||||
// Verify each attachment has required properties
|
||||
foreach (var attachment in result.Attachments)
|
||||
{
|
||||
attachment.FileName.Should().NotBeNullOrEmpty();
|
||||
attachment.MimeType.Should().NotBeNullOrEmpty();
|
||||
attachment.Size.Should().BeGreaterThan(0);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task POST_CheckAttachments_Base64_InvalidBase64_Returns400()
|
||||
{
|
||||
// Arrange
|
||||
var request = new CheckPdfAttachmentsRequest { Base64Pdf = "invalid-base64!!!" };
|
||||
|
||||
// Act
|
||||
var response = await _client.PostAsJsonAsync("/api/pdf/attachments/check", request);
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
|
||||
|
||||
var problemDetails = await response.Content.ReadAsStringAsync();
|
||||
// FormatException message contains "Base-64" (with hyphen)
|
||||
problemDetails.Should().MatchRegex("(?i)base.?64", "should contain Base64 validation error");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task POST_CheckAttachments_Base64_EmptyPdf_Returns400()
|
||||
{
|
||||
// Arrange
|
||||
var request = new CheckPdfAttachmentsRequest { Base64Pdf = string.Empty };
|
||||
|
||||
// Act
|
||||
var response = await _client.PostAsJsonAsync("/api/pdf/attachments/check", request);
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
|
||||
|
||||
var problemDetails = await response.Content.ReadAsStringAsync();
|
||||
// Empty Base64 causes FormatException or empty stream error
|
||||
problemDetails.Should().MatchRegex("(Base64|empty|stream)", "should contain validation error message");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Multipart/Form-Data Tests
|
||||
|
||||
[Fact]
|
||||
public async Task POST_CheckAttachments_Multipart_PdfWithoutAttachments_Returns200()
|
||||
{
|
||||
// Arrange
|
||||
byte[] pdfBytes = await LoadTestPdfAsync("pdfWithSwissQRCode.pdf");
|
||||
|
||||
using var content = new MultipartFormDataContent();
|
||||
var fileContent = new ByteArrayContent(pdfBytes);
|
||||
fileContent.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/pdf");
|
||||
content.Add(fileContent, "file", "pdfWithSwissQRCode.pdf");
|
||||
|
||||
// Act
|
||||
var response = await _client.PostAsync("/api/pdf/attachments/check", content);
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
|
||||
var result = await response.Content.ReadFromJsonAsync<AttachmentCheckResult>();
|
||||
result.Should().NotBeNull();
|
||||
result!.HasAttachments.Should().BeFalse();
|
||||
result.AttachmentCount.Should().Be(0);
|
||||
result.Attachments.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task POST_CheckAttachments_Multipart_PdfWithMultipleAttachments_Returns200()
|
||||
{
|
||||
// Arrange
|
||||
byte[] pdfBytes = await LoadTestPdfAsync("pdfWithMoreThanOneAttachment.pdf");
|
||||
|
||||
using var content = new MultipartFormDataContent();
|
||||
var fileContent = new ByteArrayContent(pdfBytes);
|
||||
fileContent.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/pdf");
|
||||
content.Add(fileContent, "file", "pdfWithMoreThanOneAttachment.pdf");
|
||||
|
||||
// Act
|
||||
var response = await _client.PostAsync("/api/pdf/attachments/check", content);
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
|
||||
var result = await response.Content.ReadFromJsonAsync<AttachmentCheckResult>();
|
||||
result.Should().NotBeNull();
|
||||
result!.HasAttachments.Should().BeTrue();
|
||||
result.AttachmentCount.Should().Be(6);
|
||||
result.Attachments.Should().HaveCount(6);
|
||||
|
||||
// Verify first attachment details
|
||||
var firstAttachment = result.Attachments.First();
|
||||
firstAttachment.FileName.Should().NotBeNullOrEmpty();
|
||||
firstAttachment.MimeType.Should().NotBeNullOrEmpty();
|
||||
firstAttachment.Size.Should().BeGreaterThan(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task POST_CheckAttachments_Multipart_MissingFile_Returns400()
|
||||
{
|
||||
// Arrange
|
||||
using var content = new MultipartFormDataContent(); // No file added
|
||||
|
||||
// Act
|
||||
var response = await _client.PostAsync("/api/pdf/attachments/check", content);
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task POST_CheckAttachments_Multipart_CorruptedPdf_Returns500()
|
||||
{
|
||||
// Arrange
|
||||
byte[] corruptedBytes = "This is not a valid PDF content"u8.ToArray();
|
||||
|
||||
using var content = new MultipartFormDataContent();
|
||||
var fileContent = new ByteArrayContent(corruptedBytes);
|
||||
fileContent.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/pdf");
|
||||
content.Add(fileContent, "file", "corrupted.pdf");
|
||||
|
||||
// Act
|
||||
var response = await _client.PostAsync("/api/pdf/attachments/check", content);
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.InternalServerError);
|
||||
|
||||
var problemDetails = await response.Content.ReadAsStringAsync();
|
||||
// Note: Generic error message for security reasons (doesn't expose internal details)
|
||||
problemDetails.Should().Contain("error");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Edge Cases
|
||||
|
||||
[Fact]
|
||||
public async Task POST_CheckAttachments_Base64_PdfWithSwissQrCode_Returns200()
|
||||
{
|
||||
// Arrange
|
||||
byte[] pdfBytes = await LoadTestPdfAsync("pdfWithSwissQRCode.pdf");
|
||||
string base64Pdf = Convert.ToBase64String(pdfBytes);
|
||||
var request = new CheckPdfAttachmentsRequest { Base64Pdf = base64Pdf };
|
||||
|
||||
// Act
|
||||
var response = await _client.PostAsJsonAsync("/api/pdf/attachments/check", request);
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
|
||||
var result = await response.Content.ReadFromJsonAsync<AttachmentCheckResult>();
|
||||
result.Should().NotBeNull();
|
||||
result!.HasAttachments.Should().BeFalse("Swiss QR PDF has no attachments");
|
||||
result.AttachmentCount.Should().Be(0);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Extract Attachments Tests (Multipart)
|
||||
|
||||
[Fact]
|
||||
public async Task POST_ExtractAttachments_Multipart_ValidPdfWithAttachments_Returns200WithZip()
|
||||
{
|
||||
// Arrange
|
||||
byte[] pdfBytes = await LoadTestPdfAsync("pdfWithMoreThanOneAttachment.pdf");
|
||||
|
||||
using var content = new MultipartFormDataContent();
|
||||
var fileContent = new ByteArrayContent(pdfBytes);
|
||||
fileContent.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/pdf");
|
||||
content.Add(fileContent, "file", "test.pdf");
|
||||
|
||||
// Act
|
||||
var response = await _client.PostAsync("/api/pdf/attachments/extract", content);
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
response.Content.Headers.ContentType?.MediaType.Should().Be("application/zip");
|
||||
response.Content.Headers.ContentDisposition?.FileName.Should().Be("attachments.zip");
|
||||
|
||||
byte[] zipBytes = await response.Content.ReadAsByteArrayAsync();
|
||||
zipBytes.Should().NotBeEmpty("ZIP should contain data");
|
||||
|
||||
// Verify ZIP structure
|
||||
using var zipStream = new MemoryStream(zipBytes);
|
||||
using var zipArchive = new System.IO.Compression.ZipArchive(zipStream, System.IO.Compression.ZipArchiveMode.Read);
|
||||
|
||||
zipArchive.Entries.Should().HaveCount(6, "PDF contains 6 attachments");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task POST_ExtractAttachments_Multipart_EmptyPdf_Returns400()
|
||||
{
|
||||
// Arrange
|
||||
using var content = new MultipartFormDataContent();
|
||||
var fileContent = new ByteArrayContent(Array.Empty<byte>());
|
||||
fileContent.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/pdf");
|
||||
content.Add(fileContent, "file", "empty.pdf");
|
||||
|
||||
// Act
|
||||
var response = await _client.PostAsync("/api/pdf/attachments/extract", content);
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task POST_ExtractAttachments_Multipart_CorruptedPdf_Returns500()
|
||||
{
|
||||
// Arrange
|
||||
byte[] corruptedBytes = "This is not a valid PDF content"u8.ToArray();
|
||||
|
||||
using var content = new MultipartFormDataContent();
|
||||
var fileContent = new ByteArrayContent(corruptedBytes);
|
||||
fileContent.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/pdf");
|
||||
content.Add(fileContent, "file", "corrupted.pdf");
|
||||
|
||||
// Act
|
||||
var response = await _client.PostAsync("/api/pdf/attachments/extract", content);
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.InternalServerError);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Extract Attachments Tests (Base64)
|
||||
|
||||
[Fact]
|
||||
public async Task POST_ExtractAttachments_Base64_ValidPdfWithAttachments_Returns200WithZip()
|
||||
{
|
||||
// Arrange
|
||||
byte[] pdfBytes = await LoadTestPdfAsync("pdfWithMoreThanOneAttachment.pdf");
|
||||
string base64Pdf = Convert.ToBase64String(pdfBytes);
|
||||
var request = new ExtractPdfAttachmentsRequest { Base64Pdf = base64Pdf };
|
||||
|
||||
// Act
|
||||
var response = await _client.PostAsJsonAsync("/api/pdf/attachments/extract", request);
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
response.Content.Headers.ContentType?.MediaType.Should().Be("application/zip");
|
||||
response.Content.Headers.ContentDisposition?.FileName.Should().Be("attachments.zip");
|
||||
|
||||
byte[] zipBytes = await response.Content.ReadAsByteArrayAsync();
|
||||
zipBytes.Should().NotBeEmpty("ZIP should contain data");
|
||||
|
||||
// Verify ZIP structure
|
||||
using var zipStream = new MemoryStream(zipBytes);
|
||||
using var zipArchive = new System.IO.Compression.ZipArchive(zipStream, System.IO.Compression.ZipArchiveMode.Read);
|
||||
|
||||
zipArchive.Entries.Should().HaveCount(6, "PDF contains 6 attachments");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task POST_ExtractAttachments_Base64_InvalidBase64_Returns400()
|
||||
{
|
||||
// Arrange
|
||||
var request = new ExtractPdfAttachmentsRequest { Base64Pdf = "invalid-base64!!!" };
|
||||
|
||||
// Act
|
||||
var response = await _client.PostAsJsonAsync("/api/pdf/attachments/extract", request);
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
|
||||
|
||||
var problemDetails = await response.Content.ReadAsStringAsync();
|
||||
// FormatException message contains "Base-64" (with hyphen)
|
||||
problemDetails.Should().MatchRegex("(?i)base.?64", "should contain Base64 validation error");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task POST_ExtractAttachments_Base64_EmptyPdf_Returns400()
|
||||
{
|
||||
// Arrange
|
||||
var request = new ExtractPdfAttachmentsRequest { Base64Pdf = string.Empty };
|
||||
|
||||
// Act
|
||||
var response = await _client.PostAsJsonAsync("/api/pdf/attachments/extract", request);
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
|
||||
|
||||
var problemDetails = await response.Content.ReadAsStringAsync();
|
||||
problemDetails.Should().MatchRegex("(Base64|empty|stream)", "should contain validation error message");
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,500 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Reflection;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using DocumentService.API.Controllers;
|
||||
using DocumentService.Domain.Models.ValueObjects;
|
||||
using FluentAssertions;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
|
||||
namespace DocumentService.Tests.Integration.API;
|
||||
|
||||
public class PdfOperationsControllerTests : IClassFixture<WebApplicationFactory<Program>>
|
||||
{
|
||||
private readonly HttpClient _client;
|
||||
|
||||
public PdfOperationsControllerTests(WebApplicationFactory<Program> factory)
|
||||
{
|
||||
_client = factory.CreateClient();
|
||||
}
|
||||
|
||||
private static Stream LoadTestPdfAsStream(string fileName)
|
||||
{
|
||||
var assembly = Assembly.GetExecutingAssembly();
|
||||
var resourceName = $"DocumentService.Tests.TestData.Pdfs.{fileName}";
|
||||
return assembly.GetManifestResourceStream(resourceName)
|
||||
?? throw new FileNotFoundException($"Embedded resource not found: {resourceName}");
|
||||
}
|
||||
|
||||
private static string LoadTestPdfAsBase64(string fileName)
|
||||
{
|
||||
using var stream = LoadTestPdfAsStream(fileName);
|
||||
using var ms = new MemoryStream();
|
||||
stream.CopyTo(ms);
|
||||
return Convert.ToBase64String(ms.ToArray());
|
||||
}
|
||||
|
||||
#region Merge Endpoint Tests (existing - keeping for reference)
|
||||
|
||||
[Fact]
|
||||
public async Task MergeFromFiles_ValidPdfs_ReturnsMergedPdf()
|
||||
{
|
||||
// Arrange
|
||||
using var content = new MultipartFormDataContent();
|
||||
|
||||
using var pdf1Stream = LoadTestPdfAsStream("valid.pdf");
|
||||
using var pdf2Stream = LoadTestPdfAsStream("valid.pdf");
|
||||
|
||||
var pdf1Content = new StreamContent(pdf1Stream);
|
||||
var pdf2Content = new StreamContent(pdf2Stream);
|
||||
|
||||
pdf1Content.Headers.ContentType = new MediaTypeHeaderValue("application/pdf");
|
||||
pdf2Content.Headers.ContentType = new MediaTypeHeaderValue("application/pdf");
|
||||
|
||||
content.Add(pdf1Content, "files", "file1.pdf");
|
||||
content.Add(pdf2Content, "files", "file2.pdf");
|
||||
|
||||
// Act
|
||||
var response = await _client.PostAsync("/api/pdf/operations/merge", content);
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
response.Content.Headers.ContentType?.MediaType.Should().Be("application/pdf");
|
||||
|
||||
byte[] mergedPdf = await response.Content.ReadAsByteArrayAsync();
|
||||
mergedPdf.Should().NotBeEmpty();
|
||||
mergedPdf.Length.Should().BeGreaterThan(100); // Sanity check
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MergeFromBase64_ValidPdfs_ReturnsMergedPdf()
|
||||
{
|
||||
// Arrange
|
||||
string base64Pdf1 = LoadTestPdfAsBase64("valid.pdf");
|
||||
string base64Pdf2 = LoadTestPdfAsBase64("valid.pdf");
|
||||
|
||||
var request = new MergePdfsBase64Request
|
||||
{
|
||||
Base64Pdfs = new List<string> { base64Pdf1, base64Pdf2 },
|
||||
PageRanges = null
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(request);
|
||||
var content = new StringContent(json, Encoding.UTF8, "application/json");
|
||||
|
||||
// Act
|
||||
var response = await _client.PostAsync("/api/pdf/operations/merge", content);
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
byte[] mergedPdf = await response.Content.ReadAsByteArrayAsync();
|
||||
mergedPdf.Should().NotBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MergeFromBase64_WithPageRanges_ReturnsMergedPdf()
|
||||
{
|
||||
// Arrange
|
||||
string base64Pdf1 = LoadTestPdfAsBase64("valid.pdf");
|
||||
string base64Pdf2 = LoadTestPdfAsBase64("valid.pdf");
|
||||
|
||||
var request = new MergePdfsBase64Request
|
||||
{
|
||||
Base64Pdfs = new List<string> { base64Pdf1, base64Pdf2 },
|
||||
PageRanges = new List<string?> { "1", "1" } // Only first page from each
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(request);
|
||||
var content = new StringContent(json, Encoding.UTF8, "application/json");
|
||||
|
||||
// Act
|
||||
var response = await _client.PostAsync("/api/pdf/operations/merge", content);
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
byte[] mergedPdf = await response.Content.ReadAsByteArrayAsync();
|
||||
mergedPdf.Should().NotBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MergeFromFiles_OnePdf_Returns400()
|
||||
{
|
||||
// Arrange
|
||||
using var content = new MultipartFormDataContent();
|
||||
using var pdfStream = LoadTestPdfAsStream("valid.pdf");
|
||||
var pdfContent = new StreamContent(pdfStream);
|
||||
pdfContent.Headers.ContentType = new MediaTypeHeaderValue("application/pdf");
|
||||
content.Add(pdfContent, "files", "file1.pdf");
|
||||
|
||||
// Act
|
||||
var response = await _client.PostAsync("/api/pdf/operations/merge", content);
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MergeFromBase64_InvalidBase64_Returns400()
|
||||
{
|
||||
// Arrange
|
||||
var request = new MergePdfsBase64Request
|
||||
{
|
||||
Base64Pdfs = new List<string> { "INVALID_BASE64!!!", "ANOTHER_INVALID" }
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(request);
|
||||
var content = new StringContent(json, Encoding.UTF8, "application/json");
|
||||
|
||||
// Act
|
||||
var response = await _client.PostAsync("/api/pdf/operations/merge", content);
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MergeFromBase64_InvalidPageRange_Returns400()
|
||||
{
|
||||
// Arrange
|
||||
string base64Pdf = LoadTestPdfAsBase64("valid.pdf");
|
||||
|
||||
var request = new MergePdfsBase64Request
|
||||
{
|
||||
Base64Pdfs = new List<string> { base64Pdf, base64Pdf },
|
||||
PageRanges = new List<string?> { "999-1000", null } // Exceeds page count
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(request);
|
||||
var content = new StringContent(json, Encoding.UTF8, "application/json");
|
||||
|
||||
// Act
|
||||
var response = await _client.PostAsync("/api/pdf/operations/merge", content);
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MergeFromFiles_CorruptedPdf_Returns500()
|
||||
{
|
||||
// Arrange
|
||||
using var content = new MultipartFormDataContent();
|
||||
|
||||
byte[] corruptedData = "NOT A PDF FILE"u8.ToArray();
|
||||
var corruptedContent = new ByteArrayContent(corruptedData);
|
||||
corruptedContent.Headers.ContentType = new MediaTypeHeaderValue("application/pdf");
|
||||
|
||||
using var validPdfStream = LoadTestPdfAsStream("valid.pdf");
|
||||
var validContent = new StreamContent(validPdfStream);
|
||||
validContent.Headers.ContentType = new MediaTypeHeaderValue("application/pdf");
|
||||
|
||||
content.Add(corruptedContent, "files", "corrupted.pdf");
|
||||
content.Add(validContent, "files", "valid.pdf");
|
||||
|
||||
// Act
|
||||
var response = await _client.PostAsync("/api/pdf/operations/merge", content);
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.InternalServerError);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Annotate Endpoint Tests
|
||||
|
||||
[Fact]
|
||||
public async Task AnnotateFromFile_TextMarkupHighlight_ReturnsAnnotatedPdf()
|
||||
{
|
||||
// Arrange
|
||||
using var content = new MultipartFormDataContent();
|
||||
|
||||
using var pdfStream = LoadTestPdfAsStream("valid.pdf");
|
||||
var pdfContent = new StreamContent(pdfStream);
|
||||
pdfContent.Headers.ContentType = new MediaTypeHeaderValue("application/pdf");
|
||||
content.Add(pdfContent, "file", "test.pdf");
|
||||
|
||||
content.Add(new StringContent(AnnotationType.TextMarkup.ToString()), "annotationType");
|
||||
content.Add(new StringContent("1"), "pageNumber");
|
||||
content.Add(new StringContent("100"), "x1");
|
||||
content.Add(new StringContent("100"), "y1");
|
||||
content.Add(new StringContent("200"), "x2");
|
||||
content.Add(new StringContent("120"), "y2");
|
||||
content.Add(new StringContent("Important text"), "content");
|
||||
content.Add(new StringContent("Test Author"), "author");
|
||||
content.Add(new StringContent("FFFF00"), "color");
|
||||
content.Add(new StringContent(TextMarkupStyle.Highlight.ToString()), "textMarkupStyle");
|
||||
|
||||
// Act
|
||||
var response = await _client.PostAsync("/api/pdf/operations/annotate", content);
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
response.Content.Headers.ContentType?.MediaType.Should().Be("application/pdf");
|
||||
|
||||
byte[] annotatedPdf = await response.Content.ReadAsByteArrayAsync();
|
||||
annotatedPdf.Should().NotBeEmpty();
|
||||
annotatedPdf.Length.Should().BeGreaterThan(100);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AnnotateFromBase64_FreeText_ReturnsAnnotatedPdf()
|
||||
{
|
||||
// Arrange
|
||||
string base64Pdf = LoadTestPdfAsBase64("valid.pdf");
|
||||
|
||||
var request = new AddAnnotationBase64Command
|
||||
{
|
||||
Base64Pdf = base64Pdf,
|
||||
AnnotationType = AnnotationType.FreeText,
|
||||
PageNumber = 1,
|
||||
X1 = 50,
|
||||
Y1 = 50,
|
||||
X2 = 150,
|
||||
Y2 = 100,
|
||||
Content = "Free text annotation",
|
||||
Author = "John Doe",
|
||||
Color = "FF0000"
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(request);
|
||||
var content = new StringContent(json, Encoding.UTF8, "application/json");
|
||||
|
||||
// Act
|
||||
var response = await _client.PostAsync("/api/pdf/operations/annotate", content);
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
byte[] annotatedPdf = await response.Content.ReadAsByteArrayAsync();
|
||||
annotatedPdf.Should().NotBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AnnotateFromBase64_StickyNote_ReturnsAnnotatedPdf()
|
||||
{
|
||||
// Arrange
|
||||
string base64Pdf = LoadTestPdfAsBase64("valid.pdf");
|
||||
|
||||
var request = new AddAnnotationBase64Command
|
||||
{
|
||||
Base64Pdf = base64Pdf,
|
||||
AnnotationType = AnnotationType.StickyNote,
|
||||
PageNumber = 1,
|
||||
X1 = 300,
|
||||
Y1 = 300,
|
||||
X2 = 320,
|
||||
Y2 = 320,
|
||||
Content = "Please review this section",
|
||||
Author = "Reviewer"
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(request);
|
||||
var content = new StringContent(json, Encoding.UTF8, "application/json");
|
||||
|
||||
// Act
|
||||
var response = await _client.PostAsync("/api/pdf/operations/annotate", content);
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
byte[] annotatedPdf = await response.Content.ReadAsByteArrayAsync();
|
||||
annotatedPdf.Should().NotBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AnnotateFromFile_Circle_ReturnsAnnotatedPdf()
|
||||
{
|
||||
// Arrange
|
||||
using var content = new MultipartFormDataContent();
|
||||
|
||||
using var pdfStream = LoadTestPdfAsStream("valid.pdf");
|
||||
var pdfContent = new StreamContent(pdfStream);
|
||||
pdfContent.Headers.ContentType = new MediaTypeHeaderValue("application/pdf");
|
||||
content.Add(pdfContent, "file", "test.pdf");
|
||||
|
||||
content.Add(new StringContent(AnnotationType.Circle.ToString()), "annotationType");
|
||||
content.Add(new StringContent("1"), "pageNumber");
|
||||
content.Add(new StringContent("100"), "x1");
|
||||
content.Add(new StringContent("200"), "y1");
|
||||
content.Add(new StringContent("200"), "x2");
|
||||
content.Add(new StringContent("300"), "y2");
|
||||
content.Add(new StringContent("Circle annotation"), "content");
|
||||
content.Add(new StringContent("00FF00"), "color");
|
||||
|
||||
// Act
|
||||
var response = await _client.PostAsync("/api/pdf/operations/annotate", content);
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
byte[] annotatedPdf = await response.Content.ReadAsByteArrayAsync();
|
||||
annotatedPdf.Should().NotBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AnnotateFromBase64_Square_ReturnsAnnotatedPdf()
|
||||
{
|
||||
// Arrange
|
||||
string base64Pdf = LoadTestPdfAsBase64("valid.pdf");
|
||||
|
||||
var request = new AddAnnotationBase64Command
|
||||
{
|
||||
Base64Pdf = base64Pdf,
|
||||
AnnotationType = AnnotationType.Square,
|
||||
PageNumber = 1,
|
||||
X1 = 250,
|
||||
Y1 = 250,
|
||||
X2 = 350,
|
||||
Y2 = 350,
|
||||
Color = "0000FF"
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(request);
|
||||
var content = new StringContent(json, Encoding.UTF8, "application/json");
|
||||
|
||||
// Act
|
||||
var response = await _client.PostAsync("/api/pdf/operations/annotate", content);
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
byte[] annotatedPdf = await response.Content.ReadAsByteArrayAsync();
|
||||
annotatedPdf.Should().NotBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AnnotateFromBase64_InvalidBase64_Returns400()
|
||||
{
|
||||
// Arrange
|
||||
var request = new AddAnnotationBase64Command
|
||||
{
|
||||
Base64Pdf = "INVALID_BASE64!!!",
|
||||
AnnotationType = AnnotationType.Circle,
|
||||
PageNumber = 1,
|
||||
X1 = 0,
|
||||
Y1 = 0,
|
||||
X2 = 100,
|
||||
Y2 = 100
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(request);
|
||||
var content = new StringContent(json, Encoding.UTF8, "application/json");
|
||||
|
||||
// Act
|
||||
var response = await _client.PostAsync("/api/pdf/operations/annotate", content);
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AnnotateFromBase64_InvalidPageNumber_Returns400()
|
||||
{
|
||||
// Arrange
|
||||
string base64Pdf = LoadTestPdfAsBase64("valid.pdf");
|
||||
|
||||
var request = new AddAnnotationBase64Command
|
||||
{
|
||||
Base64Pdf = base64Pdf,
|
||||
AnnotationType = AnnotationType.FreeText,
|
||||
PageNumber = 999, // Exceeds page count
|
||||
X1 = 0,
|
||||
Y1 = 0,
|
||||
X2 = 100,
|
||||
Y2 = 100,
|
||||
Content = "Test"
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(request);
|
||||
var content = new StringContent(json, Encoding.UTF8, "application/json");
|
||||
|
||||
// Act
|
||||
var response = await _client.PostAsync("/api/pdf/operations/annotate", content);
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AnnotateFromBase64_FreeTextWithoutContent_Returns400()
|
||||
{
|
||||
// Arrange
|
||||
string base64Pdf = LoadTestPdfAsBase64("valid.pdf");
|
||||
|
||||
var request = new AddAnnotationBase64Command
|
||||
{
|
||||
Base64Pdf = base64Pdf,
|
||||
AnnotationType = AnnotationType.FreeText,
|
||||
PageNumber = 1,
|
||||
X1 = 0,
|
||||
Y1 = 0,
|
||||
X2 = 100,
|
||||
Y2 = 100
|
||||
// Missing required Content
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(request);
|
||||
var content = new StringContent(json, Encoding.UTF8, "application/json");
|
||||
|
||||
// Act
|
||||
var response = await _client.PostAsync("/api/pdf/operations/annotate", content);
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AnnotateFromBase64_TextMarkupWithoutStyle_Returns400()
|
||||
{
|
||||
// Arrange
|
||||
string base64Pdf = LoadTestPdfAsBase64("valid.pdf");
|
||||
|
||||
var request = new AddAnnotationBase64Command
|
||||
{
|
||||
Base64Pdf = base64Pdf,
|
||||
AnnotationType = AnnotationType.TextMarkup,
|
||||
PageNumber = 1,
|
||||
X1 = 0,
|
||||
Y1 = 0,
|
||||
X2 = 100,
|
||||
Y2 = 100
|
||||
// Missing required TextMarkupStyle
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(request);
|
||||
var content = new StringContent(json, Encoding.UTF8, "application/json");
|
||||
|
||||
// Act
|
||||
var response = await _client.PostAsync("/api/pdf/operations/annotate", content);
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AnnotateFromBase64_InvalidColorFormat_Returns400()
|
||||
{
|
||||
// Arrange
|
||||
string base64Pdf = LoadTestPdfAsBase64("valid.pdf");
|
||||
|
||||
var request = new AddAnnotationBase64Command
|
||||
{
|
||||
Base64Pdf = base64Pdf,
|
||||
AnnotationType = AnnotationType.Circle,
|
||||
PageNumber = 1,
|
||||
X1 = 0,
|
||||
Y1 = 0,
|
||||
X2 = 100,
|
||||
Y2 = 100,
|
||||
Color = "INVALID" // Invalid hex format
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(request);
|
||||
var content = new StringContent(json, Encoding.UTF8, "application/json");
|
||||
|
||||
// Act
|
||||
var response = await _client.PostAsync("/api/pdf/operations/annotate", content);
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
using DocumentOperator.Application.Common.DTOs;
|
||||
using DocumentOperator.Application.ValidatePdf.Queries;
|
||||
using DocumentService.Application.Common.DTOs;
|
||||
using DocumentService.Client.Models.Requests;
|
||||
using FluentAssertions;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using Xunit;
|
||||
|
||||
namespace DocumentOperator.Tests.Integration.API;
|
||||
namespace DocumentService.Tests.Integration.API;
|
||||
|
||||
public class PdfValidationControllerTests : IClassFixture<WebApplicationFactory<Program>>
|
||||
{
|
||||
@@ -27,7 +27,7 @@ public class PdfValidationControllerTests : IClassFixture<WebApplicationFactory<
|
||||
// Arrange
|
||||
// Verwende ein echtes Test-PDF (embedded resource aus Unit Tests)
|
||||
var assembly = typeof(PdfValidationControllerTests).Assembly;
|
||||
var resourceName = "DocumentOperator.Tests.TestData.Pdfs.valid.pdf";
|
||||
var resourceName = "DocumentService.Tests.TestData.Pdfs.valid.pdf";
|
||||
|
||||
byte[] pdfBytes;
|
||||
using (var stream = assembly.GetManifestResourceStream(resourceName))
|
||||
@@ -43,7 +43,7 @@ public class PdfValidationControllerTests : IClassFixture<WebApplicationFactory<
|
||||
}
|
||||
|
||||
var base64Pdf = Convert.ToBase64String(pdfBytes);
|
||||
var request = new ValidatePdfQuery { Base64Pdf = base64Pdf };
|
||||
var request = new ValidatePdfBase64Request { Base64Pdf = base64Pdf };
|
||||
|
||||
// Act
|
||||
var response = await _client.PostAsJsonAsync("/api/pdf/validation/validate", request);
|
||||
@@ -62,7 +62,7 @@ public class PdfValidationControllerTests : IClassFixture<WebApplicationFactory<
|
||||
public async Task POST_ValidatePdf_Base64_InvalidBase64_Returns400()
|
||||
{
|
||||
// Arrange
|
||||
var request = new ValidatePdfQuery { Base64Pdf = "invalid-base64!!!" }; // Kein gültiges Base64
|
||||
var request = new ValidatePdfBase64Request { Base64Pdf = "invalid-base64!!!" }; // Kein gültiges Base64
|
||||
|
||||
// Act
|
||||
var response = await _client.PostAsJsonAsync("/api/pdf/validation/validate", request);
|
||||
@@ -71,14 +71,15 @@ public class PdfValidationControllerTests : IClassFixture<WebApplicationFactory<
|
||||
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
|
||||
|
||||
var problemDetails = await response.Content.ReadAsStringAsync();
|
||||
problemDetails.Should().Contain("Base64");
|
||||
// FormatException message contains "Base-64" (with hyphen)
|
||||
problemDetails.Should().MatchRegex("(?i)base.?64", "should contain Base64 validation error");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task POST_ValidatePdf_Base64_EmptyPdf_Returns400()
|
||||
{
|
||||
// Arrange
|
||||
var request = new ValidatePdfQuery { Base64Pdf = string.Empty }; // Leerer String
|
||||
var request = new ValidatePdfBase64Request { Base64Pdf = string.Empty }; // Leerer String
|
||||
|
||||
// Act
|
||||
var response = await _client.PostAsJsonAsync("/api/pdf/validation/validate", request);
|
||||
@@ -87,7 +88,8 @@ public class PdfValidationControllerTests : IClassFixture<WebApplicationFactory<
|
||||
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
|
||||
|
||||
var problemDetails = await response.Content.ReadAsStringAsync();
|
||||
problemDetails.Should().Contain("Either PdfBytes or Base64Pdf must be provided");
|
||||
// Empty Base64 causes FormatException or empty stream error
|
||||
problemDetails.Should().MatchRegex("(Base64|empty|stream)", "should contain validation error message");
|
||||
}
|
||||
|
||||
#endregion
|
||||
@@ -99,7 +101,7 @@ public class PdfValidationControllerTests : IClassFixture<WebApplicationFactory<
|
||||
{
|
||||
// Arrange
|
||||
var assembly = typeof(PdfValidationControllerTests).Assembly;
|
||||
var resourceName = "DocumentOperator.Tests.TestData.Pdfs.valid.pdf";
|
||||
var resourceName = "DocumentService.Tests.TestData.Pdfs.valid.pdf";
|
||||
|
||||
byte[] pdfBytes;
|
||||
using (var stream = assembly.GetManifestResourceStream(resourceName))
|
||||
@@ -166,4 +168,153 @@ public class PdfValidationControllerTests : IClassFixture<WebApplicationFactory<
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region PDF/A Validation Tests (Base64)
|
||||
|
||||
[Fact]
|
||||
public async Task POST_ValidatePdfA_Base64_ValidPdf_Returns200()
|
||||
{
|
||||
// Arrange
|
||||
var assembly = typeof(PdfValidationControllerTests).Assembly;
|
||||
var resourceName = "DocumentService.Tests.TestData.Pdfs.valid.pdf";
|
||||
|
||||
byte[] pdfBytes;
|
||||
using (var stream = assembly.GetManifestResourceStream(resourceName))
|
||||
{
|
||||
if (stream == null)
|
||||
{
|
||||
throw new InvalidOperationException($"Test resource '{resourceName}' not found");
|
||||
}
|
||||
|
||||
using var ms = new MemoryStream();
|
||||
await stream.CopyToAsync(ms);
|
||||
pdfBytes = ms.ToArray();
|
||||
}
|
||||
|
||||
var base64Pdf = Convert.ToBase64String(pdfBytes);
|
||||
var request = new ValidatePdfABase64Request { Base64Pdf = base64Pdf };
|
||||
|
||||
// Act
|
||||
var response = await _client.PostAsJsonAsync("/api/pdf/validation/validate-pdfa", request);
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
|
||||
var result = await response.Content.ReadFromJsonAsync<PdfAValidationResult>();
|
||||
result.Should().NotBeNull();
|
||||
result!.IsValid.Should().BeTrue();
|
||||
result.PageCount.Should().BeGreaterThan(0);
|
||||
result.PdfVersion.Should().NotBeNullOrEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task POST_ValidatePdfA_Base64_InvalidBase64_Returns400()
|
||||
{
|
||||
// Arrange
|
||||
var request = new ValidatePdfABase64Request { Base64Pdf = "invalid-base64!!!" };
|
||||
|
||||
// Act
|
||||
var response = await _client.PostAsJsonAsync("/api/pdf/validation/validate-pdfa", request);
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
|
||||
|
||||
var problemDetails = await response.Content.ReadAsStringAsync();
|
||||
// FormatException message contains "Base-64" (with hyphen)
|
||||
problemDetails.Should().MatchRegex("(?i)base.?64", "should contain Base64 validation error");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task POST_ValidatePdfA_Base64_EmptyPdf_Returns400()
|
||||
{
|
||||
// Arrange
|
||||
var request = new ValidatePdfABase64Request { Base64Pdf = string.Empty };
|
||||
|
||||
// Act
|
||||
var response = await _client.PostAsJsonAsync("/api/pdf/validation/validate-pdfa", request);
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
|
||||
|
||||
var problemDetails = await response.Content.ReadAsStringAsync();
|
||||
// Empty Base64 causes FormatException or empty stream error
|
||||
problemDetails.Should().MatchRegex("(Base64|empty|stream)", "should contain validation error message");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region PDF/A Validation Tests (Multipart)
|
||||
|
||||
[Fact]
|
||||
public async Task POST_ValidatePdfA_Multipart_ValidPdf_Returns200()
|
||||
{
|
||||
// Arrange
|
||||
var assembly = typeof(PdfValidationControllerTests).Assembly;
|
||||
var resourceName = "DocumentService.Tests.TestData.Pdfs.valid.pdf";
|
||||
|
||||
byte[] pdfBytes;
|
||||
using (var stream = assembly.GetManifestResourceStream(resourceName))
|
||||
{
|
||||
if (stream == null)
|
||||
{
|
||||
throw new InvalidOperationException($"Test resource '{resourceName}' not found");
|
||||
}
|
||||
|
||||
using var ms = new MemoryStream();
|
||||
await stream.CopyToAsync(ms);
|
||||
pdfBytes = ms.ToArray();
|
||||
}
|
||||
|
||||
// Create multipart/form-data content
|
||||
using var content = new MultipartFormDataContent();
|
||||
var fileContent = new ByteArrayContent(pdfBytes);
|
||||
fileContent.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/pdf");
|
||||
content.Add(fileContent, "file", "test.pdf");
|
||||
|
||||
// Act
|
||||
var response = await _client.PostAsync("/api/pdf/validation/validate-pdfa", content);
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
|
||||
var result = await response.Content.ReadFromJsonAsync<PdfAValidationResult>();
|
||||
result.Should().NotBeNull();
|
||||
result!.IsValid.Should().BeTrue();
|
||||
result.PageCount.Should().BeGreaterThan(0);
|
||||
result.PdfVersion.Should().NotBeNullOrEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task POST_ValidatePdfA_Multipart_EmptyFile_Returns400()
|
||||
{
|
||||
// Arrange
|
||||
using var content = new MultipartFormDataContent();
|
||||
var fileContent = new ByteArrayContent(Array.Empty<byte>());
|
||||
fileContent.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/pdf");
|
||||
content.Add(fileContent, "file", "empty.pdf");
|
||||
|
||||
// Act
|
||||
var response = await _client.PostAsync("/api/pdf/validation/validate-pdfa", content);
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
|
||||
|
||||
var problemDetails = await response.Content.ReadAsStringAsync();
|
||||
problemDetails.Should().Contain("cannot be empty");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task POST_ValidatePdfA_Multipart_NoFile_Returns400()
|
||||
{
|
||||
// Arrange
|
||||
using var content = new MultipartFormDataContent();
|
||||
|
||||
// Act
|
||||
var response = await _client.PostAsync("/api/pdf/validation/validate-pdfa", content);
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
BIN
DocumentOperator.Tests/TestData/Pdfs/ZUGFeRD-Example.pdf
Normal file
BIN
DocumentOperator.Tests/TestData/Pdfs/ZUGFeRD-Example.pdf
Normal file
Binary file not shown.
152
DocumentOperator.Tests/TestData/Pdfs/attachment.xml
Normal file
152
DocumentOperator.Tests/TestData/Pdfs/attachment.xml
Normal file
@@ -0,0 +1,152 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rsm:CrossIndustryInvoice xmlns:rsm="urn:un:unece:uncefact:data:standard:CrossIndustryInvoice:100" xmlns:ram="urn:un:unece:uncefact:data:standard:ReusableAggregateBusinessInformationEntity:100" xmlns:udt="urn:un:unece:uncefact:data:standard:UnqualifiedDataType:100" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="urn:un:unece:uncefact:data:standard:CrossIndustryInvoice:100 ../../../schemas/UN_CEFACT/CrossIndustryInvoice_100pD16B.xsd">
|
||||
<rsm:ExchangedDocumentContext>
|
||||
<ram:GuidelineSpecifiedDocumentContextParameter>
|
||||
<ram:ID>urn:cen.eu:en16931:2017</ram:ID>
|
||||
</ram:GuidelineSpecifiedDocumentContextParameter>
|
||||
</rsm:ExchangedDocumentContext>
|
||||
<rsm:ExchangedDocument>
|
||||
<ram:ID>2021_10</ram:ID>
|
||||
<ram:TypeCode>380</ram:TypeCode>
|
||||
<ram:IssueDateTime>
|
||||
<udt:DateTimeString format="102">20210924</udt:DateTimeString>
|
||||
</ram:IssueDateTime>
|
||||
</rsm:ExchangedDocument>
|
||||
<rsm:SupplyChainTradeTransaction>
|
||||
<ram:IncludedSupplyChainTradeLineItem>
|
||||
<ram:AssociatedDocumentLineDocument>
|
||||
<ram:LineID>1</ram:LineID>
|
||||
</ram:AssociatedDocumentLineDocument>
|
||||
<ram:SpecifiedTradeProduct>
|
||||
<ram:Name>Project management</ram:Name>
|
||||
<ram:Description/>
|
||||
</ram:SpecifiedTradeProduct>
|
||||
<ram:SpecifiedLineTradeAgreement>
|
||||
<ram:NetPriceProductTradePrice>
|
||||
<ram:ChargeAmount>500.000000</ram:ChargeAmount>
|
||||
</ram:NetPriceProductTradePrice>
|
||||
</ram:SpecifiedLineTradeAgreement>
|
||||
<ram:SpecifiedLineTradeDelivery>
|
||||
<ram:BilledQuantity unitCode="C62">2.00</ram:BilledQuantity>
|
||||
</ram:SpecifiedLineTradeDelivery>
|
||||
<ram:SpecifiedLineTradeSettlement>
|
||||
<ram:ApplicableTradeTax>
|
||||
<ram:TypeCode>VAT</ram:TypeCode>
|
||||
<ram:CategoryCode>S</ram:CategoryCode>
|
||||
<ram:RateApplicablePercent>19.00</ram:RateApplicablePercent>
|
||||
</ram:ApplicableTradeTax>
|
||||
<ram:SpecifiedTradeSettlementLineMonetarySummation>
|
||||
<ram:LineTotalAmount>1000.00</ram:LineTotalAmount>
|
||||
</ram:SpecifiedTradeSettlementLineMonetarySummation>
|
||||
</ram:SpecifiedLineTradeSettlement>
|
||||
</ram:IncludedSupplyChainTradeLineItem>
|
||||
<ram:IncludedSupplyChainTradeLineItem>
|
||||
<ram:AssociatedDocumentLineDocument>
|
||||
<ram:LineID>2</ram:LineID>
|
||||
</ram:AssociatedDocumentLineDocument>
|
||||
<ram:SpecifiedTradeProduct>
|
||||
<ram:Name>Consulting</ram:Name>
|
||||
<ram:Description/>
|
||||
</ram:SpecifiedTradeProduct>
|
||||
<ram:SpecifiedLineTradeAgreement>
|
||||
<ram:NetPriceProductTradePrice>
|
||||
<ram:ChargeAmount>40.000000</ram:ChargeAmount>
|
||||
</ram:NetPriceProductTradePrice>
|
||||
</ram:SpecifiedLineTradeAgreement>
|
||||
<ram:SpecifiedLineTradeDelivery>
|
||||
<ram:BilledQuantity unitCode="C62">5.00</ram:BilledQuantity>
|
||||
</ram:SpecifiedLineTradeDelivery>
|
||||
<ram:SpecifiedLineTradeSettlement>
|
||||
<ram:ApplicableTradeTax>
|
||||
<ram:TypeCode>VAT</ram:TypeCode>
|
||||
<ram:CategoryCode>S</ram:CategoryCode>
|
||||
<ram:RateApplicablePercent>19.00</ram:RateApplicablePercent>
|
||||
</ram:ApplicableTradeTax>
|
||||
<ram:SpecifiedTradeSettlementLineMonetarySummation>
|
||||
<ram:LineTotalAmount>200.00</ram:LineTotalAmount>
|
||||
</ram:SpecifiedTradeSettlementLineMonetarySummation>
|
||||
</ram:SpecifiedLineTradeSettlement>
|
||||
</ram:IncludedSupplyChainTradeLineItem>
|
||||
<ram:ApplicableHeaderTradeAgreement>
|
||||
<ram:BuyerReference>139877</ram:BuyerReference>
|
||||
<ram:SellerTradeParty>
|
||||
<ram:Name>Webware Internet Solutions GmbH</ram:Name>
|
||||
<ram:SpecifiedLegalOrganization>
|
||||
<ram:ID>HRB 15635</ram:ID>
|
||||
</ram:SpecifiedLegalOrganization>
|
||||
<ram:DefinedTradeContact>
|
||||
<ram:PersonName>John Doe</ram:PersonName>
|
||||
<ram:TelephoneUniversalCommunication>
|
||||
<ram:CompleteNumber>+49(0)561-560123456</ram:CompleteNumber>
|
||||
</ram:TelephoneUniversalCommunication>
|
||||
<ram:EmailURIUniversalCommunication>
|
||||
<ram:URIID>johndoe@webware24.de</ram:URIID>
|
||||
</ram:EmailURIUniversalCommunication>
|
||||
</ram:DefinedTradeContact>
|
||||
<ram:PostalTradeAddress>
|
||||
<ram:PostcodeCode>34130</ram:PostcodeCode>
|
||||
<ram:LineOne>Teichstr. 14-16</ram:LineOne>
|
||||
<ram:CityName>Kassel</ram:CityName>
|
||||
<ram:CountryID>DE</ram:CountryID>
|
||||
</ram:PostalTradeAddress>
|
||||
<ram:URIUniversalCommunication>
|
||||
<ram:URIID schemeID="9930">DE279247134</ram:URIID>
|
||||
</ram:URIUniversalCommunication>
|
||||
<ram:SpecifiedTaxRegistration>
|
||||
<ram:ID schemeID="FC">262/481/0918</ram:ID>
|
||||
</ram:SpecifiedTaxRegistration>
|
||||
<ram:SpecifiedTaxRegistration>
|
||||
<ram:ID schemeID="VA">DE279247134</ram:ID>
|
||||
</ram:SpecifiedTaxRegistration>
|
||||
</ram:SellerTradeParty>
|
||||
<ram:BuyerTradeParty>
|
||||
<ram:Name>Agoratech</ram:Name>
|
||||
<ram:PostalTradeAddress>
|
||||
<ram:PostcodeCode>34130</ram:PostcodeCode>
|
||||
<ram:LineOne>Teichstr. 14-16</ram:LineOne>
|
||||
<ram:CityName>Kassel</ram:CityName>
|
||||
<ram:CountryID>DE</ram:CountryID>
|
||||
</ram:PostalTradeAddress>
|
||||
<ram:URIUniversalCommunication>
|
||||
<ram:URIID schemeID="9930">DE319642369</ram:URIID>
|
||||
</ram:URIUniversalCommunication>
|
||||
</ram:BuyerTradeParty>
|
||||
</ram:ApplicableHeaderTradeAgreement>
|
||||
<ram:ApplicableHeaderTradeDelivery>
|
||||
<ram:ActualDeliverySupplyChainEvent>
|
||||
<ram:OccurrenceDateTime>
|
||||
<udt:DateTimeString format="102">20211101</udt:DateTimeString>
|
||||
</ram:OccurrenceDateTime>
|
||||
</ram:ActualDeliverySupplyChainEvent>
|
||||
</ram:ApplicableHeaderTradeDelivery>
|
||||
<ram:ApplicableHeaderTradeSettlement>
|
||||
<ram:InvoiceCurrencyCode>EUR</ram:InvoiceCurrencyCode>
|
||||
<ram:SpecifiedTradeSettlementPaymentMeans>
|
||||
<ram:TypeCode>42</ram:TypeCode>
|
||||
<ram:PayeePartyCreditorFinancialAccount>
|
||||
<ram:IBANID/>
|
||||
</ram:PayeePartyCreditorFinancialAccount>
|
||||
<ram:PayeeSpecifiedCreditorFinancialInstitution>
|
||||
<ram:BICID/>
|
||||
</ram:PayeeSpecifiedCreditorFinancialInstitution>
|
||||
</ram:SpecifiedTradeSettlementPaymentMeans>
|
||||
<ram:ApplicableTradeTax>
|
||||
<ram:CalculatedAmount>228</ram:CalculatedAmount>
|
||||
<ram:TypeCode>VAT</ram:TypeCode>
|
||||
<ram:BasisAmount>1200</ram:BasisAmount>
|
||||
<ram:CategoryCode>S</ram:CategoryCode>
|
||||
<ram:RateApplicablePercent>19.00</ram:RateApplicablePercent>
|
||||
</ram:ApplicableTradeTax>
|
||||
<ram:SpecifiedTradeSettlementHeaderMonetarySummation>
|
||||
<ram:LineTotalAmount>1200</ram:LineTotalAmount>
|
||||
<ram:ChargeTotalAmount>0</ram:ChargeTotalAmount>
|
||||
<ram:AllowanceTotalAmount>0</ram:AllowanceTotalAmount>
|
||||
<ram:TaxBasisTotalAmount>1200.00</ram:TaxBasisTotalAmount>
|
||||
<ram:TaxTotalAmount currencyID="EUR">228.00</ram:TaxTotalAmount>
|
||||
<ram:GrandTotalAmount>1428.00</ram:GrandTotalAmount>
|
||||
<ram:TotalPrepaidAmount>0.00</ram:TotalPrepaidAmount>
|
||||
<ram:DuePayableAmount>1428.00</ram:DuePayableAmount>
|
||||
</ram:SpecifiedTradeSettlementHeaderMonetarySummation>
|
||||
</ram:ApplicableHeaderTradeSettlement>
|
||||
</rsm:SupplyChainTradeTransaction>
|
||||
</rsm:CrossIndustryInvoice>
|
||||
BIN
DocumentOperator.Tests/TestData/Pdfs/withoutAttachment.pdf
Normal file
BIN
DocumentOperator.Tests/TestData/Pdfs/withoutAttachment.pdf
Normal file
Binary file not shown.
@@ -0,0 +1,122 @@
|
||||
using AutoMapper;
|
||||
using DocumentService.Application.CheckPdfAttachments.Queries;
|
||||
using DocumentService.Application.Common.DTOs;
|
||||
using DocumentService.Application.Common.Interfaces;
|
||||
|
||||
using FluentAssertions;
|
||||
using Moq;
|
||||
|
||||
namespace DocumentService.Tests.Unit.Application.CheckPdfAttachments;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for CheckPdfAttachmentsQueryHandler.
|
||||
/// Tests handler logic with mocked dependencies (IPdfProcessor, IMapper).
|
||||
/// </summary>
|
||||
public class CheckPdfAttachmentsQueryHandlerTests
|
||||
{
|
||||
private readonly Mock<IPdfProcessor> _mockPdfProcessor;
|
||||
private readonly Mock<IMapper> _mockMapper;
|
||||
private readonly CheckPdfAttachmentsQueryHandler _sut;
|
||||
|
||||
public CheckPdfAttachmentsQueryHandlerTests()
|
||||
{
|
||||
_mockPdfProcessor = new Mock<IPdfProcessor>();
|
||||
_mockMapper = new Mock<IMapper>();
|
||||
_sut = new CheckPdfAttachmentsQueryHandler(_mockPdfProcessor.Object, _mockMapper.Object);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_WithPdfBytes_CallsProcessorAndMapper()
|
||||
{
|
||||
// Arrange
|
||||
byte[] pdfBytes = "fake-pdf-content"u8.ToArray();
|
||||
var query = new CheckPdfAttachmentsQuery { PdfStream = new MemoryStream(pdfBytes) };
|
||||
|
||||
var domainResult = new AttachmentInfo(
|
||||
hasAttachments: true,
|
||||
attachmentCount: 2,
|
||||
attachments:
|
||||
[
|
||||
new("invoice.xml", "text/xml", 1024),
|
||||
new("metadata.json", "application/json", 512)
|
||||
]
|
||||
);
|
||||
|
||||
var expectedDto = new AttachmentCheckResult
|
||||
{
|
||||
HasAttachments = true,
|
||||
AttachmentCount = 2,
|
||||
Attachments =
|
||||
[
|
||||
new() { FileName = "invoice.xml", MimeType = "text/xml", Size = 1024 },
|
||||
new() { FileName = "metadata.json", MimeType = "application/json", Size = 512 }
|
||||
]
|
||||
};
|
||||
|
||||
_mockPdfProcessor.Setup(p => p.CheckAttachmentsAsync(It.IsAny<Stream>())).ReturnsAsync(domainResult);
|
||||
_mockMapper.Setup(m => m.Map<AttachmentCheckResult>(domainResult)).Returns(expectedDto);
|
||||
|
||||
// Act
|
||||
var result = await _sut.Handle(query, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result.Should().BeEquivalentTo(expectedDto);
|
||||
|
||||
_mockPdfProcessor.Verify(p => p.CheckAttachmentsAsync(It.IsAny<Stream>()), Times.Once);
|
||||
_mockMapper.Verify(m => m.Map<AttachmentCheckResult>(domainResult), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_WithBase64Pdf_DecodesAndCallsProcessor()
|
||||
{
|
||||
// Arrange
|
||||
byte[] pdfBytes = "fake-pdf-content"u8.ToArray();
|
||||
var query = new CheckPdfAttachmentsQuery { PdfStream = new MemoryStream(pdfBytes) };
|
||||
|
||||
var domainResult = new AttachmentInfo(false, 0, []);
|
||||
var expectedDto = new AttachmentCheckResult { HasAttachments = false, AttachmentCount = 0, Attachments = [] };
|
||||
|
||||
_mockPdfProcessor.Setup(p => p.CheckAttachmentsAsync(It.IsAny<Stream>())).ReturnsAsync(domainResult);
|
||||
_mockMapper.Setup(m => m.Map<AttachmentCheckResult>(domainResult)).Returns(expectedDto);
|
||||
|
||||
// Act
|
||||
var result = await _sut.Handle(query, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result.HasAttachments.Should().BeFalse();
|
||||
result.AttachmentCount.Should().Be(0);
|
||||
|
||||
_mockPdfProcessor.Verify(p => p.CheckAttachmentsAsync(It.IsAny<Stream>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_WithEmptyAttachments_ReturnsEmptyList()
|
||||
{
|
||||
// Arrange
|
||||
byte[] pdfBytes = "fake-pdf-content"u8.ToArray();
|
||||
var query = new CheckPdfAttachmentsQuery { PdfStream = new MemoryStream(pdfBytes) };
|
||||
|
||||
var domainResult = new AttachmentInfo(false, 0, []);
|
||||
var expectedDto = new AttachmentCheckResult
|
||||
{
|
||||
HasAttachments = false,
|
||||
AttachmentCount = 0,
|
||||
Attachments = []
|
||||
};
|
||||
|
||||
_mockPdfProcessor.Setup(p => p.CheckAttachmentsAsync(It.IsAny<Stream>())).ReturnsAsync(domainResult);
|
||||
_mockMapper.Setup(m => m.Map<AttachmentCheckResult>(domainResult)).Returns(expectedDto);
|
||||
|
||||
// Act
|
||||
var result = await _sut.Handle(query, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.HasAttachments.Should().BeFalse();
|
||||
result.AttachmentCount.Should().Be(0);
|
||||
result.Attachments.Should().BeEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
using AutoMapper;
|
||||
using DocumentOperator.Application.Common.DTOs;
|
||||
using DocumentOperator.Application.Common.Interfaces;
|
||||
using DocumentOperator.Application.ValidatePdf.Queries;
|
||||
using DocumentOperator.Domain.Common.Exceptions;
|
||||
using DocumentOperator.Domain.Models.ValueObjects;
|
||||
using DocumentService.Application.Common.DTOs;
|
||||
using DocumentService.Application.Common.Interfaces;
|
||||
using DocumentService.Application.ValidatePdf.Queries;
|
||||
using DocumentService.Domain.Common.Exceptions;
|
||||
using FluentAssertions;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace DocumentOperator.Tests.Unit.Application.Features.ValidatePdf;
|
||||
namespace DocumentService.Tests.Unit.Application.Features.ValidatePdf;
|
||||
|
||||
public class ValidatePdfHandlerTests
|
||||
{
|
||||
@@ -27,8 +26,8 @@ public class ValidatePdfHandlerTests
|
||||
public async Task Handle_ValidPdf_ReturnsPdfMetadata()
|
||||
{
|
||||
// Arrange
|
||||
var pdfBytes = new byte[] { 0x25, 0x50, 0x44, 0x46 }; // "%PDF"
|
||||
var query = new ValidatePdfQuery { PdfBytes = pdfBytes };
|
||||
var pdfBytes = "%PDF"u8.ToArray(); // "%PDF"
|
||||
var query = new ValidatePdfQuery { PdfStream = new MemoryStream(pdfBytes) };
|
||||
|
||||
var domainMetadata = new PdfMetadata(
|
||||
pageCount: 5,
|
||||
@@ -48,7 +47,7 @@ public class ValidatePdfHandlerTests
|
||||
);
|
||||
|
||||
_mockPdfProcessor
|
||||
.Setup(x => x.ValidateAsync(It.IsAny<byte[]>()))
|
||||
.Setup(x => x.ValidateAsync(It.IsAny<Stream>()))
|
||||
.ReturnsAsync(domainMetadata);
|
||||
|
||||
_mockMapper
|
||||
@@ -66,7 +65,7 @@ public class ValidatePdfHandlerTests
|
||||
result.HasAttachments.Should().BeFalse();
|
||||
result.AttachmentCount.Should().Be(0);
|
||||
|
||||
_mockPdfProcessor.Verify(x => x.ValidateAsync(It.IsAny<byte[]>()), Times.Once);
|
||||
_mockPdfProcessor.Verify(x => x.ValidateAsync(It.IsAny<Stream>()), Times.Once);
|
||||
_mockMapper.Verify(x => x.Map<PdfValidationResult>(domainMetadata), Times.Once);
|
||||
}
|
||||
|
||||
@@ -74,19 +73,19 @@ public class ValidatePdfHandlerTests
|
||||
public async Task Handle_PdfProcessorThrowsException_PropagatesException()
|
||||
{
|
||||
// Arrange
|
||||
var pdfBytes = new byte[] { 0x25, 0x50, 0x44, 0x46 }; // "%PDF"
|
||||
var query = new ValidatePdfQuery { PdfBytes = pdfBytes };
|
||||
var pdfBytes = "%PDF"u8.ToArray(); // "%PDF"
|
||||
var query = new ValidatePdfQuery { PdfStream = new MemoryStream(pdfBytes) };
|
||||
|
||||
_mockPdfProcessor
|
||||
.Setup(x => x.ValidateAsync(It.IsAny<byte[]>()))
|
||||
.ThrowsAsync(new PdfProcessingException("Invalid PDF format"));
|
||||
.Setup(x => x.ValidateAsync(It.IsAny<Stream>()))
|
||||
.ThrowsAsync(new BadRequestException("Invalid PDF format"));
|
||||
|
||||
// Act & Assert
|
||||
var exception = await Assert.ThrowsAsync<PdfProcessingException>(
|
||||
var exception = await Assert.ThrowsAsync<BadRequestException>(
|
||||
() => _handler.Handle(query, CancellationToken.None)
|
||||
);
|
||||
|
||||
exception.Message.Should().Be("Invalid PDF format");
|
||||
_mockPdfProcessor.Verify(x => x.ValidateAsync(It.IsAny<byte[]>()), Times.Once);
|
||||
_mockPdfProcessor.Verify(x => x.ValidateAsync(It.IsAny<Stream>()), Times.Once);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
using AutoMapper;
|
||||
using DocumentService.Application.Common.DTOs;
|
||||
using DocumentService.Application.Common.Interfaces;
|
||||
using DocumentService.Application.ValidatePdfA.Queries;
|
||||
using DocumentService.Domain.Common.Exceptions;
|
||||
|
||||
using FluentAssertions;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace DocumentService.Tests.Unit.Application.Features.ValidatePdfA;
|
||||
|
||||
public class ValidatePdfAQueryHandlerTests
|
||||
{
|
||||
private readonly Mock<IPdfProcessor> _mockPdfProcessor;
|
||||
private readonly Mock<IMapper> _mockMapper;
|
||||
private readonly ValidatePdfAQueryHandler _handler;
|
||||
|
||||
public ValidatePdfAQueryHandlerTests()
|
||||
{
|
||||
_mockPdfProcessor = new Mock<IPdfProcessor>();
|
||||
_mockMapper = new Mock<IMapper>();
|
||||
_handler = new ValidatePdfAQueryHandler(_mockPdfProcessor.Object, _mockMapper.Object);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_ValidPdfA_ReturnsPdfAMetadata()
|
||||
{
|
||||
// Arrange
|
||||
var pdfBytes = "%PDF"u8.ToArray(); // "%PDF"
|
||||
var query = new ValidatePdfAQuery { PdfStream = new MemoryStream(pdfBytes) };
|
||||
|
||||
var domainMetadata = new PdfAMetadata(
|
||||
isValid: true,
|
||||
pdfVersion: "1.7",
|
||||
pageCount: 3,
|
||||
fileSizeBytes: 2048,
|
||||
encrypted: false,
|
||||
pdfaVersion: "PDF/A-3b",
|
||||
pdfaCompliant: true,
|
||||
errors: [],
|
||||
warnings: []
|
||||
);
|
||||
|
||||
var expectedDto = new PdfAValidationResult
|
||||
{
|
||||
IsValid = true,
|
||||
PdfVersion = "1.7",
|
||||
PageCount = 3,
|
||||
FileSize = 2048,
|
||||
Encrypted = false,
|
||||
PdfAVersion = "PDF/A-3b",
|
||||
PdfACompliant = true,
|
||||
Errors = new List<string>(),
|
||||
Warnings = new List<string>()
|
||||
};
|
||||
|
||||
_mockPdfProcessor
|
||||
.Setup(x => x.ValidatePdfAAsync(It.IsAny<Stream>()))
|
||||
.ReturnsAsync(domainMetadata);
|
||||
|
||||
_mockMapper
|
||||
.Setup(x => x.Map<PdfAValidationResult>(domainMetadata))
|
||||
.Returns(expectedDto);
|
||||
|
||||
// Act
|
||||
var result = await _handler.Handle(query, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result.IsValid.Should().BeTrue();
|
||||
result.PageCount.Should().Be(3);
|
||||
result.PdfVersion.Should().Be("1.7");
|
||||
result.PdfAVersion.Should().Be("PDF/A-3b");
|
||||
result.PdfACompliant.Should().BeTrue();
|
||||
result.Encrypted.Should().BeFalse();
|
||||
result.Errors.Should().BeEmpty();
|
||||
result.Warnings.Should().BeEmpty();
|
||||
|
||||
_mockPdfProcessor.Verify(x => x.ValidatePdfAAsync(It.IsAny<Stream>()), Times.Once);
|
||||
_mockMapper.Verify(x => x.Map<PdfAValidationResult>(domainMetadata), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_NonCompliantPdfA_ReturnsErrorsAndWarnings()
|
||||
{
|
||||
// Arrange
|
||||
var pdfBytes = new byte[] { 0x25, 0x50, 0x44, 0x46 }; // "%PDF"
|
||||
var query = new ValidatePdfAQuery { PdfStream = new MemoryStream(pdfBytes) };
|
||||
|
||||
var errors = new List<string> { "Missing XMP metadata", "Invalid color space" };
|
||||
var warnings = new List<string> { "Embedded font not subset" };
|
||||
|
||||
var domainMetadata = new PdfAMetadata(
|
||||
isValid: false,
|
||||
pdfVersion: "1.4",
|
||||
pageCount: 2,
|
||||
fileSizeBytes: 1024,
|
||||
encrypted: false,
|
||||
pdfaVersion: null,
|
||||
pdfaCompliant: false,
|
||||
errors: errors,
|
||||
warnings: warnings
|
||||
);
|
||||
|
||||
var expectedDto = new PdfAValidationResult
|
||||
{
|
||||
IsValid = false,
|
||||
PdfVersion = "1.4",
|
||||
PageCount = 2,
|
||||
FileSize = 1024,
|
||||
Encrypted = false,
|
||||
PdfAVersion = null,
|
||||
PdfACompliant = false,
|
||||
Errors = errors,
|
||||
Warnings = warnings
|
||||
};
|
||||
|
||||
_mockPdfProcessor
|
||||
.Setup(x => x.ValidatePdfAAsync(It.IsAny<Stream>()))
|
||||
.ReturnsAsync(domainMetadata);
|
||||
|
||||
_mockMapper
|
||||
.Setup(x => x.Map<PdfAValidationResult>(domainMetadata))
|
||||
.Returns(expectedDto);
|
||||
|
||||
// Act
|
||||
var result = await _handler.Handle(query, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.PdfACompliant.Should().BeFalse();
|
||||
result.PdfAVersion.Should().BeNull();
|
||||
result.Errors.Should().HaveCount(2);
|
||||
result.Errors.Should().Contain("Missing XMP metadata");
|
||||
result.Warnings.Should().HaveCount(1);
|
||||
|
||||
_mockPdfProcessor.Verify(x => x.ValidatePdfAAsync(It.IsAny<Stream>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_EncryptedPdf_ReturnsEncryptedFlag()
|
||||
{
|
||||
// Arrange
|
||||
var pdfBytes = new byte[] { 0x25, 0x50, 0x44, 0x46 }; // "%PDF"
|
||||
var query = new ValidatePdfAQuery { PdfStream = new MemoryStream(pdfBytes) };
|
||||
|
||||
var domainMetadata = new PdfAMetadata(
|
||||
isValid: true,
|
||||
pdfVersion: "1.7",
|
||||
pageCount: 1,
|
||||
fileSizeBytes: 512,
|
||||
encrypted: true,
|
||||
pdfaVersion: null,
|
||||
pdfaCompliant: false,
|
||||
errors: new List<string> { "Encrypted PDFs cannot be PDF/A compliant" },
|
||||
warnings: new List<string>()
|
||||
);
|
||||
|
||||
var expectedDto = new PdfAValidationResult
|
||||
{
|
||||
IsValid = true,
|
||||
PdfVersion = "1.7",
|
||||
PageCount = 1,
|
||||
FileSize = 512,
|
||||
Encrypted = true,
|
||||
PdfAVersion = null,
|
||||
PdfACompliant = false,
|
||||
Errors = new List<string> { "Encrypted PDFs cannot be PDF/A compliant" },
|
||||
Warnings = new List<string>()
|
||||
};
|
||||
|
||||
_mockPdfProcessor
|
||||
.Setup(x => x.ValidatePdfAAsync(It.IsAny<Stream>()))
|
||||
.ReturnsAsync(domainMetadata);
|
||||
|
||||
_mockMapper
|
||||
.Setup(x => x.Map<PdfAValidationResult>(domainMetadata))
|
||||
.Returns(expectedDto);
|
||||
|
||||
// Act
|
||||
var result = await _handler.Handle(query, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result.Encrypted.Should().BeTrue();
|
||||
result.PdfACompliant.Should().BeFalse();
|
||||
result.Errors.Should().Contain("Encrypted PDFs cannot be PDF/A compliant");
|
||||
|
||||
_mockPdfProcessor.Verify(x => x.ValidatePdfAAsync(It.IsAny<Stream>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_PdfProcessorThrowsException_PropagatesException()
|
||||
{
|
||||
// Arrange
|
||||
var pdfBytes = new byte[] { 0x25, 0x50, 0x44, 0x46 }; // "%PDF"
|
||||
var query = new ValidatePdfAQuery { PdfStream = new MemoryStream(pdfBytes) };
|
||||
|
||||
_mockPdfProcessor
|
||||
.Setup(x => x.ValidatePdfAAsync(It.IsAny<Stream>()))
|
||||
.ThrowsAsync(new BadRequestException("Invalid PDF format"));
|
||||
|
||||
// Act & Assert
|
||||
var exception = await Assert.ThrowsAsync<BadRequestException>(
|
||||
() => _handler.Handle(query, CancellationToken.None)
|
||||
);
|
||||
|
||||
exception.Message.Should().Be("Invalid PDF format");
|
||||
_mockPdfProcessor.Verify(x => x.ValidatePdfAAsync(It.IsAny<Stream>()), Times.Once);
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user