Skip to content

Commit 1dc1d5c

Browse files
committed
feat: add ngx-fetcher-with-etag
1 parent 531dd7a commit 1dc1d5c

File tree

15 files changed

+812
-0
lines changed

15 files changed

+812
-0
lines changed

angular.json

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,39 @@
3535
}
3636
}
3737
}
38+
},
39+
"ngx-fetcher-with-etag": {
40+
"projectType": "library",
41+
"root": "projects/ngx-fetcher-with-etag",
42+
"sourceRoot": "projects/ngx-fetcher-with-etag/src",
43+
"prefix": "lib",
44+
"architect": {
45+
"build": {
46+
"builder": "@angular-devkit/build-angular:ng-packagr",
47+
"options": {
48+
"project": "projects/ngx-fetcher-with-etag/ng-package.json"
49+
},
50+
"configurations": {
51+
"production": {
52+
"tsConfig": "projects/ngx-fetcher-with-etag/tsconfig.lib.prod.json"
53+
},
54+
"development": {
55+
"tsConfig": "projects/ngx-fetcher-with-etag/tsconfig.lib.json"
56+
}
57+
},
58+
"defaultConfiguration": "production"
59+
},
60+
"test": {
61+
"builder": "@angular-devkit/build-angular:karma",
62+
"options": {
63+
"tsConfig": "projects/ngx-fetcher-with-etag/tsconfig.spec.json",
64+
"polyfills": [
65+
"zone.js",
66+
"zone.js/testing"
67+
]
68+
}
69+
}
70+
}
3871
}
3972
}
4073
}
Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
# ngx-fetcher-with-etag
2+
3+
**A smart, ETag-powered data fetching library for modern Angular applications.**
4+
5+
Stop re-fetching unchanged data. `ngx-fetcher-with-etag` simplifies your data layer with automatic caching, smart polling, and utilities for handling local data.
6+
7+
-----
8+
9+
## 🤔 Why ngx-fetcher-with-etag?
10+
11+
Modern web apps often make redundant API calls, fetching the same data repeatedly. This library solves that problem by leveraging browser caching and HTTP ETag headers. The server tells you when data *hasn't* changed, saving bandwidth and making your app feel faster.
12+
13+
This library automates that entire process and packages it into a clean, reactive, RxJS-powered API.
14+
15+
-----
16+
17+
## ✨ Features
18+
19+
-**ETag**: Seamlessly uses `ETag` / `If-None-Match` headers to avoid re-downloading unchanged data.
20+
- 🔄 **Polling**: Automatically refreshes data based on server `Expires` headers or custom intervals.
21+
- 🔍 **Local Lookups**: A service for fetching a list and then accessing individual items synchronously by ID.
22+
- 🛡️ **Extensible Authentication**: A simple `AuthProvider` system to inject authentication headers into HTTP requests.
23+
- 👁️ **Fully Reactive**: Built from the ground up with RxJS Observables.
24+
25+
-----
26+
27+
## 🚀 Installation
28+
29+
```bash
30+
npm install ngx-fetcher-with-etag
31+
```
32+
33+
-----
34+
35+
## Core Concepts
36+
37+
The library is built around a few key components that work together:
38+
39+
1. **`FetcherWithEtag`**: The core engine. It handles a single API endpoint, managing ETag caching and polling.
40+
3. **`LocalDataLoaderEtagService`**: The most advanced utility. It uses `FetcherWithEtag` to fetch an array of items and then indexes them for instant, synchronous access by ID.
41+
4. **`AuthProvider`**: A simple class you extend to provide authentication headers for your HTTP requests.
42+
43+
-----
44+
45+
## 📖 API and Usage
46+
47+
### FetcherWithEtag
48+
49+
This is the main class for fetching data from a single endpoint. It handles all the ETag, caching, and polling logic for you.
50+
51+
#### Basic Usage
52+
53+
```typescript
54+
import { FetcherWithEtag } from 'ngx-fetcher-with-etag';
55+
56+
// Create a new fetcher instance
57+
const fetcher = new FetcherWithEtag(
58+
httpClient,
59+
authProvider,
60+
'https://api.example.com/data',
61+
(raw: RawData) => new Data(raw), // Converter function
62+
(old: Data, new: Data) => old.id === new.id // Equality check
63+
);
64+
65+
// Subscribe to data updates (only emits when data has actually changed)
66+
fetcher.data$.subscribe(data => console.log('Data updated:', data));
67+
68+
// Subscribe to the loading state
69+
fetcher.loading$.subscribe(loading => console.log('Is loading:', loading));
70+
```
71+
72+
#### Constructor Parameters Explained
73+
74+
**Converter Function** `(raw: RawT) => T`
75+
76+
This function's job is to transform the raw, plain JSON response from your API into a new type or interface that your application can use. This is the perfect place to map fields, add computed properties, or parse dates.
77+
78+
```typescript
79+
// Example: Convert a raw API user into a clean User model
80+
type RawUser = { user_id: string; full_name: string; };
81+
type User = {
82+
id: string;
83+
name: string;
84+
initial: string;
85+
};
86+
87+
function userConverter(raw: RawUser): User {
88+
return {
89+
id: raw.user_id,
90+
name: raw.full_name,
91+
initial: raw.full_name.charAt(0)
92+
};
93+
}
94+
```
95+
96+
**Equality Check Function** `(old: T, new: T) => boolean`
97+
98+
This function prevents your `data$` observable from emitting a new value if the data is effectively the same. This is crucial for performance and preventing unnecessary re-renders in your UI. You can define what "equal" means—whether it's a simple ID check or a deep object comparison.
99+
100+
```typescript
101+
// Only emit if the user's name has changed
102+
function userEqualityCheck(old: User, new: User): boolean {
103+
return old.name === new.name;
104+
}
105+
106+
// Only emit if something has changed
107+
type ItemWithEtag = {
108+
id: string;
109+
etag: string;
110+
};
111+
112+
function userEtagCheck(old: ItemWithEtag, new: ItemWithEtag): boolean {
113+
return old.etag === new.etag;
114+
}
115+
```
116+
117+
-----
118+
119+
### LocalDataLoaderEtagService
120+
121+
This is a powerful abstract service for a common pattern: fetching a list of items and then needing to look up individual items from that list by their ID. It combines the caching of `FetcherWithEtag` with an in-memory, indexed store for O(1) lookups.
122+
123+
#### When to use it
124+
125+
Use this when you have a `users` or `products` endpoint and your app frequently needs to ask, "What's the name of the user with ID `user-123`?" without making another API call.
126+
127+
#### Example Implementation
128+
129+
```typescript
130+
import { LocalDataLoaderEtagService } from 'ngx-fetcher-with-etag';
131+
132+
// 1. Define your data structures
133+
type User = { id: string; name: string; email: string; };
134+
type RawUsersResponse = {
135+
etag: string; // ETag is required for comparison
136+
items: User[];
137+
};
138+
type ProcessedUsersData = RawUsersResponse & {
139+
// This will be added automatically: a map for fast lookups
140+
byId: Record<string, User>;
141+
};
142+
143+
// 2. Create a service that extends the base class
144+
@Injectable({ providedIn: 'root' })
145+
export class UsersService extends LocalDataLoaderEtagService<
146+
'items', // The key in the response containing the array
147+
'id', // The key on each item to use as its ID
148+
User, // The type of a single item
149+
ProcessedUsersData, // The final processed data structure (with `byId`)
150+
RawUsersResponse // The raw API response
151+
> {
152+
constructor(http: HttpClient, auth: AuthProvider) {
153+
super(
154+
// Provide a function that creates the underlying FetcherWithEtag
155+
(converter, isEqual) => new FetcherWithEtag(
156+
http,
157+
auth,
158+
'https://api.example.com/users',
159+
converter,
160+
isEqual
161+
),
162+
'id', // Tell it the ID field is named 'id'
163+
'items' // Tell it the array is in the 'items' field
164+
);
165+
}
166+
}
167+
168+
// 3. Use the service in your component
169+
constructor(private usersService: UsersService) {}
170+
171+
this.usersService.dataLoader$.subscribe((dataLoader) => {
172+
// dataLoader is an object with a `load` method for synchronous access
173+
const user = dataLoader.load('user-123'); // Instant local lookup!
174+
if (user) {
175+
console.log('User found locally:', user.name);
176+
}
177+
});
178+
```
179+
180+
-----
181+
182+
### Authentication
183+
184+
Provide authentication headers to your requests by creating a simple `AuthProvider`.
185+
186+
#### Example: JWT Authentication
187+
188+
```typescript
189+
import { AuthProvider } from 'ngx-fetcher-with-etag';
190+
import { HttpHeaders } from '@angular/common/http';
191+
192+
@Injectable({ providedIn: 'root' })
193+
export class JwtAuthProvider extends AuthProvider {
194+
override getAuthHeaders(): HttpHeaders {
195+
const token = localStorage.getItem('jwt_token');
196+
if (token) {
197+
return new HttpHeaders({
198+
Authorization: `Bearer ${token}`
199+
});
200+
}
201+
return new HttpHeaders();
202+
}
203+
}
204+
```
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"$schema": "../../node_modules/ng-packagr/ng-package.schema.json",
3+
"dest": "../../dist/ngx-fetcher-with-etag",
4+
"lib": {
5+
"entryFile": "src/public-api.ts"
6+
}
7+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
{
2+
"name": "@profusion/ngx-fetcher-with-etag",
3+
"version": "0.0.1",
4+
"peerDependencies": {
5+
"@angular/common": "^19.2.0",
6+
"@angular/core": "^19.2.0"
7+
},
8+
"dependencies": {
9+
"tslib": "^2.3.0"
10+
},
11+
"sideEffects": false
12+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { HttpHeaders } from "@angular/common/http";
2+
3+
/**
4+
* Base authentication provider for supplying HTTP headers.
5+
*
6+
* Extend this class to implement custom authentication strategies
7+
* (e.g., JWT, API key, OAuth). By default, it returns an empty
8+
* {@link HttpHeaders} instance.
9+
*
10+
* @example
11+
* ```ts
12+
* export class MyAuthProvider extends AuthProvider {
13+
* override getAuthHeaders(): HttpHeaders {
14+
* return new HttpHeaders({
15+
* Authorization: `Bearer ${localStorage.getItem('token')}`,
16+
* });
17+
* }
18+
* }
19+
* ```
20+
*/
21+
export class AuthProvider {
22+
getAuthHeaders(): HttpHeaders {
23+
return new HttpHeaders();
24+
}
25+
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
export type IdKeyValueType<T extends {}, K extends keyof T> = T[K] & (string | number | symbol);
2+
export type ById<T extends {}, K extends keyof T> = Record<IdKeyValueType<T, K>, T>;
3+
4+
/**
5+
* Creates a map of entities indexed by a specified key.
6+
*
7+
* @template T - The type of the entities.
8+
* @template K - The key of the entities used for indexing.
9+
*
10+
* @param entities - The array of entities to be indexed.
11+
* @param idKey - The key of the entities to use for indexing.
12+
*
13+
* @returns {ById<T, K>} A map of entities indexed by the specified key.
14+
*/
15+
export function createById<T extends {}, K extends keyof T>(
16+
entities: readonly T[],
17+
idKey: K
18+
): ById<T, K> {
19+
return entities.reduce(
20+
(map: ById<T, K>, entity: T) => {
21+
const key = entity[idKey] as IdKeyValueType<T, K>;
22+
map[key] = entity;
23+
return map;
24+
},
25+
{} as ById<T, K>
26+
);
27+
}
28+
29+
/**
30+
* Retrieves a batch of entities from a list of IDs.
31+
*
32+
* @template T - The type of the entities.
33+
* @template K - The type of the key used to identify entities.
34+
*
35+
* @param ids - An array of IDs to retrieve entities for. Each ID must be of type string, number, or symbol.
36+
* @param entities - An array of entities to search within.
37+
* @param idKey - The key used to identify entities within the array.
38+
*
39+
* @returns An array of entities that match the provided IDs, in the same order as the provided IDs.
40+
*/
41+
export function dataLoaderBatchFromIds<T extends {}, K extends keyof T>(
42+
ids: readonly (T[K] & (string | number | symbol))[],
43+
entities: readonly T[],
44+
idKey: K
45+
): T[] {
46+
const lookupMap = createById(entities, idKey);
47+
return ids.map((id) => lookupMap[id]);
48+
}

0 commit comments

Comments
 (0)