spiderly 19.8.4 → 19.8.6
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.
- package/agent/docs/angular-customization/SKILL.md +389 -0
- package/agent/docs/angular-customization/references/controls.generated.md +23 -0
- package/agent/docs/angular-customization/references/helper-functions.generated.md +39 -0
- package/agent/docs/angular-customization/references/ui-control-types.generated.md +24 -0
- package/agent/docs/angular-customization/references/validators.generated.md +13 -0
- package/agent/docs/authorization/SKILL.md +385 -0
- package/agent/docs/authorization/references/api-error-codes.generated.md +17 -0
- package/agent/docs/authorization/references/security-endpoints.generated.md +24 -0
- package/agent/docs/backend-hooks/SKILL.md +231 -0
- package/agent/docs/backend-localization/SKILL.md +170 -0
- package/agent/docs/backend-testing/SKILL.md +65 -0
- package/agent/docs/custom-endpoints/SKILL.md +409 -0
- package/agent/docs/e2e-testing/SKILL.md +139 -0
- package/agent/docs/entity-design/SKILL.md +346 -0
- package/agent/docs/entity-design/references/attributes.generated.md +53 -0
- package/agent/docs/file-storage/SKILL.md +262 -0
- package/agent/docs/filtering-patterns/SKILL.md +127 -0
- package/agent/docs/filtering-patterns/references/match-mode-codes.generated.md +15 -0
- package/agent/docs/frontend-localization/SKILL.md +120 -0
- package/agent/docs/mapper-customization/SKILL.md +105 -0
- package/agent/manifest.json +34 -0
- package/agent/skills/add-entity/SKILL.md +158 -0
- package/agent/skills/deployment/SKILL.md +551 -0
- package/agent/skills/ef-migrations/SKILL.md +49 -0
- package/agent/skills/report-gap/SKILL.md +110 -0
- package/agent/skills/report-gap/scripts/build-issue-url.mjs +82 -0
- package/agent/skills/spiderly-upgrade/SKILL.md +166 -0
- package/agent/skills/verify-ui/SKILL.md +148 -0
- package/agent/skills/verify-ui/scripts/get-admin-token.mjs +134 -0
- package/fesm2022/spiderly.mjs +11 -6
- package/fesm2022/spiderly.mjs.map +1 -1
- package/lib/components/spiderly-data-table/spiderly-data-table.component.d.ts +29 -3
- 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
|
+
```
|