From 668e6f22aee98ff1cbe5ba7e4556243e00c207e4 Mon Sep 17 00:00:00 2001 From: ancplua Date: Tue, 12 May 2026 12:42:07 +0200 Subject: [PATCH] feat(contracts): extract Paperless.Contracts project (DTO/validation boundary) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First slice of the TourPlanner-pattern backend restructure (vertical-slice Features/ -> layered API/BL/Contracts/DAL split). Starts with the safest layer: the transport boundary. New project: Paperless.Contracts/Paperless.Contracts.csproj — plain class library targeting net10.0 with no external dependencies. Moved into Paperless.Contracts: - BatchProcessing/AccessReportDto.cs (was Features/BatchProcessing/Presentation/Dto/) - DocumentManagement/DocumentDtos.cs (was Features/DocumentManagement/Presentation/Dto/DTOs.cs) All response/query DTOs: PaginationQuery, SearchQuery, DocumentDto, CreateDocumentResponse, DocumentSearchResultDto, SummaryDto, PaginatedDocumentsResponse. - Validation/Constraints.cs (was Configuration/Constraints.cs subset) SearchConstraints + PaginationConstraints — the boundary-level ranges the DTOs depend on. Stayed in PaperlessREST: - UploadDocumentRequest (Features/DocumentManagement/Presentation/Dto/DTOs.cs) Carries IFormFile, an ASP.NET Core input-model concern, not a wire DTO. A doc-comment now points readers at the new Paperless.Contracts namespace for the rest of the document transport surface. - FileUploadConstraints, RateLimitPolicies, CachePolicies, SearchServiceConstraints Server-side concerns. SearchConstraints.ServiceQueryMaxLength (used only in DocumentSearchService) was renamed to SearchServiceConstraints.ServiceQueryMaxLength to keep the boundary-level SearchConstraints in Paperless.Contracts free of server-side leakage. Wiring: - Paperless.slnx adds the new project before PaperlessREST. - PaperlessREST + PaperlessREST.Tests gain ProjectReference to Paperless.Contracts. - Both projects' GlobalUsings.cs add `Paperless.Contracts.{BatchProcessing,DocumentManagement,Validation}` so callers see the records under the same short names as before. Verified with ./build.sh Compile (Restore + Compile both succeed). Follow-ups in this series: - Paperless.DAL (extract Features/*/Infrastructure/ — Persistence, Storage, Search) - Paperless.BL (extract Features/*/Application/) - PaperlessREST slims to API/endpoints, or renames to Paperless.API - PaperlessServices threads through the same Contracts/BL/DAL projects Co-Authored-By: Claude Opus 4.7 (1M context) --- .../BatchProcessing}/AccessReportDto.cs | 5 +- .../DocumentManagement/DocumentDtos.cs | 133 ++++++++++++++++++ .../Paperless.Contracts.csproj | 11 ++ Paperless.Contracts/Validation/Constraints.cs | 34 +++++ Paperless.slnx | 1 + PaperlessREST.Tests/GlobalUsings.cs | 5 + .../PaperlessREST.Tests.csproj | 1 + PaperlessREST/Configuration/Constraints.cs | 32 +---- .../Search/DocumentSearchService.cs | 4 +- .../Presentation/Dto/DTOs.cs | 132 +---------------- PaperlessREST/GlobalUsings.cs | 6 +- PaperlessREST/PaperlessREST.csproj | 4 + 12 files changed, 207 insertions(+), 161 deletions(-) rename {PaperlessREST/Features/BatchProcessing/Presentation/Dto => Paperless.Contracts/BatchProcessing}/AccessReportDto.cs (84%) create mode 100644 Paperless.Contracts/DocumentManagement/DocumentDtos.cs create mode 100644 Paperless.Contracts/Paperless.Contracts.csproj create mode 100644 Paperless.Contracts/Validation/Constraints.cs diff --git a/PaperlessREST/Features/BatchProcessing/Presentation/Dto/AccessReportDto.cs b/Paperless.Contracts/BatchProcessing/AccessReportDto.cs similarity index 84% rename from PaperlessREST/Features/BatchProcessing/Presentation/Dto/AccessReportDto.cs rename to Paperless.Contracts/BatchProcessing/AccessReportDto.cs index c89da53..c8eeda3 100644 --- a/PaperlessREST/Features/BatchProcessing/Presentation/Dto/AccessReportDto.cs +++ b/Paperless.Contracts/BatchProcessing/AccessReportDto.cs @@ -1,4 +1,7 @@ -namespace PaperlessREST.Features.BatchProcessing.Presentation.Dto; +using System.Diagnostics.CodeAnalysis; +using System.Xml.Serialization; + +namespace Paperless.Contracts.BatchProcessing; [XmlRoot("accessReport")] [ExcludeFromCodeCoverage(Justification = "DTO record - XML serialization tested via ReportProcessor tests")] diff --git a/Paperless.Contracts/DocumentManagement/DocumentDtos.cs b/Paperless.Contracts/DocumentManagement/DocumentDtos.cs new file mode 100644 index 0000000..f6b4e57 --- /dev/null +++ b/Paperless.Contracts/DocumentManagement/DocumentDtos.cs @@ -0,0 +1,133 @@ +using System.ComponentModel; +using System.ComponentModel.DataAnnotations; +using Paperless.Contracts.Validation; + +namespace Paperless.Contracts.DocumentManagement; + +/// +/// Query parameters for paginated document listing. +/// +/// +/// Uses cursor-based pagination with GUIDv7 (time-ordered) document IDs. +/// Pass the last document's ID as to get the next page. +/// +public sealed record PaginationQuery +{ + [Range(PaginationConstraints.MinPageSize, PaginationConstraints.MaxPageSize, + ErrorMessage = "Page size must be between 1 and 100")] + [Description("Number of documents to return per page")] + public int PageSize { get; init; } = PaginationConstraints.DefaultPageSize; + + [Description("Cursor for pagination (last document ID from previous page)")] + public Guid? Cursor { get; init; } +} + +/// +/// Query parameters for document search. +/// +public sealed record SearchQuery +{ + [StringLength(SearchConstraints.QueryMaxLength, MinimumLength = SearchConstraints.QueryMinLength, + ErrorMessage = "Search query must be between 1 and 100 characters")] + [Description("Search text to find in documents")] + public required string Query { get; init; } + + [Range(1, SearchConstraints.MaxResultLimit, ErrorMessage = "Limit must be between 1 and 100")] + [Description("Maximum number of results to return")] + public int Limit { get; init; } = SearchConstraints.DefaultResultLimit; +} + +/// +/// Represents document metadata returned by the API. +/// +public sealed record DocumentDto +{ + [Description("Unique document identifier")] + public required Guid Id { get; init; } + + [Description("Original PDF filename")] public required string FileName { get; init; } + + [Description("Processing status (Pending, Completed, Failed)")] + public required string Status { get; init; } + + [Description("Upload timestamp (UTC)")] + public required DateTimeOffset CreatedAt { get; init; } + + [Description("Processing completion timestamp (UTC)")] + public DateTimeOffset? ProcessedAt { get; init; } + + [Description("OCR extracted text content")] + public string? Content { get; init; } + + [Description("AI-generated summary")] public string? Summary { get; init; } + + [Description("Timestamp when summary was generated (UTC)")] + public DateTimeOffset? SummaryGeneratedAt { get; init; } +} + +/// +/// Response returned after successfully uploading a document. +/// +public sealed record CreateDocumentResponse +{ + [Description("Unique document identifier")] + public required Guid Id { get; init; } + + [Description("Original PDF filename")] public required string FileName { get; init; } + + [Description("Processing status")] public required string Status { get; init; } + + [Description("Upload timestamp (UTC)")] + public required DateTimeOffset CreatedAt { get; init; } +} + +/// +/// Represents a document search result with relevant fields. +/// +public sealed record DocumentSearchResultDto +{ + [Description("Unique document identifier")] + public required Guid Id { get; init; } + + [Description("Original PDF filename")] public required string FileName { get; init; } + + [Description("OCR extracted content")] public string? Content { get; init; } + + [Description("AI-generated summary")] public string? Summary { get; init; } + + [Description("Upload timestamp (UTC)")] + public required DateTimeOffset CreatedAt { get; init; } + + [Description("Processing status")] public required string Status { get; init; } +} + +/// +/// Response containing a document summary. +/// +public sealed record SummaryDto +{ + [Description("AI-generated summary of the document content")] + public string? Summary { get; init; } +} + +/// +/// Paginated response wrapper for document listings. +/// +/// +/// Uses cursor-based pagination for efficient traversal of large datasets. +/// The is null when there are no more pages. +/// +public sealed record PaginatedDocumentsResponse +{ + [Description("List of documents in this page")] + public required List Items { get; init; } + + [Description("Cursor for the next page (null if no more pages)")] + public Guid? NextCursor { get; init; } + + [Description("Whether more documents exist after this page")] + public required bool HasMore { get; init; } + + [Description("Number of documents in this response")] + public int Count => Items.Count; +} diff --git a/Paperless.Contracts/Paperless.Contracts.csproj b/Paperless.Contracts/Paperless.Contracts.csproj new file mode 100644 index 0000000..d7dbfaa --- /dev/null +++ b/Paperless.Contracts/Paperless.Contracts.csproj @@ -0,0 +1,11 @@ + + + + net10.0 + preview + enable + enable + Paperless.Contracts + + + diff --git a/Paperless.Contracts/Validation/Constraints.cs b/Paperless.Contracts/Validation/Constraints.cs new file mode 100644 index 0000000..a34fd3d --- /dev/null +++ b/Paperless.Contracts/Validation/Constraints.cs @@ -0,0 +1,34 @@ +namespace Paperless.Contracts.Validation; + +/// +/// Search query validation constraints applied at the HTTP boundary. +/// +public static class SearchConstraints +{ + /// Maximum query string length for DTO validation. + public const int QueryMaxLength = 100; + + /// Minimum query string length. + public const int QueryMinLength = 1; + + /// Maximum results that can be requested. + public const int MaxResultLimit = 100; + + /// Default number of results returned. + public const int DefaultResultLimit = 10; +} + +/// +/// Pagination constraints for document listing applied at the HTTP boundary. +/// +public static class PaginationConstraints +{ + /// Default page size when not specified. + public const int DefaultPageSize = 20; + + /// Maximum page size allowed. + public const int MaxPageSize = 100; + + /// Minimum page size allowed. + public const int MinPageSize = 1; +} diff --git a/Paperless.slnx b/Paperless.slnx index af09639..22e5342 100644 --- a/Paperless.slnx +++ b/Paperless.slnx @@ -1,4 +1,5 @@ + diff --git a/PaperlessREST.Tests/GlobalUsings.cs b/PaperlessREST.Tests/GlobalUsings.cs index 7951ddd..13e9a53 100644 --- a/PaperlessREST.Tests/GlobalUsings.cs +++ b/PaperlessREST.Tests/GlobalUsings.cs @@ -83,6 +83,11 @@ global using PaperlessREST.Features.DocumentManagement.Presentation.Dto; global using PaperlessREST.Features.DocumentManagement.Presentation.Endpoints; +// Project namespaces - Paperless.Contracts (transport boundary) +global using Paperless.Contracts.BatchProcessing; +global using Paperless.Contracts.DocumentManagement; +global using Paperless.Contracts.Validation; + // ErrorOr for result pattern global using ErrorOr; diff --git a/PaperlessREST.Tests/PaperlessREST.Tests.csproj b/PaperlessREST.Tests/PaperlessREST.Tests.csproj index 5ad79fb..fe6afbc 100644 --- a/PaperlessREST.Tests/PaperlessREST.Tests.csproj +++ b/PaperlessREST.Tests/PaperlessREST.Tests.csproj @@ -68,6 +68,7 @@ + diff --git a/PaperlessREST/Configuration/Constraints.cs b/PaperlessREST/Configuration/Constraints.cs index a3f33ef..d7977ad 100644 --- a/PaperlessREST/Configuration/Constraints.cs +++ b/PaperlessREST/Configuration/Constraints.cs @@ -13,39 +13,13 @@ public static class FileUploadConstraints } /// -/// Search query validation constraints. +/// Server-side search constants that don't belong on the public contract. +/// Boundary-level covers query / limit ranges. /// -public static class SearchConstraints +public static class SearchServiceConstraints { - /// Maximum query string length for DTO validation. - public const int QueryMaxLength = 100; - - /// Minimum query string length. - public const int QueryMinLength = 1; - /// Maximum query length at service layer (truncation threshold). public const int ServiceQueryMaxLength = 1000; - - /// Maximum results that can be requested. - public const int MaxResultLimit = 100; - - /// Default number of results returned. - public const int DefaultResultLimit = 10; -} - -/// -/// Pagination constraints for document listing. -/// -public static class PaginationConstraints -{ - /// Default page size when not specified. - public const int DefaultPageSize = 20; - - /// Maximum page size allowed. - public const int MaxPageSize = 100; - - /// Minimum page size allowed. - public const int MinPageSize = 1; } /// diff --git a/PaperlessREST/Features/DocumentManagement/Infrastructure/Search/DocumentSearchService.cs b/PaperlessREST/Features/DocumentManagement/Infrastructure/Search/DocumentSearchService.cs index e142ed3..85bee1c 100644 --- a/PaperlessREST/Features/DocumentManagement/Infrastructure/Search/DocumentSearchService.cs +++ b/PaperlessREST/Features/DocumentManagement/Infrastructure/Search/DocumentSearchService.cs @@ -28,8 +28,8 @@ public async IAsyncEnumerable SearchAsync(string query, int limit = 10, { logger.LogInformation("Searching for query: {Query} (limit: {Limit})", query, limit); - string searchQuery = query.Length > SearchConstraints.ServiceQueryMaxLength - ? query[..SearchConstraints.ServiceQueryMaxLength] + string searchQuery = query.Length > SearchServiceConstraints.ServiceQueryMaxLength + ? query[..SearchServiceConstraints.ServiceQueryMaxLength] : query; SearchResponse response = await elastic.SearchAsync( diff --git a/PaperlessREST/Features/DocumentManagement/Presentation/Dto/DTOs.cs b/PaperlessREST/Features/DocumentManagement/Presentation/Dto/DTOs.cs index 457e8f9..a1b500b 100644 --- a/PaperlessREST/Features/DocumentManagement/Presentation/Dto/DTOs.cs +++ b/PaperlessREST/Features/DocumentManagement/Presentation/Dto/DTOs.cs @@ -3,137 +3,13 @@ namespace PaperlessREST.Features.DocumentManagement.Presentation.Dto; /// /// Request model for uploading a PDF document. /// Validation handled by . +/// +/// Stays in the API project (rather than Paperless.Contracts) because it carries an +/// — an ASP.NET Core input-model concern, not a transport DTO. +/// All response/query DTOs live in Paperless.Contracts.DocumentManagement. /// public sealed record UploadDocumentRequest { [Description("PDF file to upload (max 10MB, PDF only)")] public required IFormFile File { get; init; } } - -/// -/// Query parameters for paginated document listing. -/// -/// -/// Uses cursor-based pagination with GUIDv7 (time-ordered) document IDs. -/// Pass the last document's ID as to get the next page. -/// -public sealed record PaginationQuery -{ - [Range(PaginationConstraints.MinPageSize, PaginationConstraints.MaxPageSize, - ErrorMessage = "Page size must be between 1 and 100")] - [Description("Number of documents to return per page")] - public int PageSize { get; init; } = PaginationConstraints.DefaultPageSize; - - [Description("Cursor for pagination (last document ID from previous page)")] - public Guid? Cursor { get; init; } -} - -/// -/// Query parameters for document search. -/// -public sealed record SearchQuery -{ - [StringLength(SearchConstraints.QueryMaxLength, MinimumLength = SearchConstraints.QueryMinLength, - ErrorMessage = "Search query must be between 1 and 100 characters")] - [Description("Search text to find in documents")] - public required string Query { get; init; } - - [Range(1, SearchConstraints.MaxResultLimit, ErrorMessage = "Limit must be between 1 and 100")] - [Description("Maximum number of results to return")] - public int Limit { get; init; } = SearchConstraints.DefaultResultLimit; -} - -/// -/// Represents document metadata returned by the API. -/// -public sealed record DocumentDto -{ - [Description("Unique document identifier")] - public required Guid Id { get; init; } - - [Description("Original PDF filename")] public required string FileName { get; init; } - - [Description("Processing status (Pending, Completed, Failed)")] - public required string Status { get; init; } - - [Description("Upload timestamp (UTC)")] - public required DateTimeOffset CreatedAt { get; init; } - - [Description("Processing completion timestamp (UTC)")] - public DateTimeOffset? ProcessedAt { get; init; } - - [Description("OCR extracted text content")] - public string? Content { get; init; } - - [Description("AI-generated summary")] public string? Summary { get; init; } - - [Description("Timestamp when summary was generated (UTC)")] - public DateTimeOffset? SummaryGeneratedAt { get; init; } -} - -/// -/// Response returned after successfully uploading a document. -/// -public sealed record CreateDocumentResponse -{ - [Description("Unique document identifier")] - public required Guid Id { get; init; } - - [Description("Original PDF filename")] public required string FileName { get; init; } - - [Description("Processing status")] public required string Status { get; init; } - - [Description("Upload timestamp (UTC)")] - public required DateTimeOffset CreatedAt { get; init; } -} - -/// -/// Represents a document search result with relevant fields. -/// -public sealed record DocumentSearchResultDto -{ - [Description("Unique document identifier")] - public required Guid Id { get; init; } - - [Description("Original PDF filename")] public required string FileName { get; init; } - - [Description("OCR extracted content")] public string? Content { get; init; } - - [Description("AI-generated summary")] public string? Summary { get; init; } - - [Description("Upload timestamp (UTC)")] - public required DateTimeOffset CreatedAt { get; init; } - - [Description("Processing status")] public required string Status { get; init; } -} - -/// -/// Response containing a document summary. -/// -public sealed record SummaryDto -{ - [Description("AI-generated summary of the document content")] - public string? Summary { get; init; } -} - -/// -/// Paginated response wrapper for document listings. -/// -/// -/// Uses cursor-based pagination for efficient traversal of large datasets. -/// The is null when there are no more pages. -/// -public sealed record PaginatedDocumentsResponse -{ - [Description("List of documents in this page")] - public required List Items { get; init; } - - [Description("Cursor for the next page (null if no more pages)")] - public Guid? NextCursor { get; init; } - - [Description("Whether more documents exist after this page")] - public required bool HasMore { get; init; } - - [Description("Number of documents in this response")] - public int Count => Items.Count; -} diff --git a/PaperlessREST/GlobalUsings.cs b/PaperlessREST/GlobalUsings.cs index c14d258..57b4b40 100644 --- a/PaperlessREST/GlobalUsings.cs +++ b/PaperlessREST/GlobalUsings.cs @@ -68,7 +68,11 @@ // Project namespaces - Batch Processing Feature global using PaperlessREST.Features.BatchProcessing.Application; -global using PaperlessREST.Features.BatchProcessing.Presentation.Dto; + +// Project namespaces - Paperless.Contracts (transport boundary) +global using Paperless.Contracts.BatchProcessing; +global using Paperless.Contracts.DocumentManagement; +global using Paperless.Contracts.Validation; // Project namespaces - Event Processing Feature global using PaperlessREST.Features.EventProcessing.Presentation; diff --git a/PaperlessREST/PaperlessREST.csproj b/PaperlessREST/PaperlessREST.csproj index 7bfe7fc..2c553e9 100644 --- a/PaperlessREST/PaperlessREST.csproj +++ b/PaperlessREST/PaperlessREST.csproj @@ -50,6 +50,10 @@ + + + + .dockerignore