@malamute/ai-rules 1.0.0 → 1.2.0
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/README.md +270 -121
- package/bin/cli.js +5 -2
- package/configs/_shared/.claude/rules/conventions/documentation.md +324 -0
- package/configs/_shared/.claude/rules/conventions/git.md +265 -0
- package/configs/_shared/.claude/rules/{performance.md → conventions/performance.md} +1 -1
- package/configs/_shared/.claude/rules/conventions/principles.md +334 -0
- package/configs/_shared/.claude/rules/devops/ci-cd.md +262 -0
- package/configs/_shared/.claude/rules/devops/docker.md +275 -0
- package/configs/_shared/.claude/rules/devops/nx.md +194 -0
- package/configs/_shared/.claude/rules/domain/backend/api-design.md +203 -0
- package/configs/_shared/.claude/rules/lang/csharp/async.md +220 -0
- package/configs/_shared/.claude/rules/lang/csharp/csharp.md +314 -0
- package/configs/_shared/.claude/rules/lang/csharp/linq.md +210 -0
- package/configs/_shared/.claude/rules/lang/python/async.md +337 -0
- package/configs/_shared/.claude/rules/lang/python/celery.md +476 -0
- package/configs/_shared/.claude/rules/lang/python/config.md +339 -0
- package/configs/{python/.claude/rules → _shared/.claude/rules/lang/python}/database/sqlalchemy.md +6 -1
- package/configs/_shared/.claude/rules/lang/python/deployment.md +523 -0
- package/configs/_shared/.claude/rules/lang/python/error-handling.md +330 -0
- package/configs/_shared/.claude/rules/lang/python/migrations.md +421 -0
- package/configs/_shared/.claude/rules/lang/python/python.md +172 -0
- package/configs/_shared/.claude/rules/lang/python/repository.md +383 -0
- package/configs/{python/.claude/rules → _shared/.claude/rules/lang/python}/testing.md +2 -69
- package/configs/_shared/.claude/rules/lang/typescript/async.md +447 -0
- package/configs/_shared/.claude/rules/lang/typescript/generics.md +356 -0
- package/configs/_shared/.claude/rules/lang/typescript/typescript.md +212 -0
- package/configs/_shared/.claude/rules/quality/error-handling.md +48 -0
- package/configs/_shared/.claude/rules/quality/logging.md +45 -0
- package/configs/_shared/.claude/rules/quality/observability.md +240 -0
- package/configs/_shared/.claude/rules/quality/testing-patterns.md +65 -0
- package/configs/_shared/.claude/rules/security/secrets-management.md +222 -0
- package/configs/_shared/.claude/skills/analysis/explore/SKILL.md +257 -0
- package/configs/_shared/.claude/skills/analysis/security-audit/SKILL.md +184 -0
- package/configs/_shared/.claude/skills/dev/api-endpoint/SKILL.md +126 -0
- package/configs/_shared/.claude/{commands/generate-tests.md → skills/dev/generate-tests/SKILL.md} +6 -0
- package/configs/_shared/.claude/{commands/fix-issue.md → skills/git/fix-issue/SKILL.md} +6 -0
- package/configs/_shared/.claude/{commands/review-pr.md → skills/git/review-pr/SKILL.md} +6 -0
- package/configs/_shared/.claude/skills/infra/deploy/SKILL.md +139 -0
- package/configs/_shared/.claude/skills/infra/docker/SKILL.md +95 -0
- package/configs/_shared/.claude/skills/infra/migration/SKILL.md +158 -0
- package/configs/_shared/.claude/skills/nx/nx-affected/SKILL.md +72 -0
- package/configs/_shared/.claude/skills/nx/nx-lib/SKILL.md +375 -0
- package/configs/_shared/CLAUDE.md +52 -149
- package/configs/angular/.claude/rules/{components.md → core/components.md} +69 -15
- package/configs/angular/.claude/rules/core/resource.md +285 -0
- package/configs/angular/.claude/rules/core/signals.md +323 -0
- package/configs/angular/.claude/rules/http.md +338 -0
- package/configs/angular/.claude/rules/routing.md +291 -0
- package/configs/angular/.claude/rules/ssr.md +312 -0
- package/configs/angular/.claude/rules/state/signal-store.md +408 -0
- package/configs/angular/.claude/rules/{state.md → state/state.md} +2 -2
- package/configs/angular/.claude/rules/testing.md +7 -7
- package/configs/angular/.claude/rules/ui/aria.md +422 -0
- package/configs/angular/.claude/rules/ui/forms.md +424 -0
- package/configs/angular/.claude/rules/ui/pipes-directives.md +335 -0
- package/configs/angular/.claude/settings.json +1 -0
- package/configs/angular/.claude/skills/ngrx-slice/SKILL.md +362 -0
- package/configs/angular/.claude/skills/signal-store/SKILL.md +445 -0
- package/configs/angular/CLAUDE.md +24 -216
- package/configs/dotnet/.claude/rules/background-services.md +552 -0
- package/configs/dotnet/.claude/rules/configuration.md +426 -0
- package/configs/dotnet/.claude/rules/ddd.md +447 -0
- package/configs/dotnet/.claude/rules/dependency-injection.md +343 -0
- package/configs/dotnet/.claude/rules/mediatr.md +320 -0
- package/configs/dotnet/.claude/rules/middleware.md +489 -0
- package/configs/dotnet/.claude/rules/result-pattern.md +363 -0
- package/configs/dotnet/.claude/rules/validation.md +388 -0
- package/configs/dotnet/.claude/settings.json +21 -3
- package/configs/dotnet/CLAUDE.md +53 -286
- package/configs/fastapi/.claude/rules/background-tasks.md +254 -0
- package/configs/fastapi/.claude/rules/dependencies.md +170 -0
- package/configs/{python → fastapi}/.claude/rules/fastapi.md +61 -1
- package/configs/fastapi/.claude/rules/lifespan.md +274 -0
- package/configs/fastapi/.claude/rules/middleware.md +229 -0
- package/configs/fastapi/.claude/rules/pydantic.md +433 -0
- package/configs/fastapi/.claude/rules/responses.md +251 -0
- package/configs/fastapi/.claude/rules/routers.md +202 -0
- package/configs/fastapi/.claude/rules/security.md +222 -0
- package/configs/fastapi/.claude/rules/testing.md +251 -0
- package/configs/fastapi/.claude/rules/websockets.md +298 -0
- package/configs/fastapi/.claude/settings.json +33 -0
- package/configs/fastapi/CLAUDE.md +144 -0
- package/configs/flask/.claude/rules/blueprints.md +208 -0
- package/configs/flask/.claude/rules/cli.md +285 -0
- package/configs/flask/.claude/rules/configuration.md +281 -0
- package/configs/flask/.claude/rules/context.md +238 -0
- package/configs/flask/.claude/rules/error-handlers.md +278 -0
- package/configs/flask/.claude/rules/extensions.md +278 -0
- package/configs/flask/.claude/rules/flask.md +171 -0
- package/configs/flask/.claude/rules/marshmallow.md +206 -0
- package/configs/flask/.claude/rules/security.md +267 -0
- package/configs/flask/.claude/rules/testing.md +284 -0
- package/configs/flask/.claude/settings.json +33 -0
- package/configs/flask/CLAUDE.md +166 -0
- package/configs/nestjs/.claude/rules/common-patterns.md +300 -0
- package/configs/nestjs/.claude/rules/filters.md +376 -0
- package/configs/nestjs/.claude/rules/interceptors.md +317 -0
- package/configs/nestjs/.claude/rules/middleware.md +321 -0
- package/configs/nestjs/.claude/rules/modules.md +26 -0
- package/configs/nestjs/.claude/rules/pipes.md +351 -0
- package/configs/nestjs/.claude/rules/websockets.md +451 -0
- package/configs/nestjs/.claude/settings.json +16 -2
- package/configs/nestjs/CLAUDE.md +57 -215
- package/configs/nextjs/.claude/rules/api-routes.md +358 -0
- package/configs/nextjs/.claude/rules/authentication.md +355 -0
- package/configs/nextjs/.claude/rules/components.md +52 -0
- package/configs/nextjs/.claude/rules/data-fetching.md +249 -0
- package/configs/nextjs/.claude/rules/database.md +400 -0
- package/configs/nextjs/.claude/rules/middleware.md +303 -0
- package/configs/nextjs/.claude/rules/routing.md +324 -0
- package/configs/nextjs/.claude/rules/seo.md +350 -0
- package/configs/nextjs/.claude/rules/server-actions.md +353 -0
- package/configs/nextjs/.claude/rules/state/zustand.md +6 -6
- package/configs/nextjs/.claude/settings.json +5 -0
- package/configs/nextjs/CLAUDE.md +69 -331
- package/package.json +23 -9
- package/src/cli.js +220 -0
- package/src/config.js +29 -0
- package/src/index.js +13 -0
- package/src/installer.js +361 -0
- package/src/merge.js +116 -0
- package/src/tech-config.json +29 -0
- package/src/utils.js +96 -0
- package/configs/python/.claude/rules/flask.md +0 -332
- package/configs/python/.claude/settings.json +0 -18
- package/configs/python/CLAUDE.md +0 -273
- package/src/install.js +0 -315
- /package/configs/_shared/.claude/rules/{accessibility.md → domain/frontend/accessibility.md} +0 -0
- /package/configs/_shared/.claude/rules/{security.md → security/security.md} +0 -0
- /package/configs/_shared/.claude/skills/{debug → dev/debug}/SKILL.md +0 -0
- /package/configs/_shared/.claude/skills/{learning → dev/learning}/SKILL.md +0 -0
- /package/configs/_shared/.claude/skills/{spec → dev/spec}/SKILL.md +0 -0
- /package/configs/_shared/.claude/skills/{review → git/review}/SKILL.md +0 -0
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
---
|
|
2
|
+
paths:
|
|
3
|
+
- "**/*.component.ts"
|
|
4
|
+
- "**/*.component.html"
|
|
5
|
+
- "**/*.store.ts"
|
|
6
|
+
- "**/+state/**/*.ts"
|
|
7
|
+
- "**/data-access/**/*.ts"
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
# Angular Signals & Reactivity
|
|
11
|
+
|
|
12
|
+
## Signals - State Management
|
|
13
|
+
|
|
14
|
+
```typescript
|
|
15
|
+
// Writable signal - use protected for template-bound, private for internal
|
|
16
|
+
protected readonly count = signal(0);
|
|
17
|
+
protected readonly items = signal<Item[]>([]);
|
|
18
|
+
|
|
19
|
+
// Read value
|
|
20
|
+
const current = this.count();
|
|
21
|
+
|
|
22
|
+
// Update value
|
|
23
|
+
this.count.set(10);
|
|
24
|
+
this.count.update(v => v + 1);
|
|
25
|
+
this.items.update(items => [...items, newItem]);
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Computed - Derived State
|
|
29
|
+
|
|
30
|
+
```typescript
|
|
31
|
+
// Automatically updates when dependencies change
|
|
32
|
+
protected readonly doubleCount = computed(() => this.count() * 2);
|
|
33
|
+
protected readonly isEmpty = computed(() => this.items().length === 0);
|
|
34
|
+
protected readonly filteredItems = computed(() =>
|
|
35
|
+
this.items().filter(item => item.active)
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
// Multiple dependencies
|
|
39
|
+
protected readonly summary = computed(() => ({
|
|
40
|
+
total: this.items().length,
|
|
41
|
+
active: this.items().filter(i => i.active).length,
|
|
42
|
+
selected: this.selectedId() !== null,
|
|
43
|
+
}));
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Effect - Side Effects
|
|
47
|
+
|
|
48
|
+
```typescript
|
|
49
|
+
// Runs when any signal inside changes
|
|
50
|
+
effect(() => {
|
|
51
|
+
console.log('Count changed:', this.count());
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
// With cleanup
|
|
55
|
+
effect((onCleanup) => {
|
|
56
|
+
const subscription = someObservable$.subscribe();
|
|
57
|
+
onCleanup(() => subscription.unsubscribe());
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
// Conditional tracking
|
|
61
|
+
effect(() => {
|
|
62
|
+
if (this.isEnabled()) {
|
|
63
|
+
// Only tracks isEnabled and data when isEnabled is true
|
|
64
|
+
this.saveData(this.data());
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## Signal Inputs/Outputs
|
|
70
|
+
|
|
71
|
+
```typescript
|
|
72
|
+
// Inputs - use input() function, NOT @Input() decorator
|
|
73
|
+
public readonly name = input<string>(); // Optional, undefined if not provided
|
|
74
|
+
public readonly name = input('default'); // With default value
|
|
75
|
+
public readonly name = input.required<string>(); // Required, error if not provided
|
|
76
|
+
|
|
77
|
+
// Transform input
|
|
78
|
+
public readonly count = input(0, { transform: numberAttribute });
|
|
79
|
+
|
|
80
|
+
// Alias
|
|
81
|
+
public readonly userName = input<string>('', { alias: 'name' });
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
```typescript
|
|
85
|
+
// Outputs - use output() function, NOT @Output() decorator
|
|
86
|
+
public readonly clicked = output<void>();
|
|
87
|
+
public readonly selected = output<Item>();
|
|
88
|
+
public readonly valueChange = output<string>();
|
|
89
|
+
|
|
90
|
+
// Emit
|
|
91
|
+
this.clicked.emit();
|
|
92
|
+
this.selected.emit(item);
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
```typescript
|
|
96
|
+
// Two-way binding with model()
|
|
97
|
+
public readonly value = model<string>(''); // Creates input + output pair
|
|
98
|
+
public readonly value = model.required<string>(); // Required two-way binding
|
|
99
|
+
|
|
100
|
+
// Parent usage: [(value)]="parentValue"
|
|
101
|
+
// Internally creates: [value]="..." (valueChange)="..."
|
|
102
|
+
|
|
103
|
+
// Update in component
|
|
104
|
+
this.value.set('new value');
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
## Convert Observables to Signals
|
|
108
|
+
|
|
109
|
+
```typescript
|
|
110
|
+
// toSignal - convert observable to signal
|
|
111
|
+
protected readonly data = toSignal(this.http.get<Data[]>('/api/data'), {
|
|
112
|
+
initialValue: []
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
// With error handling
|
|
116
|
+
protected readonly data = toSignal(this.http.get<Data[]>('/api/data'), {
|
|
117
|
+
initialValue: [],
|
|
118
|
+
rejectErrors: true,
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
// From store
|
|
122
|
+
protected readonly users = this.store.selectSignal(selectAllUsers);
|
|
123
|
+
protected readonly loading = this.store.selectSignal(selectLoading);
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
## linkedSignal - Resettable Derived State
|
|
127
|
+
|
|
128
|
+
`linkedSignal` creates a writable signal that resets when its source changes:
|
|
129
|
+
|
|
130
|
+
```typescript
|
|
131
|
+
// Pagination that resets when filter changes
|
|
132
|
+
protected readonly searchQuery = signal('');
|
|
133
|
+
protected readonly currentPage = linkedSignal(() => {
|
|
134
|
+
this.searchQuery(); // Track dependency
|
|
135
|
+
return 1; // Reset to page 1 when query changes
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
// User can still manually change page
|
|
139
|
+
this.currentPage.set(5);
|
|
140
|
+
|
|
141
|
+
// But when searchQuery changes, currentPage resets to 1
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
```typescript
|
|
145
|
+
// Form field that resets to server value but can be edited
|
|
146
|
+
protected readonly selectedUser = input.required<User>();
|
|
147
|
+
protected readonly editedEmail = linkedSignal(() => this.selectedUser().email);
|
|
148
|
+
|
|
149
|
+
// User edits the email
|
|
150
|
+
this.editedEmail.set('new@email.com');
|
|
151
|
+
|
|
152
|
+
// When selectedUser changes, editedEmail resets to new user's email
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
```typescript
|
|
156
|
+
// Settings with server defaults that can be overridden
|
|
157
|
+
protected readonly serverSettings = toSignal(this.settingsService.get());
|
|
158
|
+
protected readonly localTheme = linkedSignal(() => this.serverSettings()?.theme ?? 'light');
|
|
159
|
+
|
|
160
|
+
// User can override locally
|
|
161
|
+
this.localTheme.set('dark');
|
|
162
|
+
|
|
163
|
+
// When server settings reload, resets to server value
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
## View Queries as Signals
|
|
167
|
+
|
|
168
|
+
```typescript
|
|
169
|
+
// viewChild - single element/component reference
|
|
170
|
+
protected readonly inputRef = viewChild<ElementRef<HTMLInputElement>>('nameInput');
|
|
171
|
+
protected readonly inputRef = viewChild.required<ElementRef>('nameInput'); // Required
|
|
172
|
+
|
|
173
|
+
// viewChildren - multiple references
|
|
174
|
+
protected readonly items = viewChildren(ItemComponent);
|
|
175
|
+
protected readonly listItems = viewChildren<ElementRef>('listItem');
|
|
176
|
+
|
|
177
|
+
// contentChild - projected content
|
|
178
|
+
protected readonly header = contentChild<TemplateRef<unknown>>('header');
|
|
179
|
+
protected readonly header = contentChild.required(HeaderComponent);
|
|
180
|
+
|
|
181
|
+
// contentChildren - multiple projected content
|
|
182
|
+
protected readonly tabs = contentChildren(TabComponent);
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
```typescript
|
|
186
|
+
// Usage example
|
|
187
|
+
@Component({
|
|
188
|
+
selector: 'app-search',
|
|
189
|
+
template: `
|
|
190
|
+
<input #searchInput type="text" />
|
|
191
|
+
<button (click)="focusInput()">Focus</button>
|
|
192
|
+
`,
|
|
193
|
+
})
|
|
194
|
+
export class SearchComponent {
|
|
195
|
+
private readonly searchInput = viewChild.required<ElementRef<HTMLInputElement>>('searchInput');
|
|
196
|
+
|
|
197
|
+
public focusInput(): void {
|
|
198
|
+
this.searchInput().nativeElement.focus();
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
```typescript
|
|
204
|
+
// Content projection example
|
|
205
|
+
@Component({
|
|
206
|
+
selector: 'app-card',
|
|
207
|
+
template: `
|
|
208
|
+
<div class="card">
|
|
209
|
+
<header>
|
|
210
|
+
<ng-content select="[card-header]" />
|
|
211
|
+
</header>
|
|
212
|
+
<div class="body">
|
|
213
|
+
<ng-content />
|
|
214
|
+
</div>
|
|
215
|
+
</div>
|
|
216
|
+
`,
|
|
217
|
+
})
|
|
218
|
+
export class CardComponent {
|
|
219
|
+
private readonly headerContent = contentChild<ElementRef>('card-header');
|
|
220
|
+
|
|
221
|
+
protected readonly hasHeader = computed(() => !!this.headerContent());
|
|
222
|
+
}
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
## RxJS Interop
|
|
226
|
+
|
|
227
|
+
```typescript
|
|
228
|
+
// toObservable - convert signal to observable
|
|
229
|
+
private readonly count$ = toObservable(this.count);
|
|
230
|
+
|
|
231
|
+
// Use when you need RxJS operators
|
|
232
|
+
this.searchQuery$.pipe(
|
|
233
|
+
debounceTime(300),
|
|
234
|
+
distinctUntilChanged(),
|
|
235
|
+
switchMap(query => this.searchService.search(query))
|
|
236
|
+
).subscribe();
|
|
237
|
+
|
|
238
|
+
// Cleanup with takeUntilDestroyed
|
|
239
|
+
private readonly destroyRef = inject(DestroyRef);
|
|
240
|
+
|
|
241
|
+
this.source$.pipe(
|
|
242
|
+
takeUntilDestroyed(this.destroyRef)
|
|
243
|
+
).subscribe(data => this.handleData(data));
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
## Template Control Flow
|
|
247
|
+
|
|
248
|
+
```html
|
|
249
|
+
<!-- Conditionals -->
|
|
250
|
+
@if (loading()) {
|
|
251
|
+
<app-spinner />
|
|
252
|
+
} @else if (error()) {
|
|
253
|
+
<app-error [message]="error()" />
|
|
254
|
+
} @else {
|
|
255
|
+
<app-content [data]="data()" />
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
<!-- Loops - track is REQUIRED -->
|
|
259
|
+
@for (item of items(); track item.id) {
|
|
260
|
+
<app-item [item]="item" (click)="select(item)" />
|
|
261
|
+
} @empty {
|
|
262
|
+
<p>No items found</p>
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
<!-- Switch -->
|
|
266
|
+
@switch (status()) {
|
|
267
|
+
@case ('loading') { <app-spinner /> }
|
|
268
|
+
@case ('error') { <app-error /> }
|
|
269
|
+
@default { <app-content /> }
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
<!-- Defer (lazy loading) -->
|
|
273
|
+
@defer (on viewport) {
|
|
274
|
+
<app-heavy-component />
|
|
275
|
+
} @placeholder {
|
|
276
|
+
<div>Loading...</div>
|
|
277
|
+
}
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
## Anti-patterns to Avoid
|
|
281
|
+
|
|
282
|
+
```typescript
|
|
283
|
+
// BAD: Using decorators
|
|
284
|
+
@Input() name: string; // Use input() instead
|
|
285
|
+
@Output() click = new EventEmitter(); // Use output() instead
|
|
286
|
+
@ViewChild('ref') ref: ElementRef; // Use viewChild() instead
|
|
287
|
+
|
|
288
|
+
// BAD: Constructor injection
|
|
289
|
+
constructor(private store: Store) {} // Use inject() instead
|
|
290
|
+
|
|
291
|
+
// BAD: Subscribing in component
|
|
292
|
+
ngOnInit() {
|
|
293
|
+
this.data$.subscribe(d => this.data = d); // Use toSignal() instead
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// BAD: Missing track in @for
|
|
297
|
+
@for (item of items()) { // WRONG - missing track
|
|
298
|
+
<div>{{ item.name }}</div>
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// GOOD: Always use track
|
|
302
|
+
@for (item of items(); track item.id) {
|
|
303
|
+
<div>{{ item.name }}</div>
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// BAD: Using computed() for resettable state
|
|
307
|
+
protected readonly currentPage = computed(() => 1); // Read-only, can't be changed!
|
|
308
|
+
|
|
309
|
+
// GOOD: Use linkedSignal() for resettable writable state
|
|
310
|
+
protected readonly currentPage = linkedSignal(() => {
|
|
311
|
+
this.filter(); // Reset when filter changes
|
|
312
|
+
return 1;
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
// BAD: Setting signals in computed
|
|
316
|
+
protected readonly total = computed(() => {
|
|
317
|
+
this.loading.set(true); // WRONG - side effect in computed!
|
|
318
|
+
return this.items().length;
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
// GOOD: computed should be pure
|
|
322
|
+
protected readonly total = computed(() => this.items().length);
|
|
323
|
+
```
|
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
---
|
|
2
|
+
paths:
|
|
3
|
+
- "**/interceptors/**"
|
|
4
|
+
- "**/services/**/*.service.ts"
|
|
5
|
+
- "**/*.interceptor.ts"
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# Angular HTTP & Interceptors
|
|
9
|
+
|
|
10
|
+
## HTTP Client Setup
|
|
11
|
+
|
|
12
|
+
```typescript
|
|
13
|
+
// app.config.ts
|
|
14
|
+
import { provideHttpClient, withInterceptors } from '@angular/common/http';
|
|
15
|
+
import { authInterceptor } from './core/interceptors/auth.interceptor';
|
|
16
|
+
import { errorInterceptor } from './core/interceptors/error.interceptor';
|
|
17
|
+
import { loggingInterceptor } from './core/interceptors/logging.interceptor';
|
|
18
|
+
|
|
19
|
+
export const appConfig: ApplicationConfig = {
|
|
20
|
+
providers: [
|
|
21
|
+
provideHttpClient(
|
|
22
|
+
withInterceptors([
|
|
23
|
+
loggingInterceptor,
|
|
24
|
+
authInterceptor,
|
|
25
|
+
errorInterceptor,
|
|
26
|
+
])
|
|
27
|
+
),
|
|
28
|
+
],
|
|
29
|
+
};
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Interceptors (Functional)
|
|
33
|
+
|
|
34
|
+
### Auth Interceptor
|
|
35
|
+
|
|
36
|
+
```typescript
|
|
37
|
+
// interceptors/auth.interceptor.ts
|
|
38
|
+
import { HttpInterceptorFn } from '@angular/common/http';
|
|
39
|
+
import { inject } from '@angular/core';
|
|
40
|
+
import { AuthService } from '../services/auth.service';
|
|
41
|
+
|
|
42
|
+
export const authInterceptor: HttpInterceptorFn = (req, next) => {
|
|
43
|
+
const authService = inject(AuthService);
|
|
44
|
+
const token = authService.accessToken();
|
|
45
|
+
|
|
46
|
+
if (token && !req.url.includes('/auth/')) {
|
|
47
|
+
const authReq = req.clone({
|
|
48
|
+
setHeaders: {
|
|
49
|
+
Authorization: `Bearer ${token}`,
|
|
50
|
+
},
|
|
51
|
+
});
|
|
52
|
+
return next(authReq);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return next(req);
|
|
56
|
+
};
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
### Error Interceptor
|
|
60
|
+
|
|
61
|
+
```typescript
|
|
62
|
+
// interceptors/error.interceptor.ts
|
|
63
|
+
import { HttpInterceptorFn, HttpErrorResponse } from '@angular/common/http';
|
|
64
|
+
import { inject } from '@angular/core';
|
|
65
|
+
import { Router } from '@angular/router';
|
|
66
|
+
import { catchError, throwError } from 'rxjs';
|
|
67
|
+
import { NotificationService } from '../services/notification.service';
|
|
68
|
+
|
|
69
|
+
export const errorInterceptor: HttpInterceptorFn = (req, next) => {
|
|
70
|
+
const router = inject(Router);
|
|
71
|
+
const notification = inject(NotificationService);
|
|
72
|
+
|
|
73
|
+
return next(req).pipe(
|
|
74
|
+
catchError((error: HttpErrorResponse) => {
|
|
75
|
+
switch (error.status) {
|
|
76
|
+
case 401:
|
|
77
|
+
router.navigate(['/login']);
|
|
78
|
+
break;
|
|
79
|
+
case 403:
|
|
80
|
+
notification.error('You do not have permission for this action');
|
|
81
|
+
break;
|
|
82
|
+
case 404:
|
|
83
|
+
notification.error('Resource not found');
|
|
84
|
+
break;
|
|
85
|
+
case 422:
|
|
86
|
+
// Validation errors - let component handle
|
|
87
|
+
break;
|
|
88
|
+
case 500:
|
|
89
|
+
notification.error('Server error. Please try again later.');
|
|
90
|
+
break;
|
|
91
|
+
default:
|
|
92
|
+
notification.error('An unexpected error occurred');
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return throwError(() => error);
|
|
96
|
+
})
|
|
97
|
+
);
|
|
98
|
+
};
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
### Retry Interceptor
|
|
102
|
+
|
|
103
|
+
```typescript
|
|
104
|
+
// interceptors/retry.interceptor.ts
|
|
105
|
+
import { HttpInterceptorFn } from '@angular/common/http';
|
|
106
|
+
import { retry, timer } from 'rxjs';
|
|
107
|
+
|
|
108
|
+
export const retryInterceptor: HttpInterceptorFn = (req, next) => {
|
|
109
|
+
// Only retry GET requests
|
|
110
|
+
if (req.method !== 'GET') {
|
|
111
|
+
return next(req);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return next(req).pipe(
|
|
115
|
+
retry({
|
|
116
|
+
count: 3,
|
|
117
|
+
delay: (error, retryCount) => {
|
|
118
|
+
// Exponential backoff: 1s, 2s, 4s
|
|
119
|
+
const delayMs = Math.pow(2, retryCount - 1) * 1000;
|
|
120
|
+
console.log(`Retry ${retryCount} after ${delayMs}ms`);
|
|
121
|
+
return timer(delayMs);
|
|
122
|
+
},
|
|
123
|
+
resetOnSuccess: true,
|
|
124
|
+
})
|
|
125
|
+
);
|
|
126
|
+
};
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
### Caching Interceptor
|
|
130
|
+
|
|
131
|
+
```typescript
|
|
132
|
+
// interceptors/cache.interceptor.ts
|
|
133
|
+
import { HttpInterceptorFn, HttpResponse } from '@angular/common/http';
|
|
134
|
+
import { of, tap } from 'rxjs';
|
|
135
|
+
|
|
136
|
+
const cache = new Map<string, HttpResponse<unknown>>();
|
|
137
|
+
|
|
138
|
+
export const cacheInterceptor: HttpInterceptorFn = (req, next) => {
|
|
139
|
+
// Only cache GET requests
|
|
140
|
+
if (req.method !== 'GET') {
|
|
141
|
+
return next(req);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Check for no-cache header
|
|
145
|
+
if (req.headers.has('x-no-cache')) {
|
|
146
|
+
cache.delete(req.urlWithParams);
|
|
147
|
+
return next(req);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const cached = cache.get(req.urlWithParams);
|
|
151
|
+
if (cached) {
|
|
152
|
+
return of(cached.clone());
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return next(req).pipe(
|
|
156
|
+
tap((event) => {
|
|
157
|
+
if (event instanceof HttpResponse) {
|
|
158
|
+
cache.set(req.urlWithParams, event.clone());
|
|
159
|
+
}
|
|
160
|
+
})
|
|
161
|
+
);
|
|
162
|
+
};
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
### Loading Interceptor
|
|
166
|
+
|
|
167
|
+
```typescript
|
|
168
|
+
// interceptors/loading.interceptor.ts
|
|
169
|
+
import { HttpInterceptorFn } from '@angular/common/http';
|
|
170
|
+
import { inject } from '@angular/core';
|
|
171
|
+
import { finalize } from 'rxjs';
|
|
172
|
+
import { LoadingService } from '../services/loading.service';
|
|
173
|
+
|
|
174
|
+
export const loadingInterceptor: HttpInterceptorFn = (req, next) => {
|
|
175
|
+
const loadingService = inject(LoadingService);
|
|
176
|
+
|
|
177
|
+
// Skip for specific requests
|
|
178
|
+
if (req.headers.has('x-skip-loading')) {
|
|
179
|
+
return next(req);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
loadingService.show();
|
|
183
|
+
|
|
184
|
+
return next(req).pipe(
|
|
185
|
+
finalize(() => loadingService.hide())
|
|
186
|
+
);
|
|
187
|
+
};
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
### Logging Interceptor
|
|
191
|
+
|
|
192
|
+
```typescript
|
|
193
|
+
// interceptors/logging.interceptor.ts
|
|
194
|
+
import { HttpInterceptorFn } from '@angular/common/http';
|
|
195
|
+
import { tap } from 'rxjs';
|
|
196
|
+
|
|
197
|
+
export const loggingInterceptor: HttpInterceptorFn = (req, next) => {
|
|
198
|
+
const startTime = Date.now();
|
|
199
|
+
|
|
200
|
+
return next(req).pipe(
|
|
201
|
+
tap({
|
|
202
|
+
next: (event) => {
|
|
203
|
+
if (event.type !== 0) { // Skip progress events
|
|
204
|
+
const duration = Date.now() - startTime;
|
|
205
|
+
console.log(`${req.method} ${req.url} - ${duration}ms`);
|
|
206
|
+
}
|
|
207
|
+
},
|
|
208
|
+
error: (error) => {
|
|
209
|
+
const duration = Date.now() - startTime;
|
|
210
|
+
console.error(`${req.method} ${req.url} - FAILED - ${duration}ms`, error);
|
|
211
|
+
},
|
|
212
|
+
})
|
|
213
|
+
);
|
|
214
|
+
};
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
## API Service Pattern
|
|
218
|
+
|
|
219
|
+
```typescript
|
|
220
|
+
// services/api.service.ts
|
|
221
|
+
@Injectable({ providedIn: 'root' })
|
|
222
|
+
export class ApiService {
|
|
223
|
+
private readonly http = inject(HttpClient);
|
|
224
|
+
private readonly baseUrl = inject(API_BASE_URL);
|
|
225
|
+
|
|
226
|
+
public get<T>(path: string, params?: HttpParams): Observable<T> {
|
|
227
|
+
return this.http.get<T>(`${this.baseUrl}${path}`, { params });
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
public post<T>(path: string, body: unknown): Observable<T> {
|
|
231
|
+
return this.http.post<T>(`${this.baseUrl}${path}`, body);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
public put<T>(path: string, body: unknown): Observable<T> {
|
|
235
|
+
return this.http.put<T>(`${this.baseUrl}${path}`, body);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
public patch<T>(path: string, body: unknown): Observable<T> {
|
|
239
|
+
return this.http.patch<T>(`${this.baseUrl}${path}`, body);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
public delete<T>(path: string): Observable<T> {
|
|
243
|
+
return this.http.delete<T>(`${this.baseUrl}${path}`);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// services/user.service.ts
|
|
248
|
+
@Injectable({ providedIn: 'root' })
|
|
249
|
+
export class UserService {
|
|
250
|
+
private readonly api = inject(ApiService);
|
|
251
|
+
|
|
252
|
+
public getAll(): Observable<User[]> {
|
|
253
|
+
return this.api.get<User[]>('/users');
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
public getById(id: string): Observable<User> {
|
|
257
|
+
return this.api.get<User>(`/users/${id}`);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
public create(data: CreateUserDto): Observable<User> {
|
|
261
|
+
return this.api.post<User>('/users', data);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
public update(id: string, data: UpdateUserDto): Observable<User> {
|
|
265
|
+
return this.api.patch<User>(`/users/${id}`, data);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
public delete(id: string): Observable<void> {
|
|
269
|
+
return this.api.delete<void>(`/users/${id}`);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
## Error Handling in Components
|
|
275
|
+
|
|
276
|
+
```typescript
|
|
277
|
+
@Component({ ... })
|
|
278
|
+
export class UserFormComponent {
|
|
279
|
+
private readonly userService = inject(UserService);
|
|
280
|
+
|
|
281
|
+
protected readonly error = signal<string | null>(null);
|
|
282
|
+
protected readonly fieldErrors = signal<Record<string, string>>({});
|
|
283
|
+
protected readonly isSubmitting = signal(false);
|
|
284
|
+
|
|
285
|
+
public async onSubmit(data: CreateUserDto): Promise<void> {
|
|
286
|
+
this.isSubmitting.set(true);
|
|
287
|
+
this.error.set(null);
|
|
288
|
+
this.fieldErrors.set({});
|
|
289
|
+
|
|
290
|
+
this.userService.create(data).subscribe({
|
|
291
|
+
next: (user) => {
|
|
292
|
+
this.router.navigate(['/users', user.id]);
|
|
293
|
+
},
|
|
294
|
+
error: (err: HttpErrorResponse) => {
|
|
295
|
+
if (err.status === 422 && err.error?.errors) {
|
|
296
|
+
// Handle validation errors
|
|
297
|
+
const errors: Record<string, string> = {};
|
|
298
|
+
for (const e of err.error.errors) {
|
|
299
|
+
errors[e.field] = e.message;
|
|
300
|
+
}
|
|
301
|
+
this.fieldErrors.set(errors);
|
|
302
|
+
} else {
|
|
303
|
+
this.error.set('Failed to create user');
|
|
304
|
+
}
|
|
305
|
+
this.isSubmitting.set(false);
|
|
306
|
+
},
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
```
|
|
311
|
+
|
|
312
|
+
## Anti-patterns
|
|
313
|
+
|
|
314
|
+
```typescript
|
|
315
|
+
// BAD: Class-based interceptors (deprecated)
|
|
316
|
+
@Injectable()
|
|
317
|
+
export class AuthInterceptor implements HttpInterceptor { ... }
|
|
318
|
+
|
|
319
|
+
// GOOD: Functional interceptors
|
|
320
|
+
export const authInterceptor: HttpInterceptorFn = (req, next) => { ... };
|
|
321
|
+
|
|
322
|
+
// BAD: Hardcoded URLs
|
|
323
|
+
this.http.get('https://api.example.com/users');
|
|
324
|
+
|
|
325
|
+
// GOOD: Use injection token
|
|
326
|
+
this.http.get(`${this.baseUrl}/users`);
|
|
327
|
+
|
|
328
|
+
// BAD: Not handling errors
|
|
329
|
+
this.http.get('/users').subscribe(users => this.users = users);
|
|
330
|
+
|
|
331
|
+
// GOOD: Handle errors
|
|
332
|
+
this.http.get('/users').pipe(
|
|
333
|
+
catchError(err => {
|
|
334
|
+
this.error.set('Failed to load users');
|
|
335
|
+
return of([]);
|
|
336
|
+
})
|
|
337
|
+
).subscribe(users => this.users.set(users));
|
|
338
|
+
```
|