Skip to content

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.

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.

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}Attachment entity derived from AttachmentBase<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
<PackageReference Include="Pragmatic.Attachments" />

Typical consumers also reference:

  • Pragmatic.Persistence
  • Pragmatic.Storage
  • Pragmatic.Endpoints if HTTP endpoints are desired
[Entity<Guid>]
[Resource("invoices")]
[HasAttachments(MaxPerEntity = 10, AllowedExtensions = ".pdf,.png,.jpg")]
public partial class Invoice
{
public string Number { get; set; } = "";
}
using Pragmatic.Storage;
await PragmaticApp.RunAsync(args, builder =>
{
builder.UseStorage(sp => new LocalDiskFileStorage(
"wwwroot",
sp.GetRequiredService<ILogger<LocalDiskFileStorage>>()));
});

See Generated Surface below for the exact entity, actions, endpoints, DTO, and permission strings.

OptionDefaultDescription
MaxPerEntity20Maximum number of attachments per parent entity. 0 means unlimited.
MaxFileSizeBytes10_485_760Maximum upload size in bytes. 0 means unlimited.
AllowedExtensions""Comma-separated extension allow-list such as .pdf,.jpg. Empty means allow all.
Containerparent type name in lowercaseStorage container or folder name.
SubBoundary{Parent}AttachmentsOverride generated sub-boundary name.

Generated attachment entities derive from AttachmentBase<TEntityId>, which provides:

  • ParentEntityId
  • FileName
  • FileSize
  • ContentType
  • StorageUri
  • Description
  • UploadedBy and UploadedAt
  • soft-delete fields (IsDeleted, DeletedAt, DeletedBy)

The file bytes themselves live in Pragmatic.Storage; the database entity stores metadata only.

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.

ActionBaseInputOutput
UploadInvoiceAttachmentActionDomainAction<Guid>InvoiceId, Stream FileContent, FileName, long FileSize, ContentType, string? Descriptionnew attachment Guid
GetInvoiceAttachmentActionDomainAction<InvoiceAttachmentDto>Guid AttachmentIdInvoiceAttachmentDto (or NotFoundError)
DeleteInvoiceAttachmentActionVoidDomainActionGuid AttachmentIdsoft-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.

ListInvoiceAttachmentsQuery ([Query<InvoiceAttachment, InvoiceAttachmentDto>]) with InvoiceId filter plus Page (default 1) / PageSize (default 20).

Id, InvoiceId, FileName, FileSize, ContentType, StorageUri, Description, UploadedBy, UploadedAt, plus a static EF Projection expression. (Soft-delete fields are not projected.)

Generated under /api/{boundary}/invoices/{invoiceId}/attachments (OpenAPI tag Attachments):

VerbRouteMaps to
GET/attachmentsListInvoiceAttachmentsQuery (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.upload
  • Read = {boundary}.invoice.attachments.read
  • Delete = {boundary}.invoice.attachments.delete

({boundary} is the lowercased boundary name, or app if the entity has no [BelongsTo<TBoundary>].)

  • Trigger / detection. [HasAttachments] is detected by FQN Pragmatic.Attachments.HasAttachmentsAttribute (shared/SourceGen/AttributeNames.cs). The unified generator’s TraitDetector records HasAttachments + the attribute options into a TraitSet; the dedicated [HasAttachments] pipeline in TraitFeature uses ForAttributeWithMetadataName + AttachmentTraitTransform to build the model.
  • Guards. Entity / config / navigation are generated only when HasPersistenceEFCore is detected; actions, DTO, list query, and permissions are generated only when HasActions is also present. Endpoints come from injected EndpointModels consumed by the Endpoints feature.
  • No [Resource] → diagnostic PRAG2601. Without [Resource(...)] on the parent, no route segment can be derived, so endpoints are skipped and PRAG2601 is 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, and EntityConfig.{Attachment}.g.cs for the cross-assembly EF config.
  • Caller validates FileName and ContentType. The generated Upload{Parent}AttachmentAction does 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 Stream bodies can’t be JSON-deserialized — see Endpoints above), the concrete validation point is the endpoint you write by hand. Sanitize FileName/ContentType there, before constructing and invoking Upload{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).

Local docs:

Related modules:

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).