@dsanchos/api 1.0.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/package.json +33 -0
- package/readme.md +211 -0
- package/source/api.ts +101 -0
- package/source/api.types.ts +60 -0
- package/source/core.ts +37 -0
- package/source/index.ts +3 -0
- package/source/logger.ts +9 -0
- package/source/polling.ts +22 -0
- package/tsconfig.json +26 -0
package/package.json
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@dsanchos/api",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "",
|
|
5
|
+
"main": "./dist/index.js",
|
|
6
|
+
"types": "./dist/index.d.ts",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"import": "./dist/index.js",
|
|
10
|
+
"types": "./dist/index.d.ts"
|
|
11
|
+
}
|
|
12
|
+
},
|
|
13
|
+
"scripts": {
|
|
14
|
+
"test": "echo \"Error: no test specified\" && exit 1",
|
|
15
|
+
"build": "tsc",
|
|
16
|
+
"prepublish": "npm run build"
|
|
17
|
+
},
|
|
18
|
+
"keywords": [],
|
|
19
|
+
"author": "",
|
|
20
|
+
"license": "ISC",
|
|
21
|
+
"type": "module",
|
|
22
|
+
"devDependencies": {
|
|
23
|
+
"@types/node": "^25.3.0",
|
|
24
|
+
"eslint": "^10.0.0",
|
|
25
|
+
"prettier": "^3.8.1",
|
|
26
|
+
"ts-node": "^10.9.2",
|
|
27
|
+
"typescript": "^5.9.3",
|
|
28
|
+
"vitest": "^4.0.18"
|
|
29
|
+
},
|
|
30
|
+
"dependencies": {
|
|
31
|
+
"node-fetch": "^3.3.2"
|
|
32
|
+
}
|
|
33
|
+
}
|
package/readme.md
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
# api
|
|
2
|
+
|
|
3
|
+
A lightweight TypeScript fetch wrapper with caching, polling, interceptors, and logging.
|
|
4
|
+
|
|
5
|
+
Лёгкая TypeScript обёртка над fetch с кэшированием, поллингом, интерсепторами и логированием.
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## Installation / Установка
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
npm install node-fetch
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
## Quick Start / Быстрый старт
|
|
18
|
+
|
|
19
|
+
```typescript
|
|
20
|
+
import { ApiCore, defineApi } from "./core.js";
|
|
21
|
+
import { api } from "./api.js";
|
|
22
|
+
|
|
23
|
+
// Configure once / Настройте один раз
|
|
24
|
+
ApiCore({
|
|
25
|
+
baseUrl: "https://jsonplaceholder.typicode.com",
|
|
26
|
+
baseKeepUnusedDataFor: 60,
|
|
27
|
+
baseHeader: { "Content-Type": "application/json" },
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
// Make a request / Сделайте запрос
|
|
31
|
+
const { data, isLoading, isError, error } = await api({
|
|
32
|
+
method: "GET",
|
|
33
|
+
url: "/posts",
|
|
34
|
+
});
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
---
|
|
38
|
+
|
|
39
|
+
## ApiCore
|
|
40
|
+
|
|
41
|
+
Global configuration for all requests.
|
|
42
|
+
|
|
43
|
+
Глобальная конфигурация для всех запросов.
|
|
44
|
+
|
|
45
|
+
```typescript
|
|
46
|
+
ApiCore({
|
|
47
|
+
baseUrl: "https://api.example.com", // Base URL for all requests
|
|
48
|
+
baseKeepUnusedDataFor: 60, // Cache TTL in seconds (default: 60)
|
|
49
|
+
baseHeader: {
|
|
50
|
+
// Default headers for all requests
|
|
51
|
+
"Content-Type": "application/json",
|
|
52
|
+
},
|
|
53
|
+
logging: true, // Enable request logging
|
|
54
|
+
interceptors: {
|
|
55
|
+
request: (props) => ({
|
|
56
|
+
// Modify request before sending
|
|
57
|
+
...props,
|
|
58
|
+
headers: { Authorization: `Bearer ${getToken()}` },
|
|
59
|
+
}),
|
|
60
|
+
},
|
|
61
|
+
});
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
---
|
|
65
|
+
|
|
66
|
+
## api()
|
|
67
|
+
|
|
68
|
+
```typescript
|
|
69
|
+
const { data, isLoading, isFetching, isError, error } = await api({
|
|
70
|
+
method: "GET", // GET | POST | PUT | PATCH | DELETE
|
|
71
|
+
url: "/posts", // Path (baseUrl is prepended automatically)
|
|
72
|
+
headers: {}, // Override default headers
|
|
73
|
+
body: {}, // Request body (auto JSON.stringify)
|
|
74
|
+
|
|
75
|
+
// Cache
|
|
76
|
+
provideCache: "posts", // Save response under this key
|
|
77
|
+
keepUnusedDataFor: 30, // Override TTL for this request (seconds)
|
|
78
|
+
invalidateCache: "posts", // Delete this cache key after request
|
|
79
|
+
});
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
### Response / Ответ
|
|
83
|
+
|
|
84
|
+
| Field | Type | Description |
|
|
85
|
+
| ------------ | ---------------- | ---------------------- |
|
|
86
|
+
| `data` | `any` | Response data |
|
|
87
|
+
| `isLoading` | `boolean` | True on first load |
|
|
88
|
+
| `isFetching` | `boolean` | True on any request |
|
|
89
|
+
| `isError` | `boolean` | True if request failed |
|
|
90
|
+
| `error` | `string \| null` | Error message |
|
|
91
|
+
|
|
92
|
+
---
|
|
93
|
+
|
|
94
|
+
## Caching / Кэширование
|
|
95
|
+
|
|
96
|
+
```typescript
|
|
97
|
+
// Save to cache / Сохранить в кэш
|
|
98
|
+
await api({
|
|
99
|
+
method: "GET",
|
|
100
|
+
url: "/posts",
|
|
101
|
+
provideCache: "posts", // Cache key
|
|
102
|
+
keepUnusedDataFor: 120, // 2 minutes
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
// Invalidate cache after mutation / Инвалидировать после мутации
|
|
106
|
+
await api({
|
|
107
|
+
method: "POST",
|
|
108
|
+
url: "/posts",
|
|
109
|
+
body: { title: "New post" },
|
|
110
|
+
invalidateCache: "posts", // Delete "posts" cache
|
|
111
|
+
});
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
---
|
|
115
|
+
|
|
116
|
+
## Polling
|
|
117
|
+
|
|
118
|
+
```typescript
|
|
119
|
+
import { polling } from "./polling.js";
|
|
120
|
+
|
|
121
|
+
const stop = polling(
|
|
122
|
+
{
|
|
123
|
+
method: "GET",
|
|
124
|
+
url: "/notifications",
|
|
125
|
+
pollingInterval: 3000, // Every 3 seconds / Каждые 3 секунды
|
|
126
|
+
stopAfter: 30000, // Stop after 30 seconds / Остановить через 30 секунд
|
|
127
|
+
},
|
|
128
|
+
(res, stop) => {
|
|
129
|
+
console.log(res.data);
|
|
130
|
+
if (res.isError) stop(); // Stop on error / Остановить при ошибке
|
|
131
|
+
},
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
// Stop manually / Остановить вручную
|
|
135
|
+
stop();
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
---
|
|
139
|
+
|
|
140
|
+
## defineApi
|
|
141
|
+
|
|
142
|
+
Group related endpoints without writing types manually.
|
|
143
|
+
|
|
144
|
+
Группируйте связанные запросы без ручного написания типов.
|
|
145
|
+
|
|
146
|
+
```typescript
|
|
147
|
+
const usersApi = defineApi({
|
|
148
|
+
getUsers: { method: "GET", url: "/users" },
|
|
149
|
+
createUser: { method: "POST", url: "/users" },
|
|
150
|
+
updateUser: { method: "PUT", url: "/users/1" },
|
|
151
|
+
deleteUser: { method: "DELETE", url: "/users/1" },
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
const { data } = await api(usersApi.getUsers);
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
---
|
|
158
|
+
|
|
159
|
+
## Logging / Логирование
|
|
160
|
+
|
|
161
|
+
When `logging: true` is set in `ApiCore`, every request is logged:
|
|
162
|
+
|
|
163
|
+
При `logging: true` в `ApiCore` каждый запрос логируется:
|
|
164
|
+
|
|
165
|
+
```
|
|
166
|
+
✅ [GET] /posts → 200 (123ms)
|
|
167
|
+
✅ [POST] /posts → 201 (89ms)
|
|
168
|
+
❌ [GET] /posts/99 → 404 (45ms)
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
---
|
|
172
|
+
|
|
173
|
+
## Interceptors
|
|
174
|
+
|
|
175
|
+
Modify every request automatically (e.g. add auth token).
|
|
176
|
+
|
|
177
|
+
Изменяйте каждый запрос автоматически (например, добавить токен).
|
|
178
|
+
|
|
179
|
+
```typescript
|
|
180
|
+
ApiCore({
|
|
181
|
+
baseUrl: "https://api.example.com",
|
|
182
|
+
interceptors: {
|
|
183
|
+
request: (props) => ({
|
|
184
|
+
...props,
|
|
185
|
+
headers: {
|
|
186
|
+
...props.headers,
|
|
187
|
+
Authorization: `Bearer ${localStorage.getItem("token")}`,
|
|
188
|
+
},
|
|
189
|
+
}),
|
|
190
|
+
},
|
|
191
|
+
});
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
---
|
|
195
|
+
|
|
196
|
+
## Project Structure / Структура проекта
|
|
197
|
+
|
|
198
|
+
```
|
|
199
|
+
source/
|
|
200
|
+
├── api.ts # Main fetch wrapper
|
|
201
|
+
├── core.ts # ApiCore config + defineApi
|
|
202
|
+
├── polling.ts # Polling utility
|
|
203
|
+
├── logger.ts # Request logger
|
|
204
|
+
└── api.types.ts # TypeScript types
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
---
|
|
208
|
+
|
|
209
|
+
## License / Лицензия
|
|
210
|
+
|
|
211
|
+
MIT
|
package/source/api.ts
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import fetch from "node-fetch";
|
|
2
|
+
import type { API, APIResponse, CacheEntry } from "./api.types.js";
|
|
3
|
+
import { getConfig } from "./core.js";
|
|
4
|
+
import { Logger } from "./logger.js";
|
|
5
|
+
|
|
6
|
+
const cache = new Map<string, CacheEntry>();
|
|
7
|
+
|
|
8
|
+
export async function api(props: API): Promise<APIResponse> {
|
|
9
|
+
const {
|
|
10
|
+
baseUrl,
|
|
11
|
+
baseKeepUnusedDataFor = 60,
|
|
12
|
+
baseHeader,
|
|
13
|
+
interceptors,
|
|
14
|
+
logger,
|
|
15
|
+
} = getConfig();
|
|
16
|
+
|
|
17
|
+
const finalProps = interceptors?.request
|
|
18
|
+
? await Promise.resolve(interceptors.request(props))
|
|
19
|
+
: props;
|
|
20
|
+
|
|
21
|
+
const {
|
|
22
|
+
method,
|
|
23
|
+
url,
|
|
24
|
+
headers,
|
|
25
|
+
body,
|
|
26
|
+
keepUnusedDataFor = baseKeepUnusedDataFor,
|
|
27
|
+
provideCache = "",
|
|
28
|
+
invalidateCache = "",
|
|
29
|
+
} = finalProps;
|
|
30
|
+
|
|
31
|
+
let data = null;
|
|
32
|
+
let isLoading = true;
|
|
33
|
+
let isFetching = true;
|
|
34
|
+
let isError = false;
|
|
35
|
+
let error = null;
|
|
36
|
+
|
|
37
|
+
const TTL = keepUnusedDataFor * 1000;
|
|
38
|
+
const now = Date.now();
|
|
39
|
+
|
|
40
|
+
if (provideCache) {
|
|
41
|
+
const cached = cache.get(provideCache);
|
|
42
|
+
|
|
43
|
+
if (cached && now - cached.timestamp < TTL) {
|
|
44
|
+
return {
|
|
45
|
+
data: cached.data,
|
|
46
|
+
isLoading: false,
|
|
47
|
+
isFetching: false,
|
|
48
|
+
isError: false,
|
|
49
|
+
error: null,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
try {
|
|
55
|
+
isFetching = true;
|
|
56
|
+
if (!data) isLoading = true;
|
|
57
|
+
|
|
58
|
+
const start = performance.now();
|
|
59
|
+
const response = await fetch(`${baseUrl}${url}`, {
|
|
60
|
+
method,
|
|
61
|
+
headers: headers ?? baseHeader,
|
|
62
|
+
body: body ? JSON.stringify(body) : null,
|
|
63
|
+
});
|
|
64
|
+
const ms = Math.round(performance.now() - start);
|
|
65
|
+
|
|
66
|
+
if (logger) {
|
|
67
|
+
Logger(method, url, response.status, ms);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (!response.ok) {
|
|
71
|
+
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
data = await response.json();
|
|
75
|
+
|
|
76
|
+
isLoading = false;
|
|
77
|
+
|
|
78
|
+
if (invalidateCache) {
|
|
79
|
+
for (const key of invalidateCache) {
|
|
80
|
+
cache.delete(key);
|
|
81
|
+
}
|
|
82
|
+
} else {
|
|
83
|
+
if (provideCache) {
|
|
84
|
+
cache.set(provideCache, { data, timestamp: now });
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
} catch (err) {
|
|
88
|
+
error = err instanceof Error ? err.message : String(err);
|
|
89
|
+
isError = true;
|
|
90
|
+
} finally {
|
|
91
|
+
isFetching = false;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return {
|
|
95
|
+
data,
|
|
96
|
+
isLoading,
|
|
97
|
+
isFetching,
|
|
98
|
+
isError,
|
|
99
|
+
error,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
export type METHODS = "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
|
|
2
|
+
|
|
3
|
+
export type CacheEntry = {
|
|
4
|
+
data: any;
|
|
5
|
+
timestamp: number;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
export type headersMap = {
|
|
9
|
+
"Content-Type":
|
|
10
|
+
| "application/json"
|
|
11
|
+
| "application/x-www-form-urlencoded"
|
|
12
|
+
| "multipart/form-data"
|
|
13
|
+
| "text/html"
|
|
14
|
+
| "text/plain";
|
|
15
|
+
|
|
16
|
+
Accept: "application/json" | "text/html" | "*/*";
|
|
17
|
+
|
|
18
|
+
Authorization: `Bearer ${string}` | `Basic ${string}` | `ApiKey ${string}`;
|
|
19
|
+
|
|
20
|
+
"Cache-Control": "no-cache" | "no-store" | "max-age=3600";
|
|
21
|
+
|
|
22
|
+
"Accept-Encoding": "gzip" | "deflate" | "gzip, deflate";
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export type Headers = {
|
|
26
|
+
[K in keyof headersMap]?: headersMap[K];
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export interface API {
|
|
30
|
+
method: METHODS;
|
|
31
|
+
url: string;
|
|
32
|
+
headers?: Headers;
|
|
33
|
+
body?: any;
|
|
34
|
+
keepUnusedDataFor?: number;
|
|
35
|
+
pollingInterval?: number;
|
|
36
|
+
stopAfter?: number;
|
|
37
|
+
provideCache?: string;
|
|
38
|
+
invalidateCache?: string[];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface APIResponse {
|
|
42
|
+
data: any;
|
|
43
|
+
isLoading: boolean;
|
|
44
|
+
isFetching: boolean;
|
|
45
|
+
isError: boolean;
|
|
46
|
+
error: string | null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export type OnData = (res: any, stop: () => void) => void;
|
|
50
|
+
|
|
51
|
+
export interface CORE {
|
|
52
|
+
baseUrl: string;
|
|
53
|
+
baseKeepUnusedDataFor: number;
|
|
54
|
+
baseHeader: Headers;
|
|
55
|
+
interceptors?: {
|
|
56
|
+
request?: (props: API) => API | Promise<API>;
|
|
57
|
+
response?: (res: APIResponse) => APIResponse | Promise<APIResponse>;
|
|
58
|
+
};
|
|
59
|
+
logger?: boolean;
|
|
60
|
+
}
|
package/source/core.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { API, CORE } from "./api.types.js";
|
|
2
|
+
|
|
3
|
+
const config: CORE = {
|
|
4
|
+
baseUrl: "",
|
|
5
|
+
baseKeepUnusedDataFor: 60,
|
|
6
|
+
baseHeader: {},
|
|
7
|
+
interceptors: {},
|
|
8
|
+
logger: true,
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export function ApiCore(props: CORE) {
|
|
12
|
+
config.baseUrl = props.baseUrl;
|
|
13
|
+
config.baseKeepUnusedDataFor = props.baseKeepUnusedDataFor;
|
|
14
|
+
|
|
15
|
+
config.baseHeader = props.baseHeader;
|
|
16
|
+
|
|
17
|
+
if (props.logger) {
|
|
18
|
+
config.logger = props.logger;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
if (props.interceptors) {
|
|
22
|
+
config.interceptors = props.interceptors;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function getConfig() {
|
|
27
|
+
return config;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function defineApi<T extends Record<string, API>>(props: T) {
|
|
31
|
+
return props;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Планы на будущее!
|
|
35
|
+
// retry.ts — повтор запроса при ошибке (retryCount, retryDelay)
|
|
36
|
+
// queue.ts — очередь запросов (не слать 100 запросов одновременно)
|
|
37
|
+
// abort.ts — отмена запроса через AbortController
|
package/source/index.ts
ADDED
package/source/logger.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { api } from "./api.js";
|
|
2
|
+
import type { API, OnData } from "./api.types.js";
|
|
3
|
+
|
|
4
|
+
export function polling(props: API, onData: OnData): void {
|
|
5
|
+
const { pollingInterval = 3000, stopAfter } = props;
|
|
6
|
+
let isActive = true;
|
|
7
|
+
|
|
8
|
+
const stop = () => {
|
|
9
|
+
isActive = false;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
async function poll() {
|
|
13
|
+
if (!isActive) return;
|
|
14
|
+
const res = await api(props);
|
|
15
|
+
onData(res, stop);
|
|
16
|
+
if (isActive) setTimeout(poll, pollingInterval);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
poll();
|
|
20
|
+
|
|
21
|
+
if (stopAfter) setTimeout(stop, stopAfter);
|
|
22
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"rootDir": "./source",
|
|
4
|
+
"outDir": "./dist",
|
|
5
|
+
|
|
6
|
+
"module": "nodenext",
|
|
7
|
+
"moduleResolution": "nodenext",
|
|
8
|
+
"target": "esnext",
|
|
9
|
+
"types": ["node"],
|
|
10
|
+
|
|
11
|
+
"sourceMap": true,
|
|
12
|
+
"declaration": true,
|
|
13
|
+
"declarationMap": true,
|
|
14
|
+
// "noEmit": true,
|
|
15
|
+
// "allowImportingTsExtensions":
|
|
16
|
+
|
|
17
|
+
"noUncheckedIndexedAccess": true,
|
|
18
|
+
"exactOptionalPropertyTypes": true,
|
|
19
|
+
"strict": true,
|
|
20
|
+
"verbatimModuleSyntax": true,
|
|
21
|
+
"isolatedModules": true,
|
|
22
|
+
"noUncheckedSideEffectImports": true,
|
|
23
|
+
"moduleDetection": "force",
|
|
24
|
+
"skipLibCheck": true
|
|
25
|
+
}
|
|
26
|
+
}
|