Compare commits
165 Commits
758d32d8e0
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 0db28a025f | |||
| 6a321c1022 | |||
| dcb735f8c8 | |||
| 6527cdcbb0 | |||
| 24db1b0d0a | |||
| f7cba05490 | |||
| a618e3559a | |||
| 68eeeb1eda | |||
| 0e22ffb2c5 | |||
| 62ab576e69 | |||
| 092d283a2b | |||
| 71dd32f11c | |||
| f9d4e53ddc | |||
| d92e7f0b07 | |||
| 189622c476 | |||
| f51fde6a23 | |||
| f4d87b42f3 | |||
| 0c27e4fd91 | |||
| d1fdbb494b | |||
| 14645514d9 | |||
| 2c98558131 | |||
| af0fca198a | |||
| 75bea9ef29 | |||
| 108e522316 | |||
| 95d67567f0 | |||
| 207c778f3a | |||
| 050cb55bf9 | |||
| 7559028164 | |||
| fbaefe1fd2 | |||
| 9040aed1da | |||
| 9a9d775fec | |||
| 2630860e7e | |||
| 7b9318e75c | |||
| ef78fb5cdd | |||
| 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 | |||
| 45bc90b8b8 | |||
| 57e36fc004 | |||
| d6e3a5fda1 | |||
| b460d8df39 | |||
| 398651964e | |||
| f8690b9417 | |||
| 077eb1e017 | |||
| 8b154e7378 | |||
| a12d529d9e | |||
|
|
d03806e622 | ||
|
|
84c0a54c1e | ||
|
|
c5db216f15 | ||
|
|
586fd4a207 | ||
|
|
47cba50d0f | ||
|
|
18e956c2cf | ||
|
|
1b38d5a729 | ||
|
|
930b76ecb5 | ||
|
|
afc0e34312 | ||
|
|
62c67d86d4 | ||
|
|
91f479dd0a | ||
|
|
5ac0777e5b | ||
|
|
0c16294f79 | ||
|
|
1b39ec502b | ||
|
|
b1d48418cf | ||
|
|
10cfb0c838 | ||
|
|
d50e30f7ac | ||
|
|
64be11f7ad | ||
|
|
b88f011701 | ||
|
|
b8c9e1b6a6 | ||
|
|
09cc64eff0 | ||
|
|
867e0b2655 | ||
|
|
fc79665241 | ||
|
|
196f6d9cfb | ||
|
|
498b6758bf | ||
|
|
49d1f43822 | ||
|
|
3a87ace144 | ||
|
|
cdb942210c | ||
|
|
9512913866 |
726
AGENTS.md
Normal file
726
AGENTS.md
Normal file
@@ -0,0 +1,726 @@
|
||||
# AGENTS.md
|
||||
|
||||
Agent guidance for DocumentService service. Read this before working on the codebase.
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ CRITICAL: Architecture Decision Change
|
||||
|
||||
**Previous developer used Minimal API** (`DocumentEndpoints.cs`), but this is **WRONG**.
|
||||
|
||||
**YOU MUST use Controller-based approach** as specified in `CONTROLLER_ENDPOINTS.md`.
|
||||
|
||||
### Key Differences
|
||||
|
||||
| Previous Approach (WRONG) | Required Approach (CORRECT) |
|
||||
|---------------------------|------------------------------|
|
||||
| Minimal API (`DocumentEndpoints.cs`) | **Controllers** (`PdfValidationController`, etc.) |
|
||||
| Only Base64 JSON | **Both multipart/form-data AND Base64 JSON** |
|
||||
| `/api/v1/documents/validate` | **`/api/pdf/validation/validate`** |
|
||||
|
||||
**Do NOT follow ROADMAP.md's "Minimal API" guidance.** It conflicts with the requirements.
|
||||
|
||||
### Migration Required
|
||||
|
||||
**Existing code that needs replacement:**
|
||||
- `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)
|
||||
|
||||
**Dual Input Support Required:**
|
||||
- Controllers must accept **BOTH** file upload (multipart/form-data) and Base64 JSON
|
||||
- Each endpoint should have overloads or flexible parameter binding
|
||||
- Preserve existing Base64 functionality while adding file upload support
|
||||
|
||||
---
|
||||
|
||||
## Architecture & Development Approach
|
||||
|
||||
**Clean Architecture with Controller-Based API:**
|
||||
- 4 layers: API → Application → Infrastructure → Domain
|
||||
- Domain has **ZERO** external dependencies (only standard .NET)
|
||||
- Feature-driven development: complete one feature end-to-end before starting the next
|
||||
- Feature = Domain + Infrastructure + Application + **Controller** + Tests + Swagger (all layers)
|
||||
|
||||
**Dependency flow (enforced):**
|
||||
```
|
||||
API → Application → Domain
|
||||
API → Infrastructure → Application
|
||||
Infrastructure → Application (for interfaces only)
|
||||
Domain → NOTHING
|
||||
```
|
||||
|
||||
**Vertical Slice structure** (NOT horizontal layers):
|
||||
```
|
||||
Features/Documents/
|
||||
├── ValidatePdf/
|
||||
│ ├── ValidatePdfQuery.cs (request)
|
||||
│ ├── ValidatePdfHandler.cs (logic)
|
||||
│ └── ValidatePdfValidator.cs (validation)
|
||||
└── ExtractSwissQrCode/
|
||||
├── ExtractSwissQrCodeQuery.cs
|
||||
├── ExtractSwissQrCodeHandler.cs
|
||||
└── ExtractSwissQrCodeValidator.cs
|
||||
```
|
||||
|
||||
All files for a feature live together. Do NOT create separate Commands/, Handlers/, Validators/ folders.
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ Architecture Principles
|
||||
|
||||
### Clean Architecture (Pragmatic)
|
||||
|
||||
**4 Layers with strict dependency rules:**
|
||||
- API → Application → Domain
|
||||
- Infrastructure → Application (interfaces only)
|
||||
- Domain → NOTHING (zero external dependencies)
|
||||
|
||||
**Key Principles:**
|
||||
- ✅ Testability (Application layer mocks Infrastructure services)
|
||||
- ✅ Replaceability (swap DevExpress without touching Application)
|
||||
- ✅ Separation of Concerns
|
||||
- ❌ NO overengineering (only what we need, YAGNI principle)
|
||||
- ❌ NO speculative abstractions (wait for 2nd use case)
|
||||
|
||||
### CQRS with MediatR
|
||||
|
||||
**Why MediatR:**
|
||||
- 1 Command/Query = 1 Handler = 1 Responsibility
|
||||
- Isolated, testable handlers
|
||||
- Pipeline Behaviors (Validation, Logging) run centrally
|
||||
- Avoids bloated services with 20+ methods
|
||||
|
||||
**Pattern:**
|
||||
- **Command:** Modifies data (ApplyStamp, EmbedCertificate)
|
||||
- **Query:** Reads data (ValidatePdf returns metadata only)
|
||||
|
||||
**Pipeline:** ValidationBehavior → LoggingBehavior → Handler
|
||||
|
||||
### Vertical Slice Architecture
|
||||
|
||||
**NOT Horizontal** (Commands/, Handlers/, Validators/ folders)
|
||||
**YES Vertical** (all files for one feature together)
|
||||
|
||||
**Benefits:**
|
||||
- Related code stays together (high cohesion)
|
||||
- Easier to find ("Where's ValidatePdf?" → one folder!)
|
||||
- Easier to modify (all files in same folder)
|
||||
- Fewer merge conflicts in teams
|
||||
|
||||
### Exception-Based Error Handling
|
||||
|
||||
**NO Result<T> pattern library**
|
||||
|
||||
**Flow:**
|
||||
1. FluentValidation (DTO level) → ValidationException → 400
|
||||
2. Domain validation → DomainValidationException → 400
|
||||
3. Business logic → DomainException → 400/404
|
||||
4. Infrastructure → PdfProcessingException → 500
|
||||
|
||||
**Middleware:** Central exception handler maps exceptions to HTTP status codes
|
||||
|
||||
**Why exceptions:**
|
||||
- Simpler code (no `if (result.IsSuccess)` everywhere)
|
||||
- Less boilerplate (no Result<T> wrapping)
|
||||
- Standard .NET exception flow
|
||||
- Centralized error handling (one place to maintain)
|
||||
|
||||
### Feature-Driven Development
|
||||
|
||||
**Feature-Driven (NOT Layer-by-Layer):**
|
||||
- Complete one feature end-to-end before starting next
|
||||
- Feature = Domain + Infrastructure + Application + API + Tests + Swagger
|
||||
- Feature is DONE when testable in Swagger UI
|
||||
|
||||
**Why:**
|
||||
- Faster value delivery (Feature 1 done in ~1 day)
|
||||
- Clear definition of done (Swagger testable)
|
||||
- Less complexity (not all layers in parallel)
|
||||
- Better learning (pattern repeats)
|
||||
|
||||
**Alternative rejected:** Complete all Domain → all Infrastructure → all Application → all API
|
||||
**Problem:** Too much speculative code without visible results
|
||||
|
||||
### Test-Driven Development (TDD)
|
||||
|
||||
**Flow:** Red → Green → Refactor
|
||||
|
||||
**Test Pyramid:**
|
||||
- **Unit Tests (many):** Value Objects, Handlers, Services
|
||||
- **Integration Tests (some):** Endpoints, MediatR Pipeline
|
||||
- **E2E Tests (few/none):** API is already top-level
|
||||
|
||||
**Why TDD:**
|
||||
- Tests as documentation
|
||||
- Tests as safety net for refactoring
|
||||
- Better design (testable = good code)
|
||||
- No forgotten tests (test comes FIRST)
|
||||
|
||||
### Cross-Cutting Concerns Timing
|
||||
|
||||
**Multi-Tenancy Implementation Deferred**
|
||||
|
||||
**Decision:** Implement multi-tenancy (X-API-Key header, tenant database, Redis cache) AFTER all synchronous PDF operation features are complete.
|
||||
|
||||
**Why:**
|
||||
- Multi-tenancy affects ALL endpoints
|
||||
- Better to implement once for all features (avoid repetition)
|
||||
- Easier to test features first without tenancy, then add tenancy layer
|
||||
- Cleaner separation: Features first, then cross-cutting concerns
|
||||
|
||||
**Impact on current architecture:**
|
||||
- ❌ NO Entity Framework yet (tenant database comes with multi-tenancy)
|
||||
- ❌ NO Redis yet (API key caching comes with multi-tenancy)
|
||||
- ❌ NO X-API-Key authentication yet (comes with multi-tenancy)
|
||||
- ✅ All features currently work without authentication
|
||||
|
||||
**When to implement:**
|
||||
After completing all Phase 1-3 controllers (PdfValidation, PdfAttachment, SwissQrCode, PdfOperations, PdfConversion), then add multi-tenancy to ALL endpoints in one refactoring phase.
|
||||
|
||||
---
|
||||
|
||||
## Build, Test, Run
|
||||
|
||||
**Build:**
|
||||
```powershell
|
||||
dotnet build
|
||||
```
|
||||
|
||||
**Run tests (101 passed, 7 skipped as of Feature 7 - PDF Stamp):**
|
||||
```powershell
|
||||
dotnet test
|
||||
```
|
||||
|
||||
**Run API (Development):**
|
||||
```powershell
|
||||
dotnet run --project DocumentService.API
|
||||
```
|
||||
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)
|
||||
|
||||
---
|
||||
|
||||
## Key Libraries & Their Roles
|
||||
|
||||
| Library | Purpose | Where Used |
|
||||
|---------|---------|------------|
|
||||
| **DevExpress.Document.Processor** (26.1.3) | PDF operations (validation, QR extraction, attachments) | Infrastructure layer only |
|
||||
| **Codecrete.SwissQRBill.Generator** (3.4.0) | Swiss QR Bill parsing (Standard 2.0) | Infrastructure.Services.QrCodeProcessing |
|
||||
| **ZXing.Net.Bindings.Windows.Compatibility** (0.16.14) | QR code image decoding | Infrastructure.Services.QrCodeProcessing |
|
||||
| **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`.
|
||||
|
||||
---
|
||||
|
||||
## Exception Handling Strategy
|
||||
|
||||
**No Result<T> pattern.** Use exceptions + central middleware.
|
||||
|
||||
**Flow:**
|
||||
1. FluentValidation validates request DTOs → throws `ValidationException` → HTTP 400
|
||||
2. Domain validation in Value Objects → throws `DomainValidationException` → HTTP 400
|
||||
3. Business logic errors → throws `DomainException` subtypes → HTTP 400/404/500
|
||||
4. Infrastructure errors (e.g., PDF parsing) → throws `PdfProcessingException` → HTTP 500
|
||||
|
||||
**Middleware maps exceptions to HTTP status codes** (`ExceptionHandlingMiddleware.cs`).
|
||||
|
||||
Do NOT add `if (result.IsSuccess)` checks. Throw exceptions for errors. The middleware handles the rest.
|
||||
|
||||
---
|
||||
|
||||
## Required Controllers & Endpoints
|
||||
|
||||
**See `CONTROLLER_ENDPOINTS.md` for complete specification.**
|
||||
|
||||
### Priority Order
|
||||
|
||||
**Phase 1 (PRIORITY):**
|
||||
1. `PdfValidationController` – 2 endpoints
|
||||
- `POST /api/pdf/validation/validate` (Basic PDF validation)
|
||||
- `POST /api/pdf/validation/validate-pdfa` (PDF/A conformance)
|
||||
2. `PdfAttachmentController` – check endpoint
|
||||
- `POST /api/pdf/attachments/check` (Attachment detection)
|
||||
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:**
|
||||
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:**
|
||||
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** | ✅ 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 |
|
||||
|
||||
**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.
|
||||
|
||||
---
|
||||
|
||||
## Adding a New Feature
|
||||
|
||||
**Required steps (follow CONTROLLER_ENDPOINTS.md):**
|
||||
|
||||
1. **Domain:** Value Objects, Exceptions (if needed)
|
||||
2. **Infrastructure:** Service interface + DevExpress implementation + unit tests
|
||||
3. **Application:** Query/Command + Handler + FluentValidator + DTOs + unit tests
|
||||
4. **API:** **Controller** + actions + integration tests
|
||||
5. **Swagger:** XML comments on controller actions + DTOs
|
||||
|
||||
**Example (PdfValidationController):**
|
||||
```
|
||||
Step 1: Application/Features/Documents/ValidatePdf/
|
||||
- ValidatePdfCommand.cs (record)
|
||||
- ValidatePdfHandler.cs (IRequestHandler)
|
||||
- ValidatePdfValidator.cs (AbstractValidator)
|
||||
Step 2: API/Controllers/PdfValidationController.cs
|
||||
- [HttpPost("validate")] action
|
||||
- Accepts IFormFile (multipart/form-data)
|
||||
- Returns ValidatePdfResponse
|
||||
Step 3: XML comments + [ProducesResponseType] attributes
|
||||
```
|
||||
|
||||
**CRITICAL: Support BOTH multipart/form-data AND Base64 JSON for all file-based endpoints.**
|
||||
|
||||
**Input Flexibility:**
|
||||
- Primary: `IFormFile` (multipart/form-data) - for direct file uploads
|
||||
- Secondary: `Base64String` (application/json) - for API clients that can't send multipart
|
||||
|
||||
Do NOT skip steps. Each feature is done when it's **testable in Swagger UI with both input methods**.
|
||||
|
||||
---
|
||||
|
||||
## Test Data
|
||||
|
||||
**Embedded test PDFs:**
|
||||
- `TestData/Pdfs/valid.pdf` (simple PDF for validation)
|
||||
- `TestData/Pdfs/pdfWithSwissQRCode.pdf` (Swiss QR Code on last page)
|
||||
- `TestData/Pdfs/pdfWithMoreThanOneAttachment.pdf` (6 attachments)
|
||||
|
||||
**All test PDFs are EmbeddedResource.** Access via:
|
||||
```csharp
|
||||
var stream = Assembly.GetExecutingAssembly()
|
||||
.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:
|
||||
- QR code is on the **last page** of the PDF (not first!)
|
||||
- Use `DevExpress.Pdf.PdfDocumentProcessor` to render last page as image
|
||||
- Use `ZXing` to decode QR code from image
|
||||
- Use `Codecrete.SwissQRBill.Generator` to parse Swiss QR Bill payload
|
||||
|
||||
**Known quirks:**
|
||||
- PDF must be rendered at **300 DPI** for reliable QR detection
|
||||
- Alternative procedure parameters (AV1, AV2) are split by newline, not semicolon
|
||||
|
||||
---
|
||||
|
||||
## MediatR Pipeline Behaviors
|
||||
|
||||
**Two behaviors run for EVERY request:**
|
||||
|
||||
1. **ValidationBehavior** (runs first): Executes all `IValidator<TRequest>` and throws `ValidationException` if invalid
|
||||
2. **LoggingBehavior** (runs second): Logs request name + execution time
|
||||
|
||||
**Registered in:** `Application/DependencyInjection.cs`
|
||||
|
||||
Do NOT manually call validators in handlers. The pipeline does it.
|
||||
|
||||
---
|
||||
|
||||
## Controller Pattern (CORRECT Approach)
|
||||
|
||||
**Controllers must support BOTH file upload and Base64 input.**
|
||||
|
||||
### Option 1: Separate Endpoints (Recommended)
|
||||
|
||||
```csharp
|
||||
[ApiController]
|
||||
[Route("api/pdf/validation")]
|
||||
public class PdfValidationController : ControllerBase
|
||||
{
|
||||
private readonly IMediator _mediator;
|
||||
|
||||
public PdfValidationController(IMediator mediator)
|
||||
{
|
||||
_mediator = mediator;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates a PDF document (multipart/form-data)
|
||||
/// </summary>
|
||||
[HttpPost("validate")]
|
||||
[Consumes("multipart/form-data")]
|
||||
[ProducesResponseType(typeof(ValidatePdfResponse), 200)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), 400)]
|
||||
public async Task<IActionResult> ValidateFromFile(IFormFile file, CancellationToken ct)
|
||||
{
|
||||
using var ms = new MemoryStream();
|
||||
await file.CopyToAsync(ms, ct);
|
||||
byte[] pdfBytes = ms.ToArray();
|
||||
|
||||
var command = new ValidatePdfCommand(pdfBytes);
|
||||
var result = await _mediator.Send(command, ct);
|
||||
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates a PDF document (Base64 JSON)
|
||||
/// </summary>
|
||||
[HttpPost("validate")]
|
||||
[Consumes("application/json")]
|
||||
[ProducesResponseType(typeof(ValidatePdfResponse), 200)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), 400)]
|
||||
public async Task<IActionResult> ValidateFromBase64(
|
||||
[FromBody] ValidatePdfRequest request,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var query = new ValidatePdfQuery(Base64String.Create(request.Base64Pdf));
|
||||
var result = await _mediator.Send(query, ct);
|
||||
|
||||
return Ok(result);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Option 2: Single Endpoint with Model Binding
|
||||
|
||||
```csharp
|
||||
public class PdfInputModel
|
||||
{
|
||||
public IFormFile? File { get; set; }
|
||||
public string? Base64Pdf { get; set; }
|
||||
}
|
||||
|
||||
[HttpPost("validate")]
|
||||
public async Task<IActionResult> Validate([FromForm] PdfInputModel input, CancellationToken ct)
|
||||
{
|
||||
byte[] pdfBytes = input.File != null
|
||||
? await GetBytesFromFile(input.File)
|
||||
: Base64String.Create(input.Base64Pdf!).ToByteArray();
|
||||
|
||||
// Process...
|
||||
}
|
||||
```
|
||||
|
||||
**Use Controllers, NOT Minimal API endpoints.**
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
**appsettings.json sections:**
|
||||
- `DocumentServiceSettings` (future: file size limits, temp paths)
|
||||
- `RedisSettings` (future: multi-tenancy caching)
|
||||
- `ApiKeySettings` (future: authentication)
|
||||
|
||||
**Currently:** All features work without authentication. Multi-tenancy is deferred until after all sync features are complete.
|
||||
|
||||
---
|
||||
|
||||
## Coding Standards
|
||||
|
||||
### Primary Constructors
|
||||
**ALWAYS use primary constructors** (C# 12 feature) unless there's a technical limitation.
|
||||
|
||||
**✅ Correct:**
|
||||
```csharp
|
||||
public class PdfValidationController(IMediator mediator, ILogger<PdfValidationController> logger) : ControllerBase
|
||||
{
|
||||
// Use parameters directly, no field declarations needed
|
||||
public async Task<IActionResult> Validate(...)
|
||||
{
|
||||
await mediator.Send(...);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**❌ Wrong:**
|
||||
```csharp
|
||||
public class PdfValidationController : ControllerBase
|
||||
{
|
||||
private readonly IMediator _mediator;
|
||||
private readonly ILogger<PdfValidationController> _logger;
|
||||
|
||||
public PdfValidationController(IMediator mediator, ILogger<PdfValidationController> logger)
|
||||
{
|
||||
_mediator = mediator;
|
||||
_logger = logger;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Controller Responsibilities
|
||||
**Controllers should be thin.** Do NOT add mapping logic.
|
||||
|
||||
**✅ Correct:**
|
||||
```csharp
|
||||
public async Task<IActionResult> Validate([FromBody] ValidatePdfRequest request, CancellationToken ct)
|
||||
{
|
||||
// Direct pass-through to MediatR
|
||||
var result = await mediator.Send(request, ct);
|
||||
return Ok(result);
|
||||
}
|
||||
```
|
||||
|
||||
**❌ Wrong:**
|
||||
```csharp
|
||||
public async Task<IActionResult> Validate([FromBody] ValidatePdfRequest request, CancellationToken ct)
|
||||
{
|
||||
// Manual mapping (WRONG!)
|
||||
var command = new ValidatePdfCommand(request.Base64Pdf);
|
||||
var metadata = await mediator.Send(command, ct);
|
||||
var response = new ValidatePdfResponse(metadata.PageCount, ...);
|
||||
return Ok(response);
|
||||
}
|
||||
```
|
||||
|
||||
**If mapping is absolutely necessary:** Use AutoMapper.
|
||||
|
||||
### Request DTOs - Flexible Input
|
||||
**Support BOTH `byte[]` and `Base64String` in requests.**
|
||||
|
||||
```csharp
|
||||
public record ValidatePdfRequest
|
||||
{
|
||||
public byte[]? PdfBytes { get; init; }
|
||||
public string? Base64Pdf { get; init; }
|
||||
}
|
||||
```
|
||||
|
||||
**FluentValidation:** Ensure exactly ONE is provided:
|
||||
```csharp
|
||||
public class ValidatePdfRequestValidator : AbstractValidator<ValidatePdfRequest>
|
||||
{
|
||||
public ValidatePdfRequestValidator()
|
||||
{
|
||||
RuleFor(x => x)
|
||||
.Must(x => (x.PdfBytes != null && x.PdfBytes.Length > 0) ^
|
||||
(!string.IsNullOrWhiteSpace(x.Base64Pdf)))
|
||||
.WithMessage("Either PdfBytes or Base64Pdf must be provided, but not both");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Handler:** Use `byte[]` if available, otherwise convert Base64:
|
||||
```csharp
|
||||
public async Task<PdfMetadata> Handle(ValidatePdfRequest request, CancellationToken ct)
|
||||
{
|
||||
byte[] pdfBytes = request.PdfBytes ?? Convert.FromBase64String(request.Base64Pdf!);
|
||||
return await _processor.ValidateAsync(pdfBytes);
|
||||
}
|
||||
```
|
||||
|
||||
### No Unnecessary Value Objects
|
||||
**Do NOT create value objects for simple types** (e.g., Base64String).
|
||||
|
||||
**❌ Wrong:** Creating `Base64String` value object just to wrap `string`
|
||||
**✅ Correct:** Use `string` directly + extension methods if needed
|
||||
|
||||
**Why:**
|
||||
- Performance overhead (validation runs twice: once in value object, once in FluentValidation)
|
||||
- Unnecessary abstraction (YAGNI principle)
|
||||
- `Convert.FromBase64String()` already validates format
|
||||
|
||||
### Exception Handling in Controllers
|
||||
**Let FormatException bubble up naturally.** ExceptionHandlingMiddleware will catch it.
|
||||
|
||||
```csharp
|
||||
// Handler
|
||||
byte[] pdfBytes = request.PdfBytes ?? Convert.FromBase64String(request.Base64Pdf!);
|
||||
// If Base64 is invalid, FormatException → Middleware → 400 Bad Request
|
||||
```
|
||||
|
||||
**Middleware handles:**
|
||||
- `FormatException` → 400 Bad Request
|
||||
- `ValidationException` → 400 Bad Request
|
||||
- `DomainException` → 400/404
|
||||
- `PdfProcessingException` → 500
|
||||
|
||||
---
|
||||
|
||||
## Git Commit Guidelines
|
||||
|
||||
### ⚠️ CRITICAL: Never Commit Without Approval
|
||||
**NEVER run `git commit` without explicit user approval.**
|
||||
|
||||
### Systematic Commits
|
||||
**Do NOT commit everything in one giant commit.**
|
||||
|
||||
**✅ Correct approach:**
|
||||
1. Complete one logical change (e.g., "Add PdfValidationController")
|
||||
2. Stage only related files: `git add <specific-files>`
|
||||
3. Ask user: "Ready to commit 'Add PdfValidationController'?"
|
||||
4. After approval: `git commit -m "Add PdfValidationController with dual input support"`
|
||||
5. Repeat for next logical change
|
||||
|
||||
**❌ Wrong approach:**
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "Migrate everything to controllers, update tests, add AGENTS.md, delete ROADMAP.md"
|
||||
# This is TOO MUCH in one commit!
|
||||
```
|
||||
|
||||
**Good commit messages:**
|
||||
- `feat: Add PdfValidationController with multipart/form-data support`
|
||||
- `refactor: Replace Base64String value object with direct string usage`
|
||||
- `test: Add integration tests for PdfValidationController`
|
||||
- `docs: Add AGENTS.md with architecture guidance`
|
||||
- `chore: Delete deprecated ROADMAP.md`
|
||||
|
||||
**Commit size guideline:** 1-5 files per commit, one logical change
|
||||
|
||||
---
|
||||
|
||||
## What NOT to Do
|
||||
|
||||
- ❌ Do NOT create horizontal folders (Commands/, Handlers/, Validators/)
|
||||
- ❌ Do NOT add Entity Framework until multi-tenancy phase
|
||||
- ❌ Do NOT use Minimal API endpoints (use Controllers instead)
|
||||
- ❌ Do NOT support only ONE input type (must support BOTH multipart AND Base64)
|
||||
- ❌ Do NOT use Result<T> pattern (use exceptions)
|
||||
- ❌ Do NOT skip tests (TDD: write test first, then implementation)
|
||||
- ❌ Do NOT add dependencies to Domain layer (keep it clean!)
|
||||
- ❌ Do NOT commit without user approval
|
||||
- ❌ Do NOT use old-style constructors (use primary constructors)
|
||||
- ❌ Do NOT add mapping logic in controllers (keep them thin)
|
||||
- ❌ Do NOT create unnecessary value objects (YAGNI principle)
|
||||
|
||||
---
|
||||
|
||||
## Debugging Tips
|
||||
|
||||
**DevExpress PDF errors:**
|
||||
- Check if file is actually a valid PDF (magic bytes: `%PDF-`)
|
||||
- DevExpress throws generic exceptions; wrap in try-catch and add context
|
||||
|
||||
**Swiss QR Code not found:**
|
||||
- Verify QR is on **last page** (not first)
|
||||
- Check DPI setting (300 DPI required, see ROADMAP.md Feature 2)
|
||||
- Use `ZXing` with `TryHarder` hint enabled
|
||||
|
||||
**Attachment count wrong:**
|
||||
- Search entire PDF stream, not just first 1000 chars (see ROADMAP.md fix log 17.01.2025)
|
||||
- Count `/EmbeddedFiles` object references correctly (not divided by 2)
|
||||
|
||||
---
|
||||
|
||||
## References
|
||||
|
||||
- **CONTROLLER_ENDPOINTS.md** – **PRIMARY SOURCE** for API specification (all planned endpoints)
|
||||
- **REQUIRED_FEATURES.md** – Business requirements (what PDF operations are needed and why)
|
||||
- **ROADMAP.md** – Feature-by-feature implementation plan (1101 lines, detailed) **NOTE: Uses Minimal API, which is incorrect. Follow CONTROLLER_ENDPOINTS.md instead.**
|
||||
- **DevExpress Docs** – https://docs.devexpress.com/OfficeFileAPI/
|
||||
- **Swiss QR Bill Standard** – https://www.ferd-net.de/ (ZUGFeRD/XRechnung context)
|
||||
|
||||
**When implementing endpoints:** Follow CONTROLLER_ENDPOINTS.md, NOT ROADMAP.md's Minimal API approach.
|
||||
352
CONTROLLER_ENDPOINTS.md
Normal file
352
CONTROLLER_ENDPOINTS.md
Normal file
@@ -0,0 +1,352 @@
|
||||
# DocumentService - Controller & Endpoint Specification
|
||||
|
||||
**Project:** DocumentService (DOC)
|
||||
**Ticket:** DOC-1 - GDPicture and Nutrient Replacing
|
||||
**Owner:** Hakan Tek
|
||||
**Date:** July 3, 2026
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
This specification defines the controller structure and REST API endpoints for the DocumentService service.
|
||||
|
||||
---
|
||||
|
||||
## PdfValidationController
|
||||
|
||||
### Endpoint: PDF Validation
|
||||
**Route:** `POST /api/pdf/validation/validate`
|
||||
**Function:** Checks whether the file is a valid PDF, whether it is corrupted, and returns basic information
|
||||
|
||||
**Input:**
|
||||
- PDF file (multipart/form-data)
|
||||
|
||||
**Output:**
|
||||
```json
|
||||
{
|
||||
"isValid": bool,
|
||||
"pdfVersion": string,
|
||||
"pageCount": int,
|
||||
"fileSize": long,
|
||||
"encrypted": bool,
|
||||
"errors": string[]
|
||||
}
|
||||
```
|
||||
|
||||
**Usage:** All products - basic PDF input check
|
||||
|
||||
---
|
||||
|
||||
### Endpoint: PDF/A Validation
|
||||
**Route:** `POST /api/pdf/validation/validate-pdfa`
|
||||
**Function:** PDF/A conformance check (embedded fonts, encryption, JavaScript, etc.)
|
||||
|
||||
**Input:**
|
||||
- PDF file (multipart/form-data)
|
||||
|
||||
**Output:**
|
||||
```json
|
||||
{
|
||||
"isValid": bool,
|
||||
"pdfaVersion": string,
|
||||
"pageCount": int,
|
||||
"errors": string[],
|
||||
"warnings": string[]
|
||||
}
|
||||
```
|
||||
|
||||
**Usage:** taskFLOW, eParser - ensuring PDF/A conformance
|
||||
|
||||
---
|
||||
|
||||
## PdfAttachmentController
|
||||
|
||||
### Endpoint: Attachment Check
|
||||
**Route:** `POST /api/pdf/attachments/check`
|
||||
**Function:** Detects whether embedded files (e.g. ZUGFeRD XML) are present in the PDF
|
||||
|
||||
**Input:**
|
||||
- PDF file (multipart/form-data)
|
||||
|
||||
**Output:**
|
||||
```json
|
||||
{
|
||||
"hasAttachments": bool,
|
||||
"attachmentCount": int,
|
||||
"attachments": [
|
||||
{
|
||||
"fileName": string,
|
||||
"mimeType": string,
|
||||
"size": long
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Usage:** eParser (ZUGFeRD), ErgebnisberichtCreator
|
||||
|
||||
---
|
||||
|
||||
### Endpoint: Attachment Extraction
|
||||
**Route:** `POST /api/pdf/attachments/extract`
|
||||
**Function:** Extracts all embedded files from the PDF and returns them as a ZIP archive
|
||||
|
||||
**Input:**
|
||||
- PDF file (multipart/form-data)
|
||||
|
||||
**Output:**
|
||||
- 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
|
||||
**Route:** `POST /api/pdf/operations/merge`
|
||||
**Function:** Merges multiple PDFs into a single file
|
||||
|
||||
**Input:**
|
||||
- Multiple PDF files (multipart/form-data)
|
||||
- Field name: "files" (array of IFormFile)
|
||||
|
||||
**Output:**
|
||||
- Binary stream (application/pdf)
|
||||
- Content-Disposition: attachment; filename="merged.pdf"
|
||||
- Merged PDF document
|
||||
|
||||
**Usage:** signFLOW (Envelope Generator), ErgebnisberichtCreator, ResultHandler (windream)
|
||||
|
||||
---
|
||||
|
||||
### Endpoint: PDF Stamp
|
||||
**Route:** `POST /api/pdf/operations/stamp`
|
||||
**Function:** Adds stamps to PDF pages (APPROVED, CONFIDENTIAL, etc.)
|
||||
|
||||
**Input:**
|
||||
- PDF file (multipart/form-data)
|
||||
- Stamp configuration (JSON):
|
||||
```json
|
||||
{
|
||||
"text": string,
|
||||
"position": string,
|
||||
"pages": string,
|
||||
"color": string,
|
||||
"opacity": float
|
||||
}
|
||||
```
|
||||
|
||||
**Output:**
|
||||
- Binary stream (application/pdf)
|
||||
- Content-Disposition: attachment; filename="stamped.pdf"
|
||||
- PDF with applied stamps
|
||||
|
||||
**Usage:** ErgebnisberichtCreator
|
||||
|
||||
---
|
||||
|
||||
### Endpoint: PDF Annotate
|
||||
**Route:** `POST /api/pdf/operations/annotate`
|
||||
**Function:** Adds comments, highlights, and markings to the PDF
|
||||
|
||||
**Input:**
|
||||
- PDF file (multipart/form-data)
|
||||
- Annotations (JSON):
|
||||
```json
|
||||
{
|
||||
"annotations": [
|
||||
{
|
||||
"type": string,
|
||||
"page": int,
|
||||
"position": object,
|
||||
"text": string
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Output:**
|
||||
- Binary stream (application/pdf)
|
||||
- Content-Disposition: attachment; filename="annotated.pdf"
|
||||
- PDF with applied annotations
|
||||
|
||||
**Usage:** signFLOW (Envelope Generator)
|
||||
|
||||
---
|
||||
|
||||
## SwissQrCodeController
|
||||
|
||||
### Endpoint: Swiss QR Code Extraction
|
||||
**Route:** `POST /api/swissqrcode/extract`
|
||||
**Function:** Extracts and parses Swiss QR Bill (Swiss QR Code) from PDF
|
||||
|
||||
**Input:**
|
||||
- PDF file (multipart/form-data)
|
||||
|
||||
**Output:**
|
||||
```json
|
||||
{
|
||||
"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:** 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.
|
||||
|
||||
---
|
||||
|
||||
## PdfConversionController
|
||||
|
||||
### Endpoint: Convert PDF to PDF/A
|
||||
**Route:** `POST /api/pdf/conversion/to-pdfa`
|
||||
**Function:** Converts a standard PDF to PDF/A
|
||||
|
||||
**Input:**
|
||||
- PDF file (multipart/form-data)
|
||||
- PDF/A level (query parameter): "PDF/A-1b", "PDF/A-2b", "PDF/A-3b"
|
||||
|
||||
**Output:**
|
||||
- Binary stream (application/pdf)
|
||||
- Content-Disposition: attachment; filename="converted-pdfa.pdf"
|
||||
- PDF/A compliant document
|
||||
|
||||
**Usage:** taskFLOW (optional conversion)
|
||||
|
||||
---
|
||||
|
||||
### Endpoint: Convert PDF/A to PDF
|
||||
**Route:** `POST /api/pdf/conversion/from-pdfa`
|
||||
**Function:** Converts PDF/A to a standard PDF
|
||||
|
||||
**Input:**
|
||||
- PDF/A file (multipart/form-data)
|
||||
|
||||
**Output:**
|
||||
- Binary stream (application/pdf)
|
||||
- Content-Disposition: attachment; filename="converted-pdf.pdf"
|
||||
- Standard PDF document
|
||||
|
||||
**Usage:** taskFLOW (optional conversion)
|
||||
|
||||
---
|
||||
|
||||
## Technical Specifications
|
||||
|
||||
### Framework Support
|
||||
- ✓ .NET Core (3.1+, 6.0+, 8.0+)
|
||||
- ✓ .NET Framework (4.7.2+, 4.8+)
|
||||
|
||||
### Client Usage
|
||||
The service can be used on the client side **without manual HTTP response handling**:
|
||||
- Provide REST client wrapper
|
||||
- SDK for C# clients
|
||||
- Automatic serialization/deserialization
|
||||
- Abstracted error handling
|
||||
|
||||
**Example Client SDK:**
|
||||
```csharp
|
||||
var client = new DocumentServiceClient("https://api.example.com");
|
||||
var result = await client.Pdf.Validation.ValidateAsync(pdfFile);
|
||||
if (result.IsValid) { ... }
|
||||
```
|
||||
|
||||
### Response Format
|
||||
- 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/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`)
|
||||
- Optional: OAuth2/JWT for advanced scenarios
|
||||
|
||||
### Swagger/OpenAPI
|
||||
- Complete API documentation
|
||||
- Interactive test UI
|
||||
- Code generation for clients
|
||||
|
||||
---
|
||||
|
||||
## Prioritization
|
||||
|
||||
### Phase 1 (Priority)
|
||||
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
|
||||
6. PdfOperationsController - stamp & annotate endpoints
|
||||
7. PdfAttachmentController - add attachment endpoint
|
||||
|
||||
### Phase 3
|
||||
8. PdfConversionController - both endpoints (to-pdfa, from-pdfa)
|
||||
|
||||
### Removed
|
||||
- PdfRenderController - moved to .NET client library (WinForms/WPF DevExpress controls)
|
||||
|
||||
---
|
||||
|
||||
**Last Updated:** July 3, 2026
|
||||
**Author:** Hakan Tek
|
||||
**Status:** Draft - Awaiting Feedback
|
||||
@@ -1,6 +0,0 @@
|
||||
namespace DocumentOperator.API.Configuration
|
||||
{
|
||||
public class SerilogConfiguration
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
namespace DocumentOperator.API.Configuration
|
||||
{
|
||||
public class SwaggerConfiguration
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</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
|
||||
|
||||
###
|
||||
@@ -1,6 +0,0 @@
|
||||
namespace DocumentOperator.API.Endpoints.v1
|
||||
{
|
||||
public class DocumentEndpoints
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
namespace DocumentOperator.API.Middleware
|
||||
{
|
||||
public class ExceptionHandlingMiddleware
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
namespace DocumentOperator.API.Middleware
|
||||
{
|
||||
public class RequestLoggingMiddleware
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
namespace DocumentOperator.API.Middleware
|
||||
{
|
||||
public class TenantResolutionMiddleware
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
using Serilog;
|
||||
using DocumentOperator.Infrastructure.Configuration;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
// ========================================
|
||||
// 1. Serilog Configuration
|
||||
// ========================================
|
||||
Log.Logger = new LoggerConfiguration()
|
||||
.ReadFrom.Configuration(builder.Configuration)
|
||||
.Enrich.FromLogContext()
|
||||
.Enrich.WithProperty("Application", "DocumentOperator")
|
||||
.CreateLogger();
|
||||
|
||||
builder.Host.UseSerilog();
|
||||
|
||||
Log.Information("Starting DocumentOperator API...");
|
||||
|
||||
try
|
||||
{
|
||||
// ========================================
|
||||
// 2. Options Pattern Configuration
|
||||
// ========================================
|
||||
builder.Services.Configure<DocumentOperatorSettings>(
|
||||
builder.Configuration.GetSection(DocumentOperatorSettings.SectionName));
|
||||
|
||||
builder.Services.Configure<RedisSettings>(
|
||||
builder.Configuration.GetSection(RedisSettings.SectionName));
|
||||
|
||||
builder.Services.Configure<ApiKeySettings>(
|
||||
builder.Configuration.GetSection(ApiKeySettings.SectionName));
|
||||
|
||||
// ========================================
|
||||
// 3. Services
|
||||
// ========================================
|
||||
builder.Services.AddControllers();
|
||||
builder.Services.AddEndpointsApiExplorer();
|
||||
builder.Services.AddSwaggerGen();
|
||||
|
||||
// ========================================
|
||||
// 4. Build App
|
||||
// ========================================
|
||||
var app = builder.Build();
|
||||
|
||||
// ========================================
|
||||
// 5. Middleware Pipeline
|
||||
// ========================================
|
||||
if (app.Environment.IsDevelopment())
|
||||
{
|
||||
app.UseSwagger();
|
||||
app.UseSwaggerUI();
|
||||
}
|
||||
|
||||
app.UseSerilogRequestLogging(); // Log HTTP Requests
|
||||
|
||||
app.UseHttpsRedirection();
|
||||
app.UseAuthorization();
|
||||
app.MapControllers();
|
||||
|
||||
Log.Information("DocumentOperator API started successfully");
|
||||
|
||||
app.Run();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Fatal(ex, "Application startup failed");
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
Log.CloseAndFlush();
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,66 +0,0 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*",
|
||||
|
||||
"Serilog": {
|
||||
"MinimumLevel": {
|
||||
"Default": "Information",
|
||||
"Override": {
|
||||
"Microsoft": "Warning",
|
||||
"Microsoft.AspNetCore": "Warning",
|
||||
"System": "Warning"
|
||||
}
|
||||
},
|
||||
"WriteTo": [
|
||||
{
|
||||
"Name": "Console",
|
||||
"Args": {
|
||||
"outputTemplate": "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj} {Properties:j}{NewLine}{Exception}"
|
||||
}
|
||||
},
|
||||
{
|
||||
"Name": "File",
|
||||
"Args": {
|
||||
"path": "Logs/log-.txt",
|
||||
"rollingInterval": "Day",
|
||||
"outputTemplate": "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {Message:lj} {Properties:j}{NewLine}{Exception}"
|
||||
}
|
||||
}
|
||||
],
|
||||
"Enrich": [ "FromLogContext", "WithMachineName", "WithThreadId" ]
|
||||
},
|
||||
|
||||
"DocumentOperatorSettings": {
|
||||
"TempFolderPath": "C:\\Temp\\DocumentOperator",
|
||||
"TempFileRetentionHours": 24,
|
||||
"MaxPdfSizeMB": 50,
|
||||
"EnableDetailedLogging": true
|
||||
},
|
||||
|
||||
"RedisSettings": {
|
||||
"ConnectionString": "localhost:6379",
|
||||
"InstanceName": "DocumentOperator:",
|
||||
"CacheExpirationMinutes": 60
|
||||
},
|
||||
|
||||
"ApiKeySettings": {
|
||||
"EnableValidation": true,
|
||||
"Keys": {
|
||||
"customer-a-key-12345": {
|
||||
"TenantId": "customer-a",
|
||||
"TenantName": "Customer A GmbH",
|
||||
"IsActive": true
|
||||
},
|
||||
"customer-b-key-67890": {
|
||||
"TenantId": "customer-b",
|
||||
"TenantName": "Customer B AG",
|
||||
"IsActive": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FluentValidation" Version="12.1.1" />
|
||||
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="12.1.1" />
|
||||
<PackageReference Include="MediatR" Version="14.1.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\DocumentOperator.Domain\DocumentOperator.Domain.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Folder Include="Common\Interfaces\" />
|
||||
<Folder Include="Common\Behaviors\" />
|
||||
<Folder Include="Common\DTOs\" />
|
||||
<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\" />
|
||||
<Folder Include="Features\Documents\ValidatePdf\" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -1,12 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace DocumentOperator.Application.Features.Documents.ProcessDocument
|
||||
{
|
||||
internal class ProcessDocumentCommand
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace DocumentOperator.Application.Features.Documents.ProcessDocument
|
||||
{
|
||||
internal class ProcessDocumentHandler
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace DocumentOperator.Application.Features.Documents.ProcessDocument
|
||||
{
|
||||
internal class ProcessDocumentValidator
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
namespace DocumentOperator.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 string ResourceType { get; }
|
||||
public object ResourceId { get; }
|
||||
|
||||
public NotFoundException(string resourceType, object resourceId)
|
||||
: base($"{resourceType} with ID '{resourceId}' was not found.", "RESOURCE_NOT_FOUND")
|
||||
{
|
||||
ResourceType = resourceType;
|
||||
ResourceId = resourceId;
|
||||
}
|
||||
|
||||
public NotFoundException(string resourceType, object resourceId, string customMessage)
|
||||
: base(customMessage, "RESOURCE_NOT_FOUND")
|
||||
{
|
||||
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,26 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="DevExpress.Pdf.Core" Version="25.2.8" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="8.0.0" />
|
||||
</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\" />
|
||||
<Folder Include="Services\PdfProcessing\" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -1,43 +0,0 @@
|
||||
|
||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||
# Visual Studio Version 17
|
||||
VisualStudioVersion = 17.14.37328.6
|
||||
MinimumVisualStudioVersion = 10.0.40219.1
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DocumentOperator.API", "DocumentOperator.API\DocumentOperator.API.csproj", "{BA41F6A2-EE29-48F9-A3CB-29A05E57CF30}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DocumentOperator.Application", "DocumentOperator.Application\DocumentOperator.Application.csproj", "{B2B735FC-5F39-4CFF-9C54-C4F8820880B6}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DocumentOperator.Infrastructure", "DocumentOperator.Infrastructure\DocumentOperator.Infrastructure.csproj", "{C4CA19A8-8168-453B-B4ED-77E032CFE52E}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DocumentOperator.Domain", "DocumentOperator.Domain\DocumentOperator.Domain.csproj", "{B4C1C3ED-D3E8-4272-8704-2E73CEA6A0DD}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
Release|Any CPU = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||
{BA41F6A2-EE29-48F9-A3CB-29A05E57CF30}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{BA41F6A2-EE29-48F9-A3CB-29A05E57CF30}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{BA41F6A2-EE29-48F9-A3CB-29A05E57CF30}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{BA41F6A2-EE29-48F9-A3CB-29A05E57CF30}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{B2B735FC-5F39-4CFF-9C54-C4F8820880B6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{B2B735FC-5F39-4CFF-9C54-C4F8820880B6}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{B2B735FC-5F39-4CFF-9C54-C4F8820880B6}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{B2B735FC-5F39-4CFF-9C54-C4F8820880B6}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{C4CA19A8-8168-453B-B4ED-77E032CFE52E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{C4CA19A8-8168-453B-B4ED-77E032CFE52E}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{C4CA19A8-8168-453B-B4ED-77E032CFE52E}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{C4CA19A8-8168-453B-B4ED-77E032CFE52E}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{B4C1C3ED-D3E8-4272-8704-2E73CEA6A0DD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{B4C1C3ED-D3E8-4272-8704-2E73CEA6A0DD}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{B4C1C3ED-D3E8-4272-8704-2E73CEA6A0DD}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{B4C1C3ED-D3E8-4272-8704-2E73CEA6A0DD}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
EndGlobalSection
|
||||
GlobalSection(ExtensibilityGlobals) = postSolution
|
||||
SolutionGuid = {832CA90A-06D6-4312-9B35-16CC665EB37C}
|
||||
EndGlobalSection
|
||||
EndGlobal
|
||||
125
DocumentService.API/Configuration/DualInputDocumentFilter.cs
Normal file
125
DocumentService.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" }
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace DocumentService.API.Configuration
|
||||
{
|
||||
/// <summary>
|
||||
/// Placeholder class for Serilog configuration extensions.
|
||||
/// </summary>
|
||||
public class SerilogConfiguration
|
||||
{
|
||||
}
|
||||
}
|
||||
50
DocumentService.API/Configuration/SwaggerConfiguration.cs
Normal file
50
DocumentService.API/Configuration/SwaggerConfiguration.cs
Normal file
@@ -0,0 +1,50 @@
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.OpenApi.Models;
|
||||
using System.Reflection;
|
||||
|
||||
namespace DocumentService.API.Configuration
|
||||
{
|
||||
/// <summary>
|
||||
/// Provides extension methods for configuring Swagger/OpenAPI documentation.
|
||||
/// </summary>
|
||||
public static class SwaggerConfiguration
|
||||
{
|
||||
/// <summary>
|
||||
/// 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,
|
||||
IConfiguration configuration)
|
||||
{
|
||||
var swaggerSettings = configuration.GetSection(SwaggerSettings.SectionName).Get<SwaggerSettings>()
|
||||
?? new SwaggerSettings();
|
||||
|
||||
services.AddSwaggerGen(options =>
|
||||
{
|
||||
options.SwaggerDoc(swaggerSettings.Version, new OpenApiInfo
|
||||
{
|
||||
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);
|
||||
options.IncludeXmlComments(xmlPath);
|
||||
});
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
}
|
||||
33
DocumentService.API/Configuration/SwaggerSettings.cs
Normal file
33
DocumentService.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";
|
||||
}
|
||||
292
DocumentService.API/Controllers/PdfAttachmentController.cs
Normal file
292
DocumentService.API/Controllers/PdfAttachmentController.cs
Normal file
@@ -0,0 +1,292 @@
|
||||
using DocumentService.Application.AddAttachments;
|
||||
using DocumentService.Application.CheckPdfAttachments.Queries;
|
||||
using DocumentService.Application.Common.DTOs;
|
||||
using DocumentService.Application.ExtractPdfAttachments;
|
||||
using DocumentService.Application.Common.DTOs.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");
|
||||
}
|
||||
}
|
||||
|
||||
182
DocumentService.API/Controllers/PdfConversionController.cs
Normal file
182
DocumentService.API/Controllers/PdfConversionController.cs
Normal file
@@ -0,0 +1,182 @@
|
||||
using DocumentService.Application.ConvertFromPdfA;
|
||||
using DocumentService.Application.ConvertToPdfA;
|
||||
using DocumentService.Application.Common.DTOs.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");
|
||||
}
|
||||
}
|
||||
511
DocumentService.API/Controllers/PdfOperationsController.cs
Normal file
511
DocumentService.API/Controllers/PdfOperationsController.cs
Normal file
@@ -0,0 +1,511 @@
|
||||
using DocumentService.Application.AddAnnotation;
|
||||
using DocumentService.Application.AddStamp;
|
||||
using DocumentService.Application.Common.DTOs.Requests;
|
||||
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] AddAnnotationBase64Request 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 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; }
|
||||
}
|
||||
170
DocumentService.API/Controllers/PdfValidationController.cs
Normal file
170
DocumentService.API/Controllers/PdfValidationController.cs
Normal file
@@ -0,0 +1,170 @@
|
||||
using DocumentService.Application.Common.DTOs;
|
||||
using DocumentService.Application.ValidatePdf.Queries;
|
||||
using DocumentService.Application.ValidatePdfA.Queries;
|
||||
using DocumentService.Application.Common.DTOs.Requests;
|
||||
using DocumentService.Domain.Common.Exceptions;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace DocumentService.API.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// PDF validation operations
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/pdf/validation")]
|
||||
[Produces("application/json")]
|
||||
public class PdfValidationController(IMediator Mediator) : ControllerBase
|
||||
{
|
||||
/// <summary>
|
||||
/// Validates a PDF document and returns metadata (multipart/form-data)
|
||||
/// </summary>
|
||||
/// <param name="file">PDF file to validate</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>
|
||||
/// <response code="400">Invalid PDF or file format</response>
|
||||
/// <response code="500">Internal server error during validation</response>
|
||||
[HttpPost("validate")]
|
||||
[Consumes("multipart/form-data")]
|
||||
[ProducesResponseType(typeof(PdfValidationResult), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status500InternalServerError)]
|
||||
public async Task<IActionResult> ValidateFromFile(
|
||||
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 ValidatePdfQuery { PdfStream = pdfStream };
|
||||
var result = await Mediator.Send(query, cancellationToken);
|
||||
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates a PDF document and returns metadata (Base64 JSON)
|
||||
/// </summary>
|
||||
/// <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>
|
||||
/// <response code="400">Invalid PDF or Base64 format</response>
|
||||
/// <response code="500">Internal server error during validation</response>
|
||||
[HttpPost("validate")]
|
||||
[Consumes("application/json")]
|
||||
[ProducesResponseType(typeof(PdfValidationResult), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status500InternalServerError)]
|
||||
public async Task<IActionResult> ValidateFromBase64(
|
||||
[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);
|
||||
}
|
||||
}
|
||||
103
DocumentService.API/Controllers/SwissQrCodeController.cs
Normal file
103
DocumentService.API/Controllers/SwissQrCodeController.cs
Normal file
@@ -0,0 +1,103 @@
|
||||
using DocumentService.Application.Common.DTOs;
|
||||
using DocumentService.Application.Common.DTOs.Requests;
|
||||
using DocumentService.Application.SwissQrCode.Queries;
|
||||
using DocumentService.Domain.Common.Exceptions;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace DocumentService.API.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Swiss QR Code extraction operations
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/pdf/qr-code")]
|
||||
[Produces("application/json")]
|
||||
public class SwissQrCodeController(IMediator Mediator) : ControllerBase
|
||||
{
|
||||
/// <summary>
|
||||
/// 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="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>
|
||||
/// <response code="400">Invalid PDF or file format</response>
|
||||
/// <response code="404">No Swiss QR Code found on the last page</response>
|
||||
/// <response code="500">Internal server error during extraction</response>
|
||||
[HttpPost("extract-swiss")]
|
||||
[Consumes("multipart/form-data")]
|
||||
[ProducesResponseType(typeof(SwissQrCodeExtractionResult), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status500InternalServerError)]
|
||||
public async Task<IActionResult> ExtractFromFile(
|
||||
IFormFile file,
|
||||
[FromQuery] bool raw = false,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (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 ExtractSwissQrCodeQuery
|
||||
{
|
||||
PdfStream = pdfStream
|
||||
};
|
||||
var result = await Mediator.Send(query, cancellationToken);
|
||||
|
||||
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="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>
|
||||
/// <response code="400">Invalid PDF or Base64 format</response>
|
||||
/// <response code="404">No Swiss QR Code found on the last page</response>
|
||||
/// <response code="500">Internal server error during extraction</response>
|
||||
[HttpPost("extract-swiss")]
|
||||
[Consumes("application/json")]
|
||||
[ProducesResponseType(typeof(SwissQrCodeExtractionResult), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status500InternalServerError)]
|
||||
public async Task<IActionResult> ExtractFromBase64(
|
||||
[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(raw ? result.RawLines : result.Bill);
|
||||
}
|
||||
}
|
||||
|
||||
179
DocumentService.API/Controllers/ZugferdController.cs
Normal file
179
DocumentService.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.Application.Common.DTOs.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);
|
||||
}
|
||||
}
|
||||
41
DocumentService.API/DocumentService.API.csproj
Normal file
41
DocumentService.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.1.0</Version>
|
||||
<FileVersion>1.1.0.0</FileVersion>
|
||||
<AssemblyVersion>1.1.0.0</AssemblyVersion>
|
||||
<InformationalVersion>1.1.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="..\DocumentService.Application\DocumentService.Application.csproj" />
|
||||
<ProjectReference Include="..\DocumentService.Client\DocumentService.Client.csproj" />
|
||||
<ProjectReference Include="..\DocumentService.Domain\DocumentService.Domain.csproj" />
|
||||
<ProjectReference Include="..\DocumentService.Infrastructure\DocumentService.Infrastructure.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
6
DocumentService.API/DocumentService.API.http
Normal file
6
DocumentService.API/DocumentService.API.http
Normal file
@@ -0,0 +1,6 @@
|
||||
@DocumentService.API_HostAddress = http://localhost:5028
|
||||
|
||||
GET {{DocumentService.API_HostAddress}}/weatherforecast/
|
||||
Accept: application/json
|
||||
|
||||
###
|
||||
109
DocumentService.API/Middleware/ExceptionHandlingMiddleware.cs
Normal file
109
DocumentService.API/Middleware/ExceptionHandlingMiddleware.cs
Normal file
@@ -0,0 +1,109 @@
|
||||
using DocumentService.Domain.Common.Exceptions;
|
||||
using FluentValidation;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using System.Net;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace DocumentService.API.Middleware;
|
||||
|
||||
/// <summary>
|
||||
/// Central exception handling middleware
|
||||
/// Maps exceptions to HTTP status codes and RFC 7807 Problem Details
|
||||
/// </summary>
|
||||
/// <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 static readonly JsonSerializerOptions ProbDetailsJsonOpt = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Invokes the middleware to handle incoming HTTP requests and catch exceptions.
|
||||
/// </summary>
|
||||
/// <param name="context">The HTTP context for the current request.</param>
|
||||
public async Task InvokeAsync(HttpContext context)
|
||||
{
|
||||
try
|
||||
{
|
||||
await Next(context);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await HandleExceptionAsync(context, ex);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task HandleExceptionAsync(HttpContext context, Exception exception)
|
||||
{
|
||||
var (statusCode, problemDetails) = MapExceptionToProblemDetails(exception, context);
|
||||
|
||||
context.Response.StatusCode = (int)statusCode;
|
||||
context.Response.ContentType = "application/problem+json";
|
||||
|
||||
await context.Response.WriteAsync(JsonSerializer.Serialize(problemDetails, ProbDetailsJsonOpt));
|
||||
}
|
||||
|
||||
private static (HttpStatusCode StatusCode, ProblemDetails ProblemDetails) MapExceptionToProblemDetails(
|
||||
Exception exception,
|
||||
HttpContext context)
|
||||
{
|
||||
return exception switch
|
||||
{
|
||||
// FluentValidation (400 Bad Request)
|
||||
ValidationException validationEx => (
|
||||
HttpStatusCode.BadRequest,
|
||||
new ProblemDetails
|
||||
{
|
||||
Type = "https://datatracker.ietf.org/doc/html/rfc7231#section-6.5.1",
|
||||
Title = "Validation Error",
|
||||
Status = (int)HttpStatusCode.BadRequest,
|
||||
Detail = string.Join("; ", validationEx.Errors.Select(e => e.ErrorMessage)),
|
||||
Instance = context.Request.Path
|
||||
}
|
||||
),
|
||||
|
||||
// Bad Request Exception (400 Bad Request)
|
||||
BadRequestException badReqEx => (
|
||||
HttpStatusCode.BadRequest,
|
||||
new ProblemDetails
|
||||
{
|
||||
Type = "https://datatracker.ietf.org/doc/html/rfc7231#section-6.5.4",
|
||||
Title = "Bad Request",
|
||||
Status = (int)HttpStatusCode.BadRequest,
|
||||
Detail = badReqEx.Message,
|
||||
Instance = context.Request.Path
|
||||
}
|
||||
),
|
||||
|
||||
// Not Found Exception (404 Not Found)
|
||||
NotFoundException notFoundEx => (
|
||||
HttpStatusCode.NotFound,
|
||||
new ProblemDetails
|
||||
{
|
||||
Type = "https://datatracker.ietf.org/doc/html/rfc7231#section-6.5.4",
|
||||
Title = "Resource Not Found",
|
||||
Status = (int)HttpStatusCode.NotFound,
|
||||
Detail = notFoundEx.Message,
|
||||
Instance = context.Request.Path
|
||||
}
|
||||
),
|
||||
|
||||
// Generic Exception (500 Internal Server Error)
|
||||
_ => (
|
||||
HttpStatusCode.InternalServerError,
|
||||
new ProblemDetails
|
||||
{
|
||||
Type = "https://datatracker.ietf.org/doc/html/rfc7231#section-6.6.1",
|
||||
Title = "Internal Server Error",
|
||||
Status = (int)HttpStatusCode.InternalServerError,
|
||||
Detail = "An unexpected error occurred. Please contact support.",
|
||||
Instance = context.Request.Path
|
||||
}
|
||||
)
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace DocumentService.API.Middleware
|
||||
{
|
||||
/// <summary>
|
||||
/// Placeholder middleware for HTTP request/response logging.
|
||||
/// </summary>
|
||||
public class RequestLoggingMiddleware
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace DocumentService.API.Middleware
|
||||
{
|
||||
/// <summary>
|
||||
/// Placeholder middleware for multi-tenancy resolution via X-API-Key header.
|
||||
/// </summary>
|
||||
public class TenantResolutionMiddleware
|
||||
{
|
||||
}
|
||||
}
|
||||
545
DocumentService.API/PHASENPLAN.md
Normal file
545
DocumentService.API/PHASENPLAN.md
Normal file
@@ -0,0 +1,545 @@
|
||||
# ?? DocumentService - Phasenplan (Feature-Driven Development)
|
||||
|
||||
> **Stand:** 17.01.2025 | **Aktuell:** Feature 3 - ExtractAttachments ? NEXT | **Projektdauer:** 6 Wochen
|
||||
|
||||
---
|
||||
|
||||
## ?? Übersicht
|
||||
|
||||
| Woche | Features / Concerns | Status | Fortschritt |
|
||||
|-------|---------------------|--------|-------------|
|
||||
| **W1** | Feature 1: ValidatePDF | ? Abgeschlossen | 100% (Foundation + Application + API + Swagger fertig) |
|
||||
| **W1-W2** | Feature 2: ExtractSwissQrCode | ? Abgeschlossen | 100% (Domain + Infrastructure + Application + API + Swagger fertig) |
|
||||
| **W2** | Feature 3: ExtractAttachments | ? Nächstes Feature | 0% |
|
||||
| **W2** | Feature 4: ApplyStamp | ? Geplant | 0% |
|
||||
| **W3** | Feature 5: EmbedCertificate | ? Geplant | 0% |
|
||||
| **W3** | Feature 6: ConcatenatePDFs (Async) | ? Geplant | 0% |
|
||||
| **W4** | Multi-Tenancy (X-API-Key Header) | ? Geplant | 0% |
|
||||
| **W5** | Health Checks + Polly + Logging | ? Geplant | 0% |
|
||||
| **W6** | Production Deployment | ? Geplant | 0% |
|
||||
|
||||
---
|
||||
|
||||
## ?? NEUE VORGEHENSWEISE
|
||||
|
||||
**Was hat sich geändert?**
|
||||
|
||||
? **Feature-by-Feature Development** statt Layer-by-Layer
|
||||
- Jedes Feature wird KOMPLETT umgesetzt (Domain ? Infrastructure ? Application ? API ? Tests ? Swagger)
|
||||
- Feature ist erst "DONE" wenn es im Swagger testbar ist
|
||||
- Dann nächstes Feature
|
||||
|
||||
? **Kleine Schritte** (1 Layer pro Step)
|
||||
- Nach jedem Step: ROADMAP + PHASENPLAN aktualisieren
|
||||
- Commit nach jedem Step
|
||||
- Dann weiter
|
||||
|
||||
? **Multi-Tenancy & Cross-Cutting Concerns später**
|
||||
- Erst alle synchronen Features (1-4)
|
||||
- Dann Multi-Tenancy für ALLE Endpoints
|
||||
- Dann Health Checks, Polly, Logging
|
||||
|
||||
---
|
||||
|
||||
## ?? DETAILLIERTER PLAN
|
||||
|
||||
### WOCHE 1 - Feature 1: ValidatePDF | ? ABGESCHLOSSEN - 100%
|
||||
|
||||
**Ziel:** POST /api/v1/documents/validate Endpoint im Swagger testbar
|
||||
|
||||
#### ? Step 1.0: Foundation (ABGESCHLOSSEN)
|
||||
**Dauer:** ~2 Tage
|
||||
|
||||
**Was wurde erstellt:**
|
||||
- ? Solution Structure (4 Projekte)
|
||||
- ? Domain Layer (Exceptions, Enums, Value Objects)
|
||||
- ? Infrastructure Layer (DevExpressPdfProcessor.ValidateAsync)
|
||||
- ? Tests (DevExpressPdfProcessorTests.cs - 6 Tests)
|
||||
- ? Build erfolgreich
|
||||
|
||||
---
|
||||
|
||||
#### ? Step 1.1: Application Layer (MediatR Setup + ValidatePDF Feature) - **ABGESCHLOSSEN**
|
||||
**Dauer:** ~4 Stunden
|
||||
|
||||
**Was wurde erstellt:**
|
||||
1. **MediatR Setup**
|
||||
- ? `Application/DependencyInjection.cs` (Service Registration)
|
||||
- ? `Application/Common/Behaviors/ValidationBehavior.cs` (FluentValidation Pipeline)
|
||||
- ? `Application/Common/Behaviors/LoggingBehavior.cs` (Logging Pipeline mit ILogger<T>)
|
||||
|
||||
2. **ValidatePDF Feature (Vertical Slice)**
|
||||
- ? `Application/Features/Documents/ValidatePdf/ValidatePdfQuery.cs`
|
||||
- ? `Application/Features/Documents/ValidatePdf/ValidatePdfHandler.cs`
|
||||
- ? `Application/Features/Documents/ValidatePdf/ValidatePdfValidator.cs`
|
||||
|
||||
3. **DTOs**
|
||||
- ? `Application/Common/DTOs/ValidatePdfRequest.cs`
|
||||
- ? `Application/Common/DTOs/ValidatePdfResponse.cs`
|
||||
|
||||
4. **Tests**
|
||||
- ? `Tests/Unit/Application/Features/ValidatePdf/ValidatePdfHandlerTests.cs` (2 Tests)
|
||||
|
||||
**Akzeptanzkriterien:**
|
||||
- ? Build erfolgreich
|
||||
- ? Tests grün (Handler Tests: 2/2 passed)
|
||||
- ? MediatR Pipeline funktioniert (Validation + Logging)
|
||||
|
||||
---
|
||||
|
||||
#### ? Step 1.2: API Layer (Endpoint + Exception Middleware) - **ABGESCHLOSSEN**
|
||||
**Dauer:** ~3 Stunden
|
||||
|
||||
**Was wurde erstellt:**
|
||||
1. **Exception Middleware**
|
||||
- ? `API/Middleware/ExceptionHandlingMiddleware.cs`
|
||||
- Exception ? HTTP Status Code Mapping (400, 404, 422, 500)
|
||||
- RFC 7807 Problem Details
|
||||
|
||||
2. **Minimal API Endpoint**
|
||||
- ? `API/Endpoints/v1/DocumentEndpoints.cs`
|
||||
- POST /api/v1/documents/validate
|
||||
|
||||
3. **Infrastructure DI**
|
||||
- ? `Infrastructure/DependencyInjection.cs`
|
||||
- IPdfProcessor ? DevExpressPdfProcessor registriert
|
||||
|
||||
4. **Program.cs Updates**
|
||||
- ? Exception Middleware registriert (FIRST in pipeline!)
|
||||
- ? DocumentEndpoints registriert
|
||||
- ? Application + Infrastructure Services registriert
|
||||
|
||||
5. **Integration Tests**
|
||||
- ? `Tests/Integration/API/DocumentEndpointsTests.cs` (3 Tests)
|
||||
- ? Test: `POST_ValidatePdf_ValidPdf_Returns200`
|
||||
- ? Test: `POST_ValidatePdf_InvalidBase64_Returns400`
|
||||
- ? Test: `POST_ValidatePdf_EmptyPdf_Returns400`
|
||||
|
||||
**Akzeptanzkriterien:**
|
||||
- ? Build erfolgreich
|
||||
- ? Integration Tests grün (3/3 passed)
|
||||
- ? Endpoint gibt korrekte HTTP Status Codes zurück
|
||||
|
||||
---
|
||||
|
||||
#### ? Step 1.3: Swagger Dokumentation - **ABGESCHLOSSEN**
|
||||
**Dauer:** ~1 Stunde
|
||||
|
||||
**Was wurde erstellt:**
|
||||
1. **Swagger Configuration**
|
||||
- ? `API/Configuration/SwaggerConfiguration.cs`
|
||||
- ? `AddSwaggerDocumentation()` Extension Method
|
||||
- ? XML Comments aktiviert
|
||||
|
||||
2. **XML-Dokumentation aktiviert**
|
||||
- ? `API/DocumentService.API.csproj`
|
||||
- ? `<GenerateDocumentationFile>true</GenerateDocumentationFile>`
|
||||
|
||||
3. **Endpoint Dokumentation**
|
||||
- ? `API/Endpoints/v1/DocumentEndpoints.cs`
|
||||
- ? XML Comments für `ValidatePdf` Methode
|
||||
- ? Swagger-Annotationen (`.WithSummary()`, `.WithDescription()`, `.Produces<>()`)
|
||||
|
||||
4. **DTOs Dokumentation**
|
||||
- ? `Application/Common/DTOs/ValidatePdfRequest.cs` (XML Comments)
|
||||
- ? `Application/Common/DTOs/ValidatePdfResponse.cs` (XML Comments + `FileSizeMB` hinzugefügt)
|
||||
|
||||
5. **Program.cs Updates**
|
||||
- ? `builder.Services.AddSwaggerDocumentation()` statt `AddSwaggerGen()`
|
||||
- ? `using DocumentService.API.Configuration;` hinzugefügt
|
||||
|
||||
**Akzeptanzkriterien:**
|
||||
- ? Build erfolgreich
|
||||
- ? Alle Tests grün (11/11)
|
||||
- ? 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
|
||||
|
||||
---
|
||||
|
||||
#### ? Feature 1 ABGESCHLOSSEN!
|
||||
**Gesamtdauer:** ~1 Tag
|
||||
|
||||
**Ergebnis:**
|
||||
- ? POST /api/v1/documents/validate im Swagger testbar
|
||||
- ? Unit Tests + Integration Tests grün (11/11)
|
||||
- ? Clean Architecture eingehalten
|
||||
- ? TDD angewendet
|
||||
- ? Swagger-Dokumentation vollständig
|
||||
|
||||
---
|
||||
|
||||
### WOCHE 1-2 - Feature 2: ExtractSwissQrCode | ? ABGESCHLOSSEN - 100%
|
||||
|
||||
**Dauer:** ~1-2 Tage
|
||||
**Status:** ? Abgeschlossen
|
||||
|
||||
**Endpoint:** POST /api/v1/documents/extract-swiss-qr-code
|
||||
|
||||
**Was wurde gebaut:**
|
||||
- Client sendet Referenzen (Array) + PDF (Base64)
|
||||
- API extrahiert Swiss QR Code von **letzter Seite**
|
||||
- API gibt Referenzen + alle QR Code Felder zurück (Swiss QR Bill Standard 2.0)
|
||||
|
||||
**Technologie:**
|
||||
- **DevExpress PDF Document API** (PDF-Zugriff, letzte Seite)
|
||||
- **ZXing.Net.Bindings.Windows.Compatibility** (QR Code Detection)
|
||||
- **Codecrete.SwissQRBill.Generator** (Swiss QR Code Parsing - Standard 2.0)
|
||||
- **System.Drawing.Common** (Bitmap Support)
|
||||
|
||||
**Steps:**
|
||||
- ? Step 2.1: Domain Layer (SwissQrCodeData Value Object) - ABGESCHLOSSEN
|
||||
- ? Step 2.2: Infrastructure Layer (ISwissQrCodeProcessor + DevExpressSwissQrCodeProcessor + Library Integration) - ABGESCHLOSSEN
|
||||
- ? Step 2.3: Application Layer (ExtractSwissQrCodeQuery + Handler + Validator + DTOs) - ABGESCHLOSSEN
|
||||
- ? Step 2.4: API Layer (Endpoint + Exception Mapping + Integration Tests) - ABGESCHLOSSEN
|
||||
- ? Step 2.5: Swagger Dokumentation (XML Comments + Examples) - ABGESCHLOSSEN
|
||||
|
||||
**Akzeptanzkriterien:**
|
||||
- ? QR Code wird von letzter Seite extrahiert
|
||||
- ? Alle Swiss QR Bill Felder werden geparst (Standard 2.0)
|
||||
- ? Referenzen werden durchgeschliffen (Echo)
|
||||
- ? Fehler wenn kein QR Code gefunden (404 Not Found)
|
||||
- ? Swagger-testbar
|
||||
- ? Tests grün (19/19)
|
||||
|
||||
**Ergebnis:**
|
||||
- ? POST /api/v1/documents/extract-swiss-qr-code im Swagger testbar
|
||||
- ? Nested Response Structure (References + SwissQrCodeData)
|
||||
- ? SwissQrCodeNotFoundException wird zu 404 gemappt
|
||||
- ? Unit Tests + Integration Tests grün (19/19)
|
||||
- ? Clean Architecture eingehalten
|
||||
- ? Swagger-Dokumentation vollständig
|
||||
|
||||
---
|
||||
|
||||
### WOCHE 2 - Features 3 & 4 | ? Geplant - 0%
|
||||
|
||||
#### Feature 3: ExtractAttachments (Synchron)
|
||||
**Dauer:** ~1 Tag
|
||||
**Status:** ? Pending
|
||||
|
||||
**Endpoint:** POST /api/v1/documents/extract-attachments
|
||||
|
||||
**Steps:**
|
||||
- ? Step 3.1: Infrastructure Layer (DevExpressPdfProcessor.ExtractAttachmentsAsync)
|
||||
- ? Step 3.2: Application Layer (ExtractAttachmentsCommand + Handler + Validator)
|
||||
- ? Step 3.3: API Layer (Endpoint)
|
||||
- ? Step 3.4: Swagger Dokumentation
|
||||
|
||||
**Akzeptanzkriterien:**
|
||||
- ? Endpoint im Swagger testbar
|
||||
- ? Tests grün
|
||||
|
||||
---
|
||||
|
||||
#### Feature 4: ApplyStamp (Synchron)
|
||||
**Dauer:** ~1 Tag
|
||||
**Status:** ? Pending
|
||||
|
||||
**Endpoint:** POST /api/v1/documents/apply-stamp
|
||||
|
||||
**Steps:**
|
||||
- ? Step 4.1: Infrastructure Layer (DevExpressPdfProcessor.ApplyStampAsync)
|
||||
- ? Step 4.2: Application Layer (ApplyStampCommand + Handler + Validator)
|
||||
- ? Step 4.3: API Layer (Endpoint)
|
||||
- ? Step 4.4: Swagger Dokumentation
|
||||
|
||||
**Akzeptanzkriterien:**
|
||||
- ? Endpoint im Swagger testbar
|
||||
- ? Stamp wird korrekt angewendet
|
||||
- ? Tests grün
|
||||
|
||||
---
|
||||
|
||||
### WOCHE 3 - Features 5 & 6 | ? Geplant - 0%
|
||||
|
||||
#### Feature 5: EmbedCertificate (Synchron)
|
||||
**Dauer:** ~1 Tag
|
||||
**Status:** ? Pending
|
||||
|
||||
**Endpoint:** POST /api/v1/documents/embed-certificate
|
||||
|
||||
**Steps:**
|
||||
- ? Step 5.1: Infrastructure Layer (DevExpressPdfProcessor.EmbedCertificateAsync)
|
||||
- ? Step 5.2: Application Layer (EmbedCertificateCommand + Handler + Validator)
|
||||
- ? Step 5.3: API Layer (Endpoint)
|
||||
- ? Step 5.4: Swagger Dokumentation
|
||||
|
||||
**Akzeptanzkriterien:**
|
||||
- ? Endpoint im Swagger testbar
|
||||
- ? Zertifikat wird korrekt eingebettet
|
||||
- ? Tests grün
|
||||
|
||||
---
|
||||
|
||||
#### Feature 6: ConcatenatePDFs (Asynchron)
|
||||
**Dauer:** ~2 Tage
|
||||
**Status:** ? Pending
|
||||
|
||||
**Endpoints:**
|
||||
- POST /api/v1/documents/concatenate (Async, gibt JobId zurück)
|
||||
- GET /api/v1/jobs/{jobId} (Job-Status abfragen)
|
||||
- GET /api/v1/jobs/{jobId}/download (Ergebnis herunterladen)
|
||||
|
||||
**Steps:**
|
||||
- ? Step 6.1: Infrastructure Layer (In-Memory Queue + Background Worker)
|
||||
- ? Step 6.2: Application Layer (SubmitConcatenateJobCommand + GetJobStatusQuery)
|
||||
- ? Step 6.3: API Layer (Async Endpoints)
|
||||
- ? Step 6.4: Swagger Dokumentation
|
||||
|
||||
**Akzeptanzkriterien:**
|
||||
- ? POST /concatenate gibt JobId zurück
|
||||
- ? GET /jobs/{jobId} zeigt Status (Pending, Processing, Success, Failed)
|
||||
- ? GET /jobs/{jobId}/download gibt PDF zurück
|
||||
- ? Background Worker verarbeitet Jobs korrekt
|
||||
- ? Tests grün
|
||||
|
||||
---
|
||||
|
||||
### WOCHE 4 - Multi-Tenancy | ? Geplant - 0%
|
||||
|
||||
**Ziel:** X-API-Key Header für ALLE Endpoints
|
||||
|
||||
**Was wird gebaut:**
|
||||
- EF Core + SQLite (Tenant-Datenbank)
|
||||
- Redis Cache (API-Key Lookups - optional)
|
||||
- TenantResolutionMiddleware (X-API-Key ? Tenant)
|
||||
- BCrypt API-Key Hashing
|
||||
- Admin API (Tenant CRUD)
|
||||
|
||||
**Steps:**
|
||||
|
||||
#### Step MT.1: EF Core Setup
|
||||
**Dauer:** ~3 Stunden
|
||||
|
||||
**Was wird erstellt:**
|
||||
- `Infrastructure/Data/TenantDbContext.cs`
|
||||
- `Infrastructure/Data/Entities/Tenant.cs`
|
||||
- `Infrastructure/Data/Entities/TenantSettings.cs`
|
||||
- EF Core Migration (InitialCreate)
|
||||
- SQLite Database erstellen
|
||||
|
||||
**Akzeptanzkriterien:**
|
||||
- ? Datenbank erstellt
|
||||
- ? Tenant-Tabelle existiert
|
||||
- ? Build erfolgreich
|
||||
|
||||
---
|
||||
|
||||
#### Step MT.2: TenantResolutionMiddleware
|
||||
**Dauer:** ~2 Stunden
|
||||
|
||||
**Was wird erstellt:**
|
||||
- `API/Middleware/TenantResolutionMiddleware.cs`
|
||||
- `Application/Common/Interfaces/ITenantContext.cs`
|
||||
- `Infrastructure/Services/TenantContext.cs`
|
||||
|
||||
**Akzeptanzkriterien:**
|
||||
- ? X-API-Key Header wird gelesen
|
||||
- ? Tenant aus DB geladen
|
||||
- ? ITenantContext im Request Scope verfügbar
|
||||
- ? Ungültiger API-Key ? HTTP 401
|
||||
|
||||
---
|
||||
|
||||
#### Step MT.3: Redis Cache Integration (Optional)
|
||||
**Dauer:** ~1 Stunde
|
||||
|
||||
**Was wird erstellt:**
|
||||
- Redis Cache für API-Key Lookups
|
||||
- TTL: 1 Stunde
|
||||
|
||||
**Akzeptanzkriterien:**
|
||||
- ? API-Key Lookup cached (weniger DB-Calls)
|
||||
- ? Cache Invalidation funktioniert
|
||||
|
||||
---
|
||||
|
||||
#### Step MT.4: Admin API (Tenant Management)
|
||||
**Dauer:** ~2 Stunden
|
||||
|
||||
**Was wird erstellt:**
|
||||
- POST /api/v1/admin/tenants (Create Tenant)
|
||||
- PUT /api/v1/admin/tenants/{id}/rotate-key (API-Key rotieren)
|
||||
- PATCH /api/v1/admin/tenants/{id}/deactivate (Tenant deaktivieren)
|
||||
- GET /api/v1/admin/tenants (Liste aller Tenants)
|
||||
|
||||
**Akzeptanzkriterien:**
|
||||
- ? Endpoints im Swagger testbar
|
||||
- ? API-Key wird gehashed (BCrypt)
|
||||
- ? Tests grün
|
||||
|
||||
---
|
||||
|
||||
#### Step MT.5: Alle Endpoints mit X-API-Key absichern
|
||||
**Dauer:** ~1 Stunde
|
||||
|
||||
**Was wird geändert:**
|
||||
- Alle Feature-Endpoints bekommen X-API-Key Header Requirement
|
||||
- Swagger zeigt API-Key Security Scheme
|
||||
|
||||
**Akzeptanzkriterien:**
|
||||
- ? Alle Endpoints erfordern X-API-Key Header
|
||||
- ? Swagger zeigt Security Scheme
|
||||
- ? Tests aktualisiert (mit API-Key)
|
||||
|
||||
---
|
||||
|
||||
### WOCHE 5 - Health Checks + Polly + Logging | ? Geplant - 0%
|
||||
|
||||
#### Health Checks
|
||||
**Dauer:** ~2 Stunden
|
||||
|
||||
**Was wird gebaut:**
|
||||
- `/health` Endpoint (Liveness/Readiness Probes)
|
||||
- DevExpressPdfHealthCheck (Smoke Test)
|
||||
- Database Health Check (SQLite)
|
||||
- Redis Health Check (optional)
|
||||
|
||||
**Akzeptanzkriterien:**
|
||||
- ? /health gibt HTTP 200 wenn alles OK
|
||||
- ? /health gibt HTTP 503 wenn DevExpress nicht funktioniert
|
||||
|
||||
---
|
||||
|
||||
#### Polly Resilience
|
||||
**Dauer:** ~3 Stunden
|
||||
|
||||
**Was wird gebaut:**
|
||||
- Retry Policy (3x mit Exponential Backoff)
|
||||
- Circuit Breaker (nach 5 Fehlern 30s öffnen)
|
||||
- Timeout Policy (30s max)
|
||||
|
||||
**Akzeptanzkriterien:**
|
||||
- ? DevExpress Calls werden mit Polly gewickelt
|
||||
- ? Retry funktioniert bei Transient Errors
|
||||
- ? Circuit Breaker öffnet bei vielen Fehlern
|
||||
|
||||
---
|
||||
|
||||
#### Logging & Monitoring
|
||||
**Dauer:** ~3 Stunden
|
||||
|
||||
**Was wird gebaut:**
|
||||
- CorrelationIdMiddleware (X-Correlation-ID Header)
|
||||
- Seq Sink (Log-Browsing UI)
|
||||
- File Logging (Production)
|
||||
- LoggingBehavior erweitert (Performance-Tracking)
|
||||
|
||||
**Akzeptanzkriterien:**
|
||||
- ? Correlation IDs in allen Logs
|
||||
- ? Seq UI zeigt Logs (Development)
|
||||
- ? File Logging funktioniert (Production)
|
||||
|
||||
---
|
||||
|
||||
### WOCHE 6 - Production Deployment | ? Geplant - 0%
|
||||
|
||||
**Ziel:** Service ist produktionsreif
|
||||
|
||||
**Was wird gebaut:**
|
||||
- appsettings.Production.json (Production Settings)
|
||||
- IIS Web.config (Kestrel Settings)
|
||||
- SSL/TLS Zertifikat konfigurieren
|
||||
- Deployment-Skript (PowerShell)
|
||||
|
||||
**Steps:**
|
||||
|
||||
#### Deployment Vorbereitung
|
||||
**Dauer:** ~4 Stunden
|
||||
|
||||
**Was wird erstellt:**
|
||||
- `appsettings.Production.json` (Prod-Settings)
|
||||
- `Web.config` (IIS Integration)
|
||||
- PowerShell Deploy-Skript
|
||||
- Dokumentation (README.md)
|
||||
|
||||
**Akzeptanzkriterien:**
|
||||
- ? Build in Release Mode erfolgreich
|
||||
- ? IIS Deployment funktioniert
|
||||
- ? HTTPS funktioniert
|
||||
|
||||
---
|
||||
|
||||
#### Production Testing
|
||||
**Dauer:** ~4 Stunden
|
||||
|
||||
**Was wird getestet:**
|
||||
- Alle Endpoints im Production-Modus
|
||||
- Health Checks
|
||||
- Multi-Tenancy
|
||||
- Performance (Load Testing)
|
||||
|
||||
**Akzeptanzkriterien:**
|
||||
- ? Alle Features funktionieren in Production
|
||||
- ? Health Checks grün
|
||||
- ? Performance OK (< 1s Response Time)
|
||||
|
||||
---
|
||||
|
||||
## ?? FORTSCHRITTS-TRACKING
|
||||
|
||||
### Gesamt-Fortschritt
|
||||
|
||||
| Kategorie | Status | Fortschritt |
|
||||
|-----------|--------|-------------|
|
||||
| **Foundation** | ? Abgeschlossen | 100% |
|
||||
| **Feature 1** | ? Abgeschlossen | 100% |
|
||||
| **Feature 2-5** | ? Pending | 0% |
|
||||
| **Multi-Tenancy** | ? Pending | 0% |
|
||||
| **Cross-Cutting** | ? Pending | 0% |
|
||||
| **Production** | ? Pending | 0% |
|
||||
|
||||
---
|
||||
|
||||
## ?? NEXT STEPS
|
||||
|
||||
### Nächstes Feature
|
||||
|
||||
**Feature 3: ExtractAttachments** - **NEXT**
|
||||
1. ?? Step 3.1: Domain Layer (Attachment Value Object)
|
||||
2. ?? Step 3.2: Infrastructure Layer (IAttachmentProcessor + DevExpressAttachmentProcessor)
|
||||
3. ?? Step 3.3: Application Layer (ExtractAttachmentsQuery + Handler + Validator + DTOs)
|
||||
4. ?? Step 3.4: API Layer (Endpoint + Integration Tests)
|
||||
5. ?? Step 3.5: Swagger Dokumentation
|
||||
|
||||
**Erwarteter Zeitaufwand:** ~1 Tag
|
||||
|
||||
**Akzeptanzkriterien:**
|
||||
- ? POST /api/v1/documents/extract-attachments im Swagger testbar
|
||||
- ? Alle Attachments werden extrahiert
|
||||
- ? Attachment-Metadaten werden zurückgegeben
|
||||
- ? Alle Tests grün
|
||||
- ? Clean Architecture eingehalten
|
||||
|
||||
---
|
||||
|
||||
## ?? UPDATE LOG
|
||||
|
||||
| Date | Feature/Step | Changes |
|
||||
|------|--------------|---------|
|
||||
| 2024-XX-XX | Foundation | Project setup, dependencies, folder structure |
|
||||
| 2024-XX-XX | Domain Layer | Exceptions, Enums, Value Objects |
|
||||
| 17.01.2025 | Infrastructure | DevExpressPdfProcessor.ValidateAsync implementiert |
|
||||
| 17.01.2025 | Tests | DevExpressPdfProcessorTests.cs erstellt (6 Tests) |
|
||||
| 17.01.2025 | **PHASENPLAN** | ?? **Komplett umstrukturiert** (Feature-basiert + Datum korrigiert 23.06.2026 ? 17.01.2025) |
|
||||
| 17.01.2025 | **Feature 1 - Step 1.1** | ? **ABGESCHLOSSEN** - Application Layer (MediatR, Behaviors, ValidatePDF Feature, DTOs, Tests - 2/2 grün) |
|
||||
| 17.01.2025 | **Feature 1 - Step 1.2** | ? **ABGESCHLOSSEN** - API Layer (ExceptionMiddleware, Endpoint, Program.cs, Integration Tests - 3/3 grün) |
|
||||
| 17.01.2025 | **Feature 1 - Step 1.3** | ? **ABGESCHLOSSEN** - Swagger Dokumentation (SwaggerConfiguration, XML Comments, Endpoint/DTO-Dokumentation - 11/11 Tests grün) |
|
||||
| 17.01.2025 | **Feature 1** | ? **KOMPLETT ABGESCHLOSSEN** - ValidatePDF Feature testbar im Swagger UI! |
|
||||
| 17.01.2025 | **Fix: Attachment Detection (Multiple Attachments)** | ? **KORRIGIERT** - ValidatePDF erkennt jetzt auch PDFs mit mehreren Attachments korrekt (globale Suche statt 1000-Zeichen-Limit) - 13/13 Tests grün |
|
||||
| 17.01.2025 | **Fix: Attachment Count (6 Attachments)** | ? **KORRIGIERT** - AttachmentCount wird jetzt korrekt gezählt (objectCount statt objectCount/2). PDFs mit 6 Attachments werden korrekt erkannt - 13/13 Tests grün |
|
||||
| 17.01.2025 | **PHASENPLAN** | ?? **Feature-Reihenfolge geändert** - Neues Feature 2: ExtractSwissQrCode (Swiss QR Bill Standard 2.0) eingefügt. Alte Features 2-5 werden zu Features 3-6. |
|
||||
| 17.01.2025 | **Feature 2 - Step 2.1** | ? **ABGESCHLOSSEN** - Domain Layer (SwissQrCodeData, AddressData, SwissQrCodeNotFoundException) |
|
||||
| 17.01.2025 | **Feature 2 - Step 2.2** | ? **ABGESCHLOSSEN** - Infrastructure Layer (ISwissQrCodeProcessor, DevExpressSwissQrCodeProcessor, ZXing + Codecrete Integration) |
|
||||
| 17.01.2025 | **Feature 2 - Step 2.3** | ? **ABGESCHLOSSEN** - Application Layer (ExtractSwissQrCodeQuery, Handler, Validator, Request/Response DTOs, Unit Tests) |
|
||||
| 17.01.2025 | **Feature 2 - Step 2.4** | ? **ABGESCHLOSSEN** - API Layer (Endpoint, Exception Mapping, Integration Tests - 19/19 Tests grün) |
|
||||
| 17.01.2025 | **Feature 2 - Step 2.5** | ? **ABGESCHLOSSEN** - Swagger Dokumentation (XML Comments, Examples, Endpoint Description) |
|
||||
| 17.01.2025 | **Feature 2** | ? **KOMPLETT ABGESCHLOSSEN** - ExtractSwissQrCode Feature testbar im Swagger UI! (19/19 Tests grün) |
|
||||
|
||||
---
|
||||
|
||||
**END OF PHASENPLAN**
|
||||
|
||||
*This document is a living document and will be updated after each completed step.*
|
||||
147
DocumentService.API/Program.cs
Normal file
147
DocumentService.API/Program.cs
Normal file
@@ -0,0 +1,147 @@
|
||||
using Serilog;
|
||||
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);
|
||||
|
||||
// ========================================
|
||||
// 1. Serilog Configuration
|
||||
// ========================================
|
||||
Log.Logger = new LoggerConfiguration()
|
||||
.ReadFrom.Configuration(builder.Configuration)
|
||||
.Enrich.FromLogContext()
|
||||
.Enrich.WithProperty("Application", "DocumentService")
|
||||
.CreateLogger();
|
||||
|
||||
builder.Host.UseSerilog();
|
||||
|
||||
Log.Information("Starting DocumentService API...");
|
||||
|
||||
try
|
||||
{
|
||||
// ========================================
|
||||
// 2. Options Pattern Configuration
|
||||
// ========================================
|
||||
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));
|
||||
|
||||
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(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.Configuration);
|
||||
|
||||
// ========================================
|
||||
// 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();
|
||||
|
||||
// ========================================
|
||||
// 5. Middleware Pipeline (Order matters!)
|
||||
// ========================================
|
||||
|
||||
// Exception Handling FIRST (catches all exceptions from subsequent middleware)
|
||||
app.UseMiddleware<ExceptionHandlingMiddleware>();
|
||||
|
||||
// ========================================
|
||||
// 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();
|
||||
|
||||
// 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
|
||||
|
||||
app.UseHttpsRedirection();
|
||||
|
||||
// ========================================
|
||||
// 6. Serilog.UI Dashboard
|
||||
// ========================================
|
||||
app.UseSerilogUi(); // Accessible at /serilog-ui
|
||||
|
||||
// ========================================
|
||||
// 7. Endpoints (Controller-based API)
|
||||
// ========================================
|
||||
app.MapControllers(); // Maps all [ApiController] controllers
|
||||
|
||||
Log.Information("DocumentService API started successfully");
|
||||
|
||||
app.Run();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Fatal(ex, "Application startup failed");
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
Log.CloseAndFlush();
|
||||
}
|
||||
|
||||
// Make Program class accessible for Integration Tests
|
||||
/// <summary>
|
||||
/// 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
DocumentService.API/README.md
Normal file
387
DocumentService.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
|
||||
},
|
||||
|
||||
102
DocumentService.API/appsettings.json
Normal file
102
DocumentService.API/appsettings.json
Normal file
@@ -0,0 +1,102 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"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",
|
||||
"Override": {
|
||||
"Microsoft": "Warning",
|
||||
"Microsoft.AspNetCore": "Warning",
|
||||
"System": "Warning"
|
||||
}
|
||||
},
|
||||
"WriteTo": [
|
||||
{
|
||||
"Name": "Console",
|
||||
"Args": {
|
||||
"outputTemplate": "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj} {Properties:j}{NewLine}{Exception}"
|
||||
}
|
||||
},
|
||||
{
|
||||
"Name": "File",
|
||||
"Args": {
|
||||
"path": "Logs/log-.txt",
|
||||
"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" ]
|
||||
},
|
||||
|
||||
"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": "DocumentService:",
|
||||
"CacheExpirationMinutes": 60
|
||||
},
|
||||
|
||||
"ApiKeySettings": {
|
||||
"EnableValidation": true,
|
||||
"Keys": {
|
||||
"customer-a-key-12345": {
|
||||
"TenantId": "customer-a",
|
||||
"TenantName": "Customer A GmbH",
|
||||
"IsActive": true
|
||||
},
|
||||
"customer-b-key-67890": {
|
||||
"TenantId": "customer-b",
|
||||
"TenantName": "Customer B AG",
|
||||
"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
DocumentService.Application/AddStamp/AddStampCommand.cs
Normal file
125
DocumentService.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");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
using MediatR;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using System.Diagnostics;
|
||||
|
||||
namespace DocumentService.Application.Common.Behaviors;
|
||||
|
||||
/// <summary>
|
||||
/// MediatR Pipeline Behavior that logs requests and tracks performance
|
||||
/// Executes AFTER ValidationBehavior, BEFORE Handler
|
||||
/// </summary>
|
||||
public class LoggingBehavior<TRequest, TResponse>(ILogger<LoggingBehavior<TRequest, TResponse>> Logger) : IPipelineBehavior<TRequest, TResponse>
|
||||
where TRequest : IRequest<TResponse>
|
||||
{
|
||||
public async Task<TResponse> Handle(
|
||||
TRequest request,
|
||||
RequestHandlerDelegate<TResponse> next,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var requestName = typeof(TRequest).Name;
|
||||
|
||||
// Performance Tracking
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
|
||||
try
|
||||
{
|
||||
// Handler ausführen
|
||||
var response = await next(cancellationToken);
|
||||
|
||||
stopwatch.Stop();
|
||||
|
||||
// Request Success
|
||||
Logger.LogInformation(
|
||||
"Handled {RequestName} in {ElapsedMs}ms",
|
||||
requestName,
|
||||
stopwatch.ElapsedMilliseconds
|
||||
);
|
||||
|
||||
return response;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
stopwatch.Stop();
|
||||
|
||||
// Request Failed
|
||||
Logger.LogError(
|
||||
ex,
|
||||
"Error handling {RequestName} after {ElapsedMs}ms: {ErrorMessage}",
|
||||
requestName,
|
||||
stopwatch.ElapsedMilliseconds,
|
||||
ex.Message
|
||||
);
|
||||
|
||||
throw; // Exception weiterwerfen (wird von Exception Middleware gefangen)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
using FluentValidation;
|
||||
using MediatR;
|
||||
|
||||
namespace DocumentService.Application.Common.Behaviors;
|
||||
|
||||
/// <summary>
|
||||
/// MediatR Pipeline Behavior that validates requests using FluentValidation
|
||||
/// Executes BEFORE the Handler
|
||||
/// </summary>
|
||||
public class ValidationBehavior<TRequest, TResponse> : IPipelineBehavior<TRequest, TResponse>
|
||||
where TRequest : IRequest<TResponse>
|
||||
{
|
||||
private readonly IEnumerable<IValidator<TRequest>> _validators;
|
||||
|
||||
public ValidationBehavior(IEnumerable<IValidator<TRequest>> validators)
|
||||
{
|
||||
_validators = validators;
|
||||
}
|
||||
|
||||
public async Task<TResponse> Handle(
|
||||
TRequest request,
|
||||
RequestHandlerDelegate<TResponse> next,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// Wenn keine Validators registriert sind, direkt weiter zum Handler
|
||||
if (!_validators.Any())
|
||||
{
|
||||
return await next();
|
||||
}
|
||||
|
||||
// Validation Context erstellen
|
||||
var context = new ValidationContext<TRequest>(request);
|
||||
|
||||
// Alle Validators parallel ausführen
|
||||
var validationResults = await Task.WhenAll(
|
||||
_validators.Select(v => v.ValidateAsync(context, cancellationToken))
|
||||
);
|
||||
|
||||
// Fehler sammeln
|
||||
var failures = validationResults
|
||||
.Where(r => !r.IsValid)
|
||||
.SelectMany(r => r.Errors)
|
||||
.ToList();
|
||||
|
||||
// Bei Fehlern: ValidationException werfen (wird von Exception Middleware gefangen)
|
||||
if (failures.Any())
|
||||
{
|
||||
throw new ValidationException(failures);
|
||||
}
|
||||
|
||||
// Validation erfolgreich ? weiter zum Handler
|
||||
return await next();
|
||||
}
|
||||
}
|
||||
@@ -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
DocumentService.Application/Common/DTOs/AttachmentInfo.cs
Normal file
60
DocumentService.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)";
|
||||
}
|
||||
}
|
||||
@@ -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; } = [];
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
namespace DocumentService.Application.Common.DTOs;
|
||||
|
||||
/// <summary>
|
||||
/// Response mit PDF-Metadaten
|
||||
/// </summary>
|
||||
/// <param name="PageCount">Anzahl der Seiten</param>
|
||||
/// <param name="FileSizeBytes">Dateigröße in Bytes</param>
|
||||
/// <param name="FileSizeMB">Dateigröße in MB (gerundet auf 2 Dezimalstellen)</param>
|
||||
/// <param name="PdfVersion">PDF-Version (z.B. "1.4")</param>
|
||||
/// <param name="HasAttachments">Hat das PDF Anhänge?</param>
|
||||
/// <param name="AttachmentCount">Anzahl der Anhänge</param>
|
||||
/// <param name="IsEncrypted">Ist das PDF passwortgeschützt/verschlüsselt?</param>
|
||||
public record PdfValidationResult(
|
||||
int PageCount,
|
||||
long FileSizeBytes,
|
||||
double FileSizeMB,
|
||||
string PdfVersion,
|
||||
bool HasAttachments,
|
||||
int AttachmentCount,
|
||||
bool IsEncrypted)
|
||||
{
|
||||
public bool IsValid => !IsEncrypted && PageCount > 0 && FileSizeBytes > 0;
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
namespace DocumentService.Application.Common.DTOs.Requests;
|
||||
|
||||
/// <summary>
|
||||
/// Request DTO for Base64-encoded PDF attachment check
|
||||
/// </summary>
|
||||
public record CheckPdfAttachmentsRequest : PdfBase64RequestBase;
|
||||
|
||||
/// <summary>
|
||||
/// Request DTO for Base64-encoded PDF attachment extraction
|
||||
/// </summary>
|
||||
public record ExtractPdfAttachmentsRequest : PdfBase64RequestBase;
|
||||
|
||||
/// <summary>
|
||||
/// Request DTO for Base64-encoded PDF with attachments to add
|
||||
/// </summary>
|
||||
public record AddAttachmentsRequest : PdfBase64RequestBase
|
||||
{
|
||||
/// <summary>
|
||||
/// List of attachments to embed
|
||||
/// </summary>
|
||||
public required List<AttachmentRequestDto> Attachments { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// DTO for a single attachment file in a 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; }
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace DocumentService.Application.Common.DTOs.Requests;
|
||||
|
||||
/// <summary>
|
||||
/// Base record for all single-PDF Base64 request types.
|
||||
/// Provides the common <see cref="Base64Pdf"/> property shared across endpoints.
|
||||
/// </summary>
|
||||
public abstract record PdfBase64RequestBase
|
||||
{
|
||||
/// <summary>
|
||||
/// PDF document encoded as Base64 string
|
||||
/// </summary>
|
||||
/// <example>JVBERi0xLjQKJeLjz9MKMSAwIG9iago8PC9UeXBlL0NhdGFsb2c...</example>
|
||||
public required string Base64Pdf { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
namespace DocumentService.Application.Common.DTOs.Requests;
|
||||
|
||||
/// <summary>
|
||||
/// Request DTO for converting a standard PDF to PDF/A format (Base64 JSON)
|
||||
/// </summary>
|
||||
public record ConvertToPdfARequest : PdfBase64RequestBase
|
||||
{
|
||||
/// <summary>
|
||||
/// Target PDF/A conformance level. Defaults to "PDF/A-3b".
|
||||
/// </summary>
|
||||
/// <example>PDF/A-3b</example>
|
||||
public string? PdfALevel { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request DTO for converting a PDF/A document back to standard PDF (Base64 JSON)
|
||||
/// </summary>
|
||||
public record ConvertFromPdfARequest : PdfBase64RequestBase;
|
||||
@@ -0,0 +1,209 @@
|
||||
using DocumentService.Domain.Models.ValueObjects;
|
||||
|
||||
namespace DocumentService.Application.Common.DTOs.Requests;
|
||||
|
||||
/// <summary>
|
||||
/// Request DTO for Base64-encoded PDF merge operation
|
||||
/// </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
|
||||
/// </summary>
|
||||
public record AddAnnotationBase64Request : PdfBase64RequestBase
|
||||
{
|
||||
/// <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 Base64-encoded PDF stamp operation
|
||||
/// </summary>
|
||||
public record AddStampBase64Request : PdfBase64RequestBase
|
||||
{
|
||||
/// <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; }
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace DocumentService.Application.Common.DTOs.Requests;
|
||||
|
||||
/// <summary>
|
||||
/// Request DTO for Base64-encoded PDF validation
|
||||
/// </summary>
|
||||
public record ValidatePdfBase64Request : PdfBase64RequestBase;
|
||||
|
||||
/// <summary>
|
||||
/// Request DTO for Base64-encoded PDF/A validation
|
||||
/// </summary>
|
||||
public record ValidatePdfABase64Request : PdfBase64RequestBase;
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace DocumentService.Application.Common.DTOs.Requests;
|
||||
|
||||
/// <summary>
|
||||
/// Request DTO for Base64-encoded Swiss QR Code extraction
|
||||
/// </summary>
|
||||
public record ExtractSwissQrCodeBase64Request : PdfBase64RequestBase;
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace DocumentService.Application.Common.DTOs.Requests;
|
||||
|
||||
/// <summary>
|
||||
/// Request DTO for Base64-encoded PDF ZUGFeRD check
|
||||
/// </summary>
|
||||
public record HasZugferdRequest : PdfBase64RequestBase;
|
||||
|
||||
/// <summary>
|
||||
/// Request DTO for Base64-encoded PDF ZUGFeRD extraction
|
||||
/// </summary>
|
||||
public record ExtractZugferdRequest : PdfBase64RequestBase;
|
||||
93
DocumentService.Application/Common/DTOs/SwissQrBillDto.cs
Normal file
93
DocumentService.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; }
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
namespace DocumentService.Application.Common.DTOs;
|
||||
|
||||
/// <summary>
|
||||
/// Response containing extracted Swiss QR Code in dual format.
|
||||
/// </summary>
|
||||
/// <param name="Bill">Parsed Swiss QR Bill (structured format)</param>
|
||||
/// <param name="RawLines">Raw Swiss QR Code lines (original newline-separated format)</param>
|
||||
/// <example>
|
||||
/// {
|
||||
/// "bill": {
|
||||
/// "version": "V2_0",
|
||||
/// "amount": 630.20,
|
||||
/// "currency": "CHF",
|
||||
/// "account": "CH953000520280564701R",
|
||||
/// "creditor": {
|
||||
/// "type": "Structured",
|
||||
/// "name": "ALMAT AG",
|
||||
/// "town": "Tagelswangen"
|
||||
/// },
|
||||
/// "referenceType": "QRR",
|
||||
/// "reference": "000000000000000000252080824"
|
||||
/// },
|
||||
/// "rawLines": [
|
||||
/// "SPC",
|
||||
/// "0200",
|
||||
/// "1",
|
||||
/// "CH953000520280564701R",
|
||||
/// "S",
|
||||
/// "ALMAT AG",
|
||||
/// "..."
|
||||
/// ]
|
||||
/// }
|
||||
/// </example>
|
||||
public record SwissQrCodeExtractionResult(
|
||||
#if NET
|
||||
SwissQrBillDto Bill,
|
||||
#else
|
||||
object? Bill,
|
||||
#endif
|
||||
IEnumerable<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; }
|
||||
}
|
||||
191
DocumentService.Application/Common/Interfaces/IPdfProcessor.cs
Normal file
191
DocumentService.Application/Common/Interfaces/IPdfProcessor.cs
Normal file
@@ -0,0 +1,191 @@
|
||||
using DocumentService.Application.Common.DTOs;
|
||||
|
||||
namespace DocumentService.Application.Common.Interfaces;
|
||||
|
||||
public interface IPdfProcessor
|
||||
{
|
||||
/// <summary>
|
||||
/// Validates a PDF and extracts 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>PDF metadata (page count, size, version, attachments)</returns>
|
||||
/// <exception cref="Domain.Common.Exceptions.BadRequestException">
|
||||
/// Thrown when stream is empty, invalid, or not positioned at the beginning
|
||||
/// </exception>
|
||||
Task<PdfValidationResult> 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<PdfAValidationResult> 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);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
using Codecrete.SwissQRBill.Generator;
|
||||
|
||||
namespace DocumentService.Application.Common.Interfaces;
|
||||
|
||||
/// <summary>
|
||||
/// Interface for Swiss QR Code processing operations.
|
||||
/// Extracts and parses Swiss QR Codes from PDF documents.
|
||||
/// </summary>
|
||||
public interface ISwissQrCodeProcessor
|
||||
{
|
||||
/// <summary>
|
||||
/// Extracts and parses Swiss QR Code from a PDF document.
|
||||
/// Returns both parsed Bill object and raw QR text lines.
|
||||
/// </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="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>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="ArgumentException">
|
||||
/// Thrown when stream is empty or (for seekable streams) not positioned at the beginning
|
||||
/// </exception>
|
||||
Task<(Bill Bill, string[] RawLines)> ExtractSwissQrCodeAsync(
|
||||
Stream pdfStream,
|
||||
int[]? pageNumbers = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
48
DocumentService.Application/Common/Mapping/MappingProfile.cs
Normal file
48
DocumentService.Application/Common/Mapping/MappingProfile.cs
Normal file
@@ -0,0 +1,48 @@
|
||||
using AutoMapper;
|
||||
using Codecrete.SwissQRBill.Generator;
|
||||
using DocumentService.Application.Common.DTOs;
|
||||
using DocumentService.Domain.Models.ValueObjects;
|
||||
|
||||
namespace DocumentService.Application.Common.Mapping;
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
{
|
||||
public MappingProfile()
|
||||
{
|
||||
|
||||
// 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)}");
|
||||
}
|
||||
}
|
||||
44
DocumentService.Application/DependencyInjection.cs
Normal file
44
DocumentService.Application/DependencyInjection.cs
Normal file
@@ -0,0 +1,44 @@
|
||||
using FluentValidation;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace DocumentService.Application;
|
||||
|
||||
/// <summary>
|
||||
/// Dependency Injection configuration for Application Layer
|
||||
/// </summary>
|
||||
public static class DependencyInjection
|
||||
{
|
||||
/// <summary>
|
||||
/// Registers Application Layer services (MediatR, FluentValidation, AutoMapper, Behaviors)
|
||||
/// </summary>
|
||||
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!)
|
||||
config.AddOpenBehavior(typeof(Common.Behaviors.ValidationBehavior<,>));
|
||||
config.AddOpenBehavior(typeof(Common.Behaviors.LoggingBehavior<,>));
|
||||
});
|
||||
|
||||
// Register FluentValidation (scannt Assembly nach Validators)
|
||||
services.AddValidatorsFromAssembly(assembly);
|
||||
|
||||
// Register AutoMapper (scannt Assembly nach Profiles)
|
||||
services.AddAutoMapper(cfg => {
|
||||
cfg.LicenseKey = licenseKey;
|
||||
}, typeof(Common.Mapping.MappingProfile));
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>net462;net480;net8.0</TargetFrameworks>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>latest</LangVersion>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Compile Remove="DependencyInjection\**" />
|
||||
<Compile Remove="Features\**" />
|
||||
<EmbeddedResource Remove="DependencyInjection\**" />
|
||||
<EmbeddedResource Remove="Features\**" />
|
||||
<None Remove="DependencyInjection\**" />
|
||||
<None Remove="Features\**" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup Condition="'$(TargetFramework)' == 'net8.0'">
|
||||
<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 Condition="'$(TargetFramework)' == 'net462'">
|
||||
<PackageReference Include="AutoMapper" Version="16.2.0" />
|
||||
<PackageReference Include="FluentValidation" Version="11.11.0" />
|
||||
<PackageReference Include="MediatR" Version="14.1.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="8.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="8.0.2" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup Condition="'$(TargetFramework)' == 'net480'">
|
||||
<PackageReference Include="AutoMapper" Version="16.2.0" />
|
||||
<PackageReference Include="FluentValidation" Version="11.11.0" />
|
||||
<PackageReference Include="MediatR" Version="14.1.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="8.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="8.0.2" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup Condition="'$(TargetFramework)' != 'net8.0'">
|
||||
<PackageReference Include="PolySharp" Version="1.14.1">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup Condition="'$(TargetFramework)' != 'net8.0'">
|
||||
<Compile Remove="Common\Interfaces\ISwissQrCodeProcessor.cs" />
|
||||
<Compile Remove="Common\DTOs\SwissQrBillDto.cs" />
|
||||
<Compile Remove="Common\Mapping\MappingProfile.cs" />
|
||||
<Compile Remove="SwissQrCode\**" />
|
||||
<Compile Remove="HasZugferd\**" />
|
||||
<Compile Remove="DependencyInjection.cs" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\DocumentService.Domain\DocumentService.Domain.csproj" />
|
||||
</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,115 @@
|
||||
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; }
|
||||
}
|
||||
|
||||
#if NET
|
||||
/// <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
|
||||
};
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
/// <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
DocumentService.Application/MergePdfs/MergePdfsCommand.cs
Normal file
53
DocumentService.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");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
using AutoMapper;
|
||||
using DocumentService.Application.Common.DTOs;
|
||||
using DocumentService.Application.Common.Interfaces;
|
||||
using MediatR;
|
||||
|
||||
namespace DocumentService.Application.SwissQrCode.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// Query for extracting Swiss QR Code from PDF (Stream-based)
|
||||
/// </summary>
|
||||
public record ExtractSwissQrCodeQuery : IRequest<SwissQrCodeExtractionResult>
|
||||
{
|
||||
/// <summary>
|
||||
/// PDF as stream (caller is responsible for disposal)
|
||||
/// </summary>
|
||||
public required Stream PdfStream { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handler for ExtractSwissQrCodeQuery
|
||||
/// Orchestrates Swiss QR Code extraction using ISwissQrCodeProcessor and AutoMapper
|
||||
/// </summary>
|
||||
public class ExtractSwissQrCodeQueryHandler(ISwissQrCodeProcessor qrCodeProcessor, IMapper mapper)
|
||||
: IRequestHandler<ExtractSwissQrCodeQuery, SwissQrCodeExtractionResult>
|
||||
{
|
||||
/// <summary>
|
||||
/// 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)
|
||||
{
|
||||
// Extract: returns (Bill, RawLines) - pass stream directly
|
||||
var (bill, rawLines) = await qrCodeProcessor.ExtractSwissQrCodeAsync(request.PdfStream, pageNumbers: null, cancellationToken);
|
||||
|
||||
// Map Codecrete Bill to DTO using AutoMapper
|
||||
var billDto = mapper.Map<SwissQrBillDto>(bill);
|
||||
|
||||
// Return references (passed through) + Bill DTO + raw lines
|
||||
return new SwissQrCodeExtractionResult(
|
||||
Bill: billDto,
|
||||
RawLines: rawLines
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
using DocumentService.Application.SwissQrCode.Queries;
|
||||
using FluentValidation;
|
||||
|
||||
namespace DocumentService.Application.SwissQrCode.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// Validates ExtractSwissQrCodeQuery before handler execution.
|
||||
/// Ensures PdfStream is not null.
|
||||
/// </summary>
|
||||
public sealed class ExtractSwissQrCodeQueryValidator : AbstractValidator<ExtractSwissQrCodeQuery>
|
||||
{
|
||||
public ExtractSwissQrCodeQueryValidator()
|
||||
{
|
||||
// Rule: PdfStream must be provided
|
||||
RuleFor(x => x.PdfStream)
|
||||
.NotNull()
|
||||
.WithMessage("PdfStream is required");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
using DocumentService.Application.Common.DTOs;
|
||||
using DocumentService.Application.Common.Interfaces;
|
||||
using MediatR;
|
||||
|
||||
namespace DocumentService.Application.ValidatePdf.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// Query for PDF validation (Stream-based)
|
||||
/// </summary>
|
||||
public record ValidatePdfQuery : IRequest<PdfValidationResult>
|
||||
{
|
||||
/// <summary>
|
||||
/// PDF as stream (caller is responsible for disposal)
|
||||
/// </summary>
|
||||
public required Stream PdfStream { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handler for ValidatePdfQuery
|
||||
/// </summary>
|
||||
public class ValidatePdfQueryHandler(IPdfProcessor pdfProcessor)
|
||||
: IRequestHandler<ValidatePdfQuery, PdfValidationResult>
|
||||
{
|
||||
public async Task<PdfValidationResult> Handle(ValidatePdfQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
return await pdfProcessor.ValidateAsync(request.PdfStream);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
using DocumentService.Application.ValidatePdf.Queries;
|
||||
using FluentValidation;
|
||||
|
||||
namespace DocumentService.Application.ValidatePdf.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// Validator for ValidatePdfQuery
|
||||
/// Ensures PdfStream is not null
|
||||
/// </summary>
|
||||
public class ValidatePdfQueryValidator : AbstractValidator<ValidatePdfQuery>
|
||||
{
|
||||
public ValidatePdfQueryValidator()
|
||||
{
|
||||
// Rule: PdfStream must be provided
|
||||
RuleFor(x => x.PdfStream)
|
||||
.NotNull()
|
||||
.WithMessage("PdfStream is required");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
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
|
||||
/// </summary>
|
||||
public class ValidatePdfAQueryHandler(IPdfProcessor pdfProcessor)
|
||||
: IRequestHandler<ValidatePdfAQuery, PdfAValidationResult>
|
||||
{
|
||||
public async Task<PdfAValidationResult> Handle(ValidatePdfAQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
return await pdfProcessor.ValidatePdfAAsync(request.PdfStream);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
95
DocumentService.Client/Client.cs
Normal file
95
DocumentService.Client/Client.cs
Normal file
@@ -0,0 +1,95 @@
|
||||
using DocumentService.Client.Configuration;
|
||||
using DocumentService.Client.Extensions;
|
||||
using DocumentService.Client.Interfaces;
|
||||
using DocumentService.Client.Models.ValueObjects;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace DocumentService.Client;
|
||||
|
||||
/// <summary>
|
||||
/// Static entry point for configuring and building the DocumentService client.
|
||||
/// </summary>
|
||||
public static class Client
|
||||
{
|
||||
private static Action<DocumentServiceClientOptions> Options { get; set; } = null!;
|
||||
|
||||
private static readonly Lazy<IServiceProvider> LazyProvider = new(() =>
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddDocumentServiceClients(Options);
|
||||
return services.BuildServiceProvider();
|
||||
});
|
||||
|
||||
public static bool IsConfigured => LazyProvider.IsValueCreated;
|
||||
|
||||
public static OnReconfigure OnReconfigure { get; set; } = OnReconfigure.ThrowException;
|
||||
|
||||
/// <summary>
|
||||
/// Configures the client using a full <see cref="DocumentServiceClientOptions"/> action.
|
||||
/// </summary>
|
||||
/// <param name="configuration">Action that configures the client options.</param>
|
||||
public static void Configure(Action<DocumentServiceClientOptions> configuration)
|
||||
{
|
||||
if(IsConfigured)
|
||||
switch (OnReconfigure)
|
||||
{
|
||||
case OnReconfigure.ThrowException:
|
||||
throw new InvalidOperationException("DocumentService.Client is already configured. Reconfiguration is not allowed.");
|
||||
case OnReconfigure.Ignore:
|
||||
return;
|
||||
}
|
||||
Options = configuration;
|
||||
_ = LazyProvider.Value; // init service provider
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configures the client with only a base URL. Use the overload with options action for full control.
|
||||
/// </summary>
|
||||
/// <param name="baseUrl">Base URL of the DocumentService API.</param>
|
||||
public static void Configure(string baseUrl)
|
||||
{
|
||||
if (IsConfigured)
|
||||
switch (OnReconfigure)
|
||||
{
|
||||
case OnReconfigure.ThrowException:
|
||||
throw new InvalidOperationException("DocumentService.Client is already configured. Reconfiguration is not allowed.");
|
||||
case OnReconfigure.Ignore:
|
||||
return;
|
||||
}
|
||||
|
||||
Options = options => {
|
||||
options = new DocumentServiceClientOptions
|
||||
{
|
||||
BaseUrl = baseUrl
|
||||
};
|
||||
};
|
||||
_ = LazyProvider.Value; // init service provider
|
||||
}
|
||||
|
||||
private static IServiceProvider Provider => IsConfigured
|
||||
? LazyProvider.Value
|
||||
: throw new InvalidOperationException("DocumentService.Client is not configured.");
|
||||
|
||||
private static T GetRequiredServiceOfScope<T>() where T : notnull => Provider.CreateAsyncScope().ServiceProvider.GetRequiredService<T>();
|
||||
|
||||
#region Controllers
|
||||
public static IPdfAttachmentClient Attachment => GetRequiredServiceOfScope<IPdfAttachmentClient>();
|
||||
|
||||
public static IPdfConversionClient Conversion => GetRequiredServiceOfScope<IPdfConversionClient>();
|
||||
|
||||
public static IPdfOperationsClient Operations => GetRequiredServiceOfScope<IPdfOperationsClient>();
|
||||
|
||||
public static IPdfValidationClient Validation => GetRequiredServiceOfScope<IPdfValidationClient>();
|
||||
|
||||
public static ISwissQrCodeClient SwissQrCode => GetRequiredServiceOfScope<ISwissQrCodeClient>();
|
||||
|
||||
public static IZugferdClient Zugferd => GetRequiredServiceOfScope<IZugferdClient>();
|
||||
|
||||
public static IWorkflowsClient Workflows => GetRequiredServiceOfScope<IWorkflowsClient>();
|
||||
#endregion
|
||||
}
|
||||
179
DocumentService.Client/Clients/BaseDocumentClient.cs
Normal file
179
DocumentService.Client/Clients/BaseDocumentClient.cs
Normal file
@@ -0,0 +1,179 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
|
||||
namespace DocumentService.Client.Clients;
|
||||
|
||||
/// <summary>
|
||||
/// Base class for all DocumentService HTTP clients.
|
||||
/// Provides common HTTP operations and error handling.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
///
|
||||
/// </remarks>
|
||||
/// <param name="HttpClient"></param>
|
||||
/// <param name="logger"></param>
|
||||
/// <exception cref="ArgumentNullException"></exception>
|
||||
public abstract class BaseDocumentClient(HttpClient HttpClient, ILogger logger)
|
||||
{
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
protected readonly ILogger Logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
|
||||
/// <summary>
|
||||
/// Sends a multipart/form-data POST with a single file and deserializes the JSON response.
|
||||
/// </summary>
|
||||
protected async Task<TResponse?> SendMultipartAsync<TResponse>(
|
||||
string endpoint,
|
||||
Stream fileStream,
|
||||
string fileName = "file.pdf",
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var content = new MultipartFormDataContent();
|
||||
var streamContent = new StreamContent(fileStream);
|
||||
streamContent.Headers.ContentType = new MediaTypeHeaderValue("application/pdf");
|
||||
content.Add(streamContent, "file", fileName);
|
||||
|
||||
var response = await HttpClient.PostAsync(endpoint, content, cancellationToken);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
return await response.Content.ReadFromJsonAsync<TResponse>(cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sends a multipart/form-data POST with a single file and returns the response as a stream.
|
||||
/// </summary>
|
||||
protected async Task<Stream> SendMultipartForStreamAsync(
|
||||
string endpoint,
|
||||
Stream fileStream,
|
||||
string fileName = "file.pdf",
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var content = new MultipartFormDataContent();
|
||||
var streamContent = new StreamContent(fileStream);
|
||||
streamContent.Headers.ContentType = new MediaTypeHeaderValue("application/pdf");
|
||||
content.Add(streamContent, "file", fileName);
|
||||
|
||||
var response = await HttpClient.PostAsync(endpoint, content, cancellationToken);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
#if NETFRAMEWORK
|
||||
return await response.Content.ReadAsStreamAsync();
|
||||
#else
|
||||
return await response.Content.ReadAsStreamAsync(cancellationToken);
|
||||
#endif
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sends a multipart/form-data POST with a single file and returns the raw byte array.
|
||||
/// </summary>
|
||||
protected async Task<byte[]> SendMultipartForBinaryAsync(
|
||||
string endpoint,
|
||||
Stream fileStream,
|
||||
string fileName = "file.pdf",
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var content = new MultipartFormDataContent();
|
||||
var streamContent = new StreamContent(fileStream);
|
||||
streamContent.Headers.ContentType = new MediaTypeHeaderValue("application/pdf");
|
||||
content.Add(streamContent, "file", fileName);
|
||||
|
||||
var response = await HttpClient.PostAsync(endpoint, content, cancellationToken);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
#if NETFRAMEWORK
|
||||
return await response.Content.ReadAsByteArrayAsync();
|
||||
#else
|
||||
return await response.Content.ReadAsByteArrayAsync(cancellationToken);
|
||||
#endif
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sends a caller-built <see cref="MultipartFormDataContent"/> and returns the response as a stream.
|
||||
/// Use this overload when the multipart body contains more than one file (e.g., merge).
|
||||
/// </summary>
|
||||
protected async Task<Stream> SendMultipartContentForStreamAsync(
|
||||
string endpoint,
|
||||
MultipartFormDataContent content,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var response = await HttpClient.PostAsync(endpoint, content, cancellationToken);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
#if NETFRAMEWORK
|
||||
return await response.Content.ReadAsStreamAsync();
|
||||
#else
|
||||
return await response.Content.ReadAsStreamAsync(cancellationToken);
|
||||
#endif
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Serializes <paramref name="request"/> as JSON, POSTs it, and deserializes the response.
|
||||
/// </summary>
|
||||
protected async Task<TResponse?> SendJsonAsync<TRequest, TResponse>(
|
||||
string endpoint,
|
||||
TRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var response = await HttpClient.PostAsJsonAsync(endpoint, request, cancellationToken);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
return await response.Content.ReadFromJsonAsync<TResponse>(cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Serializes <paramref name="request"/> as JSON, POSTs it, and returns the response as a stream.
|
||||
/// </summary>
|
||||
protected async Task<Stream> SendJsonForStreamAsync<TRequest>(
|
||||
string endpoint,
|
||||
TRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var response = await HttpClient.PostAsJsonAsync(endpoint, request, cancellationToken);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
#if NETFRAMEWORK
|
||||
return await response.Content.ReadAsStreamAsync();
|
||||
#else
|
||||
return await response.Content.ReadAsStreamAsync(cancellationToken);
|
||||
#endif
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Serializes <paramref name="request"/> as JSON, POSTs it, and returns the raw byte array.
|
||||
/// </summary>
|
||||
protected async Task<byte[]> SendJsonForBinaryAsync<TRequest>(
|
||||
string endpoint,
|
||||
TRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var response = await HttpClient.PostAsJsonAsync(endpoint, request, cancellationToken);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
#if NETFRAMEWORK
|
||||
return await response.Content.ReadAsByteArrayAsync();
|
||||
#else
|
||||
return await response.Content.ReadAsByteArrayAsync(cancellationToken);
|
||||
#endif
|
||||
}
|
||||
|
||||
/// <summary>Converts a byte array to a Base64 string.</summary>
|
||||
protected static string ToBase64(byte[] bytes) => Convert.ToBase64String(bytes);
|
||||
|
||||
/// <summary>Reads a stream fully into a byte array.</summary>
|
||||
protected static async Task<byte[]> StreamToBytesAsync(Stream stream, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (stream is MemoryStream ms)
|
||||
return ms.ToArray();
|
||||
|
||||
using var memoryStream = new MemoryStream();
|
||||
#if NET8_0
|
||||
await stream.CopyToAsync(memoryStream, cancellationToken);
|
||||
#else
|
||||
await stream.CopyToAsync(memoryStream);
|
||||
#endif
|
||||
return memoryStream.ToArray();
|
||||
}
|
||||
}
|
||||
125
DocumentService.Client/Clients/PdfAttachmentClient.cs
Normal file
125
DocumentService.Client/Clients/PdfAttachmentClient.cs
Normal file
@@ -0,0 +1,125 @@
|
||||
using DocumentService.Application.Common.DTOs;
|
||||
using DocumentService.Client.Interfaces;
|
||||
using DocumentService.Application.Common.DTOs.Requests;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using System.IO.Compression;
|
||||
using System.Net.Http;
|
||||
|
||||
namespace DocumentService.Client.Clients;
|
||||
|
||||
/// <summary>
|
||||
/// Implementation of PDF attachment client.
|
||||
/// </summary>
|
||||
public class PdfAttachmentClient(HttpClient httpClient, ILogger<PdfAttachmentClient> logger)
|
||||
: BaseDocumentClient(httpClient, logger), IPdfAttachmentClient
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<AttachmentCheckResult> CheckAsync(Stream pdfStream, CancellationToken cancellationToken = default)
|
||||
{
|
||||
Logger.LogDebug("Checking PDF attachments from stream (multipart)");
|
||||
|
||||
var result = await SendMultipartAsync<AttachmentCheckResult>(
|
||||
"/api/pdf/attachments/check",
|
||||
pdfStream,
|
||||
"document.pdf",
|
||||
cancellationToken);
|
||||
|
||||
return result ?? throw new InvalidOperationException("API returned null response");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<AttachmentCheckResult> CheckAsync(byte[] pdfBytes, CancellationToken cancellationToken = default)
|
||||
{
|
||||
Logger.LogDebug("Checking PDF attachments from byte array (Base64 JSON)");
|
||||
|
||||
var request = new CheckPdfAttachmentsRequest { Base64Pdf = ToBase64(pdfBytes) };
|
||||
|
||||
var result = await SendJsonAsync<CheckPdfAttachmentsRequest, AttachmentCheckResult>(
|
||||
"/api/pdf/attachments/check",
|
||||
request,
|
||||
cancellationToken);
|
||||
|
||||
return result ?? throw new InvalidOperationException("API returned null response");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Dictionary<string, Stream>> ExtractAsync(Stream pdfStream, CancellationToken cancellationToken = default)
|
||||
{
|
||||
Logger.LogDebug("Extracting PDF attachments from stream (multipart)");
|
||||
|
||||
var zipStream = await SendMultipartForStreamAsync(
|
||||
"/api/pdf/attachments/extract",
|
||||
pdfStream,
|
||||
"document.pdf",
|
||||
cancellationToken);
|
||||
|
||||
return UnzipToStreams(zipStream);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Dictionary<string, Stream>> ExtractAsync(byte[] pdfBytes, CancellationToken cancellationToken = default)
|
||||
{
|
||||
Logger.LogDebug("Extracting PDF attachments from byte array (Base64 JSON)");
|
||||
|
||||
var request = new ExtractPdfAttachmentsRequest { Base64Pdf = ToBase64(pdfBytes) };
|
||||
|
||||
var zipStream = await SendJsonForStreamAsync(
|
||||
"/api/pdf/attachments/extract",
|
||||
request,
|
||||
cancellationToken);
|
||||
|
||||
return UnzipToStreams(zipStream);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
[Obsolete("API endpoint not implemented yet")]
|
||||
public Task<Stream> AddAsync(Stream pdfStream, List<AttachmentRequestDto> attachments, CancellationToken cancellationToken = default)
|
||||
{
|
||||
Logger.LogWarning("AddAttachments endpoint is not implemented yet in API");
|
||||
throw new NotImplementedException("API endpoint /api/pdf/attachments/add is not implemented yet");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
[Obsolete("API endpoint not implemented yet")]
|
||||
public Task<Stream> AddAsync(byte[] pdfBytes, List<AttachmentRequestDto> attachments, CancellationToken cancellationToken = default)
|
||||
{
|
||||
Logger.LogWarning("AddAttachments endpoint is not implemented yet in API");
|
||||
throw new NotImplementedException("API endpoint /api/pdf/attachments/add is not implemented yet");
|
||||
}
|
||||
|
||||
// ?????????????????????????????????????????????????????????????????????????
|
||||
// Private helpers
|
||||
// ?????????????????????????????????????????????????????????????????????????
|
||||
|
||||
/// <summary>
|
||||
/// Reads a ZIP stream and returns a dictionary mapping each entry's full name
|
||||
/// to an in-memory <see cref="MemoryStream"/> containing the decompressed bytes.
|
||||
/// The caller owns the returned streams and is responsible for disposing them.
|
||||
/// </summary>
|
||||
private static Dictionary<string, Stream> UnzipToStreams(Stream zipStream)
|
||||
{
|
||||
var result = new Dictionary<string, Stream>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
using var archive = new ZipArchive(zipStream, ZipArchiveMode.Read, leaveOpen: false);
|
||||
|
||||
foreach (var entry in archive.Entries)
|
||||
{
|
||||
// Skip directory entries (name ends with '/')
|
||||
if (string.IsNullOrEmpty(entry.Name))
|
||||
continue;
|
||||
|
||||
var ms = new MemoryStream((int)entry.Length);
|
||||
|
||||
using (var entryStream = entry.Open())
|
||||
{
|
||||
entryStream.CopyTo(ms);
|
||||
}
|
||||
|
||||
ms.Position = 0;
|
||||
result[entry.FullName] = ms;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
48
DocumentService.Client/Clients/PdfConversionClient.cs
Normal file
48
DocumentService.Client/Clients/PdfConversionClient.cs
Normal file
@@ -0,0 +1,48 @@
|
||||
using DocumentService.Client.Interfaces;
|
||||
using DocumentService.Application.Common.DTOs.Requests;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using System.Net.Http;
|
||||
|
||||
namespace DocumentService.Client.Clients;
|
||||
|
||||
/// <summary>
|
||||
/// Implementation of PDF conversion client (PDF ? PDF/A).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// All methods throw <see cref="NotImplementedException"/> because the corresponding
|
||||
/// API endpoints are not yet implemented on the server side.
|
||||
/// </remarks>
|
||||
public class PdfConversionClient(HttpClient httpClient, ILogger<PdfConversionClient> logger) : BaseDocumentClient(httpClient, logger), IPdfConversionClient
|
||||
{
|
||||
/// <inheritdoc />
|
||||
[Obsolete("API endpoint not implemented yet")]
|
||||
public Task<Stream> ToPdfAAsync(Stream pdfStream, string pdfALevel = "PDF/A-3b", CancellationToken cancellationToken = default)
|
||||
{
|
||||
Logger.LogWarning("ConvertToPdfA endpoint is not implemented yet in API");
|
||||
throw new NotImplementedException("API endpoint POST /api/pdf/conversion/to-pdfa is not implemented yet");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
[Obsolete("API endpoint not implemented yet")]
|
||||
public Task<Stream> ToPdfAAsync(byte[] pdfBytes, string pdfALevel = "PDF/A-3b", CancellationToken cancellationToken = default)
|
||||
{
|
||||
Logger.LogWarning("ConvertToPdfA endpoint is not implemented yet in API");
|
||||
throw new NotImplementedException("API endpoint POST /api/pdf/conversion/to-pdfa is not implemented yet");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
[Obsolete("API endpoint not implemented yet")]
|
||||
public Task<Stream> FromPdfAAsync(Stream pdfStream, CancellationToken cancellationToken = default)
|
||||
{
|
||||
Logger.LogWarning("ConvertFromPdfA endpoint is not implemented yet in API");
|
||||
throw new NotImplementedException("API endpoint POST /api/pdf/conversion/from-pdfa is not implemented yet");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
[Obsolete("API endpoint not implemented yet")]
|
||||
public Task<Stream> FromPdfAAsync(byte[] pdfBytes, CancellationToken cancellationToken = default)
|
||||
{
|
||||
Logger.LogWarning("ConvertFromPdfA endpoint is not implemented yet in API");
|
||||
throw new NotImplementedException("API endpoint POST /api/pdf/conversion/from-pdfa is not implemented yet");
|
||||
}
|
||||
}
|
||||
161
DocumentService.Client/Clients/PdfOperationsClient.cs
Normal file
161
DocumentService.Client/Clients/PdfOperationsClient.cs
Normal file
@@ -0,0 +1,161 @@
|
||||
using DocumentService.Client.Interfaces;
|
||||
using DocumentService.Application.Common.DTOs.Requests;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
|
||||
namespace DocumentService.Client.Clients;
|
||||
|
||||
/// <summary>
|
||||
/// Implementation of PDF operations client (merge, annotate, stamp).
|
||||
/// </summary>
|
||||
public class PdfOperationsClient(HttpClient httpClient, ILogger<PdfOperationsClient> logger) : BaseDocumentClient(httpClient, logger), IPdfOperationsClient
|
||||
{
|
||||
|
||||
// ==================== MERGE OPERATIONS ====================
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Stream> MergeAsync(IEnumerable<Stream> pdfStreams, List<string?>? pageRanges = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var content = new MultipartFormDataContent();
|
||||
|
||||
foreach (var stream in pdfStreams)
|
||||
{
|
||||
var streamContent = new StreamContent(stream);
|
||||
streamContent.Headers.ContentType = new MediaTypeHeaderValue("application/pdf");
|
||||
content.Add(streamContent, "files", $"file_{Guid.NewGuid()}.pdf");
|
||||
}
|
||||
|
||||
return await SendMultipartContentForStreamAsync("/api/pdf/operations/merge", content, cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Stream> MergeAsync(IEnumerable<byte[]> pdfByteArrays, List<string?>? pageRanges = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var request = new MergePdfsBase64Request
|
||||
{
|
||||
Base64Pdfs = [.. pdfByteArrays.Select(ToBase64)],
|
||||
PageRanges = pageRanges
|
||||
};
|
||||
|
||||
return await SendJsonForStreamAsync(
|
||||
"/api/pdf/operations/merge",
|
||||
request,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
// ==================== ANNOTATION OPERATIONS ====================
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Stream> AnnotateAsync(Stream pdfStream, AddAnnotationBase64Request request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// For multipart, we need to send form data with all annotation parameters
|
||||
using var content = new MultipartFormDataContent();
|
||||
|
||||
var streamContent = new StreamContent(pdfStream);
|
||||
streamContent.Headers.ContentType = new MediaTypeHeaderValue("application/pdf");
|
||||
content.Add(streamContent, "File", "document.pdf");
|
||||
|
||||
// Add annotation parameters as form fields
|
||||
content.Add(new StringContent(request.AnnotationType.ToString()), "AnnotationType");
|
||||
content.Add(new StringContent(request.PageNumber.ToString()), "PageNumber");
|
||||
content.Add(new StringContent(request.X1.ToString()), "X1");
|
||||
content.Add(new StringContent(request.Y1.ToString()), "Y1");
|
||||
|
||||
if (request.X2.HasValue)
|
||||
content.Add(new StringContent(request.X2.Value.ToString()), "X2");
|
||||
if (request.Y2.HasValue)
|
||||
content.Add(new StringContent(request.Y2.Value.ToString()), "Y2");
|
||||
if (request.Width.HasValue)
|
||||
content.Add(new StringContent(request.Width.Value.ToString()), "Width");
|
||||
if (request.Height.HasValue)
|
||||
content.Add(new StringContent(request.Height.Value.ToString()), "Height");
|
||||
if (!string.IsNullOrEmpty(request.Content))
|
||||
content.Add(new StringContent(request.Content), "Content");
|
||||
if (!string.IsNullOrEmpty(request.Author))
|
||||
content.Add(new StringContent(request.Author), "Author");
|
||||
if (!string.IsNullOrEmpty(request.Color))
|
||||
content.Add(new StringContent(request.Color), "Color");
|
||||
if (request.TextMarkupStyle.HasValue)
|
||||
content.Add(new StringContent(request.TextMarkupStyle.Value.ToString()), "TextMarkupStyle");
|
||||
|
||||
content.Add(new StringContent(request.Origin.ToString()), "Origin");
|
||||
|
||||
return await SendMultipartContentForStreamAsync("/api/pdf/operations/annotate", content, cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Stream> AnnotateAsync(byte[] pdfBytes, AddAnnotationBase64Request request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var requestWithPdf = request with { Base64Pdf = ToBase64(pdfBytes) };
|
||||
|
||||
return await SendJsonForStreamAsync(
|
||||
"/api/pdf/operations/annotate",
|
||||
requestWithPdf,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
// ==================== STAMP OPERATIONS ====================
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Stream> StampAsync(Stream pdfStream, AddStampBase64Request request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// For multipart, we need to send form data with all stamp parameters
|
||||
using var content = new MultipartFormDataContent();
|
||||
|
||||
var streamContent = new StreamContent(pdfStream);
|
||||
streamContent.Headers.ContentType = new MediaTypeHeaderValue("application/pdf");
|
||||
content.Add(streamContent, "File", "document.pdf");
|
||||
|
||||
// Add stamp parameters as form fields
|
||||
content.Add(new StringContent(request.StampType.ToString()), "StampType");
|
||||
content.Add(new StringContent(request.X.ToString()), "X");
|
||||
content.Add(new StringContent(request.Y.ToString()), "Y");
|
||||
|
||||
if (request.PageNumbers != null && request.PageNumbers.Length > 0)
|
||||
{
|
||||
foreach (var pageNum in request.PageNumbers)
|
||||
{
|
||||
content.Add(new StringContent(pageNum.ToString()), "PageNumbers");
|
||||
}
|
||||
}
|
||||
|
||||
if (request.Width.HasValue)
|
||||
content.Add(new StringContent(request.Width.Value.ToString()), "Width");
|
||||
if (request.Height.HasValue)
|
||||
content.Add(new StringContent(request.Height.Value.ToString()), "Height");
|
||||
|
||||
content.Add(new StringContent(request.Origin.ToString()), "Origin");
|
||||
|
||||
if (!string.IsNullOrEmpty(request.Text))
|
||||
content.Add(new StringContent(request.Text), "Text");
|
||||
if (!string.IsNullOrEmpty(request.FontName))
|
||||
content.Add(new StringContent(request.FontName), "FontName");
|
||||
if (request.FontSize.HasValue)
|
||||
content.Add(new StringContent(request.FontSize.Value.ToString()), "FontSize");
|
||||
if (!string.IsNullOrEmpty(request.Color))
|
||||
content.Add(new StringContent(request.Color), "Color");
|
||||
if (request.Opacity.HasValue)
|
||||
content.Add(new StringContent(request.Opacity.Value.ToString()), "Opacity");
|
||||
if (request.Rotation.HasValue)
|
||||
content.Add(new StringContent(request.Rotation.Value.ToString()), "Rotation");
|
||||
|
||||
content.Add(new StringContent(request.Placement.ToString()), "Placement");
|
||||
|
||||
if (request.PredefinedType.HasValue)
|
||||
content.Add(new StringContent(request.PredefinedType.Value.ToString()), "PredefinedType");
|
||||
|
||||
return await SendMultipartContentForStreamAsync("/api/pdf/operations/stamp", content, cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Stream> StampAsync(byte[] pdfBytes, AddStampBase64Request request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var requestWithPdf = request with { Base64Pdf = ToBase64(pdfBytes) };
|
||||
|
||||
return await SendJsonForStreamAsync(
|
||||
"/api/pdf/operations/stamp",
|
||||
requestWithPdf,
|
||||
cancellationToken);
|
||||
}
|
||||
}
|
||||
69
DocumentService.Client/Clients/PdfValidationClient.cs
Normal file
69
DocumentService.Client/Clients/PdfValidationClient.cs
Normal file
@@ -0,0 +1,69 @@
|
||||
using DocumentService.Application.Common.DTOs;
|
||||
using DocumentService.Client.Interfaces;
|
||||
using DocumentService.Application.Common.DTOs.Requests;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using System.Net.Http;
|
||||
|
||||
namespace DocumentService.Client.Clients;
|
||||
|
||||
/// <summary>
|
||||
/// Implementation of PDF validation client.
|
||||
/// </summary>
|
||||
public class PdfValidationClient(HttpClient httpClient, ILogger<PdfValidationClient> logger) : BaseDocumentClient(httpClient, logger), IPdfValidationClient
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<PdfValidationResult> ValidateAsync(Stream pdfStream, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var result = await SendMultipartAsync<PdfValidationResult>(
|
||||
"/api/pdf/validation/validate",
|
||||
pdfStream,
|
||||
"document.pdf",
|
||||
cancellationToken);
|
||||
|
||||
return result ?? throw new InvalidOperationException("API returned null response");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<PdfValidationResult> ValidateAsync(byte[] pdfBytes, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var request = new ValidatePdfBase64Request
|
||||
{
|
||||
Base64Pdf = ToBase64(pdfBytes)
|
||||
};
|
||||
|
||||
var result = await SendJsonAsync<ValidatePdfBase64Request, PdfValidationResult>(
|
||||
"/api/pdf/validation/validate",
|
||||
request,
|
||||
cancellationToken);
|
||||
|
||||
return result ?? throw new InvalidOperationException("API returned null response");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<PdfAValidationResult> ValidatePdfAAsync(Stream pdfStream, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var result = await SendMultipartAsync<PdfAValidationResult>(
|
||||
"/api/pdf/validation/validate-pdfa",
|
||||
pdfStream,
|
||||
"document.pdf",
|
||||
cancellationToken);
|
||||
|
||||
return result ?? throw new InvalidOperationException("API returned null response");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<PdfAValidationResult> ValidatePdfAAsync(byte[] pdfBytes, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var request = new ValidatePdfABase64Request
|
||||
{
|
||||
Base64Pdf = ToBase64(pdfBytes)
|
||||
};
|
||||
|
||||
var result = await SendJsonAsync<ValidatePdfABase64Request, PdfAValidationResult>(
|
||||
"/api/pdf/validation/validate-pdfa",
|
||||
request,
|
||||
cancellationToken);
|
||||
|
||||
return result ?? throw new InvalidOperationException("API returned null response");
|
||||
}
|
||||
}
|
||||
45
DocumentService.Client/Clients/SwissQrCodeClient.cs
Normal file
45
DocumentService.Client/Clients/SwissQrCodeClient.cs
Normal file
@@ -0,0 +1,45 @@
|
||||
using DocumentService.Application.Common.DTOs;
|
||||
using DocumentService.Client.Interfaces;
|
||||
using DocumentService.Application.Common.DTOs.Requests;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using System.Net.Http;
|
||||
|
||||
namespace DocumentService.Client.Clients;
|
||||
|
||||
/// <summary>
|
||||
/// Implementation of Swiss QR Code extraction client.
|
||||
/// </summary>
|
||||
public class SwissQrCodeClient(HttpClient httpClient, ILogger<SwissQrCodeClient> logger) : BaseDocumentClient(httpClient, logger), ISwissQrCodeClient
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<SwissQrCodeExtractionResult> ExtractAsync(Stream pdfStream, bool raw = false, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var endpoint = $"/api/pdf/qr-code/extract-swiss?raw={raw}";
|
||||
|
||||
var result = await SendMultipartAsync<SwissQrCodeExtractionResult>(
|
||||
endpoint,
|
||||
pdfStream,
|
||||
"document.pdf",
|
||||
cancellationToken);
|
||||
|
||||
return result ?? throw new InvalidOperationException("API returned null response");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<SwissQrCodeExtractionResult> ExtractAsync(byte[] pdfBytes, bool raw = false, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var endpoint = $"/api/pdf/qr-code/extract-swiss?raw={raw}";
|
||||
|
||||
var request = new ExtractSwissQrCodeBase64Request
|
||||
{
|
||||
Base64Pdf = ToBase64(pdfBytes)
|
||||
};
|
||||
|
||||
var result = await SendJsonAsync<ExtractSwissQrCodeBase64Request, SwissQrCodeExtractionResult>(
|
||||
endpoint,
|
||||
request,
|
||||
cancellationToken);
|
||||
|
||||
return result ?? throw new InvalidOperationException("API returned null response");
|
||||
}
|
||||
}
|
||||
44
DocumentService.Client/Clients/WorkflowsClient.cs
Normal file
44
DocumentService.Client/Clients/WorkflowsClient.cs
Normal file
@@ -0,0 +1,44 @@
|
||||
using DocumentService.Application.Common.DTOs;
|
||||
using DocumentService.Client.Interfaces;
|
||||
using DocumentService.Client.Models.Results;
|
||||
|
||||
namespace DocumentService.Client.Clients;
|
||||
|
||||
/// <summary>
|
||||
/// Orchestrated multi-step workflows that compose multiple DocumentService clients
|
||||
/// into single, higher-level operations.
|
||||
/// </summary>
|
||||
public class WorkflowsClient(IPdfValidationClient validation, ISwissQrCodeClient swissQrCode) : IWorkflowsClient
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<SwissQrCodeResult> InspectSwissQrCodeAsync(
|
||||
byte[] pdfBytes,
|
||||
bool raw = false,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var validationResult = await validation.ValidateAsync(pdfBytes, cancellationToken);
|
||||
var qrCode = validationResult.IsValid
|
||||
? await swissQrCode.ExtractAsync(pdfBytes, raw, cancellationToken)
|
||||
: null;
|
||||
return new SwissQrCodeResult(qrCode, validationResult);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<SwissQrCodeResult> InspectSwissQrCodeAsync(
|
||||
Stream pdfStream,
|
||||
bool raw = false,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var validationResult = await validation.ValidateAsync(pdfStream, cancellationToken);
|
||||
|
||||
if (!validationResult.IsValid)
|
||||
return new SwissQrCodeResult(null, validationResult);
|
||||
|
||||
// Stream was consumed by validation – reset if possible, otherwise re-open is caller's responsibility.
|
||||
if (pdfStream.CanSeek)
|
||||
pdfStream.Position = 0;
|
||||
|
||||
var qrCode = await swissQrCode.ExtractAsync(pdfStream, raw, cancellationToken);
|
||||
return new SwissQrCodeResult(qrCode, validationResult);
|
||||
}
|
||||
}
|
||||
89
DocumentService.Client/Clients/ZugferdClient.cs
Normal file
89
DocumentService.Client/Clients/ZugferdClient.cs
Normal file
@@ -0,0 +1,89 @@
|
||||
using DocumentService.Application.Common.DTOs;
|
||||
using DocumentService.Application.ExtractZugferd;
|
||||
using DocumentService.Client.Interfaces;
|
||||
using DocumentService.Application.Common.DTOs.Requests;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using System.Net.Http;
|
||||
|
||||
namespace DocumentService.Client.Clients;
|
||||
|
||||
/// <summary>
|
||||
/// Implementation of ZUGFeRD client (detection and extraction).
|
||||
/// </summary>
|
||||
public class ZugferdClient(HttpClient httpClient, ILogger<ZugferdClient> logger) : BaseDocumentClient(httpClient, logger), IZugferdClient
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<ZugferdCheckResult> CheckAsync(Stream pdfStream, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var result = await SendMultipartAsync<ZugferdCheckResult>(
|
||||
"/api/pdf/zugferd/has-zugferd",
|
||||
pdfStream,
|
||||
"document.pdf",
|
||||
cancellationToken);
|
||||
|
||||
return result ?? throw new InvalidOperationException("API returned null response");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ZugferdCheckResult> CheckAsync(byte[] pdfBytes, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var request = new HasZugferdRequest { Base64Pdf = ToBase64(pdfBytes) };
|
||||
|
||||
var result = await SendJsonAsync<HasZugferdRequest, ZugferdCheckResult>(
|
||||
"/api/pdf/zugferd/has-zugferd",
|
||||
request,
|
||||
cancellationToken);
|
||||
|
||||
return result ?? throw new InvalidOperationException("API returned null response");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Stream> ExtractAsync(Stream pdfStream, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// asFile=true returns the XML file directly (application/xml stream)
|
||||
return await SendMultipartForStreamAsync(
|
||||
"/api/pdf/zugferd/extract?asFile=true",
|
||||
pdfStream,
|
||||
"document.pdf",
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Stream> ExtractAsync(byte[] pdfBytes, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var request = new ExtractZugferdRequest { Base64Pdf = ToBase64(pdfBytes) };
|
||||
|
||||
// format=file returns the XML file directly (application/xml stream)
|
||||
return await SendJsonForStreamAsync(
|
||||
"/api/pdf/zugferd/extract?format=file",
|
||||
request,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ZugferdExtractionResult> ExtractAsResultAsync(Stream pdfStream, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// asFile=false returns JSON with metadata + XML content
|
||||
var result = await SendMultipartAsync<ZugferdExtractionResult>(
|
||||
"/api/pdf/zugferd/extract?asFile=false",
|
||||
pdfStream,
|
||||
"document.pdf",
|
||||
cancellationToken);
|
||||
|
||||
return result ?? throw new InvalidOperationException("API returned null response");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ZugferdExtractionResult> ExtractAsResultAsync(byte[] pdfBytes, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var request = new ExtractZugferdRequest { Base64Pdf = ToBase64(pdfBytes) };
|
||||
|
||||
// format=json returns JSON with metadata + XML content
|
||||
var result = await SendJsonAsync<ExtractZugferdRequest, ZugferdExtractionResult>(
|
||||
"/api/pdf/zugferd/extract?format=json",
|
||||
request,
|
||||
cancellationToken);
|
||||
|
||||
return result ?? throw new InvalidOperationException("API returned null response");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
namespace DocumentService.Client.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for DocumentService HTTP client.
|
||||
/// </summary>
|
||||
public class DocumentServiceClientOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Base URL of the DocumentService API.
|
||||
/// </summary>
|
||||
/// <example>https://api.example.com</example>
|
||||
public string BaseUrl { get; set; } = "http://localhost:5000";
|
||||
|
||||
/// <summary>
|
||||
/// HTTP request timeout duration.
|
||||
/// </summary>
|
||||
public TimeSpan Timeout { get; set; } = TimeSpan.FromMinutes(5);
|
||||
|
||||
/// <summary>
|
||||
/// Maximum number of retry attempts for failed requests.
|
||||
/// </summary>
|
||||
public int MaxRetries { get; set; } = 3;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to throw exceptions on HTTP error responses (4xx, 5xx).
|
||||
/// If false, returns null/default values instead.
|
||||
/// </summary>
|
||||
public bool ThrowOnError { get; set; } = true;
|
||||
}
|
||||
77
DocumentService.Client/DocumentService.Client.csproj
Normal file
77
DocumentService.Client/DocumentService.Client.csproj
Normal file
@@ -0,0 +1,77 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>net462;net480;net8.0</TargetFrameworks>
|
||||
<DocumentationFile>bin\$(Configuration)\$(TargetFramework)\$(MSBuildProjectName).xml</DocumentationFile>
|
||||
<PackageId>DocumentService.Client</PackageId>
|
||||
<Authors>Digital Data GmbH</Authors>
|
||||
<Company>Digital Data GmbH</Company>
|
||||
<Product>DocumentService.Client</Product>
|
||||
<Copyright>Copyright 2026</Copyright>
|
||||
<PackageIcon>icon.png</PackageIcon>
|
||||
<RepositoryUrl>http://git.dd:3000/AppStd/Rec.git</RepositoryUrl>
|
||||
<PackageTags>digital data document service api client</PackageTags>
|
||||
<Version>1.2.2</Version>
|
||||
<AssemblyVersion>1.2.2.0</AssemblyVersion>
|
||||
<FileVersion>1.2.2.0</FileVersion>
|
||||
<Description></Description>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>latest</LangVersion>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup Condition="'$(TargetFramework)' == 'net462' Or '$(TargetFramework)' == 'net480'">
|
||||
<Reference Include="System.IO.Compression" />
|
||||
<Reference Include="System.IO.Compression.FileSystem" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.10" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging" Version="10.0.10" />
|
||||
<PackageReference Include="Microsoft.Extensions.Http" Version="8.0.0" />
|
||||
<PackageReference Include="System.Net.Http.Json" Version="8.0.0" />
|
||||
<PackageReference Include="PolySharp" Version="1.14.1">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Include="..\assets\icon.png">
|
||||
<Pack>True</Pack>
|
||||
<PackagePath>\</PackagePath>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\DocumentService.Application\DocumentService.Application.csproj">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</ProjectReference>
|
||||
<ProjectReference Include="..\DocumentService.Domain\DocumentService.Domain.csproj">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</ProjectReference>
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Embed Application and Domain DLLs into the NuGet package lib folder -->
|
||||
<Target Name="IncludeReferencedProjectDlls" BeforeTargets="_GetPackageFiles" Condition="'$(TargetFramework)' != ''">
|
||||
<ItemGroup>
|
||||
<None Include="..\DocumentService.Application\bin\$(Configuration)\$(TargetFramework)\DocumentService.Application.dll"
|
||||
Pack="true" PackagePath="lib\$(TargetFramework)\" Visible="false" />
|
||||
<None Include="..\DocumentService.Domain\bin\$(Configuration)\$(TargetFramework)\DocumentService.Domain.dll"
|
||||
Pack="true" PackagePath="lib\$(TargetFramework)\" Visible="false" />
|
||||
</ItemGroup>
|
||||
</Target>
|
||||
|
||||
<!-- Trigger inner build per-TFM to pick up IncludeReferencedProjectDlls target -->
|
||||
<PropertyGroup>
|
||||
<TargetsForTfmSpecificBuildOutput>$(TargetsForTfmSpecificBuildOutput);CopyProjectReferencesToPackage</TargetsForTfmSpecificBuildOutput>
|
||||
</PropertyGroup>
|
||||
|
||||
<Target Name="CopyProjectReferencesToPackage" DependsOnTargets="ResolveReferences">
|
||||
<ItemGroup>
|
||||
<BuildOutputInPackage Include="@(ReferenceCopyLocalPaths->WithMetadataValue('ReferenceSourceTarget','ProjectReference'))"
|
||||
Condition="'%(Filename)' == 'DocumentService.Application' Or '%(Filename)' == 'DocumentService.Domain'" />
|
||||
</ItemGroup>
|
||||
</Target>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,67 @@
|
||||
using DocumentService.Client.Clients;
|
||||
using DocumentService.Client.Configuration;
|
||||
using DocumentService.Client.Interfaces;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
|
||||
namespace DocumentService.Client.Extensions;
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for registering DocumentService clients in DI container.
|
||||
/// </summary>
|
||||
public static class ServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Registers all six DocumentService HTTP clients as Scoped via HttpClientFactory.
|
||||
/// </summary>
|
||||
/// <param name="services">Service collection</param>
|
||||
/// <param name="configureOptions">Configuration action for client options</param>
|
||||
/// <returns>Service collection for chaining</returns>
|
||||
public static IServiceCollection AddDocumentServiceClients(
|
||||
this IServiceCollection services,
|
||||
Action<DocumentServiceClientOptions> configureOptions)
|
||||
{
|
||||
services.Configure(configureOptions);
|
||||
|
||||
var options = new DocumentServiceClientOptions();
|
||||
configureOptions(options);
|
||||
|
||||
var configureHandler = () => new HttpClientHandler
|
||||
{
|
||||
AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate
|
||||
};
|
||||
|
||||
void ConfigureClient(System.Net.Http.HttpClient client)
|
||||
{
|
||||
client.BaseAddress = new Uri(options.BaseUrl);
|
||||
client.Timeout = options.Timeout;
|
||||
}
|
||||
|
||||
services.AddHttpClient<IPdfValidationClient, PdfValidationClient>(ConfigureClient)
|
||||
.ConfigurePrimaryHttpMessageHandler(configureHandler);
|
||||
|
||||
services.AddHttpClient<IPdfAttachmentClient, PdfAttachmentClient>(ConfigureClient)
|
||||
.ConfigurePrimaryHttpMessageHandler(configureHandler);
|
||||
|
||||
services.AddHttpClient<IPdfOperationsClient, PdfOperationsClient>(ConfigureClient)
|
||||
.ConfigurePrimaryHttpMessageHandler(configureHandler);
|
||||
|
||||
services.AddHttpClient<ISwissQrCodeClient, SwissQrCodeClient>(ConfigureClient)
|
||||
.ConfigurePrimaryHttpMessageHandler(configureHandler);
|
||||
|
||||
services.AddHttpClient<IZugferdClient, ZugferdClient>(ConfigureClient)
|
||||
.ConfigurePrimaryHttpMessageHandler(configureHandler);
|
||||
|
||||
// Conversion client is registered but all methods throw NotImplementedException
|
||||
// until the server-side endpoints are ready.
|
||||
services.AddHttpClient<IPdfConversionClient, PdfConversionClient>(ConfigureClient)
|
||||
.ConfigurePrimaryHttpMessageHandler(configureHandler);
|
||||
|
||||
// WorkflowsClient composes existing clients – no HttpClient needed.
|
||||
services.AddScoped<IWorkflowsClient, WorkflowsClient>();
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
|
||||
64
DocumentService.Client/Extensions/StreamExtensions.cs
Normal file
64
DocumentService.Client/Extensions/StreamExtensions.cs
Normal file
@@ -0,0 +1,64 @@
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace DocumentService.Client.Extensions;
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for Stream operations.
|
||||
/// </summary>
|
||||
public static class StreamExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Converts a stream to a Base64-encoded string.
|
||||
/// </summary>
|
||||
/// <param name="stream">Source stream</param>
|
||||
/// <param name="cancellationToken">Cancellation token</param>
|
||||
/// <returns>Base64-encoded string</returns>
|
||||
public static async Task<string> ToBase64StringAsync(this Stream stream, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (stream == null)
|
||||
throw new ArgumentNullException(nameof(stream));
|
||||
|
||||
byte[] bytes = await stream.ToBytesAsync(cancellationToken);
|
||||
return Convert.ToBase64String(bytes);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts a stream to a byte array.
|
||||
/// </summary>
|
||||
/// <param name="stream">Source stream</param>
|
||||
/// <param name="cancellationToken">Cancellation token</param>
|
||||
/// <returns>Byte array</returns>
|
||||
public static async Task<byte[]> ToBytesAsync(this Stream stream, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (stream == null)
|
||||
throw new ArgumentNullException(nameof(stream));
|
||||
|
||||
if (stream is MemoryStream ms)
|
||||
{
|
||||
return ms.ToArray();
|
||||
}
|
||||
|
||||
using var memoryStream = new MemoryStream();
|
||||
#if NET8_0
|
||||
await stream.CopyToAsync(memoryStream, cancellationToken);
|
||||
#else
|
||||
await stream.CopyToAsync(memoryStream);
|
||||
#endif
|
||||
return memoryStream.ToArray();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resets stream position to beginning if seekable.
|
||||
/// </summary>
|
||||
/// <param name="stream">Stream to reset</param>
|
||||
/// <returns>The same stream (for chaining)</returns>
|
||||
public static Stream Reset(this Stream stream)
|
||||
{
|
||||
if (stream != null && stream.CanSeek)
|
||||
stream.Position = 0;
|
||||
|
||||
return stream!;
|
||||
}
|
||||
}
|
||||
64
DocumentService.Client/Interfaces/IPdfAttachmentClient.cs
Normal file
64
DocumentService.Client/Interfaces/IPdfAttachmentClient.cs
Normal file
@@ -0,0 +1,64 @@
|
||||
using DocumentService.Application.Common.DTOs;
|
||||
using DocumentService.Application.Common.DTOs.Requests;
|
||||
|
||||
namespace DocumentService.Client.Interfaces;
|
||||
|
||||
/// <summary>
|
||||
/// Client for PDF attachment operations (check, extract, add).
|
||||
/// </summary>
|
||||
public interface IPdfAttachmentClient
|
||||
{
|
||||
/// <summary>
|
||||
/// Checks if PDF contains attachments (multipart).
|
||||
/// </summary>
|
||||
/// <param name="pdfStream">PDF file stream</param>
|
||||
/// <param name="cancellationToken">Cancellation token</param>
|
||||
/// <returns>Attachment check result with metadata</returns>
|
||||
Task<AttachmentCheckResult> CheckAsync(Stream pdfStream, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Checks if PDF contains attachments (Base64).
|
||||
/// </summary>
|
||||
/// <param name="pdfBytes">PDF file as byte array</param>
|
||||
/// <param name="cancellationToken">Cancellation token</param>
|
||||
/// <returns>Attachment check result with metadata</returns>
|
||||
Task<AttachmentCheckResult> CheckAsync(byte[] pdfBytes, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Extracts all embedded attachments from a PDF (multipart).
|
||||
/// The returned dictionary maps each file name to its decompressed content stream.
|
||||
/// </summary>
|
||||
/// <param name="pdfStream">PDF file stream</param>
|
||||
/// <param name="cancellationToken">Cancellation token</param>
|
||||
/// <returns>Dictionary of file name ? content stream for each extracted attachment</returns>
|
||||
Task<Dictionary<string, Stream>> ExtractAsync(Stream pdfStream, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Extracts all embedded attachments from a PDF (Base64).
|
||||
/// The returned dictionary maps each file name to its decompressed content stream.
|
||||
/// </summary>
|
||||
/// <param name="pdfBytes">PDF file as byte array</param>
|
||||
/// <param name="cancellationToken">Cancellation token</param>
|
||||
/// <returns>Dictionary of file name ? content stream for each extracted attachment</returns>
|
||||
Task<Dictionary<string, Stream>> ExtractAsync(byte[] pdfBytes, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Adds attachments to PDF (multipart). ?? Not implemented yet in API.
|
||||
/// </summary>
|
||||
/// <param name="pdfStream">PDF file stream</param>
|
||||
/// <param name="attachments">Attachments to add</param>
|
||||
/// <param name="cancellationToken">Cancellation token</param>
|
||||
/// <returns>Modified PDF as stream</returns>
|
||||
[Obsolete("API endpoint not implemented yet")]
|
||||
Task<Stream> AddAsync(Stream pdfStream, List<AttachmentRequestDto> attachments, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Adds attachments to PDF (Base64). ?? Not implemented yet in API.
|
||||
/// </summary>
|
||||
/// <param name="pdfBytes">PDF file as byte array</param>
|
||||
/// <param name="attachments">Attachments to add</param>
|
||||
/// <param name="cancellationToken">Cancellation token</param>
|
||||
/// <returns>Modified PDF as stream</returns>
|
||||
[Obsolete("API endpoint not implemented yet")]
|
||||
Task<Stream> AddAsync(byte[] pdfBytes, List<AttachmentRequestDto> attachments, CancellationToken cancellationToken = default);
|
||||
}
|
||||
49
DocumentService.Client/Interfaces/IPdfConversionClient.cs
Normal file
49
DocumentService.Client/Interfaces/IPdfConversionClient.cs
Normal file
@@ -0,0 +1,49 @@
|
||||
namespace DocumentService.Client.Interfaces;
|
||||
|
||||
/// <summary>
|
||||
/// Client for PDF conversion operations (PDF ? PDF/A).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// All methods on this interface are marked obsolete because the corresponding
|
||||
/// API endpoints are not yet implemented.
|
||||
/// </remarks>
|
||||
public interface IPdfConversionClient
|
||||
{
|
||||
/// <summary>
|
||||
/// Converts a standard PDF to PDF/A format (multipart).
|
||||
/// </summary>
|
||||
/// <param name="pdfStream">Source PDF stream</param>
|
||||
/// <param name="pdfALevel">Target PDF/A level (e.g., "PDF/A-3b")</param>
|
||||
/// <param name="cancellationToken">Cancellation token</param>
|
||||
/// <returns>PDF/A document as stream</returns>
|
||||
[Obsolete("API endpoint not implemented yet")]
|
||||
Task<Stream> ToPdfAAsync(Stream pdfStream, string pdfALevel = "PDF/A-3b", CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Converts a standard PDF to PDF/A format (Base64).
|
||||
/// </summary>
|
||||
/// <param name="pdfBytes">Source PDF as byte array</param>
|
||||
/// <param name="pdfALevel">Target PDF/A level (e.g., "PDF/A-3b")</param>
|
||||
/// <param name="cancellationToken">Cancellation token</param>
|
||||
/// <returns>PDF/A document as stream</returns>
|
||||
[Obsolete("API endpoint not implemented yet")]
|
||||
Task<Stream> ToPdfAAsync(byte[] pdfBytes, string pdfALevel = "PDF/A-3b", CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Converts a PDF/A document to standard PDF (multipart).
|
||||
/// </summary>
|
||||
/// <param name="pdfStream">Source PDF/A stream</param>
|
||||
/// <param name="cancellationToken">Cancellation token</param>
|
||||
/// <returns>Standard PDF as stream</returns>
|
||||
[Obsolete("API endpoint not implemented yet")]
|
||||
Task<Stream> FromPdfAAsync(Stream pdfStream, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Converts a PDF/A document to standard PDF (Base64).
|
||||
/// </summary>
|
||||
/// <param name="pdfBytes">Source PDF/A as byte array</param>
|
||||
/// <param name="cancellationToken">Cancellation token</param>
|
||||
/// <returns>Standard PDF as stream</returns>
|
||||
[Obsolete("API endpoint not implemented yet")]
|
||||
Task<Stream> FromPdfAAsync(byte[] pdfBytes, CancellationToken cancellationToken = default);
|
||||
}
|
||||
69
DocumentService.Client/Interfaces/IPdfOperationsClient.cs
Normal file
69
DocumentService.Client/Interfaces/IPdfOperationsClient.cs
Normal file
@@ -0,0 +1,69 @@
|
||||
using DocumentService.Application.Common.DTOs.Requests;
|
||||
|
||||
namespace DocumentService.Client.Interfaces;
|
||||
|
||||
/// <summary>
|
||||
/// Client for PDF operations (merge, annotate, stamp).
|
||||
/// </summary>
|
||||
public interface IPdfOperationsClient
|
||||
{
|
||||
// ==================== MERGE OPERATIONS ====================
|
||||
|
||||
/// <summary>
|
||||
/// Merges multiple PDFs (multipart).
|
||||
/// </summary>
|
||||
/// <param name="pdfStreams">PDF file streams to merge</param>
|
||||
/// <param name="pageRanges">Optional page ranges per PDF (e.g., "1-3,5")</param>
|
||||
/// <param name="cancellationToken">Cancellation token</param>
|
||||
/// <returns>Merged PDF as stream</returns>
|
||||
Task<Stream> MergeAsync(IEnumerable<Stream> pdfStreams, List<string?>? pageRanges = null, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Merges multiple PDFs (Base64).
|
||||
/// </summary>
|
||||
/// <param name="pdfByteArrays">PDF files as byte arrays</param>
|
||||
/// <param name="pageRanges">Optional page ranges per PDF (e.g., "1-3,5")</param>
|
||||
/// <param name="cancellationToken">Cancellation token</param>
|
||||
/// <returns>Merged PDF as stream</returns>
|
||||
Task<Stream> MergeAsync(IEnumerable<byte[]> pdfByteArrays, List<string?>? pageRanges = null, CancellationToken cancellationToken = default);
|
||||
|
||||
// ==================== ANNOTATION OPERATIONS ====================
|
||||
|
||||
/// <summary>
|
||||
/// Adds annotation to PDF (multipart).
|
||||
/// </summary>
|
||||
/// <param name="pdfStream">PDF file stream</param>
|
||||
/// <param name="request">Annotation request with coordinates and style</param>
|
||||
/// <param name="cancellationToken">Cancellation token</param>
|
||||
/// <returns>Annotated PDF as stream</returns>
|
||||
Task<Stream> AnnotateAsync(Stream pdfStream, AddAnnotationBase64Request request, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Adds annotation to PDF (Base64).
|
||||
/// </summary>
|
||||
/// <param name="pdfBytes">PDF file as byte array</param>
|
||||
/// <param name="request">Annotation request with coordinates and style</param>
|
||||
/// <param name="cancellationToken">Cancellation token</param>
|
||||
/// <returns>Annotated PDF as stream</returns>
|
||||
Task<Stream> AnnotateAsync(byte[] pdfBytes, AddAnnotationBase64Request request, CancellationToken cancellationToken = default);
|
||||
|
||||
// ==================== STAMP OPERATIONS ====================
|
||||
|
||||
/// <summary>
|
||||
/// Adds stamp to PDF (multipart).
|
||||
/// </summary>
|
||||
/// <param name="pdfStream">PDF file stream</param>
|
||||
/// <param name="request">Stamp request with position and style</param>
|
||||
/// <param name="cancellationToken">Cancellation token</param>
|
||||
/// <returns>Stamped PDF as stream</returns>
|
||||
Task<Stream> StampAsync(Stream pdfStream, AddStampBase64Request request, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Adds stamp to PDF (Base64).
|
||||
/// </summary>
|
||||
/// <param name="pdfBytes">PDF file as byte array</param>
|
||||
/// <param name="request">Stamp request with position and style</param>
|
||||
/// <param name="cancellationToken">Cancellation token</param>
|
||||
/// <returns>Stamped PDF as stream</returns>
|
||||
Task<Stream> StampAsync(byte[] pdfBytes, AddStampBase64Request request, CancellationToken cancellationToken = default);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user