Pragmatic.Attachments
Add file attachments to any Pragmatic entity with a single attribute.
Pragmatic.Attachments is a trait package: you decorate an entity with [HasAttachments] and the source generator creates the attachment entity, EF configuration, actions, and HTTP surface in the consuming boundary.
Status: implemented preview package. The package is already functional in the repo; the remaining work is documentation and broader ecosystem hardening, not basic runtime existence.
The Problem
Section titled “The Problem”Every “entity with files” feature usually requires the same boilerplate:
- child entity for metadata
- foreign key and indexes
- upload, download, and delete actions
- endpoint mapping
- storage provider integration
- permissions and DTOs
That boilerplate is repetitive and easy to get wrong.
The Solution
Section titled “The Solution”One attribute activates the whole pipeline:
using Pragmatic.Attachments;using Pragmatic.Persistence.Entity;
[Entity<Guid>][Resource("invoices")][HasAttachments]public partial class Invoice{ public string Number { get; set; } = "";}The generator materializes:
- a typed
{Parent}Attachmententity derived fromAttachmentBase<TEntityId> - FK and indexes in EF configuration
- upload, download, and delete actions
- attachment endpoints under the parent resource
- a navigation collection on the parent entity
Installation
Section titled “Installation”<PackageReference Include="Pragmatic.Attachments" />Typical consumers also reference:
Pragmatic.PersistencePragmatic.StoragePragmatic.Endpointsif HTTP endpoints are desired
Quick Start
Section titled “Quick Start”1. Mark the entity
Section titled “1. Mark the entity”[Entity<Guid>][Resource("invoices")][HasAttachments(MaxPerEntity = 10, AllowedExtensions = ".pdf,.png,.jpg")]public partial class Invoice{ public string Number { get; set; } = "";}2. Configure a storage provider
Section titled “2. Configure a storage provider”using Pragmatic.Storage;
await PragmaticApp.RunAsync(args, builder =>{ builder.UseStorage(sp => new LocalDiskFileStorage( "wwwroot", sp.GetRequiredService<ILogger<LocalDiskFileStorage>>()));});3. Use the generated feature
Section titled “3. Use the generated feature”See Generated Surface below for the exact entity, actions, endpoints, DTO, and permission strings.
Attribute Options
Section titled “Attribute Options”| Option | Default | Description |
|---|---|---|
MaxPerEntity | 20 | Maximum number of attachments per parent entity. 0 means unlimited. |
MaxFileSizeBytes | 10_485_760 | Maximum upload size in bytes. 0 means unlimited. |
AllowedExtensions | "" | Comma-separated extension allow-list such as .pdf,.jpg. Empty means allow all. |
Container | parent type name in lowercase | Storage container or folder name. |
SubBoundary | {Parent}Attachments | Override generated sub-boundary name. |
Attachment Model
Section titled “Attachment Model”Generated attachment entities derive from AttachmentBase<TEntityId>, which provides:
ParentEntityIdFileNameFileSizeContentTypeStorageUriDescriptionUploadedByandUploadedAt- soft-delete fields (
IsDeleted,DeletedAt,DeletedBy)
The file bytes themselves live in Pragmatic.Storage; the database entity stores metadata only.
Generated Surface
Section titled “Generated Surface”For [HasAttachments] on Invoice ([Entity<Guid>], [Resource("invoices")]), the source generator emits the following into the consuming boundary’s namespace. Types use the parent name as prefix, so {Parent} = Invoice, FK property {Parent}Id = InvoiceId.
Entity — InvoiceAttachment : AttachmentBase<Guid>
Section titled “Entity — InvoiceAttachment : AttachmentBase<Guid>”Adds the FK and parent navigation; everything else is on AttachmentBase<TEntityId> (Id, ParentEntityId, FileName, FileSize, ContentType, StorageUri, Description, UploadedBy, UploadedAt, soft-delete fields):
public Guid InvoiceId { get; set; }public Invoice? Invoice { get; set; }EF config (InvoiceAttachmentEntityConfig : IEntityTypeConfiguration<InvoiceAttachment>): PK Id (never value-generated), required FileName/ContentType/StorageUri/UploadedBy with max lengths, FK to parent with OnDelete(Cascade), index on InvoiceId, and a HasQueryFilter(e => !e.IsDeleted) soft-delete filter. The parent gets an ICollection<InvoiceAttachment> Attachments navigation.
Actions ([DomainAction])
Section titled “Actions ([DomainAction])”| Action | Base | Input | Output |
|---|---|---|---|
UploadInvoiceAttachmentAction | DomainAction<Guid> | InvoiceId, Stream FileContent, FileName, long FileSize, ContentType, string? Description | new attachment Guid |
GetInvoiceAttachmentAction | DomainAction<InvoiceAttachmentDto> | Guid AttachmentId | InvoiceAttachmentDto (or NotFoundError) |
DeleteInvoiceAttachmentAction | VoidDomainAction | Guid AttachmentId | soft-deletes (sets IsDeleted/DeletedAt/DeletedBy) |
The upload action enforces, in order, before writing to storage: MaxPerEntity (count check → ForbiddenError), MaxFileSizeBytes (→ ForbiddenError), and AllowedExtensions (extension trimmed, lowercased, leading dot optional → ForbiddenError). It then calls IFileStorage.SaveAsync(FileContent, FileName, Container, ct) and records metadata with UploadedBy = ICurrentUser.Id.
List query
Section titled “List query”ListInvoiceAttachmentsQuery ([Query<InvoiceAttachment, InvoiceAttachmentDto>]) with InvoiceId filter plus Page (default 1) / PageSize (default 20).
DTO — InvoiceAttachmentDto
Section titled “DTO — InvoiceAttachmentDto”Id, InvoiceId, FileName, FileSize, ContentType, StorageUri, Description, UploadedBy, UploadedAt, plus a static EF Projection expression. (Soft-delete fields are not projected.)
Endpoints
Section titled “Endpoints”Generated under /api/{boundary}/invoices/{invoiceId}/attachments (OpenAPI tag Attachments):
| Verb | Route | Maps to |
|---|---|---|
GET | /attachments | ListInvoiceAttachmentsQuery (paged) |
GET | /attachments/{attachmentId} | GetInvoiceAttachmentAction |
DELETE | /attachments/{attachmentId} | DeleteInvoiceAttachmentAction (204) |
There is no generated upload endpoint. The upload action is generated, but the HTTP endpoint is not, because a multipart/form-data upload (a Stream body) cannot be deserialized from a JSON body by the endpoint generator. You wire the upload endpoint manually (POST .../attachments, multipart/form-data) and call UploadInvoiceAttachmentAction. (See the security note below — this is exactly where input validation belongs.)
Permissions — InvoiceAttachmentPermissions
Section titled “Permissions — InvoiceAttachmentPermissions”Static constants only (the generated endpoints do not auto-apply them; attach them in your endpoint wiring):
Upload={boundary}.invoice.attachments.uploadRead={boundary}.invoice.attachments.readDelete={boundary}.invoice.attachments.delete
({boundary} is the lowercased boundary name, or app if the entity has no [BelongsTo<TBoundary>].)
How the SG Wiring Works
Section titled “How the SG Wiring Works”- Trigger / detection.
[HasAttachments]is detected by FQNPragmatic.Attachments.HasAttachmentsAttribute(shared/SourceGen/AttributeNames.cs). The unified generator’sTraitDetectorrecordsHasAttachments+ the attribute options into aTraitSet; the dedicated[HasAttachments]pipeline inTraitFeatureusesForAttributeWithMetadataName+AttachmentTraitTransformto build the model. - Guards. Entity / config / navigation are generated only when
HasPersistenceEFCoreis detected; actions, DTO, list query, and permissions are generated only whenHasActionsis also present. Endpoints come from injectedEndpointModels consumed by the Endpoints feature. - No
[Resource]→ diagnosticPRAG2601. Without[Resource(...)]on the parent, no route segment can be derived, so endpoints are skipped andPRAG2601is reported. - Hint names. Each artifact uses
VirtualFolderHints: per-type{Type}.Entity.g.cs/.Action.g.cs/.Dto.g.cs/.QueryClass.g.cs,{Parent}.AttachmentPermissions.g.cs, andEntityConfig.{Attachment}.g.csfor the cross-assembly EF config.
Operational notes
Section titled “Operational notes”- Caller validates
FileNameandContentType. The generatedUpload{Parent}AttachmentActiondoes enforce the attribute limits (MaxPerEntity,MaxFileSizeBytes,AllowedExtensions) before writing to storage, but it stores whatever the consumer passes for the file name and MIME content type verbatim (FileName = FileName,ContentType = ContentType). It does not scan for hostile names (.., control chars, NUL) or verify that the declared content type matches the actual bytes. - Where to validate: the manual upload endpoint. Because no upload HTTP
endpoint is generated (multipart
Streambodies can’t be JSON-deserialized — see Endpoints above), the concrete validation point is the endpoint you write by hand. SanitizeFileName/ContentTypethere, before constructing and invokingUpload{Parent}AttachmentAction.Execute(...). There is no generated validation hook between the endpoint and the action for this. - The Storage layer’s path-safety check protects the on-disk container path, not the file-name field recorded on the entity (audit P1o).
Documentation
Section titled “Documentation”Local docs:
Related modules:
License
Section titled “License”Part of the Pragmatic.Design ecosystem — see Licensing. Pragmatic.Attachments is licensed under the PolyForm Small Business 1.0.0 license (free for small businesses; commercial license above the threshold).