millas 0.2.12-beta → 0.2.12-beta-2
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 +3 -16
- package/src/admin/ActivityLog.js +153 -52
- package/src/admin/Admin.js +400 -167
- package/src/admin/AdminAuth.js +213 -98
- package/src/admin/FormGenerator.js +372 -0
- package/src/admin/HookRegistry.js +256 -0
- package/src/admin/QueryEngine.js +263 -0
- package/src/admin/ViewContext.js +309 -0
- package/src/admin/WidgetRegistry.js +406 -0
- package/src/admin/index.js +17 -0
- package/src/admin/resources/AdminResource.js +383 -97
- package/src/admin/static/admin.css +1341 -0
- package/src/admin/static/date-picker.css +157 -0
- package/src/admin/static/date-picker.js +316 -0
- package/src/admin/static/json-editor.css +649 -0
- package/src/admin/static/json-editor.js +1429 -0
- package/src/admin/static/ui.js +1044 -0
- package/src/admin/views/layouts/base.njk +65 -1013
- package/src/admin/views/pages/detail.njk +40 -16
- package/src/admin/views/pages/form.njk +47 -599
- package/src/admin/views/pages/list.njk +145 -62
- package/src/admin/views/partials/form-field.njk +53 -0
- package/src/admin/views/partials/form-footer.njk +28 -0
- package/src/admin/views/partials/form-readonly.njk +114 -0
- package/src/admin/views/partials/form-scripts.njk +476 -0
- package/src/admin/views/partials/form-widget.njk +296 -0
- package/src/admin/views/partials/json-dialog.njk +80 -0
- package/src/admin/views/partials/json-editor.njk +37 -0
- package/src/admin.zip +0 -0
- package/src/auth/Auth.js +31 -10
- package/src/auth/AuthController.js +3 -1
- package/src/auth/AuthUser.js +119 -0
- package/src/cli.js +4 -2
- package/src/commands/createsuperuser.js +254 -0
- package/src/commands/lang.js +589 -0
- package/src/commands/migrate.js +154 -81
- package/src/commands/serve.js +82 -110
- package/src/container/AppInitializer.js +215 -0
- package/src/container/Application.js +278 -253
- package/src/container/HttpServer.js +156 -0
- package/src/container/MillasApp.js +29 -279
- package/src/container/MillasConfig.js +192 -0
- package/src/core/admin.js +5 -0
- package/src/core/auth.js +9 -0
- package/src/core/db.js +9 -0
- package/src/core/foundation.js +59 -0
- package/src/core/http.js +11 -0
- package/src/core/lang.js +1 -0
- package/src/core/mail.js +6 -0
- package/src/core/queue.js +7 -0
- package/src/core/validation.js +29 -0
- package/src/facades/Admin.js +1 -1
- package/src/facades/Auth.js +22 -39
- package/src/facades/Cache.js +21 -10
- package/src/facades/Database.js +1 -1
- package/src/facades/Events.js +18 -17
- package/src/facades/Facade.js +197 -0
- package/src/facades/Http.js +42 -45
- package/src/facades/Log.js +25 -49
- package/src/facades/Mail.js +27 -32
- package/src/facades/Queue.js +22 -15
- package/src/facades/Storage.js +18 -10
- package/src/facades/Url.js +53 -0
- package/src/http/HttpClient.js +673 -0
- package/src/http/ResponseDispatcher.js +18 -111
- package/src/http/UrlGenerator.js +375 -0
- package/src/http/WelcomePage.js +273 -0
- package/src/http/adapters/ExpressAdapter.js +315 -0
- package/src/http/adapters/HttpAdapter.js +168 -0
- package/src/http/adapters/index.js +9 -0
- package/src/i18n/I18nServiceProvider.js +91 -0
- package/src/i18n/Translator.js +635 -0
- package/src/i18n/defaults.js +122 -0
- package/src/i18n/index.js +164 -0
- package/src/i18n/locales/en.js +55 -0
- package/src/i18n/locales/sw.js +48 -0
- package/src/index.js +5 -144
- package/src/logger/formatters/PrettyFormatter.js +103 -57
- package/src/logger/internal.js +2 -2
- package/src/logger/patchConsole.js +91 -81
- package/src/middleware/MiddlewareRegistry.js +62 -82
- package/src/migrations/system/0001_users.js +21 -0
- package/src/migrations/system/0002_admin_log.js +25 -0
- package/src/migrations/system/0003_sessions.js +23 -0
- package/src/orm/fields/index.js +210 -188
- package/src/orm/migration/DefaultValueParser.js +325 -0
- package/src/orm/migration/InteractiveResolver.js +191 -0
- package/src/orm/migration/Makemigrations.js +312 -0
- package/src/orm/migration/MigrationGraph.js +227 -0
- package/src/orm/migration/MigrationRunner.js +202 -108
- package/src/orm/migration/MigrationWriter.js +463 -0
- package/src/orm/migration/ModelInspector.js +412 -344
- package/src/orm/migration/ModelScanner.js +225 -0
- package/src/orm/migration/ProjectState.js +213 -0
- package/src/orm/migration/RenameDetector.js +175 -0
- package/src/orm/migration/SchemaBuilder.js +8 -81
- package/src/orm/migration/operations/base.js +57 -0
- package/src/orm/migration/operations/column.js +191 -0
- package/src/orm/migration/operations/fields.js +252 -0
- package/src/orm/migration/operations/index.js +55 -0
- package/src/orm/migration/operations/models.js +152 -0
- package/src/orm/migration/operations/registry.js +131 -0
- package/src/orm/migration/operations/special.js +51 -0
- package/src/orm/migration/utils.js +208 -0
- package/src/orm/model/Model.js +81 -13
- package/src/providers/AdminServiceProvider.js +66 -9
- package/src/providers/AuthServiceProvider.js +46 -7
- package/src/providers/CacheStorageServiceProvider.js +5 -3
- package/src/providers/DatabaseServiceProvider.js +3 -2
- package/src/providers/EventServiceProvider.js +2 -1
- package/src/providers/LogServiceProvider.js +7 -3
- package/src/providers/MailServiceProvider.js +4 -3
- package/src/providers/QueueServiceProvider.js +4 -3
- package/src/router/Router.js +119 -152
- package/src/scaffold/templates.js +83 -26
- package/src/facades/Validation.js +0 -69
|
@@ -0,0 +1,673 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Http
|
|
5
|
+
*
|
|
6
|
+
* Fluent HTTP client facade. Laravel-style API for making outbound HTTP requests.
|
|
7
|
+
* Built on the Node.js native fetch (Node 18+) — no extra dependencies.
|
|
8
|
+
*
|
|
9
|
+
* ── Quick usage ──────────────────────────────────────────────────────────────
|
|
10
|
+
*
|
|
11
|
+
* const { Http } = require('millas/facades/Http');
|
|
12
|
+
*
|
|
13
|
+
* // GET
|
|
14
|
+
* const res = await Http.get('https://api.example.com/users');
|
|
15
|
+
* const data = res.json();
|
|
16
|
+
*
|
|
17
|
+
* // POST JSON
|
|
18
|
+
* const res = await Http.post('https://api.example.com/users', { name: 'Alice' });
|
|
19
|
+
*
|
|
20
|
+
* // With headers + auth
|
|
21
|
+
* const res = await Http.withToken(token)
|
|
22
|
+
* .withHeaders({ 'X-App': 'millas' })
|
|
23
|
+
* .get('https://api.example.com/me');
|
|
24
|
+
*
|
|
25
|
+
* // Form data
|
|
26
|
+
* const res = await Http.asForm()
|
|
27
|
+
* .post('https://api.example.com/login', { email, password });
|
|
28
|
+
*
|
|
29
|
+
* // Retry on failure
|
|
30
|
+
* const res = await Http.retry(3, 200)
|
|
31
|
+
* .get('https://api.example.com/data');
|
|
32
|
+
*
|
|
33
|
+
* // Base URL
|
|
34
|
+
* const client = Http.baseUrl('https://api.example.com');
|
|
35
|
+
* const users = await client.get('/users');
|
|
36
|
+
* const post = await client.post('/posts', { title: 'Hello' });
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
// ── HttpResponse ──────────────────────────────────────────────────────────────
|
|
40
|
+
|
|
41
|
+
class HttpResponse {
|
|
42
|
+
/**
|
|
43
|
+
* @param {Response} fetchResponse — native fetch Response
|
|
44
|
+
* @param {string} body — raw response body text
|
|
45
|
+
*/
|
|
46
|
+
constructor(fetchResponse, body) {
|
|
47
|
+
this._response = fetchResponse;
|
|
48
|
+
this._body = body;
|
|
49
|
+
this._parsed = null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// ── Status ────────────────────────────────────────────────────────────────
|
|
53
|
+
|
|
54
|
+
/** HTTP status code. */
|
|
55
|
+
get status() { return this._response.status; }
|
|
56
|
+
|
|
57
|
+
/** HTTP status text (e.g. "OK", "Not Found"). */
|
|
58
|
+
get statusText() { return this._response.statusText; }
|
|
59
|
+
|
|
60
|
+
/** True if status is 200–299. */
|
|
61
|
+
get ok() { return this._response.ok; }
|
|
62
|
+
|
|
63
|
+
/** True if status is 200. */
|
|
64
|
+
get isOk() { return this._response.status === 200; }
|
|
65
|
+
|
|
66
|
+
/** True if status is 201. */
|
|
67
|
+
get isCreated() { return this._response.status === 201; }
|
|
68
|
+
|
|
69
|
+
/** True if status is 204. */
|
|
70
|
+
get isEmpty() { return this._response.status === 204; }
|
|
71
|
+
|
|
72
|
+
/** True if status is 301 or 302. */
|
|
73
|
+
get isRedirect() { return [301, 302, 303, 307, 308].includes(this._response.status); }
|
|
74
|
+
|
|
75
|
+
/** True if status is 400. */
|
|
76
|
+
get isBadRequest() { return this._response.status === 400; }
|
|
77
|
+
|
|
78
|
+
/** True if status is 401. */
|
|
79
|
+
get isUnauthorized() { return this._response.status === 401; }
|
|
80
|
+
|
|
81
|
+
/** True if status is 403. */
|
|
82
|
+
get isForbidden() { return this._response.status === 403; }
|
|
83
|
+
|
|
84
|
+
/** True if status is 404. */
|
|
85
|
+
get isNotFound() { return this._response.status === 404; }
|
|
86
|
+
|
|
87
|
+
/** True if status is 422. */
|
|
88
|
+
get isUnprocessable() { return this._response.status === 422; }
|
|
89
|
+
|
|
90
|
+
/** True if status is 429. */
|
|
91
|
+
get isTooManyRequests() { return this._response.status === 429; }
|
|
92
|
+
|
|
93
|
+
/** True if status is 500–599. */
|
|
94
|
+
get isServerError() { return this._response.status >= 500; }
|
|
95
|
+
|
|
96
|
+
/** True if status is 400–499. */
|
|
97
|
+
get isClientError() { return this._response.status >= 400 && this._response.status < 500; }
|
|
98
|
+
|
|
99
|
+
/** True if request failed (4xx or 5xx). */
|
|
100
|
+
get failed() { return !this._response.ok; }
|
|
101
|
+
|
|
102
|
+
// ── Body ──────────────────────────────────────────────────────────────────
|
|
103
|
+
|
|
104
|
+
/** Raw response body as a string. */
|
|
105
|
+
body() { return this._body; }
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Parse the response body as JSON.
|
|
109
|
+
* Parsed result is cached — safe to call multiple times.
|
|
110
|
+
*/
|
|
111
|
+
json() {
|
|
112
|
+
if (this._parsed === null) {
|
|
113
|
+
try {
|
|
114
|
+
this._parsed = JSON.parse(this._body);
|
|
115
|
+
} catch {
|
|
116
|
+
this._parsed = this._body;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return this._parsed;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Get a key from the parsed JSON body.
|
|
124
|
+
* res.data('user.name') — supports dot notation
|
|
125
|
+
* res.data('users.0.id')
|
|
126
|
+
*/
|
|
127
|
+
data(key) {
|
|
128
|
+
const parsed = this.json();
|
|
129
|
+
if (!key) return parsed;
|
|
130
|
+
return key.split('.').reduce((obj, k) => obj?.[k], parsed) ?? null;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Response headers as a plain object. */
|
|
134
|
+
get headers() {
|
|
135
|
+
const h = {};
|
|
136
|
+
this._response.headers.forEach((value, key) => { h[key] = value; });
|
|
137
|
+
return h;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Get a specific response header. */
|
|
141
|
+
header(name) {
|
|
142
|
+
return this._response.headers.get(name);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// ── Throwing helpers ──────────────────────────────────────────────────────
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Throw an HttpClientError if the request failed (4xx or 5xx).
|
|
149
|
+
* Chainable — returns this on success.
|
|
150
|
+
*
|
|
151
|
+
* const res = await Http.get(url).throw();
|
|
152
|
+
*/
|
|
153
|
+
throw() {
|
|
154
|
+
if (this.failed) {
|
|
155
|
+
throw new HttpClientError(
|
|
156
|
+
`HTTP request failed with status ${this.status}`,
|
|
157
|
+
this
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
return this;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Throw only on server errors (5xx).
|
|
165
|
+
*/
|
|
166
|
+
throwOnServerError() {
|
|
167
|
+
if (this.isServerError) {
|
|
168
|
+
throw new HttpClientError(
|
|
169
|
+
`Server error: ${this.status}`,
|
|
170
|
+
this
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
return this;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Throw only on client errors (4xx).
|
|
178
|
+
*/
|
|
179
|
+
throwOnClientError() {
|
|
180
|
+
if (this.isClientError) {
|
|
181
|
+
throw new HttpClientError(
|
|
182
|
+
`Client error: ${this.status}`,
|
|
183
|
+
this
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
return this;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// ── HttpClientError ───────────────────────────────────────────────────────────
|
|
191
|
+
|
|
192
|
+
class HttpClientError extends Error {
|
|
193
|
+
constructor(message, response) {
|
|
194
|
+
super(message);
|
|
195
|
+
this.name = 'HttpClientError';
|
|
196
|
+
this.response = response;
|
|
197
|
+
this.status = response?.status;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// ── PendingRequest (the fluent builder) ───────────────────────────────────────
|
|
202
|
+
|
|
203
|
+
class PendingRequest {
|
|
204
|
+
constructor(defaults = {}) {
|
|
205
|
+
this._headers = { ...defaults.headers };
|
|
206
|
+
this._baseUrl = defaults.baseUrl || '';
|
|
207
|
+
this._timeout = defaults.timeout || 30000;
|
|
208
|
+
this._retries = defaults.retries || 0;
|
|
209
|
+
this._retryDelay = defaults.retryDelay || 100;
|
|
210
|
+
this._bodyFormat = defaults.bodyFormat || 'json'; // 'json' | 'form' | 'multipart' | 'raw'
|
|
211
|
+
this._beforeSend = defaults.beforeSend || null; // (request) => request
|
|
212
|
+
this._afterReceive = defaults.afterReceive || null; // (response) => response
|
|
213
|
+
this._throwOnError = defaults.throwOnError || false;
|
|
214
|
+
this._pool = null;
|
|
215
|
+
this._auth = null;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// ── Configuration ─────────────────────────────────────────────────────────
|
|
219
|
+
|
|
220
|
+
/** Set a base URL prefixed to every request. */
|
|
221
|
+
baseUrl(url) {
|
|
222
|
+
this._baseUrl = url.replace(/\/$/, '');
|
|
223
|
+
return this;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Merge additional headers.
|
|
228
|
+
*
|
|
229
|
+
* Http.withHeaders({ 'X-Tenant': tenantId }).get(url)
|
|
230
|
+
*/
|
|
231
|
+
withHeaders(headers) {
|
|
232
|
+
Object.assign(this._headers, headers);
|
|
233
|
+
return this;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Add a Bearer token Authorization header.
|
|
238
|
+
*
|
|
239
|
+
* Http.withToken(accessToken).get(url)
|
|
240
|
+
*/
|
|
241
|
+
withToken(token, type = 'Bearer') {
|
|
242
|
+
this._headers['Authorization'] = `${type} ${token}`;
|
|
243
|
+
return this;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* HTTP Basic auth.
|
|
248
|
+
*
|
|
249
|
+
* Http.withBasicAuth('user', 'pass').get(url)
|
|
250
|
+
*/
|
|
251
|
+
withBasicAuth(username, password) {
|
|
252
|
+
const encoded = Buffer.from(`${username}:${password}`).toString('base64');
|
|
253
|
+
this._headers['Authorization'] = `Basic ${encoded}`;
|
|
254
|
+
return this;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Add cookies to the request.
|
|
259
|
+
*
|
|
260
|
+
* Http.withCookies({ session: 'abc123' }).get(url)
|
|
261
|
+
*/
|
|
262
|
+
withCookies(cookies) {
|
|
263
|
+
const cookieStr = Object.entries(cookies)
|
|
264
|
+
.map(([k, v]) => `${k}=${encodeURIComponent(v)}`)
|
|
265
|
+
.join('; ');
|
|
266
|
+
this._headers['Cookie'] = cookieStr;
|
|
267
|
+
return this;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Set a custom user-agent.
|
|
272
|
+
*
|
|
273
|
+
* Http.withUserAgent('MyApp/1.0').get(url)
|
|
274
|
+
*/
|
|
275
|
+
withUserAgent(ua) {
|
|
276
|
+
this._headers['User-Agent'] = ua;
|
|
277
|
+
return this;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Set the Accept header.
|
|
282
|
+
*
|
|
283
|
+
* Http.accept('text/html').get(url)
|
|
284
|
+
* Http.accept('application/xml').get(url)
|
|
285
|
+
*/
|
|
286
|
+
accept(contentType) {
|
|
287
|
+
this._headers['Accept'] = contentType;
|
|
288
|
+
return this;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** Accept application/json (default). */
|
|
292
|
+
acceptJson() {
|
|
293
|
+
return this.accept('application/json');
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Set request timeout in milliseconds.
|
|
298
|
+
*
|
|
299
|
+
* Http.timeout(5000).get(url)
|
|
300
|
+
*/
|
|
301
|
+
timeout(ms) {
|
|
302
|
+
this._timeout = ms;
|
|
303
|
+
return this;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Retry failed requests.
|
|
308
|
+
*
|
|
309
|
+
* Http.retry(3).get(url)
|
|
310
|
+
* Http.retry(3, 500).get(url) // 500ms delay between retries
|
|
311
|
+
*/
|
|
312
|
+
retry(times, delay = 100) {
|
|
313
|
+
this._retries = times;
|
|
314
|
+
this._retryDelay = delay;
|
|
315
|
+
return this;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// ── Body format ───────────────────────────────────────────────────────────
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Send body as JSON (default).
|
|
322
|
+
* Sets Content-Type: application/json.
|
|
323
|
+
*/
|
|
324
|
+
asJson() {
|
|
325
|
+
this._bodyFormat = 'json';
|
|
326
|
+
return this;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Send body as application/x-www-form-urlencoded.
|
|
331
|
+
* Useful for OAuth endpoints, legacy form APIs.
|
|
332
|
+
*
|
|
333
|
+
* Http.asForm().post(url, { grant_type: 'client_credentials' })
|
|
334
|
+
*/
|
|
335
|
+
asForm() {
|
|
336
|
+
this._bodyFormat = 'form';
|
|
337
|
+
return this;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Send body as multipart/form-data.
|
|
342
|
+
* Use for file uploads.
|
|
343
|
+
*
|
|
344
|
+
* Http.asMultipart().post(url, formData)
|
|
345
|
+
*/
|
|
346
|
+
asMultipart() {
|
|
347
|
+
this._bodyFormat = 'multipart';
|
|
348
|
+
return this;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Send body as plain text.
|
|
353
|
+
*/
|
|
354
|
+
withBody(body, contentType = 'text/plain') {
|
|
355
|
+
this._rawBody = body;
|
|
356
|
+
this._rawContentType = contentType;
|
|
357
|
+
this._bodyFormat = 'raw';
|
|
358
|
+
return this;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// ── Hooks ─────────────────────────────────────────────────────────────────
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Run a callback before the request is sent.
|
|
365
|
+
* Receives and must return the options object.
|
|
366
|
+
*
|
|
367
|
+
* Http.beforeSending(opts => {
|
|
368
|
+
* opts.headers['X-Timestamp'] = Date.now();
|
|
369
|
+
* return opts;
|
|
370
|
+
* }).get(url)
|
|
371
|
+
*/
|
|
372
|
+
beforeSending(fn) {
|
|
373
|
+
this._beforeSend = fn;
|
|
374
|
+
return this;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* Run a callback after the response is received.
|
|
379
|
+
* Receives and must return the HttpResponse.
|
|
380
|
+
*
|
|
381
|
+
* Http.afterReceiving(res => {
|
|
382
|
+
* Log.d('Http', `${res.status} ${url}`);
|
|
383
|
+
* return res;
|
|
384
|
+
* }).get(url)
|
|
385
|
+
*/
|
|
386
|
+
afterReceiving(fn) {
|
|
387
|
+
this._afterReceive = fn;
|
|
388
|
+
return this;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* Automatically throw HttpClientError on 4xx/5xx responses.
|
|
393
|
+
*/
|
|
394
|
+
throwOnFailure() {
|
|
395
|
+
this._throwOnError = true;
|
|
396
|
+
return this;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// ── HTTP verbs ────────────────────────────────────────────────────────────
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* Send a GET request.
|
|
403
|
+
*
|
|
404
|
+
* Http.get('https://api.example.com/users')
|
|
405
|
+
* Http.get('https://api.example.com/users', { page: 1, per_page: 20 })
|
|
406
|
+
*/
|
|
407
|
+
async get(url, query = {}) {
|
|
408
|
+
return this._send('GET', url, null, query);
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* Send a HEAD request.
|
|
413
|
+
*/
|
|
414
|
+
async head(url, query = {}) {
|
|
415
|
+
return this._send('HEAD', url, null, query);
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* Send a POST request.
|
|
420
|
+
*
|
|
421
|
+
* Http.post('https://api.example.com/users', { name: 'Alice', email: 'alice@example.com' })
|
|
422
|
+
*/
|
|
423
|
+
async post(url, data = {}) {
|
|
424
|
+
return this._send('POST', url, data);
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/**
|
|
428
|
+
* Send a PUT request.
|
|
429
|
+
*
|
|
430
|
+
* Http.put('https://api.example.com/users/1', { name: 'Alice' })
|
|
431
|
+
*/
|
|
432
|
+
async put(url, data = {}) {
|
|
433
|
+
return this._send('PUT', url, data);
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* Send a PATCH request.
|
|
438
|
+
*
|
|
439
|
+
* Http.patch('https://api.example.com/users/1', { name: 'Alice' })
|
|
440
|
+
*/
|
|
441
|
+
async patch(url, data = {}) {
|
|
442
|
+
return this._send('PATCH', url, data);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* Send a DELETE request.
|
|
447
|
+
*
|
|
448
|
+
* Http.delete('https://api.example.com/users/1')
|
|
449
|
+
*/
|
|
450
|
+
async delete(url, data = {}) {
|
|
451
|
+
return this._send('DELETE', url, data);
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* Send an OPTIONS request.
|
|
456
|
+
*/
|
|
457
|
+
async options(url) {
|
|
458
|
+
return this._send('OPTIONS', url, null);
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// ── Pool (concurrent requests) ────────────────────────────────────────────
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* Send multiple requests concurrently and get all responses.
|
|
465
|
+
*
|
|
466
|
+
* const [users, posts] = await Http.pool(http => [
|
|
467
|
+
* http.get('https://api.example.com/users'),
|
|
468
|
+
* http.get('https://api.example.com/posts'),
|
|
469
|
+
* ]);
|
|
470
|
+
*/
|
|
471
|
+
async pool(callback) {
|
|
472
|
+
const requests = callback(this);
|
|
473
|
+
return Promise.all(requests);
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
// ── Internal ──────────────────────────────────────────────────────────────
|
|
477
|
+
|
|
478
|
+
async _send(method, url, data, query = {}) {
|
|
479
|
+
const fullUrl = this._buildUrl(url, query);
|
|
480
|
+
let attempt = 0;
|
|
481
|
+
const maxTries = this._retries + 1;
|
|
482
|
+
|
|
483
|
+
while (attempt < maxTries) {
|
|
484
|
+
attempt++;
|
|
485
|
+
try {
|
|
486
|
+
const response = await this._attempt(method, fullUrl, data);
|
|
487
|
+
if (this._throwOnError) response.throw();
|
|
488
|
+
return response;
|
|
489
|
+
} catch (err) {
|
|
490
|
+
const isLast = attempt >= maxTries;
|
|
491
|
+
|
|
492
|
+
// Don't retry HttpClientError (those are intentional throws)
|
|
493
|
+
if (err instanceof HttpClientError) throw err;
|
|
494
|
+
|
|
495
|
+
if (isLast) throw err;
|
|
496
|
+
|
|
497
|
+
await _sleep(this._retryDelay * attempt); // exponential-ish backoff
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
async _attempt(method, url, data) {
|
|
503
|
+
const controller = new AbortController();
|
|
504
|
+
const timeoutId = setTimeout(() => controller.abort(), this._timeout);
|
|
505
|
+
|
|
506
|
+
const headers = {
|
|
507
|
+
Accept: 'application/json',
|
|
508
|
+
...this._headers,
|
|
509
|
+
};
|
|
510
|
+
|
|
511
|
+
let body;
|
|
512
|
+
|
|
513
|
+
if (data !== null && data !== undefined && method !== 'GET' && method !== 'HEAD') {
|
|
514
|
+
if (this._bodyFormat === 'json') {
|
|
515
|
+
headers['Content-Type'] = 'application/json';
|
|
516
|
+
body = JSON.stringify(data);
|
|
517
|
+
|
|
518
|
+
} else if (this._bodyFormat === 'form') {
|
|
519
|
+
headers['Content-Type'] = 'application/x-www-form-urlencoded';
|
|
520
|
+
body = new URLSearchParams(data).toString();
|
|
521
|
+
|
|
522
|
+
} else if (this._bodyFormat === 'multipart') {
|
|
523
|
+
// FormData — let fetch set the Content-Type with boundary
|
|
524
|
+
if (data instanceof FormData) {
|
|
525
|
+
body = data;
|
|
526
|
+
} else {
|
|
527
|
+
const fd = new FormData();
|
|
528
|
+
for (const [k, v] of Object.entries(data)) fd.append(k, v);
|
|
529
|
+
body = fd;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
} else if (this._bodyFormat === 'raw') {
|
|
533
|
+
headers['Content-Type'] = this._rawContentType;
|
|
534
|
+
body = this._rawBody;
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
let opts = {
|
|
539
|
+
method,
|
|
540
|
+
headers,
|
|
541
|
+
body,
|
|
542
|
+
signal: controller.signal,
|
|
543
|
+
};
|
|
544
|
+
|
|
545
|
+
if (this._beforeSend) {
|
|
546
|
+
opts = await this._beforeSend(opts);
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
let fetchResponse;
|
|
550
|
+
try {
|
|
551
|
+
fetchResponse = await fetch(url, opts);
|
|
552
|
+
} catch (err) {
|
|
553
|
+
if (err.name === 'AbortError') {
|
|
554
|
+
throw new HttpClientError(`Request timed out after ${this._timeout}ms`, null);
|
|
555
|
+
}
|
|
556
|
+
throw new HttpClientError(`Network error: ${err.message}`, null);
|
|
557
|
+
} finally {
|
|
558
|
+
clearTimeout(timeoutId);
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
const text = await fetchResponse.text();
|
|
562
|
+
let response = new HttpResponse(fetchResponse, text);
|
|
563
|
+
|
|
564
|
+
if (this._afterReceive) {
|
|
565
|
+
response = await this._afterReceive(response);
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
return response;
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
_buildUrl(url, query = {}) {
|
|
572
|
+
// Prepend base URL if url is relative
|
|
573
|
+
let full = url.startsWith('http') ? url : `${this._baseUrl}${url}`;
|
|
574
|
+
|
|
575
|
+
const params = Object.entries(query).filter(([, v]) => v !== undefined && v !== null);
|
|
576
|
+
if (params.length) {
|
|
577
|
+
const qs = new URLSearchParams(params).toString();
|
|
578
|
+
full += (full.includes('?') ? '&' : '?') + qs;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
return full;
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
// ── Http namespace (entry point) ──────────────────────────────────────────────
|
|
586
|
+
|
|
587
|
+
/**
|
|
588
|
+
* HttpClient service.
|
|
589
|
+
*
|
|
590
|
+
* Registered in the container under 'http'.
|
|
591
|
+
* Access via the Http facade — never instantiate directly.
|
|
592
|
+
*
|
|
593
|
+
* Every method creates a fresh PendingRequest so calls are stateless.
|
|
594
|
+
*/
|
|
595
|
+
const HttpClient = {
|
|
596
|
+
// ── Shorthand verb methods (create a fresh PendingRequest each time) ──────
|
|
597
|
+
|
|
598
|
+
get: (url, query) => new PendingRequest().get(url, query),
|
|
599
|
+
head: (url, query) => new PendingRequest().head(url, query),
|
|
600
|
+
post: (url, data) => new PendingRequest().post(url, data),
|
|
601
|
+
put: (url, data) => new PendingRequest().put(url, data),
|
|
602
|
+
patch: (url, data) => new PendingRequest().patch(url, data),
|
|
603
|
+
delete: (url, data) => new PendingRequest().delete(url, data),
|
|
604
|
+
options: (url) => new PendingRequest().options(url),
|
|
605
|
+
|
|
606
|
+
// ── Fluent builder starters ───────────────────────────────────────────────
|
|
607
|
+
|
|
608
|
+
/** Set a base URL for all requests on this client. */
|
|
609
|
+
baseUrl: (url) => new PendingRequest().baseUrl(url),
|
|
610
|
+
|
|
611
|
+
/** Add headers. */
|
|
612
|
+
withHeaders: (headers) => new PendingRequest().withHeaders(headers),
|
|
613
|
+
|
|
614
|
+
/** Bearer token auth. */
|
|
615
|
+
withToken: (token, type) => new PendingRequest().withToken(token, type),
|
|
616
|
+
|
|
617
|
+
/** HTTP Basic auth. */
|
|
618
|
+
withBasicAuth: (user, pass) => new PendingRequest().withBasicAuth(user, pass),
|
|
619
|
+
|
|
620
|
+
/** Add request cookies. */
|
|
621
|
+
withCookies: (cookies) => new PendingRequest().withCookies(cookies),
|
|
622
|
+
|
|
623
|
+
/** Set User-Agent header. */
|
|
624
|
+
withUserAgent: (ua) => new PendingRequest().withUserAgent(ua),
|
|
625
|
+
|
|
626
|
+
/** Set Accept header. */
|
|
627
|
+
accept: (type) => new PendingRequest().accept(type),
|
|
628
|
+
|
|
629
|
+
/** Accept application/json. */
|
|
630
|
+
acceptJson: () => new PendingRequest().acceptJson(),
|
|
631
|
+
|
|
632
|
+
/** Set timeout in ms. */
|
|
633
|
+
timeout: (ms) => new PendingRequest().timeout(ms),
|
|
634
|
+
|
|
635
|
+
/** Retry on failure. */
|
|
636
|
+
retry: (times, delay) => new PendingRequest().retry(times, delay),
|
|
637
|
+
|
|
638
|
+
/** Send as JSON (default). */
|
|
639
|
+
asJson: () => new PendingRequest().asJson(),
|
|
640
|
+
|
|
641
|
+
/** Send as form-urlencoded. */
|
|
642
|
+
asForm: () => new PendingRequest().asForm(),
|
|
643
|
+
|
|
644
|
+
/** Send as multipart/form-data. */
|
|
645
|
+
asMultipart: () => new PendingRequest().asMultipart(),
|
|
646
|
+
|
|
647
|
+
/** Hook before sending. */
|
|
648
|
+
beforeSending: (fn) => new PendingRequest().beforeSending(fn),
|
|
649
|
+
|
|
650
|
+
/** Hook after receiving. */
|
|
651
|
+
afterReceiving: (fn) => new PendingRequest().afterReceiving(fn),
|
|
652
|
+
|
|
653
|
+
/** Throw on 4xx/5xx. */
|
|
654
|
+
throwOnFailure: () => new PendingRequest().throwOnFailure(),
|
|
655
|
+
|
|
656
|
+
/** Send multiple concurrent requests. */
|
|
657
|
+
pool: (cb) => new PendingRequest().pool(cb),
|
|
658
|
+
};
|
|
659
|
+
|
|
660
|
+
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
661
|
+
|
|
662
|
+
function _sleep(ms) {
|
|
663
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
// ── Exports ───────────────────────────────────────────────────────────────────
|
|
667
|
+
|
|
668
|
+
module.exports = {
|
|
669
|
+
HttpClient,
|
|
670
|
+
PendingRequest,
|
|
671
|
+
HttpResponse,
|
|
672
|
+
HttpClientError,
|
|
673
|
+
};
|