# Add New Entity (/docs/add-new-entity) ## Overview The EF Core entity and its attributes form the foundation of everything in Spiderly. All other components are built and generated based on these entities. In this step-by-step guide, you'll learn how to create a new entity in your project using the Spiderly CLI, which automates the entire process including backend entity creation, frontend page generation, routing, and navigation menu setup. ## Add New Entity Run the following command from the root of your application: ```bash spiderly add-new-entity ``` Or, if you want to generate a data view instead of a table for the list page: ```bash spiderly add-new-entity --data-view ``` The CLI will prompt you to enter the entity name in PascalCase (e.g., `YourEntityName`). This command will automatically generate: 1. **Backend Entity**: * `Backend\YourAppName.Business\Entities\YourEntityName.cs` 2. **List Page**: * `Frontend\src\app\pages\your-entity-name\your-entity-name-list.component.ts` * `Frontend\src\app\pages\your-entity-name\your-entity-name-list.component.html` 3. **Details Page**: * `Frontend\src\app\pages\your-entity-name\your-entity-name-details.component.ts` * `Frontend\src\app\pages\your-entity-name\your-entity-name-details.component.html` 4. **Routes** in `Frontend\src\app\app.routes.ts` 5. **Menu Item** in `Frontend\src\app\business\layout\layout.component.ts` ## Customize the Entity After generation, open the entity file at `Backend\YourAppName.Business\Entities\YourEntityName.cs` and customize it according to your needs. The entity class inherits from `BusinessObject` which supports create, read, update and delete operations. If you need a read-only entity that does not support create, update, or delete operations from the UI, change the base class to `ReadonlyObject`. ### Optimistic concurrency Every `BusinessObject` carries a `[ConcurrencyCheck]` `Version` column that gives you optimistic concurrency for free — `ReadonlyObject` has none. Spiderly manages it end to end, so you never set or increment it yourself: * On insert, `Version` is set to `1`; on every update it is incremented inside `SaveChanges`. * The value round-trips to the client on the entity's DTO, so the client always holds the version it last read. * On update, the generated `Save{Entity}` reloads the row and compares the stored `Version` against the one the client sent back. If they differ, the write is rejected with a localized `ConcurrencyException`. This means two users editing the same record can't silently overwrite each other — the second save fails loudly instead of clobbering the first. No per-entity configuration is required. (The check runs on update only: deletes use a bulk `ExecuteDeleteAsync` and are not version-guarded, and inserts have no version to race — guard duplicate creation with a unique index instead.) ### Example of a customized entity: ```csharp namespace YourAppName.Business.Entities { [SpiderlyEntity] [DoNotAuthorize] public class YourEntityName : BusinessObject { [StringLength(75, MinimumLength = 1)] [Required] public string Name { get; set; } [UIControlType(nameof(UIControlTypeCodes.TextArea))] [StringLength(500, MinimumLength = 1)] public string Description { get; set; } } } ``` The `[SpiderlyEntity]` attribute is **required** — source generators only enroll classes that carry it. The CLI adds it for you; on hand-created entities, add it yourself. Hand-written DTOs use `[SpiderlyDTO]`. Generated DTOs (`{Entity}DTO`, `{Entity}SaveBodyDTO`, `{Entity}MainUIFormDTO`) are emitted by Spiderly and need no marker. ## Database Table Naming Spiderly creates database tables with the **exact same name** as your entity class — singular, PascalCase. For example, a `YourEntityName` class produces a `"YourEntityName"` table, not `"YourEntityNames"`. ## Add Your Entity to the Database Open a terminal in the `Backend` folder and run the following commands to create and apply a migration: ```bash spiderly add-migration YourMigrationName spiderly update-database ``` --- # API Keys (/docs/api-keys) When a partner integration, a CI bot, or an AI agent needs to call your API without going through the interactive email-code login, they use an **API key** — a long-lived secret sent in an `X-Api-Key` header. In Spiderly an API key is a **first-class principal** (`PrincipalKinds.ApiKey`), not an impersonation of a user. Each key carries **its own roles**, and authorization resolves a key's permissions through the same pipeline as a logged-in user. So your existing permission-protected endpoints accept a key with no per-endpoint change. API keys are **opt-in**. An app has zero API-key surface — no scheme, no endpoints, no database table — until you enable the feature. Don't add it unless you actually need machine/partner/agent access. ## How a key authenticates A client sends the key in the `X-Api-Key` header: ```bash curl https://api.yourapp.com/api/Product/GetPaginatedProductList \ -H "X-Api-Key: " \ -H "Content-Type: application/json" \ -d '{"first": 0, "rows": 10}' ``` Enabling the feature installs a **forwarding scheme** as the default: a request with the `X-Api-Key` header is authenticated by the key handler, and everything else falls through to JWT. The upshot is that the *same* [`[HasPermission]`](/docs/authorization#haspermission) / `[AuthGuard]` endpoints accept **either** a logged-in user's JWT **or** an API key — you don't annotate endpoints with a second scheme. ## Keys are principals — with their own roles A key's authority is the **union of its own roles' permissions** (a many-to-many, exactly like a user's roles): * A key with no roles has **no permissions** — it authenticates, but is denied everything. Assign it a role (e.g. a read-only role) to grant access. * A key's roles are independent of the user who created it. Changing the creator's roles later does not change the key's. ### Why a key, and not just a long-lived JWT? Because an API key must be **individually revocable**, and a JWT can't be. A JWT is stateless — once signed, it's valid until it expires, and you can't kill a single one without rotating the signing key (which logs out every user). An API key is an opaque secret stored **hashed**; every request checks it against a row, so revoking, expiring, or disabling a single key takes effect immediately. ## Enabling API keys The fastest path is the **`api-keys` skill** — with [Claude Code](/docs/claude-code) set up, ask it to *"add API keys"* and it assembles every piece below into your project, including the database migration. To do it by hand, the shape is: ### 1. The `ApiKey` entity Add an entity that implements `IApiKey` (which extends `ISecurityPrincipal`), so the framework can verify a presented key generically: ```csharp [Index(nameof(KeyHash), IsUnique = true)] [SpiderlyEntity] public class ApiKey : BusinessObject, IApiKey { [UIDoNotGenerate] public string KeyHash { get; set; } // SHA-256 of the key; the plaintext is never stored [DisplayName] public string Name { get; set; } public DateTime? ExpiresAt { get; set; } public bool? IsRevoked { get; set; } public bool? IsDisabled { get; set; } public virtual List Roles { get; } = new(); // M2M — the key's authority IReadOnlyCollection ISecurityPrincipal.Roles => Roles; // Creator — auto-stamped at generation. A soft reference (id + kind), because the principal that // mints a key can be any kind (a user, another key, ...). [UIDoNotGenerate] public long CreatedById { get; set; } [UIDoNotGenerate] public string CreatedByPrincipalKind { get; set; } } ``` ### 2. Registration Enable API keys by passing the `AddApiKeys()` sub-builder to your existing `AddSecurity<…>` call in `Startup.cs`: ```csharp spiderly.AddSecurity(s => s.AddApiKeys()); ``` `AddApiKeys()` registers the `ApiKey` principal kind and wires the default lookup (`DefaultApiKeyAuthenticator`) over your entity — **you don't write the verification code**. Register your own `IApiKeyAuthenticator` before this call only if you need a non-standard lookup (e.g. an external key store). ## Generating and revoking keys Keys are minted by a small pair of endpoints (the `api-keys` skill scaffolds them, or write them yourself): * **Generate** returns the plaintext key **exactly once** — store only its hash. The caller copies it on creation and it can never be retrieved again. * **Revoke** flags the key; the next request it makes is rejected. Day-to-day, you manage keys conversationally through the skill (*"generate a read-only key for the ACME partner, expiring in 90 days"*) rather than building admin screens for a feature most end users never touch — though nothing stops you from generating an admin page for the `ApiKey` entity if you want one. ## Security model | Property | How it's enforced | | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | | Plaintext is never stored | Only the SHA-256 hash is persisted; the key is shown once at generation. | | Individually revocable / expirable | The authenticator checks `IsRevoked`, `ExpiresAt`, and `IsDisabled` live on every request. | | A key can't outrank its creator | Issuance is guarded — the creating principal (any kind — user, key, …) may only grant roles whose permissions it already holds. | | Role-less means powerless | A key with no roles is denied every permission check. | Treat a key like a password. It grants its roles' permissions to anyone who holds it — rotate or revoke it immediately if it leaks. --- # Architecture (/docs/architecture) ## Overview Spiderly's core idea is simple: **you define C# entity classes with attributes, and the framework generates everything else**. At build time, Roslyn source generators read your entity definitions and produce: * .NET services, controllers, DTOs, mappers, validators, and permission codes * Angular entities, API services, form components, validators, and enums You never edit generated files. Instead, you extend them through inheritance and partial classes. ``` Your Entity Classes (C# + Attributes) │ ▼ Source Generators (build time) │ ▼ Generated Base Code (*.generated.cs / *.generated.ts) │ ▼ Your Code (extends generated base classes) ``` ## Project Structure Running `spiderly init` creates the following structure: ``` your-app-name/ ├── Backend/ │ ├── YourApp.Business/ │ │ ├── Entities/ ← Your entity classes (source of truth) │ │ ├── Services/ ← Entity services, AuthorizationService, SecurityService │ │ ├── DTO/ ← Custom DTO extensions (partial classes) │ │ ├── DataMappers/ ← Custom Mapster mappings (partial class) │ │ ├── Enums/ ← Custom PermissionCodes (partial class) │ │ └── Settings.cs │ │ │ ├── YourApp.Infrastructure/ ← EF Core DbContext, migrations │ ├── YourApp.WebAPI/ ← Custom controllers, Program.cs │ ├── YourApp.Shared/ │ │ └── Translations/ ← Translation JSON files (en.json, sr-Latn-RS.json, ...) │ └── YourApp.sln │ └── Frontend/ └── src/app/ ├── business/ │ ├── components/ ← Generated base detail components │ ├── entities/ ← Generated TypeScript entity classes │ ├── enums/ ← Generated TypeScript enums │ ├── layout/ ← Navigation menu and layout │ └── services/ │ ├── api/ ← API service (extends generated base) │ ├── auth/ ← Auth service (extends generated base) │ └── validators/ ← Generated form validators └── pages/ ← Your list and details page components ``` ## What Gets Generated Spiderly has 14 source generators (9 for .NET, 5 for Angular) that run at build time: ### .NET Generators | Generator | Output File | Purpose | | ------------------------------ | --------------------------------------- | -------------------------------------------------------- | | ServicesGenerator | `{Entity}Service.generated.cs` | Per-entity CRUD operations, data retrieval, Excel export | | ControllerGenerator | `BaseControllers.generated.cs` | REST API endpoints for each entity | | EntitiesToDTOGenerator | `DTOList.generated.cs` | DTO classes mirroring your entities | | MapperGenerator | `Mapper.generated.cs` | Mapster config for entity ↔ DTO mapping | | FluentValidationGenerator | `ValidationRules.generated.cs` | FluentValidation rules from attributes | | AuthorizationServicesGenerator | `AuthorizationService.generated.cs` | Permission checks for CRUD operations | | PermissionCodesGenerator | `PermissionCodes.generated.cs` | Static permission code constants | | PaginatedResultGenerator | `PaginatedResultGenerator.generated.cs` | Dynamic EF Core filtering queries | | ExcelPropertiesGenerator | `ExcelPropertiesToExclude.generated.cs` | Properties to exclude from Excel export | ### Angular Generators | Generator | Output File | Purpose | | ---------------------- | --------------------------- | ------------------------------------------ | | NgEntitiesGenerator | `entities.generated.ts` | TypeScript classes mirroring C# DTOs | | NgControllersGenerator | `api.service.generated.ts` | Typed HTTP methods for all API endpoints | | NgBaseDetailsGenerator | `base-details.generated.ts` | Form components for entity detail pages | | NgValidatorsGenerator | `validators.generated.ts` | Angular form validators from C# attributes | | NgEnumsGenerator | `enums.generated.ts` | TypeScript enums from C# enums | When a generator encounters a contract violation on your entity (bad `[ForeignKey]`, missing base class, malformed `[DisplayName]` path, etc.), it emits a located `SPIDERLY###` diagnostic rather than crashing the build. See [Build Diagnostics](/docs/build-diagnostics) for the full code reference. ### What You Write by Hand * Entity classes with attributes (the single source of truth) * Business logic overrides in entity service classes (e.g., `ProductService`) * Custom controllers for non-CRUD endpoints * Angular list and details page components * Custom DTO properties (via partial classes) * Custom mapper configurations (via partial class) * Translations ## You Own the Generated Code Spiderly is MIT-licensed, and so is everything it produces. The generators run at build time: the Angular generators write `.generated.ts` files into your project, and the Roslyn source generators emit C# straight into the compiled assembly (or onto disk too, if you set `EmitCompilerGeneratedFiles`). Either way, the output is plain C# and TypeScript — no runtime magic, no proprietary format. Your app depends on a handful of base classes from the Spiderly NuGet packages (`ServiceBase`, `SpiderlyBaseController`, `AuthorizationServiceBase`, …), nothing more. A Spiderly project is a normal ASP.NET Core + EF Core project. Anything you'd reach for in a regular .NET app — your own controllers and minimal API endpoints, Hangfire, SignalR, custom middleware, Dapper alongside EF Core, your preferred logging stack (Serilog, OpenTelemetry) — drops in the same way it would anywhere else. The framework adds scaffolding on top; it doesn't fork the runtime or close off the ecosystem. If you ever want to stop using Spiderly, you have two paths: * **Freeze the generated output.** Your Angular `.generated.ts` files are already on disk. For the .NET side, set `true` in `Directory.Build.props` and build once to write the C# to disk. Commit both, vendor a copy of the runtime base classes you use, drop the Spiderly package references — your hand-written code already extends the generated base classes through inheritance, so the app still compiles and runs. * **Fork the generators.** The source generators live in [the Spiderly repo](https://github.com/filiptrivan/spiderly) under MIT. Copy the ones you use into your codebase and evolve them on your own schedule. ## The Inheritance Pattern Spiderly uses a three-tier inheritance pattern. The framework provides a base class, generators produce a middle layer, and you extend at the top: ### Services ``` ServiceBase ← Framework (Spiderly NuGet package) ↓ {Entity}ServiceGenerated ← Generated per entity (readonly, rebuilt every build) ↓ {Entity}Service ← Your code (override virtual methods here) ``` ### Controllers ``` SpiderlyBaseController ← Framework ↓ {Entity}BaseController ← Generated (e.g., ProductBaseController) ↓ {Entity}Controller ← Your code (optional, only if you need overrides) ``` ### Authorization ``` AuthorizationServiceBase ← Framework ↓ AuthorizationServiceGenerated ← Generated ↓ AuthorizationService ← Your code ``` ### Angular API Service ``` ApiSecurityService ← Framework (Spiderly Angular library) ↓ ApiGeneratedService ← Generated ↓ ApiService ← Your code ``` Never edit files with `.generated.cs` or `.generated.ts` suffixes — they are overwritten on every build. Always extend through the hand-written classes above. ## How Angular and .NET Stay in Sync Both the .NET and Angular code are generated from the same source: your C# entity classes. This means: * **TypeScript entities** mirror your C# DTOs — same property names and types * **Angular API service methods** mirror your .NET controller endpoints — same method names and parameter types * **Angular form validators** mirror your FluentValidation rules — same validation logic on both sides * **TypeScript enums** mirror your C# enums — same values and names When you add a property to an entity, rebuild, and both sides update automatically. ## Base Entities Every entity in Spiderly inherits from one of two base classes. ### BusinessObject\ For entities that support full CRUD (create, read, update, delete) operations: ```csharp public class BusinessObject : IBusinessObject where T : struct { public T Id { get; set; } [ConcurrencyCheck] [Required] public int Version { get; set; } [Required] public DateTime CreatedAt { get; set; } [Required] public DateTime ModifiedAt { get; set; } } ``` * `Id` — Auto-generated primary key. `T` can be `long`, `int`, or `byte`. **`Guid` is not supported as the PK type** — `ServiceSaveGenerator` emits `dto.Id > 0` arithmetic checks that fail with CS0019 when `T` is `Guid`. `Guid` scalar properties on entities are fine; only the PK type argument is restricted. * `Version` — Optimistic concurrency control. Spiderly checks this on every update to prevent conflicting writes. * `CreatedAt` / `ModifiedAt` — Automatically managed by the framework. ```csharp namespace YourAppName.Business.Entities { public class BlogPost : BusinessObject { [Required] [StringLength(200, MinimumLength = 1)] public string Title { get; set; } [UIControlType(nameof(UIControlTypeCodes.TextArea))] [StringLength(5000, MinimumLength = 1)] public string Content { get; set; } } } ``` ### ReadonlyObject\ For lookup/reference tables that are read-only from the UI (no create, update, or delete operations): ```csharp public class ReadonlyObject : IReadonlyObject where T : struct { public T Id { get; set; } } ``` No `Version`, `CreatedAt`, or `ModifiedAt` — these are simple lookup entities with data typically seeded in migrations. ```csharp namespace YourAppName.Business.Entities { public class PostStatus : ReadonlyObject { [Required] [StringLength(100, MinimumLength = 1)] public string Name { get; set; } } } ``` Entity classes must be in a namespace ending with `.Entities` (e.g., `YourAppName.Business.Entities`) to be discovered by the source generators. --- # Attribute Reference (/docs/attribute-reference) ## How to Use This Page This page is a flat A–Z index — useful when you know the attribute name and want to jump straight to its full documentation. For tutorials and worked examples, follow the **Documented in** link to the relevant topic page. Every attribute on this list lives in the `Spiderly.Shared` package. Standard .NET data annotations (`[Required]`, `[StringLength]`, `[Range]`, `[Precision]`) that Spiderly also reacts to are documented in [Validation](/docs/validation). ## Reference | Attribute | Target | Description | | --------------------------------------- | ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `[AcceptedFileTypes]` | Property | Specifies the accepted file types for a blob property. Mandatory on every blob property (build error SPIDERLY014 when missing) alongside any StorageAttribute subclass (e.g. \[S3PublicStorage]). Entry semantics: — MIME entries ("image/png") are enforced server-side (declared type whitelist + content inspection) and passed to the UI file picker. — Type wildcards ("image/\*") match any declared type with that prefix. — Extension entries (".pdf" — leading dot, no '/') only widen the UI file picker; the server validates the MIME entries, so always pair an extension with its MIME type. — "image/svg+xml" is validated structurally (XML with an \ root, active content rejected) since SVG has no magic bytes, and is uploaded as-is (no ImageSharp optimization). | | `[AuthGuard]` | Class, Method | Provides authentication protection for API endpoints by validating JWT tokens in the request. | | `[CascadeDelete]` | Property | Implements cascade delete behavior in many-to-one relationships. When the referenced entity is deleted, all entities that reference it will automatically be deleted as well. This attribute is useful when: - Child entities should not exist without their parent | | `[ComplexManyToManyList]` | Property | Generates an editable list UI for complex many-to-many relationships (junction tables with additional fields). Shows ALL entities from the "other side" with editable junction fields. No add/remove/reorder controls. Warning: This loads all "other side" entities into the form. Suitable for small sets (e.g., 3 warehouses), not for large sets (e.g., thousands of entities). | | `[ComplexManyToManyReadonlyTable]` | Property | Just showing the complex (with additional fields) M2M relationship in a table form | | `[Controller]` | Class | Specifies a custom controller name for an entity, overriding the default naming convention. This attribute allows grouping multiple related entities under a single controller. Default behavior without 'Controller' attribute: Controllers are named as '\{EntityName}Controller' | | `[DiskStorage]` | Property | Routes upload/delete operations for the decorated string property through DiskStorageService, storing files under the local filesystem. Intended for local development; not recommended for production deployments where the host filesystem is ephemeral or shared across replicas. | | `[DisplayName]` | Class, Property | Specifies which property should be used as the display name for an entity in UI elements: - When applied to a property: The property's value will be used to represent the entity - When applied to a class: The specified property's value will be used to represent the entity - If no property or class is marked with this attribute: The entity's 'Id' will be used | | `[DoNotAuthorize]` | Class | Disables authorization checks for CRUD operations on the decorated entity. By default, all entities require authorization for CRUD operations. Warning: This attribute bypasses security checks and should be used with extreme caution. It is primarily intended for testing purposes and should generally be avoided in production environments. | | `[Email]` | Property | Validates that a string property value is a valid email address. This attribute provides both server-side and client-side validation. | | `[ExcludeFromDTO]` | Property | Specifies that a property should be excluded from the generated DTO. | | `[ExcludeFromExcelExport]` | Property | Specifies that a property should be excluded from the generated Excel export (the Export\{Entity}ListToExcel column set), while remaining present in the DTO and the rest of the API/UI. Use it for internal/technical columns that are noise in a human-readable sheet (raw foreign-key ids, gateway correlation data, sync plumbing, etc.). Place it on the property that produces the column: a scalar property excludes the matching DTO column; a many-to-one navigation property excludes the generated \{Nav}DisplayName (and the synthesized \{Nav}Id when there is no explicit foreign-key scalar). Differs from ExcludeFromDTOAttribute, which removes the property from the DTO entirely (no API exposure at all). This one only hides the column from the Excel export. Placement controls which column disappears, so for a many-to-one you can drop just the raw id while keeping the human-readable name (or vice-versa): Putting it on the navigation property instead (OrderStatus) hides OrderStatusDisplayName, and also OrderStatusId only when no explicit foreign-key scalar like the one above is declared. | | `[ExcludeServiceMethodsFromGeneration]` | Property | Prevents the generation of standard service methods for the decorated property in the \{Entity}ServiceGenerated class. Use this attribute when you want to: - Implement custom business logic instead of using generated methods - Override the default generated behavior with your own implementation - Exclude specific properties from the standard service method generation Note: The property will still be part of the entity, but no service methods will be generated for it in the \{Entity}ServiceGenerated class. | | `[GenerateCommaSeparatedDisplayName]` | Property | Generates a string property in the DTO containing comma-separated display names for a collection property in the entity. | | `[GreaterThanOrEqualTo]` | Property | Validates that a numeric property value is greater than or equal to a specified number. This attribute provides both server-side and client-side validation. | | `[ImageHeight]` | Property | Validates exact image height for a blob property. This attribute provides both server-side and client-side validation. Use this attribute alongside any StorageAttribute subclass for image uploads. | | `[ImageWidth]` | Property | Validates exact image width for a blob property. This attribute provides both server-side and client-side validation. Use this attribute alongside any StorageAttribute subclass for image uploads. | | `[IncludeInDTO]` | Property | Specifies that a property should be included in the generated DTO. This attribute is particularly useful for enumerable properties, which are not included in DTOs by default. Note: This attribute only affects DTO generation and does not influence the mapping behavior (Entity to DTO and vice versa). | | `[M2M]` | Class | Indicates that the entity represents a helper table for a many-to-many (M2M) relationship. | | `[M2MWithMany]` | Property | Marks a property in a many-to-many (M2M) relationship. | | `[MaxFileSize]` | Property | Specifies the maximum allowed file size (in bytes) for a blob property. When not applied, the file upload defaults to 20 MB (20,000,000 bytes). Use this attribute alongside any StorageAttribute subclass for file uploads. | | `[Output]` | All | Specifies the output configuration for the Source Generator. Note: This is a temporary solution and may be replaced in future versions. | | `[ProjectToDTO]` | Class | Specifies custom mapping configuration when projecting an entity to its DTO. | | `[S3PrivateStorage]` | Property | Routes upload/delete operations for the decorated string property through S3PrivateStorageService. The column stores an opaque S3 key; access is expected to be mediated via signed URLs or a backend proxy rather than direct CDN retrieval. Intended for files that contain personal or compliance-sensitive data (warranty receipts, ID documents, customer-uploaded invoices). | | `[S3PublicStorage]` | Property | Routes upload/delete operations for the decorated string property through S3PublicStorageService. The bucket is configured for public access and the column stores a fully-qualified CDN URL; objects are uploaded with Cache-Control: public, max-age=31536000, immutable. | | `[SetNull]` | Property | Specifies that the property should be set to null when the parent entity is deleted. Apply this attribute to a many-to-one relationship property. | | `[ShowSpinner]` | All | Forces the global full-screen loading spinner ON for the decorated controller method, overriding the generator's auto-skip inference. The explicit attribute always wins over inference. You rarely need this. The spinner is shown by default; you only reach for this attribute to re-enable it on a call the generator would otherwise auto-skip — namely a deliberately slow HttpGet that returns a bare scalar (which is auto-skipped because such reads are normally instant). If your endpoint does real work, it is usually a POST — and POSTs keep the spinner without any attribute, so prefer the correct verb over this. Do not put this on the read-shaped responses (NamebookDTO, CodebookDTO, PaginatedResultDTO, LazyLoadSelectedIdsResultDTO): those power autocomplete, dropdowns and table pagination, where a full-screen blackout on every keystroke / page change is a regression. (The attribute still wins there if you insist — it just shouldn't be used that way.) Example (a slow scalar read where the blocking overlay is wanted): | | `[SimpleManyToManyTableLazyLoad]` | Property | Specifies that a table items for the many-to-many relationship administration should be loaded lazily (on-demand) rather than eagerly. | | `[SkipSpinner]` | All | Indicates that the global full-screen loading spinner should be skipped for the decorated controller method. You usually don't need this. The generator already skips the spinner automatically for read-shaped responses (NamebookDTO, CodebookDTO, PaginatedResultDTO, LazyLoadSelectedIdsResultDTO) and for any HttpGet that returns a bare scalar (int, long, bool, decimal, DateTime, …). Reach for this attribute only when the inference can't see your intent. For the inverse — forcing the spinner back ON for a call the inference auto-skips — use ShowSpinnerAttribute. Use when: - A GET returns a full DTO but is polled / refreshed on a timer (e.g. a dashboard fetched every minute) - You want to implement custom loading behavior - The operation runs in the background Example (a polled DTO — a bare-scalar count GET like GetUnreadNotificationsCountForCurrentUser would be auto-skipped without this): | | `[SpiderlyController]` | Class | Marks a class as a Spiderly custom controller. Source generators only enroll classes carrying this attribute. | | `[SpiderlyDTO]` | Class | Marks a hand-written DTO class for inclusion in the Spiderly pipeline. Generated DTOs (\{Entity}DTO, \{Entity}SaveBodyDTO, \{Entity}MainUIFormDTO) do not need this attribute. | | `[SpiderlyDataMapper]` | Class | Marks a class as a hand-written Spiderly data mapper. Source generators enroll classes carrying this attribute when composing Mapster configuration with user-provided overrides. | | `[SpiderlyEntity]` | Class | Marks a class as a Spiderly entity. Source generators only enroll classes carrying this attribute. | | `[SpiderlyEnum]` | Class, Enum | Marks a C# enum or a class-based enum (static class of string constants) as a Spiderly enum. Source generators enroll enums carrying this attribute when emitting Angular enum definitions. | | `[SpiderlyService]` | Class | Marks a hand-written entity service as a Spiderly service. Source generators enroll classes carrying this attribute when composing DI registration and dependency lookups. The class is still expected to extend the generated \{Entity}ServiceGenerated base. | | `[UIAdditionalPermissionCodeForInsert]` | Class | Specifies additional permission requirements for inserting entities in the UI. The user must have ONE of the specified permissions to perform the insert operation. Multiple instances of this attribute can be applied to a single entity. | | `[UIAdditionalPermissionCodeForUpdate]` | Class | Specifies additional permission requirements for updating entities in the UI. The user must have ONE of the specified permissions to perform the update operation. Multiple instances of this attribute can be applied to a single entity. | | `[UIControlType]` | Property | Specifies the UI control type for a property. If not specified, the control type is automatically determined based on the property type: - string: TextBox (or TextArea if \[StringLength] value is large) - int/long: Number - decimal: Decimal - bool: CheckBox - DateTime: Calendar - many-to-one: Autocomplete | | `[UIControlWidth]` | Property | Specifies the width of a UI field using Spiderly column classes (from spiderly-grid). Default values: - "col-8" for TextArea and Editor controls - "col-8 md:col-4" for all other controls | | `[UIDoNotGenerate]` | Class, Method, Property | Apply to a property to exclude it from the UI form. Apply to an entity to exclude the entire UI form generation. Apply to a controller method to exclude the UI controller method generation. | | `[UIOrderedOneToMany]` | Property | Enables management of child entities through an ordered list in the parent entity's main UI form component. | | `[UIPropertyBlockOrder]` | Property | Specifies the display order of UI controls. Controls are displayed in the order of property declaration, except for: 'file', 'text-area', 'editor', and 'table' controls, which are always displayed last in their declaration order. | | `[UISection]` | Property | Groups the property into a named section (card) on the generated details page. All properties sharing the same section name render together inside one panel, in the order the properties are declared; sections themselves are ordered by first appearance (the position of the first property that declares the section). Properties without this attribute collapse into a single implicit, headerless section positioned by the same first-appearance rule — so a newly added property always shows up automatically, either in its declared section or the implicit one. The argument is a Transloco translation key used as the section header (e.g. "Security" resolves through t('Security')), matching how other generated panel titles are translated. Backward compatibility: if no property on the entity declares this attribute, the details page renders as before (a single panel with one grid). Sectioning activates only when at least one property is annotated. | | `[UITableColumn]` | Property | Specifies which columns should be displayed in a table view for a many-to-many relationship. Must be used in combination with \[SimpleManyToManyTableLazyLoad] attribute. | | `[WithMany]` | Property | Specifies the collection navigation property name in a related entity for establishing a bidirectional relationship in Entity Framework. Purpose: This attribute is used to define the inverse navigation property in a relationship, enabling proper relationship configuration and navigation in both directions. | | `[WithOne]` | Property | Declares a one-to-one relationship. Place on the dependent (foreign-key-holding) side's single-valued reference navigation. Its presence designates this side as the dependent; the other side is the principal. Required vs optional: add \[Required] to make the dependent's FK non-nullable ("dependent must have a principal"). Omit it for an optional 1-1 (nullable FK, many NULLs allowed). The schema cannot enforce "principal must have a dependent" — that direction is always 0..1. Unidirectional: use the parameterless constructor when the principal has no back-navigation. | ## Standard .NET Attributes Spiderly also reacts to the standard `System.ComponentModel.DataAnnotations` attributes: | Attribute | Generates | | ------------------------------------------------------------------ | ---------------------------------------------------------- | | `[Required]` | Server + client `NotEmpty` validation, non-nullable column | | `[StringLength(max)]` / `[StringLength(max, MinimumLength = min)]` | Server + client length validation, EF column length | | `[Range(min, max)]` | Server + client range validation | | `[Precision(p, s)]` | Server + client decimal validation, EF column precision | See [Validation](/docs/validation) for usage examples. --- # Authentication & Authorization (/docs/authorization) ## Authentication Strategies Spiderly supports two authentication strategies. Both use JWT tokens under the hood — the difference is how tokens are transported between client and server. ### Token-Based (Default) Tokens are returned in the response body and sent via the `Authorization` header. The client is responsible for storing and managing tokens. **Angular methods:** | Method | Returns | Description | | ---------------------------------- | ------------ | ------------------------------------------- | | `login(request)` | `AuthResult` | Login with email verification token | | `loginExternal(provider)` | `AuthResult` | Login with external provider (e.g., Google) | | `logout(browserId)` | — | Invalidate tokens | | `refreshTokenWithHeaders(request)` | `AuthResult` | Refresh an expired access token | `AuthResult` includes `accessToken`, `refreshToken`, `userId`, and `email`. ### Cookie-Based Tokens are stored in `HttpOnly` cookies — the browser handles storage and sends them automatically. No token management needed on the client side. **Angular methods:** | Method | Returns | Description | | ------------------------------------ | ----------------------- | ------------------------------------------- | | `loginWithCookies(request)` | `AuthResultWithCookies` | Login with email verification token | | `loginExternalWithCookies(provider)` | `AuthResultWithCookies` | Login with external provider (e.g., Google) | | `logoutWithCookies(browserId)` | — | Clear auth cookies | | `refreshTokenWithCookies(browserId)` | `AuthResultWithCookies` | Refresh via cookie | `AuthResultWithCookies` includes `userId`, `email`, and `accessTokenExpiresAt` — no tokens in the response body. Cookie-based authentication requires the [`[AuthGuard]`](#authguard) attribute's CSRF protection. All state-changing requests (`POST`, `PUT`, `DELETE`, `PATCH`) must include the `X-CSRF: "1"` header. ### Choosing a Strategy | | Token-Based | Cookie-Based | | ------------------- | ----------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- | | **Best for** | Mobile apps, cross-domain APIs, SPAs with explicit token handling | Same-domain/subdomain setups, simpler frontend code | | **Token storage** | Client manages (localStorage, memory) | Browser manages (HttpOnly cookies) | | **CSRF protection** | Not needed (tokens in headers) | Required (`X-CSRF` header) | | **Cross-subdomain** | Works out of the box | Requires [`CookieDomain` and `CookieSameSite`](/docs/deployment#cookie-configuration) configuration | ## Overview By default, all entities require authorization for Create, Read, Update, and Delete operations. If your code encounters an authorization issue (e.g., an `UnauthorizedException - You don't have the necessary rights to perform the operation.`), it means the current user lacks the necessary permissions. If your entity requires access control, follow the steps in this tutorial to configure proper authorization. ## Registration Authentication and authorization are turned on by a single call inside the `AddSpiderly` builder in your `Startup.cs`. The `spiderly init` template scaffolds it for you — you rarely write it by hand: ```csharp services.AddSpiderly(Configuration, spiderly => { spiderly.UsePostgreSQL(); spiderly.AddSecurity(); }); ``` `AddSecurity` registers the whole auth core as one unit: the email-login and JWT services, the `User` principal kind, your `AuthorizationService`, and — crucially — the `[HasPermission]` handler that forwards to it. These pieces are **co-required**: an app with some but not all of them is silently broken, so `AddSecurity` bundles them all-or-nothing. A single-principal app needs nothing more. Optional add-ons opt in through a sub-builder. [API keys](/docs/api-keys), for example, register a second principal kind and its auth scheme: ```csharp spiderly.AddSecurity(s => s.AddApiKeys()); ``` If you replace the scaffolded `AddSecurity` call with hand-rolled registration and forget the `[HasPermission]` handler, the app **fails at startup**: *"Spiderly authentication is enabled, so `[HasPermission]` materializes a `PermissionRequirement` policy, but no `IAuthorizationHandler` that satisfies `PermissionRequirement` is registered."* Fix it by calling `services.AddSpiderlyAuthorization()` — or simply keep using `AddSecurity`, which registers the handler for you. It's a fail-loud boot guard, never a silent 403. ## Authorization Attributes ### \[AuthGuard] Protects API endpoints by checking the authentication status set by the authentication middleware. For **cookie-based** authentication, `[AuthGuard]` also enforces CSRF protection on state-changing requests (`POST`, `PUT`, `DELETE`, `PATCH`). These requests must include an `X-CSRF` header — the value doesn't matter, only its presence is checked. If the header is missing, the request is rejected with `403 Forbidden`. Requests authenticated via the `Authorization` header (Bearer token) are not affected by CSRF checks. `GET`, `HEAD`, and `OPTIONS` requests are also not affected, regardless of the auth strategy. ```csharp [SpiderlyController] [ApiController] [Route("/api/[controller]/[action]")] public class ReportController : ReportBaseController { [HttpPost] [AuthGuard] // Authenticated users only; cookie auth requires X-CSRF header public async Task RunExport() { /* ... */ } } ``` ### \[DoNotAuthorize] Disables authorization checks for **all CRUD operations** on the decorated entity. By default, every entity requires the user to hold the relevant `Read{Entity}` / `Insert{Entity}` / `Update{Entity}` / `Delete{Entity}` permission. `[DoNotAuthorize]` bypasses security checks entirely and is intended primarily for testing or for entities that are truly public. Avoid it on entities that contain user data or any value worth protecting in production. ```csharp [SpiderlyEntity] [DoNotAuthorize] // Public catalog — no permissions required public class PublicAnnouncement : BusinessObject { [DisplayName] public string Title { get; set; } } ``` ### \[HasPermission] Gates a custom controller action on a specific permission code. The principal — a logged-in user or an [API key](/docs/api-keys) — must hold that permission, or the request is rejected with `403 Forbidden`. Use it on endpoints you write yourself; entity CRUD already enforces the default `Read{Entity}` / `Insert{Entity}` / `Update{Entity}` / `Delete{Entity}` permissions with no attribute needed. ```csharp [SpiderlyController] [ApiController] [Route("/api/[controller]/[action]")] public class ReportController : ReportBaseController { [HttpPost] [HasPermission("RunSalesReport")] // Caller must hold the RunSalesReport permission public async Task RunSalesReport() { /* ... */ } } ``` The permission code must be [seeded](#add-permissions-to-your-application) and assigned to the caller's role. ### Per-action permissions For finer-grained control — e.g. requiring an extra permission on top of the default `Insert{Entity}` / `Update{Entity}` permission — see `[UIAdditionalPermissionCodeForInsert]` and `[UIAdditionalPermissionCodeForUpdate]` in [UI Customization — Per-Action Permissions](/docs/ui-customization#per-action-permissions). ## Add Permissions to Your Application Navigate to your `ApplicationDbContext.cs` file: ``` Backend\{your-app-name}.Infrastructure\{your-app-name}ApplicationDbContext.cs ``` In the `SeedData` method, add your entity permissions to the permissions array. Replace `YourEntityName` with your actual entity name: ```csharp private static void SeedData(ModelBuilder modelBuilder) { Permission[] permissions = [ // ... existing permissions ... // Add your new entity permissions new Permission { Id = 13, Name = "View YourEntityName", Code = "ReadYourEntityName" }, new Permission { Id = 14, Name = "Edit existing YourEntityName", Code = "UpdateYourEntityName" }, new Permission { Id = 15, Name = "Add new YourEntityName", Code = "InsertYourEntityName" }, new Permission { Id = 16, Name = "Delete YourEntityName", Code = "DeleteYourEntityName" }, ]; modelBuilder.Entity().HasData(permissions); // ... rest of seed data ... } ``` **Important**: Make sure to use sequential IDs that don't conflict with existing permissions. After adding the permissions, create and apply a migration: ```bash spiderly add-migration AddYourEntityNamePermissions spiderly update-database ``` ## Assign Permissions to a Role In the application UI: 1. Navigate to *Administration → Roles*. 2. Select the role you want to modify. 3. In the **Permissions** control, add the newly created permissions. This ensures users assigned to this role will have access to the specified entity operations. ## See Also * [UI Customization — Per-Action Permissions](/docs/ui-customization#per-action-permissions) — require an extra permission on top of the default `Insert{Entity}` / `Update{Entity}` permission * [Backend Customization — Authorization Service](/docs/backend-customization#authorization-service) — override authorization logic in code (e.g., row-level checks) * [Deployment — Cookie Configuration](/docs/deployment#cookie-configuration) — `CookieDomain` and `CookieSameSite` settings for cookie-based authentication across subdomains --- # Backend Customization (/docs/backend-customization) ## Overview Spiderly is built on the **Template Method Pattern**, providing multiple extension mechanisms: 1. **Service Inheritance** - Extend generated base classes and override virtual methods 2. **Partial Classes** - Add custom members to generated partial classes (DTOs, Mapper, PermissionCodes) 3. **Interface Replacement** - Implement Spiderly interfaces and register your own services in DI for complete behavior replacement Services follow this inheritance pattern: ``` Spiderly Framework Base Classes (e.g., ServiceBase) ↓ Generated Classes - readonly (e.g., ProductServiceGenerated) ↓ Your Classes (e.g., ProductService) - override virtual methods here ``` Each entity gets its own generated service class. When you run `spiderly init`, the CLI creates skeleton files for your customizations: ``` Backend\YourAppName.Business\ ├── Services\ │ ├── EntityServices\ ← Your entity service overrides (e.g., ProductService.cs) │ ├── AuthorizationService.cs │ └── SecurityService.cs │ ├── DataMappers\ │ └── Mapper.cs ← Partial class │ └── Enums\ └── PermissionCodes.cs ← Partial class Backend\YourAppName.WebAPI\Controllers\ ├── SecurityController.cs └── UserController.cs ``` ## Entity Services Override virtual methods in your entity service class to customize entity operations. Create an `{Entity}Service` class that inherits from `{Entity}ServiceGenerated` and mark it with `[SpiderlyService]` so the source generator picks it up for DI registration. ### Entity Lifecycle Hooks * `OnBefore{Entity}IsMapped()` - Customize before DTO-to-entity conversion * `OnBefore{Entity}Insert()` - Execute logic before inserting a new entity * `OnBefore{Entity}Update()` - Execute logic before updating an existing entity * `OnBefore{Entity}ListDelete()` - Execute logic before deletion (handles both single and bulk cases) * `OnBefore{Entity}Delete()` - Per-id variant; defaults to forwarding to `OnBefore{Entity}ListDelete` with a one-element list. Override only when the per-id flow genuinely diverges from the batch case. ### Save Operation Hooks * `OnBeforeSave{Entity}AndReturnMainUIFormDTO()` - Validate or modify before save * `OnAfterSave{Entity}AndReturnMainUIFormDTO()` - Execute post-save business logic #### Save Flow Execution Order The generated save method runs these steps in order: ``` 1. SaveBody validation (SaveBodyDTOValidationRules — usually empty) 2. OnBeforeSave{Entity}AndReturnMainUIFormDTO(SaveBodyDTO) 3. DTO validation ({Entity}DTOValidationRules — NotEmpty, Length, etc.) 4. OnBefore{Entity}IsMapped(DTO) 5. OnBefore{Entity}Insert(entity, DTO) — or — OnBefore{Entity}Update(entity, DTO) 6. SaveChangesAsync 7. Update M2M + ordered O2M collections 8. OnAfterSave{Entity}AndReturnMainUIFormDTO(SaveBodyDTO, MainUIFormDTO) ``` Step 2 runs **before** step 3 — this means `OnBeforeSave` can set server-generated fields (e.g., `[UIDoNotGenerate]` + `[Required]` properties like hashes or computed values) before DTO validation runs. Step 8 runs **after** everything is persisted (including M2M updates), making it the right place for side effects like sending notifications, indexing, or cache invalidation. ### Get Hooks * `OnAfterGet{Entity}MainUIFormDTO()` - Enrich the DTO with computed fields after it's constructed ```csharp protected override async Task OnAfterGetProductMainUIFormDTO( ProductMainUIFormDTO mainUIFormDTO) { mainUIFormDTO.ProductDTO.ComputedField = await CalculateValue(mainUIFormDTO.ProductDTO.Id); } ``` ### Blob/File Processing Hooks * `OnBefore{Property}BlobFor{Entity}UploadIsAuthorized()` - Custom authorization for file uploads * `OnBefore{Property}BlobFor{Entity}IsUploaded()` - Process files before storage * `ValidateImageFor{Property}Of{Entity}()` - Custom image dimension validation * `OptimizeImageFor{Property}Of{Entity}()` - Custom image optimization ### Query Hooks * `GetAll{Property}QueryFor{Entity}()` - Customize lazy-loaded relationship queries ### Example: Overriding Entity Service Hooks ```csharp // File: Backend\YourAppName.Business\Services\EntityServices\ProductService.cs using Spiderly.Shared.Attributes; namespace YourAppName.Business.Services { [SpiderlyService] public class ProductService : ProductServiceGenerated { private readonly AuthenticationService _authenticationService; private readonly EmailingService _emailingService; public ProductService( EntityServiceDependencies deps, AuthenticationService authenticationService, EmailingService emailingService ) : base(deps) { _authenticationService = authenticationService; _emailingService = emailingService; } protected override async Task OnBeforeProductInsert(Product product, ProductDTO productDTO) { // Custom logic before inserting a product product.CreatedByUserId = _authenticationService.GetCurrentUserId(); } protected override async Task OnAfterSaveProductAndReturnMainUIFormDTO(ProductSaveBodyDTO saveBodyDTO, ProductMainUIFormDTO mainUIFormDTO) { // Send notification after product is saved await _emailingService.SendProductCreatedNotification(mainUIFormDTO.ProductDTO.Id); } } } ``` The `EntityServiceDependencies` object (`_deps`) provides access to shared framework services: `Context`, `ExcelService`, `AuthorizationService`, `FileManager`, `Localizer`, and `ServiceProvider`. Add custom dependencies to your entity service constructor. ### DI Registration All entity service DI registration is **auto-generated** — you never register entity services manually. Call `services.AddEntityServices()` in your startup configuration. The source generator auto-detects user-written `{Entity}Service` classes marked with `[SpiderlyService]` and registers them alongside the generated base classes: * If you create `[SpiderlyService] ProductService : ProductServiceGenerated`, the generator registers both the concrete type and a forwarding registration so that resolving `ProductServiceGenerated` returns your `ProductService`. * If no user override exists for an entity, only the generated service is registered. * Without the `[SpiderlyService]` marker, the override is invisible to the generator and the forwarding registration is not emitted — the generated base wins at runtime. ## Security Service Your `SecurityService` class inherits from `SecurityServiceBase` from the `Spiderly.Security` package. ### Available Hooks * `CreateLoginEmailTemplate(string verificationCode)` - Customize the login verification email * `OnAfterLogin(AuthResultDTO authResultDTO)` - Execute logic after successful login ### Example: Overriding Security Service Hooks ```csharp // File: Backend\YourAppName.Business\Services\SecurityService.cs using Spiderly.Security.Services; namespace YourAppName.Business.Services { public class SecurityService : SecurityServiceBase where TUser : class, IUser, new() { // ... constructor and fields ... public override EmailVerifyUIDTO CreateLoginEmailTemplate(string verificationCode) { return new EmailVerifyUIDTO { Subject = "Your Login Code for MyApp", Body = $"Your verification code is: {verificationCode}" }; } public override async Task OnAfterLogin(AuthResultDTO authResultDTO) { // Log successful login _logger.LogInformation($"User {authResultDTO.UserId} logged in"); await base.OnAfterLogin(authResultDTO); } } } ``` ## Authorization Service Your `AuthorizationService` class inherits from `AuthorizationServiceGenerated`. Mark it with `[SpiderlyService]` and override virtual methods to customize permission checks. ### Example: Custom Authorization Logic ```csharp // File: Backend\YourAppName.Business\Services\AuthorizationService.cs using Spiderly.Shared.Attributes; namespace YourAppName.Business.Services { [SpiderlyService] public class AuthorizationService : AuthorizationServiceGenerated { // ... constructor and fields ... public override async Task AuthorizeProductDeleteAndThrow(long productId) { await _context.WithTransactionAsync(async () => { // Custom logic: Only allow deletion of products created by the current user var product = await _context.DbSet().FindAsync(productId); if (product.CreatedByUserId != _authenticationService.GetCurrentUserId()) { throw new UnauthorizedException("You can only delete products you created."); } await base.AuthorizeProductDeleteAndThrow(productId); }); } } } ``` ## Partial Classes Some generated classes use the partial class pattern, allowing you to add custom members in a separate file. ### Custom Mappings (Mapper) The `Mapper` class is generated as a partial class. You can add your own custom mapping methods here. If you define a method with the same name as a generated one (e.g., `{Entity}DTOToEntityConfig`, `{Entity}ToDTOConfig`, `{Entity}ProjectToConfig`), the generator will skip that method and use yours instead. ```csharp // File: Backend\YourAppName.Business\DataMappers\Mapper.cs using Mapster; using Spiderly.Shared.Attributes; using YourAppName.Business.DTO; using YourAppName.Business.Entities; namespace YourAppName.Business.DataMappers { [SpiderlyDataMapper] public static partial class Mapper { // Custom mapping method for your own use public static ProductSummaryDTO ToSummaryDTO(this Product product) { return new ProductSummaryDTO { Id = product.Id, Name = product.Name, Price = product.Price }; } // Override the generated mapping configuration for Product -> ProductDTO // This method won't be generated because you defined it here public static TypeAdapterConfig ProductToDTOConfig() { TypeAdapterConfig config = new(); config .NewConfig() .Map(dest => dest.FullName, src => $"{src.Name} - {src.Category}") ; return config; } } } ``` ### Custom Permission Codes The `PermissionCodes` class is generated as a partial class. Add custom permission codes in your `PermissionCodes.cs`: ```csharp // File: Backend\YourAppName.Business\Enums\PermissionCodes.cs namespace YourAppName.Business.Enums { public static partial class PermissionCodes { // Add custom permission codes here public static string ExportReports { get; } = "ExportReports"; public static string ManageSettings { get; } = "ManageSettings"; } } ``` ### Custom DTO Properties DTOs are generated as partial classes. Create a matching partial class to add custom properties or methods: ```csharp // File: Backend\YourAppName.Business\DTO\ProductDTO.cs namespace YourAppName.Business.DTO { public partial class ProductDTO { // Add custom computed properties public decimal PriceWithTax => Price * 1.2m; // Add custom methods public string GetDisplayName() => $"{Name} (${Price})"; } } ``` These members are merged into the generated `ProductDTO` automatically — **no `[SpiderlyDTO]` needed** on the extension. Custom **properties** flow through to every generated artifact, including the Angular entity type (`entities.generated.ts`) and validators; **methods** stay server-side (only properties become TypeScript fields). ## Controller Overrides Generated controllers create a base class (e.g., `ProductBaseController`) with virtual methods. Create your own controller that inherits from the base and overrides specific endpoints or add new ones. ### Example: Overriding a Controller Method ```csharp // File: Backend\YourAppName.WebAPI\Controllers\ProductController.cs using YourAppName.Business.Services; using Spiderly.Shared.Attributes; using Spiderly.Shared.Interfaces; using Microsoft.Extensions.Localization; namespace YourAppName.WebAPI.Controllers { [SpiderlyController] [ApiController] [Route("/api/Product/[action]")] public class ProductController : ProductBaseController { public ProductController( IApplicationDbContext context, IServiceProvider serviceProvider, IStringLocalizer localizer ) : base(context, serviceProvider, localizer) { } public override async Task> GetPaginatedProductList(FilterDTO filterDTO) { // Add custom filtering logic filterDTO.AdditionalFilters.Add("IsActive", "true"); return await base.GetPaginatedProductList(filterDTO); } } } ``` ### Grouping entities under one controller with \[Controller] By default each entity's generated base controller is named `{EntityName}BaseController` and lives at its own route. Use `[Controller("CustomName")]` to share one controller across multiple related entities — useful when several entities form a single bounded context (e.g. user/role/permission all served by a security controller). ```csharp [Controller("SecurityController")] public class User { } [Controller("SecurityController")] public class Role { } [Controller("SecurityController")] public class Permission { } ``` ### Suppressing the loading spinner with \[SkipSpinner] The Angular HTTP loading interceptor shows a global full-screen spinner for every request by default. Most reads are already exempt automatically, so you rarely need this attribute: the generator skips the spinner for read-shaped responses (`NamebookDTO`, `CodebookDTO`, `PaginatedResultDTO`, `LazyLoadSelectedIdsResultDTO`) and for any `[HttpGet]` that returns a bare scalar (`int`, `long`, `bool`, `decimal`, `DateTime`, …) — for example an unread-notifications count. Mark a controller method with `[SkipSpinner]` only when the inference can't see your intent: a `[HttpGet]` that returns a full DTO but is **polled / refreshed on a timer**, a background submit, an endpoint with its own inline loading UI, or a fetch feeding a **lightweight popover / inline panel** (e.g. a row-level "show order items" popover on a list page) where blocking the whole screen is disproportionate. Make this a deliberate decision for every endpoint you add — the inference covers the common cases, but only you know how the client consumes the response. The example below is a background `void` GET (which resolves to `any`, not a scalar, so it isn't auto-skipped): ```csharp [HttpGet] [SkipSpinner] public async Task SendNotificationEmail(long notificationId) { await _emailingService.SendNotification(notificationId); } ``` ### Forcing the spinner back on with \[ShowSpinner] `[ShowSpinner]` is the inverse: it forces the overlay ON for a call the inference would auto-skip. Explicit attributes always win over inference. You rarely need it — a user-triggered operation that does real work is usually a `POST`, which keeps the spinner without any attribute. Reach for `[ShowSpinner]` only for a deliberately slow `[HttpGet]` that returns a bare scalar where you still want the blocking overlay. Don't put it on the read-shaped DTO responses (autocomplete, dropdowns, table pagination) — a full-screen blackout on every keystroke or page change is a regression. ```csharp [HttpGet] [ShowSpinner] public async Task RecalculateScore() // expensive, user-triggered { return await _businessService.RecalculateScore(); } ``` ## Customizing What Gets Generated The attributes below fine-tune the generated DTOs and service methods on a per-property basis. ### \[IncludeInDTO] Forces a property to be included in the generated DTO. Use this for enumerable properties, which are excluded from DTOs by default. This attribute affects DTO **shape** only — it does not change mapping behavior. ### \[ExcludeFromDTO] Excludes a property from all generated DTOs. The property still exists on the entity (and in the database), but is invisible to the API surface and Angular client. ```csharp [SpiderlyEntity] public class User : BusinessObject { [DisplayName] public string Name { get; set; } [ExcludeFromDTO] public string InternalAuditNotes { get; set; } // Server-only, never sent to clients } ``` ### \[ExcludeFromExcelExport] Hides a property's column(s) from the generated `Export{Entity}ListToExcel` output, while keeping the property in the DTO and the rest of the API/UI. Use it for internal/technical columns that are noise in a human-readable sheet — raw foreign-key ids, payment-gateway correlation data, sync plumbing, and the like. (Enumerable/collection properties are excluded from the export automatically and don't need the attribute.) Place it on the property that produces the column: a **scalar** property hides the matching column; a **many-to-one navigation** property hides the generated `{Nav}DisplayName` (and the synthesized `{Nav}Id` when there is no explicit foreign-key scalar). ```csharp [SpiderlyEntity] public class Order : BusinessObject { [DisplayName] public string OrderNumber { get; set; } [ExcludeFromExcelExport] public string PaymentGatewayRrn { get; set; } // Kept in the API, hidden from the sheet [ExcludeFromExcelExport] // Hides the OrderStatusId column; OrderStatusDisplayName stays public byte OrderStatusId { get; set; } [WithMany(nameof(OrderStatus.Orders))] public virtual OrderStatus OrderStatus { get; set; } } ``` Unlike `[ExcludeFromDTO]`, the property is **not** removed from the DTO — it remains available to the admin grid, details views, and API consumers; only the spreadsheet column is dropped. ### \[ProjectToDTO] Adds a custom Mapster projection step to the generated `{Entity}DTO`, executed when fetching a single entity. The attribute argument is the raw `.Map(...)` call appended to the projection config — pass the exact source/destination expression. `[ProjectToDTO]` only fills a **value** — the destination property must already exist on the DTO (add it as a [custom DTO property](#custom-dto-properties)). It does not create the property, so mapping to a `dest` with no matching DTO property maps nothing and the field won't appear in the generated Angular client. ```csharp [ProjectToDTO(".Map(dest => dest.TransactionPrice, src => src.Transaction.Price)")] public class Achievement : BusinessObject { [DisplayName] public string Name { get; set; } [WithMany(nameof(Transaction.Achievements))] public virtual Transaction Transaction { get; set; } } ``` For more involved mapping logic, prefer overriding `{Entity}ToDTOConfig()` in your partial `Mapper` class — see [Partial Classes → Custom Mappings (Mapper)](#custom-mappings-mapper). ### \[ExcludeServiceMethodsFromGeneration] Prevents the generator from emitting the standard CRUD service methods for the decorated property. Use this when you want full control over how the property is read or saved — e.g., a navigation that's loaded through a custom join, or a blob that goes through a non-standard upload path. The property still exists on the entity; only its generated service methods are skipped. ## Replacing Services via Interfaces For complete behavior replacement, Spiderly provides interfaces that you can implement and register in the DI container. This is useful when you need to completely change how a service works rather than just extending it. ### Available Interfaces (Spiderly.Security) | Interface | Description | | ------------------ | ------------------------------------------------------ | | `ITokenStorage` | Token storage operations (in-memory, Redis, or custom) | | `IJwtAuthManager` | JWT token generation, validation, and refresh | ### Example: Custom Token Storage ```csharp // Implement your own token storage public class DatabaseTokenStorage : ITokenStorage where T : class, IExpirableToken { private readonly IApplicationDbContext _context; public DatabaseTokenStorage(IApplicationDbContext context) { _context = context; } public async Task AddOrUpdateAsync(string key, T token) { // Store token in database } public async Task TryGetValueAsync(string key) { // Retrieve token from database } public async Task TryRemoveAsync(string key) { // Remove token from database } public async Task>> GetByIndexAsync(string indexName, string indexValue) { // Query tokens by a secondary index (e.g., "UserId" or "Email") } // ... implement other methods } // Register in Program.cs builder.Services.AddScoped(typeof(ITokenStorage<>), typeof(DatabaseTokenStorage<>)); ``` ### Secondary Index Constants When the security service calls `GetByIndexAsync`, it uses these predefined index names: | Constant | Value | Purpose | | -------------------------------------- | ---------- | -------------------------------------------------------------------- | | `RefreshTokenDTO.UserIdIndex` | `"UserId"` | Find all refresh tokens for a user (used during logout-all-sessions) | | `LoginVerificationTokenDTO.EmailIndex` | `"Email"` | Find pending login verifications by email | Your custom `ITokenStorage` implementation must support querying by these indexes for the security service to work correctly. ## Transaction Management Every generated save/delete method wraps its logic in `_context.WithTransactionAsync(...)`. All hooks called within that flow (e.g., `OnBeforeInsert`, `OnAfterSave`) automatically run inside the same transaction. Nested `WithTransactionAsync` calls reuse the existing transaction — you do **not** need to start your own transaction in hooks. Generated save and delete operations also **flush the change tracker for you** before the transaction commits — so a hook (including `OnBefore{Entity}Delete`) can stage a tracked write such as `IOutbox.Enqueue` and have it persist atomically with the operation. This holds for deletes even though `[CascadeDelete]` removes dependents through untracked bulk `ExecuteDeleteAsync`. A manual `SaveChangesAsync` is only needed in **custom** (non-hook) `WithTransactionAsync` blocks (see below). If you need a transaction in **custom** (non-hook) methods: ```csharp await _context.WithTransactionAsync(async () => { // All DB operations here are transactional. // If any operation throws, everything is rolled back. }); ``` `WithTransactionAsync` throws `InvalidOperationException` if the change tracker still has pending writes when the transaction is about to commit. `transaction.CommitAsync()` only commits the DB transaction — it does **not** flush the tracker — so a missed `SaveChangesAsync` after `Add` would otherwise silently drop the writes (a classic outbox-pattern footgun). In a **custom** (non-hook) block, either call `SaveChangesAsync` before returning from the action, or detach entries you don't intend to persist — generated save/delete operations already flush for you, so hooks don't need it. ## Exception Types | Type | HTTP Status | When to Use | | ------------------------------ | ----------- | ------------------------------------------------------- | | `BusinessException(message)` | 400 | Validation the user can trigger through normal UI usage | | `SecurityViolationException()` | 403 | Impossible conditions, tampering, unauthorized access | ```csharp throw new BusinessException("Sale price must be less than regular price."); throw new SecurityViolationException(); // Logs detailed message server-side, returns generic error to client ``` ## Common Pitfalls ### PostgreSQL MARS (Multiple Active Result Sets) EF Core on PostgreSQL does **not** support multiple active result sets. You'll get a `NpgsqlOperationInProgressException` if you enumerate two queries concurrently on the same connection. **Fix 1 — Materialize with `.Select()` before starting another query:** ```csharp // BAD — lazy enumeration holds the connection open var dict = await _context.DbSet() .ToDictionaryAsync(x => x.Id, x => x.Name); // GOOD — materialize first var dict = await _context.DbSet() .Select(x => new { x.Id, x.Name }) .ToDictionaryAsync(x => x.Id, x => x.Name); ``` **Fix 2 — Use `.Include()` instead of lazy loading navigation properties:** ```csharp // BAD — accessing Product.Category triggers lazy load while connection is busy var products = await _context.DbSet().ToListAsync(); var names = products.Select(p => p.Category.Name); // MARS error // GOOD — eager load var products = await _context.DbSet() .Include(p => p.Category) .ToListAsync(); ``` --- # Build Diagnostics (/docs/build-diagnostics) ## Overview When the build emits **hundreds of CS0246 errors about missing `{Entity}DTO` types**, look for the **SPIDERLY-prefixed diagnostic first** — that is the real cause. A single contract violation aborts `MapperGenerator`, which deletes every entity's generated DTO and produces a flood of downstream CS0246 errors that look unrelated. When an entity class violates a contract the source generators rely on — a missing base class, a malformed `[ForeignKey]`, an ambiguous relationship, a broken `[DisplayName]` path — Spiderly emits a **located Roslyn diagnostic** at build time rather than crashing with `CS8785 "generator failed to generate source"`. Each diagnostic: * Has a stable `SPIDERLY###` ID (never reused, never renumbered). * Renders as a red squiggle in Visual Studio / Rider / VS Code on the offending class or property. * Appears in the build output with a clickable file and line. * Is parseable by CI logs and AI agents for automated fix suggestions. Diagnostics are reported by the generator's `SourceProductionContext`, so they behave like any other compiler warning or error — you can suppress individual codes via `` in the `.csproj`, treat warnings as errors, or filter them in CI output. Severity is **Error** for everything except `SPIDERLY013` (Warning). ## Diagnostic Reference | ID | Severity | Title | | --------------------------- | -------- | ------------------------------------------------------------------------- | | [SPIDERLY001](#spiderly001) | Error | Controller type is not a discovered entity or DTO | | [SPIDERLY002](#spiderly002) | Error | Many-to-many entity requires exactly two `[M2MWithMany]` properties | | [SPIDERLY003](#spiderly003) | Error | `[ForeignKey]` references a property that does not exist | | [SPIDERLY004](#spiderly004) | Error | Foreign key type does not match target primary key | | [SPIDERLY005](#spiderly005) | Error | Foreign key is ambiguous — multiple convention matches | | [SPIDERLY006](#spiderly006) | Error | Foreign key nullability does not match navigation property | | [SPIDERLY007](#spiderly007) | Error | `[DisplayName]` path references a property that does not exist | | [SPIDERLY008](#spiderly008) | Error | `[DisplayName]` path segment is not a many-to-one navigation | | [SPIDERLY009](#spiderly009) | Error | `[DisplayName]` navigation target entity not found | | [SPIDERLY010](#spiderly010) | Error | Entity missing required `BusinessObject` / `ReadonlyObject` base | | [SPIDERLY011](#spiderly011) | Error | Controller property type is not resolvable for client generation | | [SPIDERLY012](#spiderly012) | Error | One-to-many back-reference missing `[M2MWithMany]` | | [SPIDERLY013](#spiderly013) | Warning | `Backend` folder not found under calling project | | [SPIDERLY014](#spiderly014) | Error | Blob property missing `[AcceptedFileTypes]` attribute | | [SPIDERLY015](#spiderly015) | Error | Many-to-one navigation missing `[WithMany]` attribute | | [SPIDERLY016](#spiderly016) | Error | `[WithMany]` target collection does not exist on the related entity | | [SPIDERLY017](#spiderly017) | Error | `[WithMany]` target collection has the wrong element type | | [SPIDERLY018](#spiderly018) | Error | Entity primary key type must be `int`, `long`, or `byte` | | [SPIDERLY019](#spiderly019) | Error | `[WithOne]` declared on both sides of a one-to-one | | [SPIDERLY020](#spiderly020) | Error | `[WithOne]` inverse navigation does not exist on the principal | | [SPIDERLY021](#spiderly021) | Error | `[Required]` on the principal navigation of a one-to-one is unenforceable | | [SPIDERLY022](#spiderly022) | Error | Self-referential one-to-one is not supported | ## SPIDERLY001 **Controller type is not a discovered entity or DTO.** A controller action takes or returns a class that isn't marked with `[SpiderlyDTO]` and doesn't inherit from `BusinessObject` / `ReadonlyObject`. The generated Angular client would reference an undefined TypeScript type. **Fix:** add `[SpiderlyDTO]` to the class, or make it inherit from a Spiderly entity base class. ## SPIDERLY002 **Many-to-many entity requires exactly two `[M2MWithMany]` properties.** An entity declared as a many-to-many join has zero, one, or more than two `[M2MWithMany]` attributes. A join needs exactly one on each side. **Fix:** annotate each of the two navigation properties on the join entity with `[M2MWithMany]` pointing at the opposite side. See [Relationships](/docs/relationships) for a worked example. ## SPIDERLY003 **`[ForeignKey]` references a property that does not exist.** `[ForeignKey(nameof(X))]` on a navigation or scalar points at a member name that isn't declared on the entity. **Fix:** correct the `nameof(...)` argument, or add the missing scalar/navigation property. ## SPIDERLY004 **Foreign key type does not match target primary key.** The FK scalar's type (e.g. `int`) doesn't match the type of the target entity's `Id` (e.g. `long`). **Fix:** change the FK scalar to the target entity's `Id` type. The primary key type comes from the entity's `BusinessObject` / `ReadonlyObject` base. ## SPIDERLY005 **Foreign key is ambiguous — multiple convention matches.** Multiple scalar properties match the `{NavigationName}Id` convention for the same navigation, so the generator cannot pick a unique FK. **Fix:** use `[ForeignKey(nameof(ExplicitFkScalar))]` on the navigation to disambiguate. ## SPIDERLY006 **Foreign key nullability does not match navigation property.** Either a `[Required]` navigation is paired with a nullable FK scalar, or an optional navigation is paired with a non-nullable FK scalar. **Fix:** align them. `[Required] Category Category` must pair with non-nullable `long CategoryId`; optional `Category Category` must pair with nullable `long? CategoryId`. ## SPIDERLY007 **`[DisplayName]` path references a property that does not exist.** A segment of `[DisplayName("A.B.C")]` names a property that isn't declared on the corresponding entity. **Fix:** correct the path, or add the missing property. ## SPIDERLY008 **`[DisplayName]` path segment is not a many-to-one navigation.** An intermediate segment of a `[DisplayName]` path must be a many-to-one navigation so the generator can follow the chain. **Fix:** use only M2O navigations for intermediate segments. Scalars and collection properties are only valid as the final segment. ## SPIDERLY009 **`[DisplayName]` navigation target entity not found.** The entity type referenced by a `[DisplayName]` navigation segment is not discovered in the current project or any referenced project with a `.Entities` namespace. **Fix:** ensure the target entity lives in a `.Entities` namespace and its project is referenced. ## SPIDERLY010 **Entity missing required `BusinessObject` / `ReadonlyObject` base.** Every Spiderly entity must inherit — directly or transitively — from `BusinessObject` or `ReadonlyObject`. The generators cannot resolve the entity's `Id` type otherwise. **Fix:** add the appropriate base class. See [Add New Entity](/docs/add-new-entity). ## SPIDERLY011 **Controller property type is not resolvable for client generation.** A generated controller method references a navigation property whose target entity cannot be discovered, so the Angular autocomplete / dropdown method cannot be generated. **Fix:** ensure the target entity exists in a `.Entities` namespace and its project is referenced. ## SPIDERLY012 **One-to-many back-reference missing `[M2MWithMany]`.** An entity declares a one-to-many collection into a complex many-to-many join, but the join entity has no property annotated with a matching `[M2MWithMany(nameof(...))]` pointing back. **Fix:** add `[M2MWithMany(nameof(ThisCollection))]` to the corresponding navigation on the join entity. ## SPIDERLY013 **`Backend` folder not found under calling project.** The file-emitting generators (Angular entities/controllers/validators/details/enums) walk up from the calling project to find a `Backend` folder as an anchor for writing output. They emit this warning and skip file emission if the folder can't be found. **Fix:** ensure your backend project lives under a folder named `Backend` (or pass a custom name if your generator consumer supports it). ## SPIDERLY014 **Blob property missing `[AcceptedFileTypes]` attribute.** Every blob property (any property decorated with a `StorageAttribute` subclass like `[S3PublicStorage]`, `[S3PrivateStorage]`, `[DiskStorage]`, or a custom one) must declare `[AcceptedFileTypes("mime/type", ...)]` with at least one MIME-typed value. There is no implicit default — the old images-only fallback has been removed so every blob property's upload whitelist is an explicit, auditable decision at the source level. An attribute that contains only extension values (e.g. `[AcceptedFileTypes(".pdf")]`) also trips this because the generator filters to MIME-typed entries (values containing `/`) when emitting the server-side validator. **Fix:** add `[AcceptedFileTypes(...)]` with one or more MIME types. Examples: ```csharp [S3PublicStorage] [AcceptedFileTypes("image/jpeg", "image/png", "image/webp", "image/avif")] public string ImageUrl { get; set; } [S3PublicStorage] [AcceptedFileTypes("application/pdf", ".pdf")] public string PdfFile { get; set; } ``` ## SPIDERLY015 **Many-to-one navigation missing `[WithMany]` attribute.** A virtual navigation property on a `[SpiderlyEntity]` class targets another entity (a many-to-one relationship), but is missing the required `[WithMany(nameof(Target.Collection))]` attribute. Without it, `ApplicationDbContext` cannot configure the `HasOne(...).WithMany(...)` relationship and the parent collection it points at. **Fix:** add `[WithMany(nameof(Target.Collection))]` to the navigation, and declare `public virtual List Collection { get; } = new();` on the target entity if it doesn't exist yet. If the relationship really should be unidirectional, drop the virtual navigation property entirely and configure the relationship manually via a partial `OnModelCreating` — see [Relationships](/docs/relationships) for the worked pattern. ## SPIDERLY016 **`[WithMany]` target collection does not exist on the related entity.** `[WithMany("OrderItems")]` (or `[WithMany(nameof(Order.OrderItems))]`) is declared on a many-to-one navigation, but the named collection property doesn't exist on the target entity. EF Core would die mid-`OnModelCreating` with a generic "no such navigation" error; this diagnostic surfaces it at build time with the offending file and line. **Fix:** add the named collection (`public virtual List OrderItems { get; } = new();`) on the target entity, or correct the `[WithMany]` argument to match an existing collection name. ## SPIDERLY017 **`[WithMany]` target collection has the wrong element type.** `[WithMany("OrderItems")]` resolves to an existing property on the target entity, but the property's collection element type doesn't match the source entity. For example: `[WithMany(nameof(User.OrderItems))]` on `OrderItem.User`, but `User.OrderItems` is declared as `List` rather than `List`. Typical cause: a copy-paste mistake or a rename that updated one side but not the other. **Fix:** align the back-collection's element type with the source entity (`List`), or change the `[WithMany]` target to a collection of the correct element type. ## SPIDERLY018 **Entity primary key type must be `int`, `long`, or `byte`.** An entity inherits `BusinessObject` or `ReadonlyObject` where `T` is something other than `int`, `long`, or `byte` (e.g. `Guid`, `decimal`, `short`, `DateTime`). The C# generic constraint on the base class is `where T : struct`, which technically admits any value type — but the rest of Spiderly (audit/versioning in `ApplicationDbContext`, `FilterDTO.AdditionalFilterIdInt/Long/Byte`, Angular type mapping) only handles the three documented types. Allowing anything else would silently produce broken generated code or, worse, dropped audit rows at runtime. **Fix:** change `T` to `int`, `long`, or `byte`. If you need a public, non-enumerable identifier (UUID-style URLs for security/anti-enumeration), keep the numeric `Id` as the primary key and add a separate `Guid PublicId` property alongside it — `Guid` is a fully supported non-PK base data type. ```csharp // ✗ Rejected by SPIDERLY018 public class Order : BusinessObject { } // ✓ Recommended pattern for public-facing identifiers public class Order : BusinessObject { public Guid PublicId { get; set; } } ``` ## SPIDERLY019 **`[WithOne]` declared on both sides of a one-to-one.** A one-to-one is declared by placing `[WithOne]` on the **dependent** (foreign-key-holding) side only — that single placement is what designates which side owns the FK. If both navigations carry `[WithOne]`, the dependent side is ambiguous. **Fix:** keep `[WithOne]` on the dependent and make the principal a plain single-valued navigation with no attribute. ## SPIDERLY020 **`[WithOne]` inverse navigation does not exist on the principal.** `[WithOne(nameof(Principal.InverseNav))]` names the back-navigation on the principal entity, but the named property doesn't exist (or isn't a single-valued navigation of the dependent's type). **Fix:** add `public virtual {Dependent} {InverseNav} { get; set; }` on the principal, or use the parameterless `[WithOne]` for a unidirectional one-to-one. ## SPIDERLY021 **`[Required]` on the principal navigation of a one-to-one is unenforceable.** A unique foreign-key index guarantees *at most one* dependent per principal — never *at least one*. "Every principal has a dependent" cannot be enforced by the schema, so `[Required]` on the principal-side navigation is meaningless. **Fix:** configure requiredness on the dependent (`[WithOne]`) side — a non-nullable FK means the dependent must have a principal. If you genuinely need "every principal has a dependent", create the dependent in the principal's `OnAfter{Entity}Insert` hook. ## SPIDERLY022 **Self-referential one-to-one is not supported.** A `[WithOne]` navigation whose target type is the declaring entity itself is rejected in this version. **Fix:** model it differently (e.g. a self-referential many-to-one), or split the data into two entities. ## Suppressing Diagnostics To suppress a specific diagnostic project-wide, add it to `` in the consuming project's `.csproj`: ```xml $(NoWarn);SPIDERLY013 ``` Suppress at a single site with a `#pragma`: ```csharp #pragma warning disable SPIDERLY013 // ...code that triggers the diagnostic... #pragma warning restore SPIDERLY013 ``` Use sparingly — each diagnostic points at a real contract violation that will produce broken generated code. --- # Claude Code Plugin (/docs/claude-code) Spiderly's Claude Code plugin gives Claude deep knowledge of the framework so it produces correct, idiomatic code without you explaining conventions from scratch every time. Instead of pasting docs into your prompt, the plugin automatically loads the right context — entity patterns, hook signatures, migration commands, Angular form system — based on what you're working on. ## Installation [Claude Code](https://docs.anthropic.com/en/docs/claude-code/overview) must be installed before adding the plugin. Install the plugin from GitHub by running these two commands inside Claude Code: ```bash /plugin marketplace add filiptrivan/spiderly /plugin install spiderly@spiderly ``` ## How It Works The plugin contains **skills** and **commands**: * **Skills** are context packs that Claude loads automatically when it detects a relevant task. You don't need to invoke them — just describe what you want to do and Claude picks the right skill. * **Commands** are user-invoked workflows you trigger with a slash command. They guide Claude through a multi-step process. ``` Your prompt → Claude analyzes intent → Matches skill? → Loads context automatically → Slash command? → Runs guided workflow ``` ## Available Skills | Skill | Activates When You Ask About | What Claude Gets | | ----------------------- | ----------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- | | `entity-design` | Creating/modifying entities, attributes, relationships | Base classes, property rules, relationship patterns, attribute reference, UI control mapping | | `backend-hooks` | Customizing CRUD behavior, lifecycle hooks | Save/delete/get hook signatures, execution order, exception types, transaction model | | `filtering-patterns` | Server-side filtering, pagination, custom queries | FilterDTO structure, paginated list overrides, programmatic filter API | | `ef-migrations` | Database migrations, schema changes | CLI commands, naming conventions, which changes need migrations | | `angular-customization` | Angular forms, data tables, layout, translations | Form system, data table config, service overrides, validation, UI controls | | `custom-endpoints` | Non-CRUD API endpoints, custom controllers | Controller patterns, database access, transactions, DI registration | | `file-storage` | File uploads, S3, Cloudinary, Azure Blob | Provider selection, entity attributes, upload hooks, cleanup methods | | `authorization` | Permissions, roles, authentication, Google OAuth | Permission codes, seeding, auth flow, frontend guards | | `mapper-customization` | DTO mapping, Mapster configuration | Generated mapper methods, `[ProjectToDTO]`, partial class overrides | | `ai-agentic-design` | Working on Spiderly CLI, generators, or framework internals | Non-interactive design principles, exit code rules, prerequisite validation patterns, Docker-first strategy | ## Commands ### `/spiderly:add-entity` A guided workflow that scaffolds a new entity end-to-end. Claude walks you through each step: 1. Gather requirements (entity name, properties, relationships) 2. Run CLI scaffold command 3. Write the entity class with attributes 4. Build backend 5. Create and apply migration 6. Customize Angular components (list and details pages) 7. Add translation keys 8. Build frontend 9. Add backend hooks if needed This is the recommended way to add new entities — it ensures nothing is missed and follows Spiderly conventions at every step. ## Example Prompts These prompts naturally trigger the right skills without any special syntax: **Entity creation:** > Create a `Warehouse` entity with Name (required, max 200), Address, and a many-to-one relationship to City. **Backend customization:** > After saving an Order, send an email notification to the customer. **Migrations:** > I added a `Phone` property to the Customer entity. Create and apply the migration. **Angular:** > Customize the Product details form — make the Description field use a rich text editor and move Price into a separate panel. **Authorization:** > Set up role-based permissions so only users with the `ManageOrders` permission can access the Order pages. **Custom endpoints:** > Add a `POST /api/storefront/contact` endpoint that accepts a name, email, and message, then sends an email to the site owner. ## Plugin Structure For contributors or advanced users, here is the plugin file tree: ``` .claude-plugin/ plugin.json marketplace.json skills/ entity-design/SKILL.md backend-hooks/SKILL.md filtering-patterns/SKILL.md ef-migrations/SKILL.md angular-customization/SKILL.md custom-endpoints/SKILL.md file-storage/SKILL.md authorization/SKILL.md mapper-customization/SKILL.md ai-agentic-design/SKILL.md commands/ add-entity.md ``` You don't need to read or reference these files directly. Claude loads them automatically based on what you're working on. --- # CLI Reference (/docs/cli-reference) ## Installation ```bash dotnet tool install -g Spiderly.CLI ``` To update to the latest version: ```bash dotnet tool update -g Spiderly.CLI ``` ## Usage ```bash spiderly [command] [options] ``` Run `spiderly help` to see all available commands. ## Commands ### init Initialize a new Spiderly project with a .NET backend and an Angular frontend. ```bash spiderly init [--name ] [--db ] [--db-connection-string ] [--pm ] ``` | Option | Description | | ------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `--name` | App name in PascalCase, without spaces (e.g., `MyApp`). If omitted, the CLI will prompt you. Required in non-interactive mode. | | `--db` | Database provider: `postgresql`, `sqlserver`, or `skip`. If omitted, the CLI will prompt you. Required in non-interactive mode. See [Database Providers](/docs/database-providers) for details on what each option generates. | | `--db-connection-string` | Full EF Core connection string. Bypasses auto-discovery and Docker. Example: `"Host=localhost;Port=5432;Database=myapp;Username=postgres;Password=secret"` | | `--pm` | Package manager: `npm` (default), `pnpm`, `yarn`, or `bun`. If omitted, the CLI will prompt you in interactive mode or default to npm. | In non-interactive mode (e.g., AI agents, CI), if no running database is found and Docker is available, the CLI will automatically start a database container. **Examples:** ```bash spiderly init ``` ```bash spiderly init --name MyApp --db postgresql ``` ```bash spiderly init --name MyApp --db postgresql --pm pnpm ``` ```bash spiderly init --name MyApp --db postgresql --db-connection-string "Host=localhost;Port=5432;Database=myapp;Username=postgres;Password=secret" ``` ### add-new-entity Create a new entity and generate all necessary files: entity class, Angular pages (list and details), routes, and menu item. ```bash spiderly add-new-entity [--name ] [--data-view] ``` | Option | Description | | ------------- | ------------------------------------------------------------------------------------------------------------------- | | `--name` | Entity name in PascalCase (e.g., `Product`). If omitted, the CLI will prompt you. Required in non-interactive mode. | | `--data-view` | Generate a DataView template instead of the default Table template for the list page. | **Examples:** ```bash spiderly add-new-entity ``` ```bash spiderly add-new-entity --name Product ``` ```bash spiderly add-new-entity --name Product --data-view ``` ### add-migration Create a new EF Core migration. ```bash spiderly add-migration ``` | Argument | Description | | -------- | ------------------------------------------ | | `` | The name for the new migration (required). | **Example:** ```bash spiderly add-migration AddProductTable ``` ### update-database Apply all pending EF Core migrations to the database. ```bash spiderly update-database ``` ### remove-migration Remove the last EF Core migration. ```bash spiderly remove-migration ``` ### list-migrations List all available EF Core migrations. ```bash spiderly list-migrations ``` ### help Show all available commands and their options. ```bash spiderly help ``` ## What Needs a Migration? Not every entity change requires a database migration. Use this table to decide: | Change | Migration needed? | | ------------------------------------------------------------------------------------------------------------- | ------------------------------------- | | Add/remove entity class | Yes | | Add/remove/rename property | Yes | | Change property type | Yes | | Change `[StringLength]` | Yes | | Change `[Required]` | Yes | | Add/remove `[CascadeDelete]`/`[SetNull]` | Yes | | Change `[DisplayName]` | No (UI only) | | Change `[UIControlType]` | No (UI only) | | Change `[UIControlWidth]` | No (UI only) | | Add/remove `[UIDoNotGenerate]` | No (UI only) | | Change `[UIOrderedOneToMany]` | No (UI only) | | Add/remove a `StorageAttribute` subclass (`[DiskStorage]`, `[S3PublicStorage]`, `[S3PrivateStorage]`, custom) | No (mapped to existing string column) | | Change `[AcceptedFileTypes]`/`[MaxFileSize]` | No (validation only) | | Add/remove `[DoNotAuthorize]` | No (authorization only) | | Change `[Controller]` | No (routing only) | --- # Database Providers (/docs/database-providers) ## Overview Spiderly supports **PostgreSQL** and **SQL Server** via Entity Framework Core. The provider is selected during [`spiderly init`](/docs/cli-reference#init) (or via the `--db` flag). Your application code — entities, services, controllers, DTOs — is **identical** regardless of which provider you choose. The only differences are a handful of NuGet packages, one Hangfire configuration line, and the recommended VS Code extension. ## Comparison Table | Feature | PostgreSQL | SQL Server | | ------------------- | --------------------------------------- | -------------------------------------------- | | EF Core package | `Npgsql.EntityFrameworkCore.PostgreSQL` | `Microsoft.EntityFrameworkCore.SqlServer` | | Hangfire package | `Hangfire.PostgreSql` | `Hangfire.SqlServer` | | VS Code extension | `ckolkman.vscode-postgres` | `ms-mssql.mssql` | | Default auth method | Password (`postgres` user) | Windows Authentication or Docker (`sa` user) | ## What Gets Generated Per Provider ### NuGet Packages In your `.WebAPI.csproj`, the two provider-specific packages are: **PostgreSQL:** ```xml ``` **SQL Server:** ```xml ``` All other packages in the `.csproj` are shared between providers. ### Startup Configuration In `Startup.cs`, the Hangfire storage and EF Core provider are configured based on your choice: **PostgreSQL:** ```csharp services.AddHangfire(config => config.UseHangfirePostgreSqlStorage( Configuration.GetValue($"{Spiderly.Shared.Settings.ConfigurationSection}:ConnectionString")) ); services.SpiderlyConfigureServices( dbProvider: DbProviderCodes.PostgreSQL); ``` **SQL Server:** ```csharp services.AddHangfire(config => config.UseSqlServerStorage( Configuration.GetValue($"{Spiderly.Shared.Settings.ConfigurationSection}:ConnectionString")) ); services.SpiderlyConfigureServices( dbProvider: DbProviderCodes.SQLServer); ``` ### VS Code Extensions The generated `.vscode/extensions.json` recommends the matching database extension: **PostgreSQL:** ```json { "recommendations": [ "angular.ng-template", "formulahendry.auto-rename-tag", "ckolkman.vscode-postgres", "esbenp.prettier-vscode" ] } ``` **SQL Server:** ```json { "recommendations": [ "angular.ng-template", "formulahendry.auto-rename-tag", "ms-mssql.mssql", "esbenp.prettier-vscode" ] } ``` ## Connection Strings Connection strings are stored in `Backend/YourApp.WebAPI/appsettings.Development.local.json` (gitignored) and configured automatically during `spiderly init`. **PostgreSQL format:** ``` Host=localhost;Port=5432;Database=YourApp;Username=postgres;Password=yourpassword; ``` **SQL Server — Windows Authentication:** ``` Data Source=localhost;Initial Catalog=YourApp;Integrated Security=True;Encrypt=False;MultipleActiveResultSets=True; ``` **SQL Server — Docker:** ``` Data Source=localhost,14330;Initial Catalog=YourApp;User ID=sa;Password=SqlServer123;Encrypt=False;MultipleActiveResultSets=True;TrustServerCertificate=True; ``` ## Automatic Installation `spiderly init` detects whether the chosen database is already running. If it isn't: 1. **Docker available** — the CLI offers to start the database via `docker run` with a named volume for persistent data. This works the same way on Windows, macOS, and Linux. 2. **Docker not available** — the CLI displays a link to install the database manually and a link to install Docker. ## Switching Providers After Init If you need to switch providers after project creation, follow these steps: 1. **Swap NuGet packages** in `Backend/YourApp.WebAPI/YourApp.WebAPI.csproj` — replace the EF Core and Hangfire packages (see [comparison table](#comparison-table) above). 2. **Update Hangfire storage** in `Startup.cs` — change `UseHangfirePostgreSqlStorage` to `UseSqlServerStorage` (or vice versa). 3. **Change `DbProviderCodes`** in the `SpiderlyConfigureServices` call in `Startup.cs`. 4. **Update the connection string** in `Backend/YourApp.WebAPI/appsettings.Development.local.json`. 5. **Delete existing migrations** from `Backend/YourApp.Infrastructure/Migrations/` and create a new initial migration with `spiderly add-migration Init`. Switching providers requires a fresh database — your existing data will not be migrated automatically. ## Which Provider Should I Choose? **PostgreSQL (recommended):** Free, open-source, and runs natively on all operating systems. This is the default recommendation for most projects. **SQL Server:** Choose if your organization already uses SQL Server, you need Windows Authentication, or you rely on SQL Server Management Studio (SSMS). On macOS and Linux, SQL Server runs via Docker. Both providers are fully supported — the generated application code is identical. --- # Deployment (/docs/deployment) ## Overview This page covers deploying the Spiderly .NET backend as a Docker container to any hosting platform. The instructions are platform-generic — they work on Cloud Run, ECS, Azure App Service, Railway, and similar container-based services. ## Dockerfile Create a `Dockerfile` in your `Backend/` directory: ```dockerfile # Build stage FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build WORKDIR /src COPY ["YourApp.WebAPI/YourApp.WebAPI.csproj", "YourApp.WebAPI/"] COPY ["YourApp.Business/YourApp.Business.csproj", "YourApp.Business/"] COPY ["YourApp.Infrastructure/YourApp.Infrastructure.csproj", "YourApp.Infrastructure/"] COPY ["YourApp.Shared/YourApp.Shared.csproj", "YourApp.Shared/"] RUN dotnet restore "YourApp.WebAPI/YourApp.WebAPI.csproj" COPY . . RUN dotnet build "YourApp.WebAPI/YourApp.WebAPI.csproj" -c Release -o /app/build # Publish stage FROM build AS publish RUN dotnet publish "YourApp.WebAPI/YourApp.WebAPI.csproj" -c Release -o /app/publish /p:UseAppHost=false # Runtime stage FROM mcr.microsoft.com/dotnet/aspnet:9.0 WORKDIR /app COPY --from=publish /app/publish . EXPOSE 8080 ENTRYPOINT ["dotnet", "YourApp.WebAPI.dll"] ``` The `.csproj` files are copied separately before `COPY . .` so that Docker can cache the NuGet restore layer. As long as the `.csproj` files don't change, subsequent builds skip the `dotnet restore` step entirely. ## Database Migrations EF Core migrations must be applied before (or during) deployment. There are two approaches. ### Run Migrations Before Deployment (Recommended) Run `dotnet ef database update` from your CI pipeline, pointing at the production database: ```bash dotnet ef database update \ --project YourApp.Infrastructure \ --startup-project YourApp.WebAPI \ --connection "Host=...;Database=...;Username=...;Password=..." ``` This keeps migrations explicit and avoids race conditions. If your database is inside a private network (VPC), the CI runner won't have direct access. Use a database proxy (e.g., Cloud SQL Auth Proxy) or a bastion host to tunnel the connection. ### Run Migrations at Startup Alternatively, apply migrations when the app starts by adding this to `Startup.Configure`: ```csharp public void Configure(IApplicationBuilder app, IWebHostEnvironment env) { IApplicationDbContext dbContext = app.ApplicationServices .CreateScope().ServiceProvider .GetRequiredService(); ((DbContext)dbContext).Database.Migrate(); // ... rest of Configure } ``` If your hosting platform runs multiple instances, they will all try to migrate simultaneously on deploy. This can cause race conditions and failed deployments. Use this approach only with single-instance deployments or if your platform supports pre-deploy hooks. ## Health Checks Generated apps ship with a `/health` endpoint and forwarded-headers middleware out of the box — no opt-in required. **What's wired:** * `services.AddHealthChecks().AddDbContextCheck()` — `GET /health` returns `200 OK` when the app is up and the DB is reachable, and `503 Service Unavailable` when the `DbContext` cannot connect. Point your uptime probe (Cloud Run, ECS, Kubernetes, Uptime Kuma, etc.) at this path. * `spiderly.AddForwardedHeaders()` + `app.SpiderlyConfigureForwardedHeaders()` — so the app reports the correct scheme and host when running behind a reverse proxy or CDN (Caddy, nginx, Cloudflare). **Production tuning:** `appsettings.Production.json` ships with `ForwardLimit: 2`, which is correct when traffic flows through one CDN hop and one reverse-proxy hop. If you have a different topology (direct-to-origin, multiple CDNs), tune `ForwardLimit` and `KnownProxies` / `KnownNetworks` to match. Setting it too low truncates `X-Forwarded-For` chains; setting it too high lets clients spoof their IP. When using a Docker health check that calls `/health`, set `start_period` to at least `60s`. EF Core's first connection takes 20–30 seconds to warm up, and a too-tight start period will mark the container unhealthy before it ever goes green. ## Local Dev Secrets For local development, real secrets (database connection, JWT key, API tokens) live in `Backend/YourApp.WebAPI/appsettings.Development.local.json`. This file is **gitignored** and written automatically by `spiderly init`. Load order for the running app: 1. `appsettings.json` — committed, safe defaults. 2. `appsettings.Development.json` — committed, dev-only overrides (e.g. local URLs). 3. `appsettings.Development.local.json` — **gitignored**, real dev secrets. 4. Environment variables — used in production. A committed `appsettings.Development.local.example.json` ships alongside as a template — new developers copy it to `appsettings.Development.local.json` and fill in values. ## Environment Variables The generated `Program.cs` calls `config.AddEnvironmentVariables()`, so any setting from `appsettings.json` can be overridden with an environment variable. This is the standard way to configure secrets and per-environment values in production. ### Mapping Convention .NET uses `__` (double underscore) as the separator for nested configuration keys. The mapping from `appsettings.json` to environment variables follows this pattern: | `appsettings.json` path | Environment variable | | ------------------------------------------------------------ | ---------------------------------------------------------------- | | `AppSettings:Spiderly.Shared:ConnectionString` | `AppSettings__Spiderly.Shared__ConnectionString` | | `AppSettings:Spiderly.Shared:JwtKey` | `AppSettings__Spiderly.Shared__JwtKey` | | `AppSettings:Spiderly.Security:ExternalProviders:0:ClientId` | `AppSettings__Spiderly.Security__ExternalProviders__0__ClientId` | `Spiderly.Shared` is the **section name** — the dots are part of the name, not configuration separators. The correct variable is `AppSettings__Spiderly.Shared__JwtKey`. A common mistake is `AppSettings__Spiderly__Shared__JwtKey` (treating the dot as a separator), which will be silently ignored and leave the setting at its default value. ### Required Variables These must be set for every production deployment: | Variable | Description | | ------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------- | | `ASPNETCORE_ENVIRONMENT` | Set to `Production`. | | `AppSettings__Spiderly.Shared__ConnectionString` | Database connection string. During development this is stored in `appsettings.Development.local.json` (gitignored), written by `spiderly init`. | | `AppSettings__Spiderly.Shared__JwtKey` | JWT signing key. Also stored in `appsettings.Development.local.json` during development. Must be at least 32 characters. | | `AppSettings__Spiderly.Shared__FrontendUrl` | URL of your Angular admin panel. Used for CORS. Default: `http://localhost:4200`. | | `AppSettings__Spiderly.Shared__JwtIssuer` | JWT issuer claim. Default: `https://localhost:7260;` — **must** be changed for production. | | `AppSettings__Spiderly.Shared__JwtAudience` | JWT audience claim. Default: `https://localhost:7260;` — **must** be changed for production. | The default `JwtIssuer` and `JwtAudience` are `https://localhost:7260;`. If you don't override them in production, every authenticated request will fail with a 401 because the token's issuer/audience won't match. ### Optional Variables | Variable | Description | | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | | `AppSettings__Spiderly.Shared__EmailSender`, `AppSettings__Spiderly.Shared__EmailSenderPassword` | Email sending credentials. See [Set Up Emailing](/docs/set-up-emailing). | | `AppSettings__Spiderly.Security__ExternalProviders__0__Code`, `AppSettings__Spiderly.Security__ExternalProviders__0__ClientId`, `AppSettings__Spiderly.Security__ExternalProviders__0__ClientSecret` | External auth provider config (one entry per provider). See [Set Up Google Authentication](/docs/set-up-google-authentication). | | `AppSettings__Spiderly.Shared__CookieDomain` | Cookie domain for cross-subdomain sharing (e.g., `.example.com`). See [Cookie Configuration](#cookie-configuration). | | `AppSettings__Spiderly.Shared__CookieSameSite` | Cookie SameSite attribute (`None`, `Lax`, `Strict`, `Unspecified`). Default: `None`. See [Cookie Configuration](#cookie-configuration). | | File storage variables (`S3BucketName`, `S3PublicEndpoint`, plus your own S3 credentials / custom-adapter settings) | Depends on the `StorageAttribute` subclasses your entities use. See [File Storage — Provider Setup](/docs/file-storage#provider-setup). | ## CORS The generated `Startup.cs` reads `FrontendUrl` from `Spiderly.Shared` settings and passes it to `.WithOrigins()`: ```csharp builder .AllowAnyMethod() .AllowAnyHeader() .AllowCredentials() .WithOrigins(new[] { Configuration.GetValue($"{Spiderly.Shared.Settings.ConfigurationSection}:FrontendUrl") }); ``` If you have additional frontends (e.g., a Next.js storefront), you can add more origins by overriding the CORS configuration in your `Startup.Configure`: ```csharp app.UseCors(builder => { builder .AllowAnyMethod() .AllowAnyHeader() .AllowCredentials() .WithOrigins(new[] { Configuration.GetValue($"{Spiderly.Shared.Settings.ConfigurationSection}:FrontendUrl"), Configuration.GetValue("AppSettings:StorefrontUrl"), }) .WithExposedHeaders("Content-Disposition"); }); ``` Trailing slashes matter. `https://admin.example.com/` and `https://admin.example.com` are treated as different origins. Make sure `FrontendUrl` matches exactly what the browser sends in the `Origin` header (typically without a trailing slash). ## Cookie Configuration When your frontend and API are on different subdomains (e.g., `admin.example.com` and `api.example.com`), you need to configure cookie settings so the browser shares cookies across both. ### Settings | Setting | Type | Default | Description | | ---------------- | -------- | --------- | ------------------------------------------------------------------------------------------------------------------------------------- | | `CookieDomain` | `string` | *(empty)* | Domain attribute for cookies. Set to `.example.com` to share cookies across all subdomains. Leave empty for default browser behavior. | | `CookieSameSite` | `enum` | `None` | SameSite attribute for cookies. Options: `None`, `Lax`, `Strict`, `Unspecified`. | ### appsettings.json ```json { "AppSettings": { "Spiderly.Shared": { "CookieDomain": ".example.com", "CookieSameSite": "Lax" } } } ``` ### When to Configure * **Same-domain setup** (frontend and API on same origin): No cookie configuration needed — defaults work. * **Subdomain setup** (e.g., `admin.example.com` + `api.example.com`): Set `CookieDomain` to `.example.com` and `CookieSameSite` to `Lax`. * **Cross-domain setup** (completely different domains): Keep `CookieSameSite` at `None` (default). The browser requires `Secure` cookies with `SameSite=None`, which Spiderly sets automatically. If you switch from `SameSite=None` to `Lax`, existing cookies in users' browsers will still have the old `SameSite` value until they expire. For a smooth transition, deploy the change and wait for cookie expiration, or advise users to clear cookies. ## Production Checklist * [ ] `ASPNETCORE_ENVIRONMENT` set to `Production` * [ ] `ConnectionString` points to the production database * [ ] `JwtKey` is set to a strong, unique key (at least 32 characters) * [ ] `JwtIssuer` and `JwtAudience` are changed from their localhost defaults * [ ] `FrontendUrl` is set to the production admin panel URL (no trailing slash) * [ ] Database migrations are applied * [ ] CORS origins include all frontends that call the API * [ ] Email and file storage variables are set (if using those features) * [ ] `CookieDomain` and `CookieSameSite` are set (if using cookie-based authentication across subdomains) * [ ] HTTPS is configured (via your hosting platform's load balancer or reverse proxy) --- # Enums (/docs/enums) ## Overview Spiderly lets you define enums once in C# and automatically generates matching TypeScript enums for your Angular frontend. This keeps backend and frontend enum definitions in sync — no manual duplication or copy-paste errors. ## How It Works `NgEnumsGenerator` is a Roslyn source generator that runs at **build time** (when you build the `.Business` project). It scans for two kinds of types carrying the `[SpiderlyEnum]` marker: * **`enum` declarations** → generated as numeric TypeScript enums (explicit values preserved) * **`static class` declarations** → generated as string TypeScript enums (property names become string values) Types without the marker stay backend-only. This is the correct default — most enums exist to label database rows and never need to reach the frontend. All output is consolidated into a single file: ``` Frontend\src\app\business\enums\enums.generated.ts ``` Enums are sorted alphabetically by name in the generated file. Do not edit `enums.generated.ts` — your changes will be overwritten on the next build. To add custom TypeScript enums, create a separate file alongside the generated one. See [Architecture](/docs/architecture) for the full list of generators, and [Spiderly Configuration](/docs/spiderly-json-configuration) for disabling the generator. ## Defining an Enum 1. Create a file in `Backend\{YourAppName}.Business\Enums\` 2. Use a block namespace (file-scoped syntax is not supported) 3. Define a standard C# enum and decorate it with `[SpiderlyEnum]` **C# input:** ```csharp // File: Backend\YourAppName.Business\Enums\OrderStatusCodes.cs using Spiderly.Shared.Attributes; namespace YourAppName.Business.Enums { [SpiderlyEnum] public enum OrderStatusCodes { PendingPayment = 1, Pending = 2, Processing = 3, Shipped = 4, Delivered = 5, Cancelled = 6 } } ``` **Generated TypeScript output:** ```typescript // File: Frontend\src\app\business\enums\enums.generated.ts (auto-generated) export enum OrderStatusCodes { PendingPayment = 1, Pending = 2, Processing = 3, Shipped = 4, Delivered = 5, Cancelled = 6, } ``` You must use block namespace syntax (`namespace X.Y {'{'} ... {'}'}`), not file-scoped (`namespace X.Y;`). The source generator relies on the syntax tree structure of block namespaces to read the enum. ## Naming Convention Spiderly's examples use the `...Codes` suffix (`OrderStatusCodes`, `PaymentMethodCodes`, `CartStatusCodes`) to make it clear the type represents a fixed set of coded values. This is a style recommendation, not a technical requirement — the only thing that marks a type as a generator-aware enum is the `[SpiderlyEnum]` attribute. ## Class-Based Enums Static classes carrying `[SpiderlyEnum]` are emitted as **string enums**. This is used internally for `PermissionCodes` — a partial class where Spiderly auto-generates CRUD permission codes for every entity. **C# input:** ```csharp // File: Backend\YourAppName.Business\Enums\PermissionCodes.cs using Spiderly.Shared.Attributes; namespace YourAppName.Business.Enums { [SpiderlyEnum] public static partial class PermissionCodes { } } ``` The class itself is empty — Spiderly auto-generates `Read`/`Update`/`Insert`/`Delete` permission codes for every entity in your project. You can add custom permission codes by adding `public static string` properties to the partial class. **Generated TypeScript output:** ```typescript // File: Frontend\src\app\business\enums\enums.generated.ts (auto-generated) export enum PermissionCodes { ReadBanner = "ReadBanner", UpdateBanner = "UpdateBanner", InsertBanner = "InsertBanner", DeleteBanner = "DeleteBanner", // ... Read/Update/Insert/Delete for every entity } ``` See [Backend Customization](/docs/backend-customization) for how to extend `PermissionCodes` with custom permissions. ## Enum Properties on Entities A `[SpiderlyEnum]`-decorated type can also be used **directly as an entity property type** — Spiderly recognizes it as a scalar value and generates a dropdown UI control instead of treating it as a foreign-key relationship. **C# input:** ```csharp // File: Backend\YourAppName.Business\Enums\OrderStatusCodes.cs using Spiderly.Shared.Attributes; namespace YourAppName.Business.Enums { [SpiderlyEnum] public enum OrderStatusCodes { Pending, Paid, Shipped, Cancelled, } } ``` ```csharp // File: Backend\YourAppName.Business\Entities\Order.cs [SpiderlyEntity] public class Order : BusinessObject { public OrderStatusCodes Status { get; set; } } ``` **Generated behavior:** * The Angular form renders `Status` as a dropdown whose options are populated **client-side from the generated TypeScript enum** — no lookup table, no M2O navigation, and no API round-trip. Alongside each enum, the generator emits a `get{Enum}NamebookList(translocoService)` builder in `enums.generated.ts`, and the form binds the dropdown to it. * `IsManyToOneType` short-circuits for properties whose type is in the `[SpiderlyEnum]` registry, so the generators that would otherwise emit FK plumbing skip it for these properties. * `[SpiderlyEnum]` is the single source of truth — the type does **not** need to follow the `...Codes` naming convention to be recognized. * This applies to real `enum` types only. A [class-based enum](#class-based-enums) (a `static class` of string constants) can't be used as a property type, so it can't drive a dropdown — use an `enum` for dropdown fields. Use this pattern when the set of values is fixed at compile time and you don't need per-row metadata. For values that need labels, ordering, or admin-managed lifetimes, keep the lookup-table pattern with `ReadonlyObject`. ### Translating enum options Option labels are localized through Transloco, with the **translation key equal to the enum member name**. The generated builder wraps each member in `translate(marker('Member'))` so `transloco-keys-manager` discovers the keys — run `npm run i18n:extract`, then fill each locale file: ```json // Frontend/src/assets/i18n/sr-Latn-RS.json { "Pending": "Na čekanju", "Paid": "Plaćeno", "Shipped": "Poslato", "Cancelled": "Otkazano" } ``` A missing value renders the raw key, so don't skip the locale entries. If two enums share a member name (e.g. both have `Pending`) but need different translations, **rename one member** (e.g. `PendingReview`) — the key follows the member name, so no extra configuration is needed. The same `get{Enum}NamebookList(translocoService)` builder also feeds a list-table column's multiselect filter — wrap it in the `spiderly` helper `getPrimengNamebookOptions`, which maps `Namebook[]` to the table's `{ label, code }[]` shape: ```typescript { name: t('Status'), filterType: 'multiselect', field: 'status', dropdownOrMultiselectValues: getPrimengNamebookOptions(getOrderStatusCodesNamebookList(this.translocoService)) } ``` --- # Excel Export (/docs/excel-export) ## Overview Every entity gets a full **Excel export pipeline** auto-generated from its definition. Admin panel users can export any entity's data table to an `.xlsx` file with one click. Key characteristics: * **Respects current filters and sorting** — exports exactly what the user sees (filtered + sorted), but without pagination limits (capped at `ExcelExportMaxRows`, default 100,000) * **Column headers are auto-translated** via the [translation system](/docs/translation) * Uses **MiniExcel** for `.xlsx` generation * The exported filename is translated (e.g., `ProductList.xlsx` in English, `ListaProizvoda.xlsx` in Serbian) ## How It Works ``` ┌─────────────────────────────┐ │ Angular Data Table │ User clicks "Export to Excel" │ (SpiderlyDataTable) │ └────────────┬────────────────┘ │ Current filters + sorting (FilterDTO) ▼ ┌─────────────────────────────┐ │ Generated API Service │ export{Entity}ListToExcel(filter) │ (Angular HttpClient) │ POST with { observe: 'response', responseType: 'blob' } └────────────┬────────────────┘ │ HTTP POST /api/{Entity}/Export{Entity}ListToExcel ▼ ┌─────────────────────────────┐ │ Generated Controller │ Calls service, returns File() response └────────────┬────────────────┘ │ FilterDTO ▼ ┌─────────────────────────────┐ │ Generated Service │ Applies filters/sorting │ │ → Caps at ExcelExportMaxRows (default 100,000) │ │ → Projects to DTO via ExcelProjectToConfig │ │ → Excludes IEnumerable properties │ │ → Streams rows via IAsyncEnumerable (not buffered) │ │ → Generates Excel with MiniExcel └────────────┬────────────────┘ │ byte[] (.xlsx file) ▼ ┌─────────────────────────────┐ │ Browser Download │ FileSaver.js triggers .xlsx download └─────────────────────────────┘ ``` ## What Gets Generated ### Backend For each entity, the following are auto-generated: **Service method** — applies filters, projects to DTO, generates the Excel file: ```csharp public async virtual Task Export{Entity}ListToExcel( FilterDTO filterDTO, IQueryable<{Entity}> query, bool authorize, CancellationToken cancellationToken = default) ``` **Controller endpoint** — receives the filter, calls the service, returns the file: ```csharp [HttpPost] [AuthGuard] public virtual async Task Export{Entity}ListToExcel(FilterDTO filterDTO) ``` **Mapper config** — a Mapster projection specifically for Excel export (separate from the regular `ProjectToConfig`): ```csharp public static TypeAdapterConfig {Entity}ExcelProjectToConfig() ``` **Exclusion list** — determines which DTO properties to skip in the export: ```csharp ExcelPropertiesToExclude.GetHeadersToExclude(new {Entity}DTO()) ``` ### Frontend (Angular) **API service method** — sends the current table filters to the export endpoint and receives a blob: ```typescript export{Entity}ListToExcel = (filterDTO: Filter): Observable => { return this.http.post( `${this.config.apiUrl}/{Entity}/Export{Entity}ListToExcel`, filterDTO, { observe: 'response', responseType: 'blob' } ); } ``` **Data table button** — an "Export to Excel" button is shown by default in every `spiderly-data-table`. When clicked, it sends the current `FilterDTO` (including active column filters, sorting, and `additionalFilterIdLong`) to the export endpoint. ## Column Selection All public DTO properties are included by default, except: 1. **`IEnumerable` properties** — collections (one-to-many, many-to-many) are automatically excluded 2. **Properties marked with `[MapperIgnoreTarget]`** — on a custom `ExcelProjectTo` method in the `Mapper` class (see below) ## Excluding Columns To exclude specific properties from the Excel export, add a method named `{Entity}ExcelProjectToConfig` in your hand-written `Mapper` class. This replaces the auto-generated one. Use Mapster's `.Ignore()` to exclude properties from the projection: ```csharp [SpiderlyDataMapper] public static partial class Mapper { public static TypeAdapterConfig ProductExcelProjectToConfig() { TypeAdapterConfig config = new(); config .NewConfig() .Map(dest => dest.CategoryId, src => src.Category.Id) .Map(dest => dest.CategoryDisplayName, src => src.Category.Name) .Ignore(dest => dest.Description) ; return config; } } ``` When you write a custom `{Entity}ExcelProjectToConfig` method, the generator skips its auto-generated version. You must include all the many-to-one mappings yourself (the generator won't merge them in). To also exclude properties from the column headers (not just the projection), add `[MapperIgnoreTarget]` attributes to a method named `ExcelProjectTo` with the DTO as its return type: ```csharp [MapperIgnoreTarget("Description")] [MapperIgnoreTarget("InternalNotes")] public ProductDTO ExcelProjectTo() => default; ``` This tells the `ExcelPropertiesToExclude` generator to add `Description` and `InternalNotes` to the exclusion list, so they won't appear as columns in the exported file. ## Hiding the Export Button To hide the "Export to Excel" button on a specific data table, set the `showExportToExcelButton` input to `false`: ```html ``` ## Excel Formatting The generated Excel file uses the following formatting: | Element | Style | | -------------- | ------------------------------------ | | Column width | Type-based (see table below) | | Date columns | MiniExcel default (no custom format) | | Sheet name | `Sheet1` (MiniExcel default) | | Data start row | Row 2 (row 1 is the header) | **Column widths by type:** | Type | Width | | --------------------------------- | ----- | | `bool` | 10 | | `byte`, `short`, `int`, `long` | 14 | | `decimal`, `double`, `float` | 16 | | `DateTime`, `DateOnly` | 18 | | `string` (`[StringLength]` ≤ 50) | 20 | | `string` (`[StringLength]` ≤ 200) | 30 | | `string` (`[StringLength]` > 200) | 40 | | Other / no `[StringLength]` | 22 | ## Overriding the Export Logic The service method is `virtual` — override it in your entity service class to customize the export behavior: ```csharp public override async Task ExportProductListToExcel( FilterDTO filterDTO, IQueryable query, bool authorize, CancellationToken cancellationToken = default) { query = query.Where(x => x.IsActive); return await base.ExportProductListToExcel(filterDTO, query, authorize, cancellationToken); } ``` Common use cases for overriding: * **Pre-filter the query** — add `.Where()` clauses to restrict what gets exported * **Modify the FilterDTO** — change sorting or add filters programmatically * **Completely replace the export** — skip calling `base` and generate a custom Excel file ## Max Rows Limit By default, exports are capped at **100,000 rows** to prevent memory and timeout issues on very large tables. You can change this limit via `ExcelExportMaxRows` in `appsettings.json`: ```json { "AppSettings": { "Spiderly.Shared": { "ExcelExportMaxRows": 50000 } } } ``` The cap is applied as a `Take(maxRows)` on the `IQueryable` before the query executes, so it translates to a SQL `LIMIT`/`TOP` clause. --- # Exceptions (/docs/exceptions) Spiderly's global exception handler catches all exceptions and maps them to appropriate HTTP responses. Responses share a common `ApiErrorDTO` shape: ```ts { statusCode: number; message: string; errorCode?: string; // machine-readable discriminator (e.g. "invalid_token") fieldErrors?: { [field: string]: string[] }; // populated for 422 responses exception?: string; // full stack trace, development only } ``` The `errorCode` discriminator is one of these machine-readable values (generated from the backend `ApiErrorCodes` contract): | Name | Value | Description | | ------------------------------- | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `ConcurrencyConflict` | `concurrency_conflict` | An optimistic-concurrency check failed — the row was modified by someone else after it was loaded. The client should reload the latest data and retry. | | `EmailNotVerified` | `email_not_verified` | Login or auto-provisioning was blocked because the account's email address is not verified (e.g. an external provider returned an unverified email). | | `ExternalEmailMissing` | `external_email_missing` | An external (OAuth/OIDC) login was validated but the provider returned no email address (e.g. the user declined the email permission, or a phone-only Facebook account). Auto-provisioning needs an email to key the account on, so login is rejected with this code and the client should route the user to another sign-in method. Distinct from EmailNotVerified, which means an email was returned but not verified. | | `ExternalProviderNotConfigured` | `external_provider_not_configured` | An external (OAuth) login was attempted for a provider that is not configured on the server, or that provider's token exchange failed. | | `ForeignKeyViolation` | `foreign_key_violation` | A database foreign-key constraint was violated — e.g. referencing a row that does not exist, or deleting a row that is still referenced by dependent rows. | | `InvalidToken` | `invalid_token` | The JWT bearer token is missing, malformed, or expired. Returned with HTTP 401 (also surfaced in the WWW-Authenticate header); the client should refresh the token or re-authenticate. | | `UniqueViolation` | `unique_violation` | A database unique constraint (or unique index) was violated — e.g. saving a duplicate value for a column that must be unique. | | `ValidationFailed` | `validation_failed` | One or more request fields failed server-side validation. Returned with HTTP 400; the per-field messages are carried in ApiErrorDTO.FieldErrors. | | Exception | Status | Log Level | Message to Client | Notification | | ----------------------------------------- | -------------- | ----------- | -------------------------------- | ------------ | | `BusinessException` | 400 | Information | Exception message | No | | `ExpiredVerificationException` | 400 | Information | Exception message | No | | `SpiderlyValidationException` | 422 | Information | Per-field errors | No | | `FluentValidation.ValidationException` | 422 | Information | Per-field errors | No | | `UnauthorizedException` | 401 | Warning | Exception message | No | | `SecurityTokenException` | 401 (RFC 6750) | Information | Exception message | No | | `SecurityViolationException` | 403 | Error | Generic error | Yes | | `DbUpdateConcurrencyException` | 409 | Warning | Localized `ConcurrencyException` | No | | `DbUpdateException` (unique/FK violation) | 409 | Warning | Localized message | No | | Unhandled | 500 | Error | Generic error | Yes | ## BusinessException The primary way to reject invalid input with a user-facing message. Throw it from lifecycle hooks (e.g., `OnBeforeProductInsert`) to return a meaningful error to the client: ```csharp throw new BusinessException("Product name must be unique."); ``` Always returns `400 Bad Request`. Use a different exception type if you need a different status — that's the whole point of semantic exceptions. ## ExpiredVerificationException Returns `400 Bad Request` with the exception message, logged at `Information` level. Used internally by the security module when a verification code has expired. ## SpiderlyValidationException Throw when domain rules produce per-field errors that can't be expressed via FluentValidation attributes. Returns `422 Unprocessable Entity` with a `fieldErrors` dictionary the client can render inline next to the offending inputs: ```csharp throw new SpiderlyValidationException(new Dictionary { ["Sku"] = new[] { "SKU already exists." } }); ``` Standard FluentValidation failures (`ValidateAndThrow()` from generated validators) are converted to the same response shape automatically — you don't need to catch and rethrow. ## UnauthorizedException For custom authorization checks — returns `401 Unauthorized` with your message. See the [Authorization Service](/docs/backend-customization#authorization-service) section for a usage example. ## SecurityTokenException Thrown from refresh-token flows when the presented token is missing, expired, or tampered with. Returns `401 Unauthorized` with: * `WWW-Authenticate: Bearer error="invalid_token", error_description="..."` (per RFC 6750) * `errorCode: "invalid_token"` in the body The handler also clears the access, refresh, and auth-result cookies. Clients should check either the header or the `errorCode` and treat the response as a forced logout. ## SecurityViolationException For requests that indicate malicious intent (tampered IDs, forged hidden fields, file-size bypass attempts). Returns a generic error message (never revealing what went wrong) and triggers an admin notification so you can investigate. Used internally by Spiderly's generated services for integrity checks. ## DbUpdateException The handler recognises common Postgres and SQL Server constraint violations and maps them to `409 Conflict` with localized messages: | Dialect | Code | Meaning | Localization key | | ---------- | -------------- | --------------------- | ------------------------------- | | Postgres | `23505` | Unique violation | `UniqueConstraintException` | | Postgres | `23503` | Foreign-key violation | `ForeignKeyConstraintException` | | SQL Server | `2627`, `2601` | Unique violation | `UniqueConstraintException` | | SQL Server | `547` | Foreign-key violation | `ForeignKeyConstraintException` | Other `DbUpdateException` instances fall through to the generic 500 handler. `DbUpdateConcurrencyException` is handled separately with the `ConcurrencyException` message. ## Unhandled Exceptions Any exception that doesn't match the types above returns a generic error message to the client (never exposing internal details) and triggers an admin notification in production via the notification framework (`INotifier` → the Email channel). ## Keeping expected exceptions out of your error tracker The log levels in the table above are the contract for what counts as a *problem*: the `Information`/`Warning` rows are expected, client-facing conditions; the `Error` rows (`SecurityViolationException` and unhandled 500s) are genuine faults. An error tracker wired through its ASP.NET Core integration — for example Sentry's `UseSentry()` — **auto-captures the exception object directly**, and on **.NET 8 and 9 the framework emits that diagnostic even for exceptions your `IExceptionHandler` already handled into a clean 4xx**, regardless of log level. Left unfiltered, a routine `BusinessException` lands in your tracker as an `error` and alerts you for something that isn't a bug. `SpiderlyExceptionClassifier` is the single source of truth for that decision. `SpiderlyExceptionHandler` consumes it for the log levels above, so your tracker filter and the handler's logging can't drift: ```csharp // true for the Information/Warning rows (expected); false for SecurityViolationException + 500s bool expected = SpiderlyExceptionClassifier.IsExpected(exception); // the level the handler logs the exception at LogLevel level = SpiderlyExceptionClassifier.GetLogLevel(exception); ``` Wire `IsExpected` into your tracker's pre-send filter so only real faults are reported. With Sentry: ```csharp webBuilder.UseSentry(options => { options.SetBeforeSend((sentryEvent, _) => sentryEvent.Exception != null && SpiderlyExceptionClassifier.IsExpected(sentryEvent.Exception) ? null // drop expected, client-facing exceptions : sentryEvent); // report SecurityViolationException and genuine 500s }); ``` > **.NET 10+** suppresses diagnostics for handled exceptions (`IExceptionHandler.TryHandleAsync` returning `true`) by default, so the auto-capture stops firing for handled exceptions on its own — but wiring `IsExpected` remains the explicit, version-independent way to express the rule. ## How errors surface in the admin UI The Angular admin handles failed requests globally — there is no per-call error wiring. An HTTP-error interceptor shows the right toast and **rethrows**, so calling code only ever runs its success path: | Response | What the user sees | | ----------------------------- | ---------------------------------------------------------------------------------------------------- | | Connection failure (status 0) | "Server lost connection" warning | | `400` | Warning toast with the server's message (your `BusinessException` text) | | `401` | Session cleared on an invalid token (guards redirect to login); otherwise a "login required" warning | | `403` | Permission warning | | `404` | Not-found warning | | Anything else | Generic error toast | Uncaught errors that are not HTTP responses get a generic toast from the global Angular `ErrorHandler`. The practical rule: throw the right exception server-side and the UI story is done — add client-side `catchError` only to *react* to a failure (e.g. reset local state), never to show the message, which has already been shown by the time your handler runs. --- # File Storage (/docs/file-storage) ## Overview Spiderly's file-storage model is **per-property**: every blob property on an entity declares its storage adapter via a `StorageAttribute` subclass. The source generator emits the upload pipeline (controller endpoint, validation, optimization hooks, save-time cleanup) and resolves the right `IFileManager` adapter from DI for that property only — there is no global storage registration. ## Built-in Adapters Spiderly ships three built-in storage classes and matching attributes. Anything else (Cloudinary, Azure Blob, Backblaze, on-prem MinIO, …) is implemented by the consumer (see [Custom Adapters](#custom-adapters)). | Adapter | Attribute | Returns | Best For | | ------------------------- | -------------------- | ------------ | ------------------------------------------- | | `DiskStorageService` | `[DiskStorage]` | File key | Local development | | `S3PublicStorageService` | `[S3PublicStorage]` | Full CDN URL | Public images/assets with CloudFront/R2 CDN | | `S3PrivateStorageService` | `[S3PrivateStorage]` | S3 key | Private documents, signed-URL access | All three implement `Spiderly.Shared.Interfaces.IFileManager`. ## Entity Configuration Decorate a `string` property with a storage attribute. The attribute itself marks the property as a blob — there is no separate marker attribute. ### Public CDN file The column stores the full public URL. Ideal for images served directly from a CDN: ```csharp public class Brand : BusinessObject { [S3PublicStorage] [AcceptedFileTypes("image/jpeg", "image/png", "image/webp", "image/avif")] [MaxFileSize(2_000_000)] [StringLength(1000, MinimumLength = 1)] public string LogoUrl { get; set; } } ``` ### Private S3 file (signed-URL access) The column stores an opaque S3 key; access is mediated via signed URLs or a backend proxy. Use for personal data, compliance-sensitive uploads, etc.: ```csharp public class WarrantyRegistration : BusinessObject { [S3PrivateStorage] [AcceptedFileTypes("image/jpeg", "image/png", "application/pdf")] [MaxFileSize(10_000_000)] [StringLength(1000, MinimumLength = 1)] public string ReceiptImageUrl { get; set; } } ``` ### Local disk (development) ```csharp public class User : BusinessObject { [DiskStorage] [AcceptedFileTypes("image/*")] [StringLength(1000, MinimumLength = 1)] public string ProfilePicture { get; set; } } ``` ## File Validation Attributes These attributes add both server-side and client-side validation. See the [Validation](/docs/validation) page for details. | Attribute | Description | Default | | ---------------------------------------- | ------------------------------------- | ------------------------------------------------------------------------------------------------------- | | `[AcceptedFileTypes("image/*", ".pdf")]` | Allowed MIME types or extensions | **Required.** No default — build error [`SPIDERLY014`](/docs/build-diagnostics#spiderly014) if missing. | | `[MaxFileSize(5_000_000)]` | Max file size in bytes | 20 MB | | `[ImageWidth(800)]` | Required exact image width in pixels | No validation | | `[ImageHeight(600)]` | Required exact image height in pixels | No validation | ### Example with all validation attributes ```csharp public class Brand : BusinessObject { [DisplayName] [Required] [StringLength(100, MinimumLength = 1)] public string Name { get; set; } [S3PublicStorage] [AcceptedFileTypes("image/*")] [MaxFileSize(2_000_000)] [ImageWidth(400)] [ImageHeight(400)] [StringLength(1000, MinimumLength = 1)] public string Logo { get; set; } } ``` ## Provider Setup ### S3 (public or private) Both `S3PublicStorageService` and `S3PrivateStorageService` share a single `IAmazonS3` registration. **appsettings.json:** ```json { "AppSettings": { "Spiderly.Shared": { "S3BucketName": "my-bucket", "S3PublicEndpoint": "https://cdn.example.com" } } } ``` `S3PublicEndpoint` is the base URL `S3PublicStorageService` uses to format returned URLs as `{S3PublicEndpoint}/{key}`. The setting is unused by `S3PrivateStorageService`. S3 credentials (`S3AccessKey`, `S3SecretKey`, `S3ServiceUrl`) live in your application's settings, not in `Spiderly.Shared`. **DI registration (your `AppServiceExtensions` or equivalent):** ```csharp services.AddSingleton(sp => { IConfiguration configuration = sp.GetRequiredService(); AmazonS3Config s3Config = new AmazonS3Config { ServiceURL = configuration.GetValue($"{Spiderly.Shared.Settings.ConfigurationSection}:S3ServiceUrl"), ForcePathStyle = true, AuthenticationRegion = "auto", }; return new AmazonS3Client( new BasicAWSCredentials( configuration.GetValue($"{Spiderly.Shared.Settings.ConfigurationSection}:S3AccessKey"), configuration.GetValue($"{Spiderly.Shared.Settings.ConfigurationSection}:S3SecretKey") ), s3Config ); }); services.AddTransient(); services.AddTransient(); ``` The source generator emits `_deps.ServiceProvider.GetRequiredService()` per blob property, so each adapter you reference must be registered by its concrete type. `S3PublicStorageService` sets `Cache-Control: public, max-age=31536000, immutable` and disables payload signing for Cloudflare R2 compatibility. ### Disk (local development) No appsettings entries needed. Files are stored under `{CurrentDirectory}/FileStorage`. ```csharp services.AddTransient(); ``` `DiskStorageService` is intended for development. In a Linux/Docker production deployment the host filesystem is ephemeral and not shared across replicas; switch to `S3PublicStorage` / `S3PrivateStorage` (or a custom adapter) for production. ## Custom Adapters Spiderly ships only the three adapters above. Other backends (Cloudinary, Azure Blob, Backblaze B2, on-prem MinIO, …) are written by the consumer. ### Step 1 — Implement `IFileManager` ```csharp public class CloudinaryStorageService : IFileManager { public Task UploadFileAsync(...) { /* impl */ } public Task DeleteNonActiveBlobs(...) { /* impl */ } public Task GetFileDataAsync(string key) { /* impl */ } public Task MoveBlobToEntityPathAsync(...) { /* impl */ } public Task DeleteNonActiveEditorImages(...) { /* impl */ } } ``` ### Step 2 — Subclass `StorageAttribute` Pass your service type to the base constructor: ```csharp public sealed class CloudinaryStorageAttribute : StorageAttribute { public CloudinaryStorageAttribute() : base(typeof(CloudinaryStorageService)) { } } ``` ### Step 3 — Register the service ```csharp services.AddTransient(); ``` ### Step 4 — Use the attribute on entity properties ```csharp public class User : BusinessObject { [CloudinaryStorage] [AcceptedFileTypes("image/*")] [StringLength(500, MinimumLength = 1)] public string Photo { get; set; } } ``` The source generator detects custom storage attributes by the convention "attribute simple name ends with `Storage`" and treats the property as a blob automatically. Field-name resolution in the generator is currently hard-coded for the three built-ins; for custom adapters in auto-generated CRUD, you may need to inject your custom service directly into hand-written upload paths until Spiderly's source generator gains symbol-level resolution of `StorageAttribute.ServiceType`. ## Generated Upload Pipeline ### Upload flow 1. Client sends `POST /api/{Entity}/Upload{Property}For{Entity}` with the file 2. `OnBefore{Property}BlobFor{Entity}UploadIsAuthorized()` hook runs 3. Authorization check (insert vs update based on entity ID) 4. File size validation — `[MaxFileSize]` if set, otherwise **20 MB default** 5. MIME-type + content signature validation — `[AcceptedFileTypes]` is **required** on every blob property and must declare at least one MIME-typed value (e.g. `[AcceptedFileTypes("image/jpeg", "image/png", "image/webp", "image/avif")]`). If it is missing or contains only extension values, the source generator emits build error [`SPIDERLY014`](/docs/build-diagnostics#spiderly014). MIME entries match the declared `Content-Type` exactly, or by prefix for type wildcards (`"image/*"`); extension entries (`".pdf"` — leading dot) only widen the UI file picker, so always pair them with a MIME type. The content is then verified against the declared type: binary types by magic-byte inspection, and `image/svg+xml` (a text format with no magic bytes) structurally — the document must parse as XML with an `` root, and active content (script elements, `on*` event attributes, `javascript:` hrefs, `foreignObject`) is rejected. Spoofing the header does not bypass validation. Failures throw `BusinessException`, so the user sees the specific localized message (`FileTypeNotAllowed`, `FileContentDoesNotMatchType`, `FileContainsActiveContent`, `FileIsEmpty`, `FileSizeExceeded`). 6. `OnBefore{Property}BlobFor{Entity}IsUploaded()` hook runs — for raster images, this validates dimensions and optimizes; SVG and non-image files pass through raw 7. File is uploaded to the storage adapter resolved per the property's `[*Storage]` attribute 8. The file identifier (key or URL) is returned to the client ### Rate Limiting All generated `Upload*For*` endpoints are decorated with `[EnableRateLimiting(SpiderlyRateLimitPolicies.BlobUpload)]`. Calling `spiderly.AddRateLimiting()` in your `AddSpiderly(...)` setup registers the policy with a default of **20 requests per minute per IP**. Override the policy in your own `Configure` call to tune the limit without forking Spiderly. ### Default image processing For raster image files (`image/*` except `image/svg+xml` — see `Helper.IsOptimizableImage`), the default `OnBefore{Property}BlobFor{Entity}IsUploaded` hook: 1. **Validates dimensions** — if `[ImageWidth]` or `[ImageHeight]` are set, checks exact pixel dimensions 2. **Optimizes** — converts to **WebP** format at **85% quality** using SixLabors.ImageSharp SVG is a vector text format ImageSharp cannot decode, so it skips both steps and uploads as-is; its safety is enforced by the structural validation in the upload flow above. ## File Processing Hooks All hooks are `virtual` methods on the generated entity service class (e.g., `ProductServiceGenerated`). Override them in your entity service class (e.g., `ProductService`) to customize behavior. | Hook | Purpose | Default Behavior | | ------------------------------------------------------- | ------------------------------ | ------------------------------------------------- | | `OnBefore{Property}BlobFor{Entity}UploadIsAuthorized()` | Custom pre-authorization logic | No-op | | `OnBefore{Property}BlobFor{Entity}IsUploaded()` | Process file before storage | Images: validate + optimize. Others: read bytes | | `ValidateImageFor{Property}Of{Entity}()` | Custom dimension validation | Exact match if `[ImageWidth]`/`[ImageHeight]` set | | `OptimizeImageFor{Property}Of{Entity}()` | Custom image optimization | Convert to WebP at 85% quality | ### Example: custom image optimization Override the optimization hook to resize images before storage: ```csharp public override async Task OptimizeImageForLogoOfBrand( Stream stream, IFormFile file, int id) { return await Helper.OptimizeImage( stream, newImageSize: new Size(400, 400), quality: 90 ); } ``` ### Example: skip optimization for a specific property ```csharp public override async Task OptimizeImageForBannerOfHomePage( Stream stream, IFormFile file, long id) { return await Helper.ReadAllBytesAsync(stream); } ``` ## Displaying Files How uploaded files appear in DTOs depends on the storage adapter. ### DTO generation For every blob property, Spiderly generates a companion `{Property}Data` field on the DTO: ```csharp // Entity: public string ProfilePicture { get; set; } // Generated DTO: public string ProfilePicture { get; set; } // storage key or URL public string ProfilePictureData { get; set; } // file content for display ``` ### What `{Property}Data` contains | Adapter | Format | Usage | | ------------------------- | ------------------------------ | ------------------------- | | `DiskStorageService` | `filename={key};base64,{data}` | Decode base64 for display | | `S3PrivateStorageService` | `filename={key};base64,{data}` | Decode base64 for display | | `S3PublicStorageService` | Full public URL | Use directly as `src` | In the Angular admin panel, `spiderly-file` handles this automatically. It uses the `[isUrlFileData]` input (auto-generated) to determine how to render the preview. For `[S3PublicStorage]` properties, the column itself contains the full CDN URL. You can use it directly as an image `src` without going through the `{Property}Data` base64 field. ## Storage Paths and Orphan Cleanup All adapters place uploaded blobs under a hierarchical, entity-scoped key: ``` {EntityName}/{PropertyName}/{ObjectId}/{BlobGuid}.{ext} ``` ### Insert flow — staging prefix When a user uploads a file for an entity that doesn't exist yet (insert), the entity ID is `0`. Spiderly routes these uploads to a temporary staging prefix: ``` {EntityName}/{PropertyName}/_tmp/{UploadGuid}/{BlobGuid}.{ext} ``` Once the entity is saved and has a real ID, the generated save code calls `IFileManager.MoveBlobToEntityPathAsync(...)`, which copies the blob to its permanent key (`{Entity}/{Prop}/{realId}/{BlobGuid}.ext`), deletes the staging source, and updates the DB column. The client never sees the staged path. Configure a storage lifecycle rule to auto-expire objects under the `_tmp/` prefix after 7 days (S3/R2 lifecycle rule, etc.). This cleans up uploads that were abandoned before the entity was saved — no cron needed. ### Update flow — replace and clean When a user replaces a file on an existing entity: 1. User uploads a new file → new key/URL is returned 2. User saves the entity with the new key/URL 3. After `SaveChangesAsync()`, the generated code calls `DeleteNonActiveBlobs()` on the storage adapter 4. The adapter lists all files under `{Entity}/{Prop}/{id}/` and deletes everything except the active file This design is intentional — files are uploaded **before** the entity is saved (so the upload endpoint works independently). Cleanup only happens at save time, which means refreshing the page without saving won't lose the old file. --- # Filtering & Pagination (/docs/filtering-and-pagination) ## Overview Spiderly auto-generates a full **server-side filtering + pagination pipeline** from your entity definitions. The frontend sends a filter object (column filters, pagination offset, sort rules) → the backend applies those filters to EF Core queries → and returns paginated DTOs with a total count. This page covers the system internals. For configuring the UI components that drive filtering, see [Data Components](/docs/angular-components/data-components). ## How It Works ``` ┌─────────────────────────────┐ │ Angular Component │ DataTable / DataView fires lazy load event │ (SpiderlyDataTable) │ └────────────┬────────────────┘ │ Filter object (JSON) ▼ ┌─────────────────────────────┐ │ Generated Controller │ GetPaginated{Entity}List endpoint └────────────┬────────────────┘ │ FilterDTO ▼ ┌─────────────────────────────┐ │ Generated Service │ Calls PaginatedResultGenerator.Build() │ │ → applies column filters to IQueryable │ │ → counts total matching records │ │ → applies Skip(first) + Take(rows) └────────────┬────────────────┘ │ PaginatedResult ▼ ┌─────────────────────────────┐ │ Mapster Projection │ Projects entities to DTOs └────────────┬────────────────┘ │ PaginatedResultDTO ▼ ┌─────────────────────────────┐ │ JSON Response │ { data: [...], totalRecords: N } └─────────────────────────────┘ ``` 1. The Angular component (`SpiderlyDataTable` or `SpiderlyDataView`) fires a lazy load event containing a `Filter` object. 2. The `Filter` is sent via the generated API service to the `GetPaginated{Entity}List` controller endpoint. 3. The generated service calls `PaginatedResultGenerator.Build()`, which applies column filters to the EF Core `IQueryable` using LinqKit's `PredicateBuilder`. 4. It counts all matching records (before pagination). 5. It applies `Skip(first)` + `Take(rows)` for pagination. 6. The result is projected to DTOs via Mapster. 7. A `PaginatedResultDTO` with `Data` + `TotalRecords` is returned to the frontend. ## FilterDTO The C# class that represents a filter request: ```csharp public class FilterDTO { public Dictionary> Filters { get; set; } = new(); public int First { get; set; } // zero-based offset public int Rows { get; set; } // page size public List MultiSortMeta { get; set; } = new(); public int? AdditionalFilterIdInt { get; set; } public long? AdditionalFilterIdLong { get; set; } public byte? AdditionalFilterIdByte { get; set; } } ``` * **`Filters`** dictionary: keys are property names (camelCase), values are lists of filter rules. * Multiple rules per column are supported (e.g., price > 100 AND price \< 500). ## FilterRuleDTO A single filter condition: ```csharp public class FilterRuleDTO { public object Value { get; set; } public MatchModeCodes MatchMode { get; set; } public string Operator { get; set; } // "and" / "or" between rules on the same column } ``` ## Match Modes All supported match modes: | Name | Value | Description | | ------------- | ------------- | ---------------------------------------------------------------------------------------------------------------------------- | | `Contains` | `contains` | String substring match, case-insensitive (value.Contains(...)). | | `Equals` | `equals` | Equality match. For strings it is case-insensitive; for bool, number, and date/time properties it is an exact == comparison. | | `GreaterThan` | `greaterThan` | Greater-than comparison (>), for number and date/time properties. | | `In` | `in` | Membership match against a JSON array of values (value IN \[...]), for number and id properties. | | `LessThan` | `lessThan` | Less-than comparison (\<), for number and date/time properties. | | `StartsWith` | `startsWith` | String prefix match, case-insensitive (value.StartsWith(...)). | String comparisons are always case-insensitive (`.ToLower()` is applied automatically). ## Sorting Single and multi-column sorting is handled via `MultiSortMeta`: ```csharp public class FilterSortMetaDTO { public string Field { get; set; } public int Order { get; set; } // 1 = ascending, -1 = descending } ``` Sort rules are applied in list order — the first entry is the primary sort. ## PaginatedResultDTO The response wrapper returned by every paginated endpoint: ```csharp public class PaginatedResultDTO where T : class { public IList Data { get; set; } public int TotalRecords { get; set; } } ``` `TotalRecords` is the count of all records matching the filters (before pagination), used by the UI paginator to calculate total pages. ## Programmatic Filtering When you need to build filters in custom backend code, use the generic `FilterDTO` which provides a fluent, expression-based API: ```csharp var filter = new FilterDTO() .AddFilter(x => x.Name, "widget", MatchModeCodes.Contains) .AddFilter(x => x.Price, 100, MatchModeCodes.GreaterThan) .AddSort(x => x.CreatedAt, -1) .SetPagination(0, 25); var result = await GetPaginatedProductList(filter); ``` This is compile-time safe — no magic strings. The expression `x => x.Name` is converted to the camelCase property name `"name"` automatically. ## Custom Filtering with AdditionalFilterId `AdditionalFilterIdInt`, `AdditionalFilterIdLong`, and `AdditionalFilterIdByte` are extra filter parameters designed for **parent-child filtering** — when you need to show a list of child records filtered by a parent ID. Pick the variant that matches the parent entity's primary key type. **Frontend:** Pass the parent ID via the `additionalFilterIdLong` input on `spiderly-data-table`: ```html ``` **Backend:** Override the generated `GetPaginated{Entity}List` method to apply the additional filter: ```csharp public override async Task> GetPaginatedCommentList( FilterDTO filterDTO, IQueryable query, bool authorize) { if (filterDTO.AdditionalFilterIdLong.HasValue) { query = query.Where(x => x.BlogPost.Id == filterDTO.AdditionalFilterIdLong.Value); } return await base.GetPaginatedCommentList(filterDTO, query, authorize); } ``` ## DTO Property Resolution The generated filter code resolves **DTO property names** to entity paths automatically: * `categoryDisplayName` → `Category.Name` (follows navigation property + `[DisplayName]` attribute) * `categoryId` → `Category.Id` (follows FK relationship) * Properties with `[ProjectToDTO]` custom mappings are also resolved This means you can filter by DTO fields that don't directly exist on the entity — the generator knows how to translate them to the correct EF Core expression. ## TypeScript Types The Angular frontend has type-safe equivalents for all filter types. For usage with the UI components, see [Data Components](/docs/angular-components/data-components). ### Filter\ The request object, mirrors `FilterDTO`: ```typescript class Filter { filters?: { [K in keyof T]?: FilterRule[] }; first?: number; rows?: number; multiSortMeta?: FilterSortMeta[]; additionalFilterIdInt?: number; additionalFilterIdLong?: number; } ``` ### FilterRule\ Individual filter condition with **type-safe match modes**: ```typescript class FilterRule { matchMode: AllowedMatchModes; value: T; operator?: string; } ``` The `AllowedMatchModes` type restricts which match modes are valid based on the value type: | Type | Allowed Match Modes | | --------- | ----------------------------------------- | | `string` | `Contains`, `StartsWith`, `Equals` | | `boolean` | `Equals` | | `Date` | `Equals`, `GreaterThan`, `LessThan` | | `number` | `Equals`, `GreaterThan`, `LessThan`, `In` | ### MatchModeCodes ```typescript enum MatchModeCodes { StartsWith = 0, Contains = 1, Equals = 2, LessThan = 3, GreaterThan = 4, In = 5, } ``` ### PaginatedResult\ ```typescript class PaginatedResult { data?: T[]; totalRecords: number; } ``` ## Custom Projection (Advanced) When you need a fully custom DTO shape (e.g., for a storefront API), you can use the internal `GetPaginated{Entity}List` overload that returns `PaginatedResult` (filtered query + total count), then apply your own projection: ```csharp public async Task> GetPaginatedProductsCustom( FilterDTO filterDTO, IQueryable query) { PaginatedResult result = await GetPaginatedProductList(filterDTO, query); List dtos = await result.Query .Skip(filterDTO.First) .Take(filterDTO.Rows) .Select(x => new CustomProductDTO { Id = x.Id, Title = x.Title }) .ToListAsync(); return new PaginatedResultDTO { Data = dtos, TotalRecords = result.TotalRecords }; } ``` This gives you full control over the SELECT while reusing Spiderly's filtering, sorting, and pagination count logic. --- # Frontend Customization (/docs/frontend-customization) ## Overview Spiderly offers full flexibility when it comes to frontend customization. While you're not required to use the default Angular-based UI or the Spiderly Angular library, doing so provides the best integration and experience. This guide covers how to customize key elements of your Spiderly app, including: app name, logo, favicon, theme colors, layout, service overrides, and UI components. ## Change the App Name Open the `Frontend\src\environments\environment.ts` file and change the value of `companyName` to your the desired app name. ## Change the Logo Locate the `Frontend\src\assets\images\logo\logo.svg` file and replace it with your own `logo.svg`. If your logo file is not named `logo.svg` or you want to use a custom path, open the `Frontend\src\app\business\services\config.service.ts` file and override the logo path like this: ```typescript export class ConfigService extends ConfigBaseService { // ... other overrides override logoPath: string = 'assets/your-logo-path.png'; // ... } ``` ## Change the Favicon Locate the `Frontend\src\assets\images\logo\favicon.ico` file and change it with your own `favicon.ico`. ## Change Theme Colors Open the `Frontend\src\assets\primeng-theme.ts` file and edit the theme values, such as the `primary`. For more information and advanced theming options, visit the [PrimeNG Theming Guide](https://primeng.org/theming). ### Using theme colors as Tailwind utilities The generated `Frontend\src\assets\tailwind.css` imports [`tailwindcss-primeui`](https://github.com/primefaces/tailwindcss-primeui), which bridges your PrimeNG theme to Tailwind. This means utilities such as `bg-primary-50`, `text-primary-600`, and `border-surface-200` resolve to the live theme variables defined in `primeng-theme.ts` — change a color there and every utility follows. The bridge import must stay **before** `@import "tailwindcss/utilities"`; it registers the `surface`/`primary` color tokens, and Tailwind only picks up tokens that exist when the utilities import runs. Tokens registered afterwards are silently ignored, leaving `*-surface-*` / `*-primary-*` as dead classes. Tailwind CSS v4 defaults a bare `border` (with no color modifier) to `currentColor`, not a gray. Spiderly omits Tailwind's Preflight reset on purpose (to avoid clobbering PrimeNG base styles), so always pair a border with a color — e.g. `border border-surface-200`. ## Enable Dark Theme Open the `Frontend\src\index.html` file and add the `dark` class to the `html` tag, like this: ```html // ... ``` For more information and advanced dark theme options, visit the [PrimeNG Dark Theme Guide](https://primeng.org/theming#darkmode). ## Showing or Hiding a Field on the Details Page Every field the generator renders into `{YourEntityName}BaseDetailsComponent` is exposed through a `show{PropertyName}For{YourEntityName}` input that defaults to `true`. Pass it from your details page to hide a field outright, or bind it to a condition to toggle the field at runtime — without editing the generated component. Open the `Frontend\src\app\pages\your-entity-name\your-entity-name-details.html` file, locate the `` component, and add the input: ```html ``` Names are PascalCase — for example `[showEmailForUser]="false"` hides the email field, while `[showIsDisabledForUser]="isAdmin"` shows it only when `isAdmin` is `true`. Calendar fields additionally expose `showTimeOn{PropertyName}For{YourEntityName}` (defaults to `false`) to also render the time picker — e.g. `[showTimeOnBirthDateForUser]="true"`. ## Customize Routes The `spiderly add-new-entity` CLI command automatically adds routes to `Frontend\src\app\app.routes.ts`. If you need to customize them, the generated routes look like this: ```typescript { path: 'your-entity-name-list', // URL for the list page (e.g., /product-list) loadComponent: () => import('./pages/your-entity-name/your-entity-name-list.component').then(c => c.YourEntityNameListComponent), canActivate: [AuthGuard], }, { path: 'your-entity-name-list/:id', // URL for the details page (e.g., /product-list/123) loadComponent: () => import('./pages/your-entity-name/your-entity-name-details.component').then(c => c.YourEntityNameDetailsComponent), canActivate: [AuthGuard], }, ``` ## Customize the Navigation Menu The `spiderly add-new-entity` CLI command automatically adds a menu item to `Frontend\src\app\business\layout\layout.component.ts`. If you need to customize it, the generated menu item looks like this: ```typescript { label: this.translocoService.translate('YourEntityNameList'), icon: 'pi pi-fw pi-list', // Refer to https://primeng.org/icons#list for available icons routerLink: ['/your-entity-name-list'], }, ``` ## Switching to a Top Menu Layout By default, Spiderly generates your app with a side menu layout. However, if you'd prefer a top menu layout, you can easily update it by following these steps: 1. Open the `Frontend\src\app\business\layout\layout.component.html` file. 2. Update the layout component by setting the `[isSideMenuLayout]` attribute to `false`: ```html ``` If you want to add custom actions (buttons, links, or any other content) to the header area, positioned to the left of the profile avatar, you can do so like this: ```html
Your custom actions
``` ## Inject Content into the Login Screen The login component (``) exposes a `loginExtra` projection slot for consumer-supplied content — a bot-protection widget (e.g. Cloudflare Turnstile), a legal notice, or an extra field. Projected content renders in **both** the email-entry and code-verification states, so a single-use challenge widget stays mounted across the resend flow. ```html ``` Spiderly stays vendor-neutral — it only ships the slot. Attaching a token to the login request (e.g. an `X-Turnstile-Token` header) is done with a standard Angular HTTP interceptor in your own app, so the framework never depends on a specific bot-protection provider. ## Service Overrides Spiderly's Angular library uses base service classes that you extend in your project. ### ConfigService Extend `ConfigServiceBase` and override properties in `Frontend\src\app\business\services\config.service.ts`: ```typescript export class ConfigService extends ConfigBaseService { override logoPath: string = 'assets/my-custom-logo.png'; override primaryColor: string = '#3498db'; override companyName: string = 'My Company'; } ``` ### AuthService Extend `AuthServiceBase` and override methods in `Frontend\src\app\business\services\auth.service.ts`: * `onAfterLoginExternal()` - Executes after external authentication (e.g., Google) * `onAfterLogout()` - Runs after logout completion * `onAfterRefreshToken()` - Triggers following token refresh * `onAfterNgOnDestroy()` - Custom cleanup during component destruction #### Example ```typescript export class AuthService extends AuthServiceBase { override onAfterLoginExternal(): void { // Custom post-login logic this.analyticsService.trackLogin(); this.router.navigate(['/dashboard']); } override onAfterLogout(): void { // Custom logout cleanup this.cacheService.clear(); super.onAfterLogout(); } } ``` ## Generated Files Spiderly generates several Angular/TypeScript files with the `.generated.ts` suffix. These files are regenerated on each build and **should not be modified**. All generated files are located in `Frontend\src\app\business\`: | File | Purpose | | --------------------------------------------- | ------------------------------------------------- | | `entities\entities.generated.ts` | TypeScript classes mirroring your C# DTOs | | `services\api\api.service.generated.ts` | Strongly-typed API service methods | | `components\base-details.generated.ts` | Base detail components for entity forms | | `services\validators\validators.generated.ts` | Form validators matching your C# validation rules | | `enums\enums.generated.ts` | TypeScript enums matching your C# enums | To customize behavior, use the non-generated counterparts (e.g., `api.service.ts` extends `ApiGeneratedService`). If you want to understand how these files are generated or create advanced customizations, you can explore the Angular generator source code in the [Spiderly repository](https://github.com/filiptrivan/spiderly/tree/main/Spiderly.SourceGenerators/Angular). ## Using the Spiderly Data View Component If you want to display data in a cleaner, card-based layout instead of a plain table — while still keeping filtering capabilities — you can use the `spiderly-data-view` Angular component. In your `html` file: ```html {{item.name}} ``` In your `ts` file: ```typescript import { Component, OnInit } from '@angular/core'; import { TranslocoService } from '@jsverse/transloco'; import { ApiService } from 'src/app/business/services/api/api.service'; import { Filter, DataViewCardBody, Role, SpiderlyControlsModule, SpiderlyDataViewComponent, SpiderlyTemplateTypeDirective, } from 'spiderly'; @Component({ selector: 'your-component-name', templateUrl: './your-component-name.component.html', imports: [SpiderlyTemplateTypeDirective, SpiderlyDataViewComponent, SpiderlyControlsModule], }) export class YourEntityNameDataViewComponent implements OnInit { templateType?: DataViewCardBody; filters: Filter[]; getYourEntityNameTableDataObservableMethod = this.apiService.getYourEntityNameTableData; constructor( private apiService: ApiService, private translocoService: TranslocoService, ) {} ngOnInit() { this.filters = [ { name: this.translocoService.translate('Name'), filterType: 'text', field: 'name' }, { name: this.translocoService.translate('Id'), filterType: 'numeric', field: 'id', showMatchModes: true, }, { name: this.translocoService.translate('CreatedAt'), filterType: 'date', field: 'createdAt', showMatchModes: true, }, ]; } } ``` --- # Getting Started With Spiderly (/docs/getting-started) ## Install Prerequisites Before getting started with Spiderly, make sure you have the following prerequisites installed: * [Visual Studio Code](https://code.visualstudio.com/download) * [.NET 9.0](https://dotnet.microsoft.com/en-us/download/dotnet/9.0) * [Docker](https://www.docker.com/get-started/) (recommended) or a direct [PostgreSQL](https://www.postgresql.org/download/)/[SQL Server](https://www.microsoft.com/en-us/sql-server/sql-server-downloads) installation — the CLI can start a database container automatically if Docker is available; see [Database Providers](/docs/database-providers) for guidance on choosing * [Node.js](https://nodejs.org/en/download/) — npm comes bundled with Node.js. You can also use [pnpm](https://pnpm.io/installation), [yarn](https://yarnpkg.com/getting-started/install), or [bun](https://bun.sh/docs/installation) via the `--pm` flag * [C# Dev Kit](https://marketplace.visualstudio.com/items?itemName=ms-dotnettools.csdevkit) * Run the Spiderly CLI installation command from any terminal location: ```bash dotnet tool install -g Spiderly.CLI ``` ## Initialize the App Open a terminal in the folder **where you want your app to be created** and run: ```bash spiderly init ``` This will create a new folder with your app name containing the full Spiderly project structure. ### Non-Interactive Mode For AI agents or CI pipelines, use flags to skip all interactive prompts: ```bash spiderly init --name MyApp --db postgresql ``` With a specific package manager: ```bash spiderly init --name MyApp --db postgresql --pm pnpm ``` With a custom database connection string: ```bash spiderly init --name MyApp --db postgresql --db-connection-string "Host=localhost;Port=5432;Database=myapp;Username=postgres;Password=secret" ``` To skip database setup entirely: ```bash spiderly init --name MyApp --db skip ``` When `--db postgresql` or `--db sqlserver` is specified in non-interactive mode and no running database is found, the CLI will automatically start one via Docker if Docker is available. ## Open the Project Navigate into your newly created app folder and open it in VS Code: ```bash cd your-app-name ``` ```bash code . ``` If the `code .` command doesn’t work, open your newly created app folder manually in VS Code. ## Start the App Press `F5` to start the app. ## Register the User Use the UI of your generated app to register a new user via email. --- # Documentation (/docs) ## Welcome to Spiderly Documentation Spiderly is a powerful framework for building full-stack applications with .NET and Angular. This documentation will guide you through everything from getting started to advanced customization. ## Get Started The best place to start is the [Getting Started guide](/docs/getting-started), which will walk you through installing prerequisites, initializing your first app, and running it locally. --- # Relationships (/docs/relationships) ## Overview Relationships in Spiderly are configured via attributes on your C# entity classes. From those attributes, Spiderly generates: * EF Core relationship configuration (foreign keys, delete behavior, composite keys) * API endpoints for loading related data * Angular UI controls (dropdowns, autocompletes, multi-selects, editable lists) This guide covers all four relationship types using a cohesive **blog domain** as an example: authors, categories, tags, posts, and comments. ## Many-to-One A many-to-one relationship is defined by adding a `virtual` navigation property on the child entity pointing to the parent, decorated with `[WithMany]`. ```csharp public class BlogPost : BusinessObject { [DisplayName] [Required] [StringLength(200, MinimumLength = 1)] public string Title { get; set; } [Required] [WithMany(nameof(Category.BlogPosts))] public virtual Category Category { get; set; } } ``` Spiderly auto-generates: * A shadow foreign key property `CategoryId` on the `BlogPost` table * An autocomplete dropdown in the Angular UI for selecting a `Category` * DTO fields `CategoryId` and `CategoryDisplayName` `[Required]` makes the FK non-nullable — every blog post must have a category. Without `[Required]`, the FK is nullable and the dropdown allows clearing the selection. ### Explicit Foreign Key By default, the FK is a **shadow property** — EF Core creates the column but you can't reference it directly on the entity. If you need direct access to the FK value, you can declare it as a scalar property alongside the navigation: ```csharp public class BlogPost : BusinessObject { public long? CategoryId { get; set; } [Required] [WithMany(nameof(Category.BlogPosts))] public virtual Category Category { get; set; } } ``` Spiderly resolves the FK name via `[ForeignKey("...")]` or the `{Nav}Id` convention. When a scalar FK is declared, all generated code (DTOs, mappers, validators, service queries) routes through it instead of the shadow property. **When to use this:** * You write custom LINQ filters and want to avoid EF Core's [unresolved `nav.Id` JOIN issue](https://github.com/dotnet/efcore/issues/15826) — `x.CategoryId == id` generates a clean `WHERE` instead of a spurious `INNER JOIN` * You want to set the FK without first loading the parent entity: `post.CategoryId = 42` instead of `post.Category = await _context.Categories.FindAsync(42)` * The FK column name doesn't follow the `{Nav}Id` convention and needs an explicit `[ForeignKey]` mapping ## One-to-Many with \[WithMany] To make the relationship bidirectional, add a `List` collection property on the parent entity. The `[WithMany]` attribute on the child points to this collection by name: ```csharp public class Category : BusinessObject { [DisplayName] [Required] [StringLength(100, MinimumLength = 1)] public string Name { get; set; } public virtual List BlogPosts { get; } = new(); } ``` ```csharp public class BlogPost : BusinessObject { [DisplayName] [Required] [StringLength(200, MinimumLength = 1)] public string Title { get; set; } [Required] [WithMany(nameof(Category.BlogPosts))] public virtual Category Category { get; set; } } ``` Always use the `{"{ get; } = new()"}` pattern for collection navigation properties — not `{"{ get; set; }"}`. This ensures the list is never null. ### Unidirectional many-to-one (pointer-only, no back-collection) The bidirectional pattern above is the default. A navigation property *without* a matching collection on the target throws at `DbContext` init: `[WithMany(...)] attribute is required for ManyToOne property: X.Y`. When you want only the child-to-parent pointer — `Comment.LastReadMessage`, `Message.ParentMessage`, etc. — and a back-collection on the target would just be noise, **drop the navigation property entirely** and configure the relationship manually in `OnModelCreating`. ```csharp public class Comment : BusinessObject { public long PostId { get; set; } // no Post navigation } ``` ```csharp // In OnModelCreating modelBuilder.Entity() .HasOne() .WithMany() .HasForeignKey(c => c.PostId) .OnDelete(DeleteBehavior.Cascade); ``` This trades Spiderly's generated UI (autocomplete dropdown, `{Nav}DisplayName` in DTOs) for hand-managed configuration — use it only when the parent is never displayed alongside the child in the admin UI. ## Delete Behavior: \[CascadeDelete] vs \[SetNull] By default, Spiderly blocks deletion of a parent entity if it has children referencing it (EF Core `DeleteBehavior.NoAction`). You can change this with two attributes: ### \[CascadeDelete] Deleting the parent automatically deletes all children: ```csharp public class Comment : BusinessObject { [Required] [StringLength(1000, MinimumLength = 1)] public string Text { get; set; } [Required] [CascadeDelete] [WithMany(nameof(BlogPost.Comments))] public virtual BlogPost BlogPost { get; set; } } ``` When a `BlogPost` is deleted, all of its `Comment` entities are deleted too. ### \[SetNull] Deleting the parent sets the foreign key to null on children: ```csharp public class BlogPost : BusinessObject { [DisplayName] [Required] [StringLength(200, MinimumLength = 1)] public string Title { get; set; } [SetNull] [WithMany(nameof(Author.BlogPosts))] public virtual Author Author { get; set; } } ``` When an `Author` is deleted, their blog posts remain but `AuthorId` becomes null. `[SetNull]` requires a **nullable** foreign key — do not combine it with `[Required]`. A required FK means the child cannot exist without the parent, which contradicts set-null behavior. ### Summary | Attribute | On Delete | Use When | | ----------------- | ---------------------------------- | --------------------------------------- | | *(none)* | Deletion blocked if children exist | Parent is referenced master data | | `[CascadeDelete]` | All children deleted | Children have no meaning without parent | | `[SetNull]` | FK set to null on children | Children can exist independently | ## One-to-One with \[WithOne] A one-to-one relationship is defined with `[WithOne]` on the **dependent** (foreign-key-holding) side's single-valued navigation. Its presence designates that side as the dependent; the other side is the principal — a plain navigation with no attribute. Use this for a true 1-1 between two independent entities; for a value object that only lives inside its parent, inline the fields or use an EF owned type instead. ```csharp public class Conversation : BusinessObject // dependent — owns the FK { public long? OwningTaskItemId { get; set; } // explicit FK; nullable => optional 1-1 [WithOne(nameof(TaskItem.Conversation))] [CascadeDelete] // delete the TaskItem => delete its Conversation public virtual TaskItem OwningTaskItem { get; set; } } public class TaskItem : BusinessObject // principal { public virtual Conversation Conversation { get; set; } // inverse navigation, no attribute } ``` This maps a real one-to-one (`HasOne().WithOne().HasForeignKey()`), adds an automatic **unique index** on the FK, flattens the dependent's DTO to `{Nav}Id` + `{Nav}DisplayName` (like many-to-one), and renders an autocomplete on the dependent's page. **Required vs optional** is the dependent FK's nullability: * `[Required]` on the `[WithOne]` navigation → non-nullable FK → the dependent must have a principal. * No `[Required]` → nullable FK → optional; the unique index allows **many NULLs** (Postgres `NULLS DISTINCT` / SQL Server's auto `IS NOT NULL` filter — handled by provider conventions, no manual config). The schema cannot enforce "every principal has a dependent" (that direction is always 0..1), so `[Required]` on the principal-side navigation is a build error ([SPIDERLY021](/docs/build-diagnostics#spiderly021)). Other notes: cascade is app-layer (`[CascadeDelete]` / `[SetNull]`), exactly like many-to-one — no DB-level `ON DELETE CASCADE`. A parameterless `[WithOne]` makes the relationship unidirectional. Self-referential one-to-one is unsupported ([SPIDERLY022](/docs/build-diagnostics#spiderly022)). The principal-side inverse renders nothing in the admin by default (the FK lives on the dependent); add `[UIDoNotGenerate]` to the dependent's nav for a fully code-managed 1-1. ## Simple Many-to-Many A many-to-many relationship requires a **junction entity** marked with both `[M2M]` and `[SpiderlyEntity]`, containing two navigation properties decorated with `[M2MWithMany]`. `[M2M]` flags the class as a junction; `[SpiderlyEntity]` enrolls it in the generator pipeline — without it, opposite-M2M navigation lookups return null and the parent entity's generated service fails to compile. ### Junction entity ```csharp [M2M] [SpiderlyEntity] public class BlogPostTag { [M2MWithMany(nameof(BlogPost.Tags))] public virtual BlogPost BlogPost { get; set; } [M2MWithMany(nameof(Tag.BlogPosts))] public virtual Tag Tag { get; set; } } ``` A simple junction entity does **not** inherit from `BusinessObject` — it has no `Id`, `Version`, or timestamps. Spiderly creates a composite primary key from the two FKs. ### Both sides ```csharp public class BlogPost : BusinessObject { [DisplayName] [Required] [StringLength(200, MinimumLength = 1)] public string Title { get; set; } [UIControlType(nameof(UIControlTypeCodes.MultiSelect))] public virtual List Tags { get; } = new(); } ``` ```csharp public class Tag : BusinessObject { [DisplayName] [Required] [StringLength(50, MinimumLength = 1)] public string Name { get; set; } public virtual List BlogPosts { get; } = new(); } ``` `[UIControlType(nameof(UIControlTypeCodes.MultiSelect))]` renders a multi-select dropdown. For entities with many items, use `MultiAutocomplete` instead, which adds search-as-you-type. ## \[DisplayName] in Relationships `[DisplayName]` controls what text appears in dropdowns, autocompletes, and table columns when referencing an entity. ### Property-level Mark a property to use its value as the display text: ```csharp public class Author : BusinessObject { [DisplayName] [Required] [StringLength(100, MinimumLength = 1)] public string Name { get; set; } [Required] [StringLength(200, MinimumLength = 1)] public string Email { get; set; } } ``` When a dropdown shows authors, it displays each author's `Name`. ### Class-level Use dot notation to display a property from a related entity: ```csharp [DisplayName("BlogPost.Title")] public class Comment : BusinessObject { [Required] [StringLength(1000, MinimumLength = 1)] public string Text { get; set; } [Required] [CascadeDelete] [WithMany(nameof(BlogPost.Comments))] public virtual BlogPost BlogPost { get; set; } } ``` When a `Comment` appears in a list or dropdown, it shows the related `BlogPost.Title` instead of the comment's own `Id`. If no `[DisplayName]` is specified, the entity falls back to displaying the `Id`. ## \[GenerateCommaSeparatedDisplayName] When displaying many-to-many relationships in list tables, you often want to show all related items as a comma-separated string. Apply `[GenerateCommaSeparatedDisplayName]` to the collection property: ```csharp public class BlogPost : BusinessObject { [DisplayName] [Required] [StringLength(200, MinimumLength = 1)] public string Title { get; set; } [GenerateCommaSeparatedDisplayName] [UIControlType(nameof(UIControlTypeCodes.MultiSelect))] public virtual List Tags { get; } = new(); } ``` This generates a `TagsCommaSeparatedDisplayNames` property on the DTO. In the blog post list table, the Tags column shows `"Tutorial, C#, Beginner"` instead of requiring a separate column for each tag. ## Complex Many-to-Many When a junction entity needs additional fields beyond the two foreign keys, it becomes a **complex** many-to-many. The junction entity inherits from `BusinessObject` to get its own `Id`, `Version`, and timestamps. Example: a blog post can have multiple authors, each with a specific role (e.g., "Lead Author", "Contributor"): ```csharp [M2M] [SpiderlyEntity] public class BlogPostAuthor : BusinessObject { [M2MWithMany(nameof(BlogPost.BlogPostAuthors))] public virtual BlogPost BlogPost { get; set; } [M2MWithMany(nameof(Author.BlogPostAuthors))] public virtual Author Author { get; set; } [Required] [StringLength(50, MinimumLength = 1)] public string Role { get; set; } } ``` Both sides reference the junction entity directly (not the other-side entity): ```csharp public class BlogPost : BusinessObject { [DisplayName] [Required] [StringLength(200, MinimumLength = 1)] public string Title { get; set; } [ComplexManyToManyList] public virtual List BlogPostAuthors { get; } = new(); } ``` ```csharp public class Author : BusinessObject { [DisplayName] [Required] [StringLength(100, MinimumLength = 1)] public string Name { get; set; } public virtual List BlogPostAuthors { get; } = new(); } ``` ### Read-only display: \[ComplexManyToManyReadonlyTable] Use `[ComplexManyToManyReadonlyTable]` when the junction data is meaningful context for the parent but is created or edited elsewhere — generated by a background job, owned by a different entity's workflow, or part of an audit trail. Unlike `[ComplexManyToManyList]`, this renders no add/remove/reorder controls and fetches rows lazily, so it is safe for relationships of any size. ```csharp public class Author : BusinessObject { [DisplayName] [Required] [StringLength(100, MinimumLength = 1)] public string Name { get; set; } // Posts authored by this user, shown as a read-only summary on the Author page. // Editing happens on the BlogPost detail page. [ComplexManyToManyReadonlyTable] public virtual List BlogPostAuthors { get; } = new(); } ``` ## Choosing the Right M2M UI Spiderly provides three attributes for controlling how many-to-many relationships render in the Angular admin UI: | Attribute | UI Behavior | Loading | Best For | | ---------------------------------- | ------------------------------------------------- | ------- | --------------------------------------- | | `[ComplexManyToManyList]` | Editable inline list with all other-side entities | Eager | Small sets (e.g., 3 warehouses) | | `[SimpleManyToManyTableLazyLoad]` | Paginated table with add/remove | Lazy | Large sets, pair with `[UITableColumn]` | | `[ComplexManyToManyReadonlyTable]` | Read-only paginated table | Lazy | Display-only complex M2M data | For **simple M2M** (no extra fields on junction), use `[UIControlType]` on the collection property instead: | Control Type | UI Behavior | Best For | | ------------------- | --------------------------------------- | ------------------------------------- | | `MultiSelect` | Multi-select dropdown showing all items | Small lists (e.g., tags, permissions) | | `MultiAutocomplete` | Search-as-you-type multi-select | Large lists (e.g., users, products) | `[ComplexManyToManyList]` loads **all** entities from the other side into memory at once. Only use it for small sets (under \~50 items). For larger sets, use `[SimpleManyToManyTableLazyLoad]`. ## Complete Example: Blog Domain Here is a complete blog domain that ties together all relationship types covered in this guide: ```csharp namespace YourAppName.Business.Entities { public class Category : BusinessObject { [DisplayName] [Required] [StringLength(100, MinimumLength = 1)] public string Name { get; set; } public virtual List BlogPosts { get; } = new(); } public class Author : BusinessObject { [DisplayName] [Required] [StringLength(100, MinimumLength = 1)] public string Name { get; set; } [Required] [StringLength(200, MinimumLength = 1)] public string Email { get; set; } public virtual List BlogPostAuthors { get; } = new(); } public class Tag : BusinessObject { [DisplayName] [Required] [StringLength(50, MinimumLength = 1)] public string Name { get; set; } public virtual List BlogPosts { get; } = new(); } public class BlogPost : BusinessObject { [DisplayName] [Required] [StringLength(200, MinimumLength = 1)] public string Title { get; set; } [UIControlType(nameof(UIControlTypeCodes.TextArea))] [StringLength(10000, MinimumLength = 1)] public string Content { get; set; } // Many-to-one (required) [Required] [WithMany(nameof(Category.BlogPosts))] public virtual Category Category { get; set; } // One-to-many (cascade delete) public virtual List Comments { get; } = new(); // Simple many-to-many [GenerateCommaSeparatedDisplayName] [UIControlType(nameof(UIControlTypeCodes.MultiSelect))] public virtual List Tags { get; } = new(); // Complex many-to-many [ComplexManyToManyList] public virtual List BlogPostAuthors { get; } = new(); } [DisplayName("BlogPost.Title")] public class Comment : BusinessObject { [Required] [StringLength(1000, MinimumLength = 1)] public string Text { get; set; } [Required] [CascadeDelete] [WithMany(nameof(BlogPost.Comments))] public virtual BlogPost BlogPost { get; set; } } // Simple M2M junction (no Id, no timestamps) [M2M] [SpiderlyEntity] public class BlogPostTag { [M2MWithMany(nameof(BlogPost.Tags))] public virtual BlogPost BlogPost { get; set; } [M2MWithMany(nameof(Tag.BlogPosts))] public virtual Tag Tag { get; set; } } // Complex M2M junction (has extra fields) [M2M] [SpiderlyEntity] public class BlogPostAuthor : BusinessObject { [M2MWithMany(nameof(BlogPost.BlogPostAuthors))] public virtual BlogPost BlogPost { get; set; } [M2MWithMany(nameof(Author.BlogPostAuthors))] public virtual Author Author { get; set; } [Required] [StringLength(50, MinimumLength = 1)] public string Role { get; set; } } } ``` **What Spiderly generates from this:** * `BlogPost → Category`: required FK, autocomplete dropdown for selecting category * `BlogPost → Comments`: one-to-many, deleting a post cascades to its comments * `BlogPost ↔ Tags`: simple M2M via `BlogPostTag`, multi-select dropdown, tags shown as comma-separated string in list tables * `BlogPost ↔ Authors`: complex M2M via `BlogPostAuthor` with editable `Role` field, inline list UI --- # Set Up Emailing (/docs/set-up-emailing) ## Configure Email Settings Locate the `Backend\YourAppName.WebAPI\appsettings.json` file and set the following fields under `AppSettings.Spiderly.Shared`: * `"EmailSender"` — the **existing email address** to send from (verification codes, exception alerts, notifications), as an `{ "email": "...", "name": "..." }` object (`name` is the optional display name). * `"EmailSenderPassword"` — **not** your regular Gmail password. For Gmail this must be a **Gmail App Password** (see steps below). * `"EmailReplyTo"` — optional `{ "email": "...", "name": "..." }` Reply-To address attached to emails sent with the default `EmailSender`. Useful when the sender is a no-reply address: replies land in a monitored inbox instead of bouncing. `SmtpHost` defaults to `smtp.gmail.com` and `SmtpPort` defaults to `587`, so for Gmail you only need to set `EmailSender` and `EmailSenderPassword`. ## Generate a Gmail App Password App Passwords are 16-character codes that let an app sign in to your Google Account without your regular password. Two-Step Verification must be enabled first. 1. Open [myaccount.google.com/security](https://myaccount.google.com/security) while signed in to the Google account you want to send from. 2. Under **How you sign in to Google**, enable **2-Step Verification** if it isn't already on. 3. Open [myaccount.google.com/apppasswords](https://myaccount.google.com/apppasswords) (or search "App passwords" from the security page). 4. Enter an app name (e.g. `Spiderly`) and click **Create**. 5. Copy the **16-character code** shown in the yellow box — spaces are decorative, the code itself has no spaces. This is your `EmailSenderPassword`. 6. The code is shown only once. If you lose it, delete the entry and create a new one. ### Video walkthrough