spiderly 19.8.4 → 19.8.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/agent/docs/angular-customization/SKILL.md +389 -0
  2. package/agent/docs/angular-customization/references/controls.generated.md +23 -0
  3. package/agent/docs/angular-customization/references/helper-functions.generated.md +39 -0
  4. package/agent/docs/angular-customization/references/ui-control-types.generated.md +24 -0
  5. package/agent/docs/angular-customization/references/validators.generated.md +13 -0
  6. package/agent/docs/authorization/SKILL.md +385 -0
  7. package/agent/docs/authorization/references/api-error-codes.generated.md +17 -0
  8. package/agent/docs/authorization/references/security-endpoints.generated.md +24 -0
  9. package/agent/docs/backend-hooks/SKILL.md +231 -0
  10. package/agent/docs/backend-localization/SKILL.md +170 -0
  11. package/agent/docs/backend-testing/SKILL.md +65 -0
  12. package/agent/docs/custom-endpoints/SKILL.md +409 -0
  13. package/agent/docs/e2e-testing/SKILL.md +139 -0
  14. package/agent/docs/entity-design/SKILL.md +346 -0
  15. package/agent/docs/entity-design/references/attributes.generated.md +53 -0
  16. package/agent/docs/file-storage/SKILL.md +262 -0
  17. package/agent/docs/filtering-patterns/SKILL.md +127 -0
  18. package/agent/docs/filtering-patterns/references/match-mode-codes.generated.md +15 -0
  19. package/agent/docs/frontend-localization/SKILL.md +120 -0
  20. package/agent/docs/mapper-customization/SKILL.md +105 -0
  21. package/agent/manifest.json +34 -0
  22. package/agent/skills/add-entity/SKILL.md +158 -0
  23. package/agent/skills/deployment/SKILL.md +551 -0
  24. package/agent/skills/ef-migrations/SKILL.md +49 -0
  25. package/agent/skills/report-gap/SKILL.md +110 -0
  26. package/agent/skills/report-gap/scripts/build-issue-url.mjs +82 -0
  27. package/agent/skills/spiderly-upgrade/SKILL.md +165 -0
  28. package/agent/skills/verify-ui/SKILL.md +148 -0
  29. package/agent/skills/verify-ui/scripts/get-admin-token.mjs +134 -0
  30. package/fesm2022/spiderly.mjs +11 -6
  31. package/fesm2022/spiderly.mjs.map +1 -1
  32. package/lib/components/spiderly-data-table/spiderly-data-table.component.d.ts +29 -3
  33. package/package.json +1 -1
@@ -0,0 +1,170 @@
1
+ ---
2
+ name: backend-localization
3
+ description: How Spiderly localizes backend (.NET) strings — error messages, Excel export names, any server-side text. Spiderly uses a JSON file localizer (flat {culture}.json files), NOT .resx. Use whenever you add or translate a backend string, hit a raw translation key leaking to the user, wonder where backend translations live or why there are no .resx files, localize a BusinessException message, register a custom (e.g. DB-backed) IStringLocalizer, or set up UseTranslations / UseCulture. For Angular admin UI strings (Transloco, assets/i18n/*.json), use the frontend-localization skill instead.
4
+ ---
5
+
6
+ # Backend Localization
7
+
8
+ Spiderly localizes server-side strings through the standard .NET `IStringLocalizer` abstraction, but the **implementation is JSON-file based — there is no `.resx` anywhere in the framework**. Do not create `.resx`/`.resources` files or a `ResourceManager`; they will not be read. The choice is deliberate: flat JSON files are diff-friendly, trivial to edit by hand or by an agent, and need no designer/codegen step.
9
+
10
+ Because everything goes through `IStringLocalizer`, call sites look identical to a resx-backed app — the JSON nature only matters when you *define* or *find* a translation.
11
+
12
+ ## The three localizer modes
13
+
14
+ `IStringLocalizer` is registered as a **singleton** in `AddSpiderly` based on the builder config (`StartupExtensions.cs`):
15
+
16
+ | Builder call | Registered implementation | Behavior |
17
+ |---|---|---|
18
+ | `spiderly.UseTranslations()` | `JsonStringLocalizer` | Loads every `Translations/{culture}.json` file. |
19
+ | `spiderly.UseTranslations<TLocalizer>()` | your `TLocalizer` | Custom source (e.g. database-backed). DI resolves its constructor. |
20
+ | *(neither called)* | `PassthroughStringLocalizer` | No-op: returns the key as its own value, so the app runs untranslated. |
21
+
22
+ `PassthroughStringLocalizer` being the default is why a brand-new app shows raw keys until you opt in with `UseTranslations()`.
23
+
24
+ ## Enabling JSON translations
25
+
26
+ ```csharp
27
+ // Startup.cs — inside AddSpiderly(spiderly => { ... })
28
+ spiderly.UsePostgreSQL();
29
+ spiderly.UseCulture("sr-Latn-RS"); // default + supported cultures
30
+ spiderly.UseTranslations(); // register the JSON localizer
31
+ ```
32
+
33
+ Translation files live in a `Translations/` directory and **must be copied to the build output** — `JsonStringLocalizer` reads from `AppContext.BaseDirectory/Translations`, not the source tree. Add this to the `.csproj` that holds them:
34
+
35
+ ```xml
36
+ <ItemGroup>
37
+ <None Update="Translations\*.json" CopyToOutputDirectory="PreserveNewest" />
38
+ </ItemGroup>
39
+ ```
40
+
41
+ ## File format and naming
42
+
43
+ Each file is a **flat** key→value JSON object (no nesting) named exactly after the culture:
44
+
45
+ ```jsonc
46
+ // Translations/sr-Latn-RS.json
47
+ {
48
+ "ConcurrencyException": "Podaci su u međuvremenu izmenjeni. Osvežite stranicu.",
49
+ "EntityDoesNotExistInDatabase": "Traženi podatak ne postoji.",
50
+ "WelcomeUser": "Dobrodošli, {0}!"
51
+ }
52
+ ```
53
+
54
+ Two hard naming rules — both fail silently if broken:
55
+
56
+ 1. **The filename must equal `CultureInfo.CurrentCulture.Name`** that the request runs under (e.g. `sr-Latn-RS.json`, `en.json`, `bs-Latn-BA.json`). The localizer keys off the running culture's exact name; a mismatch (`sr.json` when requests run as `sr-Latn-RS`) means *no file matches* and every key falls through to its raw self.
57
+ 2. **The culture must be registered via `UseCulture(default, ...additional)`.** `RequestLocalization` only switches `CurrentCulture` to cultures in that supported list; anything else falls back to the default culture, so its JSON file is never consulted.
58
+
59
+ Format placeholders use `string.Format`: `_localizer["WelcomeUser", user.Name]` → `"Dobrodošli, Filip!"`.
60
+
61
+ ## Using the localizer in code
62
+
63
+ ### Inside an entity service (`{Entity}Service : {Entity}ServiceGenerated`)
64
+
65
+ The base `ServiceBase` exposes a `protected readonly IStringLocalizer _localizer`, so use it directly. This is the canonical way to produce a **localized error message**:
66
+
67
+ ```csharp
68
+ public class ProductService : ProductServiceGenerated
69
+ {
70
+ public ProductService(EntityServiceDependencies deps) : base(deps) { }
71
+
72
+ protected override async Task OnBeforeSaveProductAndReturnSaveBodyDTO(ProductSaveBodyDTO dto)
73
+ {
74
+ if (dto.Price < 0)
75
+ throw new BusinessException(_localizer["PriceCannotBeNegative"]);
76
+ }
77
+ }
78
+ ```
79
+
80
+ `_deps.Localizer` is the same instance (the dependency bundle) — prefer `_localizer` for brevity inside a service; pass `_deps.Localizer` when a helper needs it (e.g. `Helper.ValidateFileSize(file.Length, max, _deps.Localizer)`).
81
+
82
+ ### Inside a custom (non-entity) service or controller
83
+
84
+ Inject `IStringLocalizer` through the constructor like any other dependency:
85
+
86
+ ```csharp
87
+ public class WarrantyRegistrationService
88
+ {
89
+ private readonly IStringLocalizer _localizer;
90
+
91
+ public WarrantyRegistrationService(IStringLocalizer localizer)
92
+ {
93
+ _localizer = localizer;
94
+ }
95
+
96
+ public void Validate(IFormFile file)
97
+ {
98
+ Helper.ValidateFileSize(file.Length, MaxReceiptFileSize, _localizer);
99
+ }
100
+ }
101
+ ```
102
+
103
+ Custom controllers that extend a generated base already receive `IStringLocalizer localizer` and pass it to `base(...)` — see the custom-endpoints skill.
104
+
105
+ ### Indexer vs. the `Translate` extension
106
+
107
+ ```csharp
108
+ _localizer["Key"] // LocalizedString; implicitly converts to string. Renders "Key" if missing.
109
+ _localizer["Key", arg0, arg1] // string.Format with the translation value as the format string.
110
+ _localizer.Translate("Key") // plain string, explicitly returns the key itself on a miss.
111
+ _localizer.GetExcelTranslation("ProductExcelExportName", "ProductList")
112
+ // Excel-specific key first, then the plural/list key, then the raw key.
113
+ ```
114
+
115
+ `Translate` and `GetExcelTranslation` are in `Spiderly.Shared.Localization.StringLocalizerExtensions` (`using Spiderly.Shared.Localization;`).
116
+
117
+ ## Gotchas
118
+
119
+ - **Translations are loaded once, at app start.** The JSON localizer is a singleton that reads all files in its constructor. **Editing a `*.json` file has no effect until you restart the backend** — there is no file watcher. (A custom `UseTranslations<T>()` localizer can reload per request if you build it that way.)
120
+ - **A missing key fails open, not loud.** Both the JSON localizer (unknown key) and the passthrough default return the *key string itself* with `ResourceNotFound = true`. So a typo'd or untranslated key silently leaks the raw identifier (e.g. `PriceCannotBeNegative`) to the end user instead of throwing. Keep keys in sync across every `{culture}.json`, and treat a raw-key-in-the-UI sighting as a missing-translation bug, not a code bug.
121
+ - **Don't reach for `.resx`.** Adding a resource file or `ResourceManager` won't integrate — the abstraction only resolves through the registered `IStringLocalizer`.
122
+
123
+ ## Custom localizer (e.g. database-backed)
124
+
125
+ When translations must be editable at runtime (admin-managed) rather than shipped as files, implement `IStringLocalizer` and register it. DI resolves its constructor, so it can depend on your `DbContext` or a cache:
126
+
127
+ ```csharp
128
+ public class DbStringLocalizer : IStringLocalizer
129
+ {
130
+ private readonly IApplicationDbContext _context;
131
+ public DbStringLocalizer(IApplicationDbContext context) => _context = context;
132
+
133
+ public LocalizedString this[string name]
134
+ {
135
+ get
136
+ {
137
+ string culture = CultureInfo.CurrentCulture.Name;
138
+ string value = _context.DbSet<Translation>()
139
+ .Where(t => t.Culture == culture && t.Key == name)
140
+ .Select(t => t.Value)
141
+ .FirstOrDefault();
142
+ return value != null
143
+ ? new LocalizedString(name, value)
144
+ : new LocalizedString(name, name, resourceNotFound: true);
145
+ }
146
+ }
147
+
148
+ public LocalizedString this[string name, params object[] arguments]
149
+ {
150
+ get
151
+ {
152
+ LocalizedString s = this[name];
153
+ return new LocalizedString(name, string.Format(s.Value, arguments), s.ResourceNotFound);
154
+ }
155
+ }
156
+
157
+ public IEnumerable<LocalizedString> GetAllStrings(bool includeParentCultures) =>
158
+ Enumerable.Empty<LocalizedString>();
159
+ }
160
+ ```
161
+
162
+ ```csharp
163
+ spiderly.UseTranslations<DbStringLocalizer>(); // takes precedence over the built-in JSON localizer
164
+ ```
165
+
166
+ Mirror the built-in's key-on-miss contract (`resourceNotFound: true`, return the key) so callers behave consistently.
167
+
168
+ ## Frontend strings are a separate system
169
+
170
+ This skill is backend only. The Angular admin panel localizes through **Transloco** (`src/assets/i18n/{lang}.json` + `translocoService.translate(...)`), which is unrelated to the .NET `IStringLocalizer` here. For UI labels, menu items, validation messages, and auto-translated form labels, use the **frontend-localization** skill.
@@ -0,0 +1,65 @@
1
+ ---
2
+ name: backend-testing
3
+ description: Unit-test a Spiderly backend service against an EF Core InMemory database — covers the two InMemory pitfalls that silently corrupt assertions (the change tracker masking a missing SaveChanges, and WithTransactionAsync throwing TransactionIgnoredWarning). Use when writing xUnit/NUnit tests for entity services, business logic, or save/delete hooks against an InMemory DbContext, or when an InMemory-backed test passes/throws for reasons that don't match production.
4
+ ---
5
+
6
+ # Backend Testing (EF Core InMemory)
7
+
8
+ Spiderly entity services run against a relational DbContext in production but are usually unit-tested against `Microsoft.EntityFrameworkCore.InMemory`. InMemory is *not* a relational database — two of its behavioral gaps will silently make a test lie unless you guard against them.
9
+
10
+ ## Pitfall 1: the change tracker masks a missing `SaveChanges`
11
+
12
+ EF's change tracker serves **tracked-but-unsaved** entities back through later LINQ queries on the same context. So a method that adds/mutates an entity but forgets to call `SaveChangesAsync()` still appears to work — a follow-up `await _context.Set<T>().FirstOrDefaultAsync(...)` in the test returns the in-memory tracked instance, and the assertion passes. In production (separate request/context) the row was never persisted.
13
+
14
+ **If the method's contract is "I persist before returning," assert the flush happened — not just that the value is readable:**
15
+
16
+ ```csharp
17
+ [Fact]
18
+ public async Task Activate_PersistsTheChange()
19
+ {
20
+ using var context = NewContext();
21
+ context.Product.Add(new Product { Id = 1, IsActive = false });
22
+ await context.SaveChangesAsync();
23
+
24
+ var service = new ProductService(context);
25
+ await service.ActivateAsync(productId: 1);
26
+
27
+ // ❌ This passes even if ActivateAsync never called SaveChangesAsync —
28
+ // the tracker hands back the mutated, unsaved instance.
29
+ // var p = await context.Product.FirstAsync(x => x.Id == 1);
30
+ // Assert.True(p.IsActive);
31
+
32
+ // ✅ Assert the unit of work actually flushed.
33
+ Assert.False(context.ChangeTracker.HasChanges());
34
+
35
+ // For extra confidence, read through a FRESH context so nothing is served
36
+ // from the tracker — this is the closest InMemory gets to a real round-trip:
37
+ using var verify = NewContext();
38
+ Assert.True((await verify.Product.FirstAsync(x => x.Id == 1)).IsActive);
39
+ }
40
+ ```
41
+
42
+ `NewContext()` must reuse the **same** InMemory database name across both instances so the second context sees the first's saved data (see the shared options helper below).
43
+
44
+ ## Pitfall 2: `WithTransactionAsync` throws `TransactionIgnoredWarning`
45
+
46
+ Spiderly service methods wrap their unit of work in `_context.WithTransactionAsync(...)`. The InMemory provider has no transaction support, so EF raises `InMemoryEventId.TransactionIgnoredWarning` — and by default warnings of this severity are promoted to **exceptions**, so the test throws before it reaches any assertion. Suppress that one warning when building the options:
47
+
48
+ ```csharp
49
+ using Microsoft.EntityFrameworkCore;
50
+ using Microsoft.EntityFrameworkCore.Diagnostics;
51
+
52
+ private static MyAppDbContext NewContext(string dbName) =>
53
+ new(new DbContextOptionsBuilder<MyAppDbContext>()
54
+ .UseInMemoryDatabase(dbName)
55
+ // Without this, any code path that calls WithTransactionAsync throws:
56
+ // "An 'IServiceProvider' ... transaction ... was ignored ..."
57
+ .ConfigureWarnings(w => w.Ignore(InMemoryEventId.TransactionIgnoredWarning))
58
+ .Options);
59
+ ```
60
+
61
+ Pass a stable `dbName` (e.g. one per test, `Guid.NewGuid().ToString()`) so each test is isolated but a test's own multiple contexts share state.
62
+
63
+ ## When InMemory isn't enough
64
+
65
+ InMemory ignores relational constraints (unique indexes, FK cascade, check constraints, `[Required]` at the DB level), provider-specific SQL, and real transaction semantics. If the behavior under test depends on any of those, prefer the **SQLite in-memory** provider (`UseSqlite("DataSource=:memory:")`, keep the connection open) or a disposable real database — don't assert relational guarantees against `UseInMemoryDatabase`.
@@ -0,0 +1,409 @@
1
+ ---
2
+ name: custom-endpoints
3
+ description: Add custom (non-CRUD) API endpoints to a Spiderly project. Use when creating custom controllers, adding new service methods beyond generated CRUD, building storefront or webhook endpoints, calling generated services from custom code, or choosing between return types.
4
+ ---
5
+
6
+ # Custom Endpoints
7
+
8
+ ## Controller Patterns
9
+
10
+ ### Pattern 1: Extend Generated Base Controller
11
+
12
+ Add custom methods alongside generated CRUD endpoints:
13
+
14
+ ```csharp
15
+ [ApiController]
16
+ [Route("/api/[controller]/[action]")]
17
+ public class OrderController : OrderBaseController
18
+ {
19
+ private readonly IServiceProvider _serviceProvider;
20
+
21
+ public OrderController(
22
+ IApplicationDbContext context,
23
+ IServiceProvider serviceProvider,
24
+ IStringLocalizer localizer
25
+ )
26
+ : base(context, serviceProvider, localizer)
27
+ {
28
+ _serviceProvider = serviceProvider;
29
+ }
30
+
31
+ [HttpGet]
32
+ [AuthGuard]
33
+ public async Task UpdateOrderStatus(long orderId, byte newStatusId)
34
+ {
35
+ OrderServiceGenerated orderService =
36
+ _serviceProvider.GetRequiredService<OrderServiceGenerated>();
37
+ await orderService.UpdateOrderStatus(orderId, newStatusId);
38
+ }
39
+ }
40
+ ```
41
+
42
+ The generated `OrderBaseController` already provides `GetPaginatedOrderList`, `SaveOrder`, `DeleteOrder`, etc. Your custom methods are added alongside them.
43
+
44
+ ### Pattern 2: Fully Custom Controller
45
+
46
+ For endpoints with no generated entity CRUD (storefronts, webhooks), inject domain services directly:
47
+
48
+ ```csharp
49
+ [ApiController]
50
+ [Route("/api/[controller]/[action]")]
51
+ public class StorefrontController : ControllerBase
52
+ {
53
+ private readonly StorefrontCatalogService _catalogService;
54
+
55
+ public StorefrontController(
56
+ StorefrontCatalogService catalogService
57
+ )
58
+ {
59
+ _catalogService = catalogService;
60
+ }
61
+
62
+ [HttpGet]
63
+ public async Task<List<StorefrontCategoryDTO>> Categories()
64
+ {
65
+ return await _catalogService.GetCategoriesForDisplay();
66
+ }
67
+
68
+ [HttpGet]
69
+ public async Task<ActionResult<StorefrontBrandDTO>> BrandBySlug(string slug)
70
+ {
71
+ StorefrontBrandDTO result = await _catalogService.GetBrandBySlug(slug);
72
+ if (result == null) return NotFound();
73
+ return result;
74
+ }
75
+ }
76
+ ```
77
+
78
+ ### Decide spinner behavior for every new endpoint
79
+
80
+ The global blocking spinner is inferred automatically — POSTs and full-DTO GETs keep it; read-shaped returns (`Namebook`/`Codebook`/`PaginatedResult`) and bare-scalar GETs skip it. When adding an endpoint, ask whether the inference matches the UX: a full-DTO GET that is polled on a timer, runs in the background, or feeds a lightweight popover/inline panel (e.g. a row-level "show order items" popover on a list page) should be marked `[SkipSpinner]` — blacking out the whole screen there is disproportionate. See [Key Attributes](#key-attributes) for `[SkipSpinner]`/`[ShowSpinner]` details.
81
+
82
+ ### `[Controller("Name")]` — Grouping Entities
83
+
84
+ Multiple entities under one controller:
85
+
86
+ ```csharp
87
+ [Controller("SecurityController")]
88
+ public class User : BusinessObject<long> { ... }
89
+
90
+ [Controller("SecurityController")]
91
+ public class Role : BusinessObject<int> { ... }
92
+ ```
93
+
94
+ Generates a single `SecurityBaseController` with CRUD for both entities.
95
+
96
+ ## Custom Service Methods
97
+
98
+ Create standalone service classes for custom logic. Inject `IApplicationDbContext` or `EntityServiceDependencies` as needed:
99
+
100
+ ```csharp
101
+ public class StorefrontCatalogService
102
+ {
103
+ private readonly IApplicationDbContext _context;
104
+
105
+ public StorefrontCatalogService(IApplicationDbContext context)
106
+ {
107
+ _context = context;
108
+ }
109
+
110
+ public async Task<List<StorefrontCategoryDTO>> GetCategoriesForDisplay()
111
+ {
112
+ return await _context.DbSet<Category>()
113
+ .AsNoTracking()
114
+ .Select(x => new StorefrontCategoryDTO
115
+ {
116
+ Id = x.Id,
117
+ Name = x.Name,
118
+ Slug = x.Slug,
119
+ })
120
+ .ToListAsync();
121
+ }
122
+ }
123
+ ```
124
+
125
+ To add custom methods to a generated entity service, create an `{Entity}Service` class:
126
+
127
+ ```csharp
128
+ public class OrderService : OrderServiceGenerated
129
+ {
130
+ public OrderService(EntityServiceDependencies deps) : base(deps) { }
131
+
132
+ public async Task UpdateOrderStatus(long orderId, byte newStatusId)
133
+ {
134
+ await _deps.Context.DbSet<Order>()
135
+ .Where(x => x.Id == orderId)
136
+ .ExecuteUpdateAsync(x => x.SetProperty(o => o.OrderStatusId, newStatusId));
137
+ }
138
+ }
139
+ ```
140
+
141
+ ### Database Access Patterns
142
+
143
+ ```csharp
144
+ // Simple query
145
+ List<Product> products = await _context.DbSet<Product>()
146
+ .Where(x => x.IsActive)
147
+ .ToListAsync();
148
+
149
+ // Eager load navigations
150
+ List<ProductVariant> variants = await _context.DbSet<ProductVariant>()
151
+ .Include(x => x.Product)
152
+ .Where(x => ids.Contains(x.Id))
153
+ .ToListAsync();
154
+
155
+ // Fetch with version check (optimistic concurrency)
156
+ Notification notification = await GetInstanceAsync<Notification, long>(id, version);
157
+
158
+ // Fetch without version check
159
+ OrderStatus status = await GetInstanceAsync<OrderStatus, byte>(statusId);
160
+
161
+ // Add + save
162
+ _context.DbSet<Order>().Add(order);
163
+ await _context.SaveChangesAsync();
164
+
165
+ // Batch delete
166
+ await _context.DbSet<OrderItem>()
167
+ .Where(x => x.Order.Id == orderId)
168
+ .ExecuteDeleteAsync();
169
+ ```
170
+
171
+ ### Transactions
172
+
173
+ ```csharp
174
+ StorefrontPlaceOrderResultDTO result = await _context.WithTransactionAsync(async () =>
175
+ {
176
+ // All operations here are atomic
177
+ _context.DbSet<Order>().Add(order);
178
+ await _context.SaveChangesAsync();
179
+
180
+ foreach (var item in dto.Items)
181
+ {
182
+ _context.DbSet<OrderItem>().Add(new OrderItem { Order = order, ... });
183
+ variant.Stock -= item.Quantity;
184
+ }
185
+ await _context.SaveChangesAsync();
186
+
187
+ return new StorefrontPlaceOrderResultDTO { OrderNumber = order.OrderNumber };
188
+ });
189
+ ```
190
+
191
+ ### Current User Context
192
+
193
+ ```csharp
194
+ long currentUserId = _authenticationService.GetCurrentUserId();
195
+
196
+ UserWishlist wishlist = await _context.DbSet<UserWishlist>()
197
+ .FirstOrDefaultAsync(x => x.User.Id == currentUserId);
198
+ ```
199
+
200
+ ### Calling Generated Service Methods
201
+
202
+ Within an entity service, call inherited methods directly. From other services, resolve the entity service via DI:
203
+
204
+ ```csharp
205
+ // Within ProductService — call inherited generated methods directly
206
+ PaginatedResultDTO<ProductDTO> products = await GetPaginatedProductList(
207
+ filterDTO, _deps.Context.DbSet<Product>(), authorize: false);
208
+
209
+ // From a different service — resolve the entity service via DI
210
+ ProductServiceGenerated productService =
211
+ _deps.ServiceProvider.GetRequiredService<ProductServiceGenerated>();
212
+ PaginatedResultDTO<ProductDTO> products = await productService.GetPaginatedProductList(
213
+ filterDTO, _deps.Context.DbSet<Product>(), authorize: false);
214
+
215
+ // Use the internal overload for custom projection
216
+ PaginatedResult<Product> result = await GetPaginatedProductList(filterDTO, query);
217
+ List<CustomDTO> dtos = await result.Query
218
+ .Skip(filterDTO.First).Take(filterDTO.Rows)
219
+ .Select(x => new CustomDTO { ... })
220
+ .ToListAsync();
221
+ ```
222
+
223
+ ## Return Types
224
+
225
+ | Return Type | When to Use | HTTP Status |
226
+ |---|---|---|
227
+ | `Task<TDto>` | Single entity | 200 with JSON |
228
+ | `Task<List<TDto>>` | Entity list | 200 with JSON array |
229
+ | `Task<PaginatedResultDTO<T>>` | Paginated data | 200 with `{ data, totalRecords }` |
230
+ | `Task` (void) | Fire-and-forget actions | 200 empty |
231
+ | `Task<int>` / `Task<string>` | Scalar values | 200 with value |
232
+ | `Task<IActionResult>` | File downloads, conditional status | Varies |
233
+ | `Task<ActionResult<TDto>>` | Entity or 404 | 200 or 404 |
234
+
235
+ ```csharp
236
+ // File download (inject IOptions<Spiderly.Shared.ExcelOptions> excelOptions via the constructor)
237
+ return File(bytes, excelOptions.Value.ExcelContentType, "export.xlsx");
238
+
239
+ // Conditional 404
240
+ StorefrontBrandDTO result = await _catalogService.GetBrandBySlug(slug);
241
+ if (result == null) return NotFound();
242
+ return result;
243
+
244
+ // Webhook acknowledgement
245
+ return Ok();
246
+ ```
247
+
248
+ ### Return and parameter types must be discoverable (SPIDERLY001)
249
+
250
+ Any custom class used as a controller return type or `[FromBody]` parameter must be discoverable by the source generator, otherwise the generated Angular TS client will reference an undefined type. A type is discoverable when it carries one of:
251
+
252
+ - `[SpiderlyDTO]` — hand-written DTO, **or**
253
+ - `[SpiderlyEntity]` — Spiderly entity.
254
+
255
+ If the generator can't resolve the type, it emits build error **SPIDERLY001** at `dotnet build` time — no more broken TypeScript references surfacing later at `ng build`.
256
+
257
+ **Canonical pattern for custom response shapes:**
258
+
259
+ ```csharp
260
+ using Spiderly.Shared.Attributes;
261
+
262
+ namespace MyProject.Business.DTO
263
+ {
264
+ [SpiderlyDTO]
265
+ public partial class CheckoutSummaryDTO
266
+ {
267
+ public decimal Total { get; set; }
268
+ public int ItemCount { get; set; }
269
+ }
270
+ }
271
+
272
+ // In controller
273
+ [HttpGet]
274
+ public async Task<CheckoutSummaryDTO> GetCheckoutSummary() => await _service.BuildSummary();
275
+ ```
276
+
277
+ **Anti-pattern** — plain C# class with no marker attribute:
278
+
279
+ ```csharp
280
+ // WILL TRIGGER SPIDERLY001
281
+ public class CheckoutSummary { public decimal Total { get; set; } }
282
+
283
+ [HttpGet]
284
+ public async Task<CheckoutSummary> GetCheckoutSummary() => ...; // ❌ broken TS ref
285
+ ```
286
+
287
+ **Fix:** add `[SpiderlyDTO]` (conventionally suffix the class name with `DTO`).
288
+
289
+ ### Prefer generated TS over hand-written `api.service.ts`
290
+
291
+ The Spiderly CLI generates a typed method in `api.service.generated.ts` for every action on a `[SpiderlyController]` whose DTOs are `[SpiderlyDTO]`, plus the matching TS classes in `entities.generated.ts`. **Default to this** — one source of truth (C# DTOs), automatic regeneration on changes, no drift between backend and frontend types.
292
+
293
+ Requirements to enable auto-generation:
294
+
295
+ 1. Put `[SpiderlyDTO]` DTOs in the flat `{App}.Business.DTO` namespace — **not** a sub-namespace like `{App}.Business.DTO.Foo`. The `ExcelPropertiesToExclude` and `ValidationRules` source generators only emit `using {App}.Business.DTO;` and will fail with `CS0246` for types in sub-namespaces.
296
+ 2. Mark the controller with `[SpiderlyController]` and **do not** add `[UIDoNotGenerate]`.
297
+ 3. Use explicit action names that disambiguate across controllers (e.g. `GetSupplierReplenishmentDrafts`, not bare `GetDrafts`) — the generated TS method is camelCase of the action name, unscoped by controller.
298
+
299
+ Naming convention the generator applies:
300
+ - DTO `FooBarDTO` → TS class `FooBar` in `entities.generated.ts`
301
+ - Action `GetFooBar` → TS method `getFooBar` in `api.service.generated.ts`
302
+
303
+ Consume in Angular by importing from the generated files directly — do **not** re-declare local interfaces or add a hand-written method to `api.service.ts`.
304
+
305
+ ### When to opt out (manual `api.service.ts`)
306
+
307
+ Add `[UIDoNotGenerate]` to the controller + leave DTOs plain (no `[SpiderlyDTO]`) + write the method by hand in `api.service.ts` **only** when the shape cannot be auto-generated:
308
+ - `IFormFile` uploads (bulk Excel import, image upload)
309
+ - `Blob` responses (PDF download via `responseType: 'blob'`)
310
+ - Custom content-type / header negotiation
311
+
312
+ ### Validation traps
313
+
314
+ - `[StringLength]` on a `List<string>` (or any collection-of-scalar) breaks the FluentValidation generator with `CS1929` — it emits `.MaximumLength(...)` which only exists for scalar strings. Apply `[StringLength]` only to scalar `string` properties; leave collection elements unconstrained at the DTO level.
315
+
316
+ ## Exception Handling
317
+
318
+ | Type | HTTP | When |
319
+ |---|---|---|
320
+ | `BusinessException(message)` | 400 | User-facing validation errors |
321
+ | `SecurityViolationException()` | 403 | Tampering, impossible conditions |
322
+
323
+ ```csharp
324
+ if (dto.Items.Count == 0)
325
+ throw new BusinessException("Cart is empty.");
326
+
327
+ if (paymentMethod == null)
328
+ throw new SecurityViolationException($"Invalid PaymentMethodId: {dto.PaymentMethodId}");
329
+ ```
330
+
331
+ ## Key Attributes
332
+
333
+ | Attribute | Purpose |
334
+ |---|---|
335
+ | `[AuthGuard]` | Require valid JWT |
336
+ | `[UIDoNotGenerate]` | Hide from Swagger / skip Angular UI generation |
337
+ | `[SkipSpinner]` | Skip the global full-screen blocking spinner. **Usually unnecessary** — auto-applied to `Namebook`/`Codebook`/`PaginatedResult`/`LazyLoadSelectedIds` returns and to any `HttpGet` returning a bare scalar (`int`/`bool`/`decimal`/`DateTime`/…). Add it manually only when the inference can't see your intent: a GET that returns a **full DTO but is polled/refreshed on a timer**, a background submit, or a fetch feeding a **lightweight popover/inline panel** where a full-screen block is disproportionate. |
338
+ | `[ShowSpinner]` | Force the spinner back ON, overriding the auto-skip. **Rarely needed** — a slow user-triggered operation is usually a `POST` (which keeps the spinner without any attribute). Use only for a deliberately slow `HttpGet` returning a bare scalar where you still want the blocking overlay. |
339
+ | `[ApiExplorerSettings(GroupName = "...")]` | Swagger grouping |
340
+ | `[FromForm]` | Bind file uploads |
341
+ | `[FromBody]` | Bind JSON body |
342
+
343
+ ## DI Registration
344
+
345
+ Entity services are auto-registered by the generated `EntityServiceRegistration` class. Register custom services in `Extensions/AppServiceExtensions.cs`:
346
+
347
+ ```csharp
348
+ public static class AppServiceExtensions
349
+ {
350
+ public static IServiceCollection AddAppServices(this IServiceCollection services)
351
+ {
352
+ // Entity services (auto-generated — registers all {Entity}ServiceGenerated + user overrides)
353
+ services.AddEntityServices();
354
+
355
+ // Custom services
356
+ services.AddTransient<StorefrontCatalogService>();
357
+ services.AddTransient<MeilisearchService>();
358
+ services.AddTransient<IPaymentGateway, RaiAcceptPaymentGateway>();
359
+
360
+ return services;
361
+ }
362
+ }
363
+ ```
364
+
365
+ Then call `services.AddAppServices()` in `Startup.ConfigureServices()`. Inject into controllers via constructor — the DI container resolves all dependencies automatically.
366
+
367
+ If you create an `{Entity}Service` that extends `{Entity}ServiceGenerated`, the auto-generated registration detects it and registers both the concrete type and the generated base type to resolve to your override.
368
+
369
+ ## Custom DTOs
370
+
371
+ Define in `Business/DTOs/` or a similar folder:
372
+
373
+ ```csharp
374
+ public class StorefrontProductDTO
375
+ {
376
+ [Required]
377
+ public long Id { get; set; }
378
+
379
+ [Required]
380
+ public string Title { get; set; }
381
+
382
+ public decimal? SalePrice { get; set; }
383
+
384
+ [Required]
385
+ public string ImageUrl { get; set; }
386
+ }
387
+ ```
388
+
389
+ Use `[Required]` on non-nullable fields for correct Swagger/TypeScript generation.
390
+
391
+ **Validation attributes belong on input DTOs only.** On a DTO that accepts data (a request body you `ValidateAndThrow` and persist), `[StringLength]`, `[GreaterThanOrEqualTo]`, etc. produce FluentValidation + Angular rules that actually run. On a readonly/output-only DTO — something you only return, like the example above — skip them: nothing ever validates data the server itself produced, so the rules are dead weight. The one attribute to keep on output DTOs is `[Required]`, because it also drives Swagger nullability and therefore the generated TypeScript types. (A render-only `[SpiderlyDTO]` currently still emits unused Angular form-validators — harmless noise; tracked as filiptrivan/spiderly#242.)
392
+
393
+ ## Extending PermissionCodes
394
+
395
+ Add custom permission codes via partial class:
396
+
397
+ ```csharp
398
+ public static partial class PermissionCodes
399
+ {
400
+ public static string ExportReports { get; } = "ExportReports";
401
+ public static string ManageSettings { get; } = "ManageSettings";
402
+ }
403
+ ```
404
+
405
+ Then check in custom code:
406
+
407
+ ```csharp
408
+ await _authorizationService.AuthorizeAndThrowAsync<User>(PermissionCodes.ExportReports);
409
+ ```