ebely 0.0.6 → 0.1.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/dist/index.d.ts +137 -3
- package/dist/index.js +326 -58
- package/dist/index.mjs +312 -57
- package/package.json +2 -2
package/dist/index.d.ts
CHANGED
|
@@ -110,6 +110,24 @@ type AfterHookArgs<Ctx, Body = unknown, ResBody = unknown> = {
|
|
|
110
110
|
type BeforeHook<Ctx, Body = unknown> = (args: BeforeHookArgs<Ctx, Body>) => void | Promise<void>;
|
|
111
111
|
/** `after`-хук: вызывается ПОСЛЕ ответа; `response` — только на чтение. */
|
|
112
112
|
type AfterHook<Ctx, Body = unknown, ResBody = unknown> = (args: AfterHookArgs<Ctx, Body, ResBody>) => void | Promise<void>;
|
|
113
|
+
/**
|
|
114
|
+
* `retry`-хук: вызывается ПОСЛЕ ответа (и после всех `after`-хуков) и
|
|
115
|
+
* РЕШАЕТ, нужно ли переиграть тот же запрос. Возврат `true` → ebely
|
|
116
|
+
* выполняет запрос заново (с нуля: снова прогоняет `before`-хуки и шлёт
|
|
117
|
+
* fetch), поэтому правки, сделанные внутри хука (напр. свежий токен в
|
|
118
|
+
* `ctx`), автоматически подхватятся повторным `before`. Любой
|
|
119
|
+
* не-истинный возврат (`false` / `undefined`) → ретрая нет, отдаём ответ
|
|
120
|
+
* как есть.
|
|
121
|
+
*
|
|
122
|
+
* Аргумент — тот же, что у `after` (`request` / `response` / `ctx`):
|
|
123
|
+
* `response` тут на чтение (его смотрят, чтобы решить про ретрай), а
|
|
124
|
+
* подготовку к повтору (refresh токена и т.п.) хук делает через `ctx`.
|
|
125
|
+
*
|
|
126
|
+
* Число повторов ограничено ядром (`EbelyConfig.maxRetries`, по
|
|
127
|
+
* умолчанию 3) — даже если хук упрямо возвращает `true`, бесконечного
|
|
128
|
+
* цикла не будет.
|
|
129
|
+
*/
|
|
130
|
+
type RetryHook<Ctx, Body = unknown, ResBody = unknown> = (args: AfterHookArgs<Ctx, Body, ResBody>) => boolean | void | Promise<boolean | void>;
|
|
113
131
|
/**
|
|
114
132
|
* Регистратор хуков, который пользователь передаёт в конфиг одной
|
|
115
133
|
* переменной (`EbelyConfig.hooks`). Точную типизированную форму аргумента
|
|
@@ -137,6 +155,9 @@ type HooksRegistrar = (registrar: any) => void;
|
|
|
137
155
|
declare class HookRegistry {
|
|
138
156
|
private beforeMap;
|
|
139
157
|
private afterMap;
|
|
158
|
+
private globalBeforeHooks;
|
|
159
|
+
private globalAfterHooks;
|
|
160
|
+
private globalRetryHooks;
|
|
140
161
|
/** Зарегистрировать `before`-хук на ключ `"<группа>.<метод>"`. */
|
|
141
162
|
before(args: {
|
|
142
163
|
key: string;
|
|
@@ -147,19 +168,52 @@ declare class HookRegistry {
|
|
|
147
168
|
key: string;
|
|
148
169
|
fn: AfterHook<any>;
|
|
149
170
|
}): void;
|
|
150
|
-
/**
|
|
171
|
+
/** Зарегистрировать ГЛОБАЛЬНЫЙ `before`-хук (на все операции). */
|
|
172
|
+
globalBefore(args: {
|
|
173
|
+
fn: BeforeHook<any>;
|
|
174
|
+
}): void;
|
|
175
|
+
/** Зарегистрировать ГЛОБАЛЬНЫЙ `after`-хук (на все операции). */
|
|
176
|
+
globalAfter(args: {
|
|
177
|
+
fn: AfterHook<any>;
|
|
178
|
+
}): void;
|
|
179
|
+
/** Зарегистрировать ГЛОБАЛЬНЫЙ `retry`-хук (решает про повтор запроса). */
|
|
180
|
+
globalRetry(args: {
|
|
181
|
+
fn: RetryHook<any>;
|
|
182
|
+
}): void;
|
|
183
|
+
/**
|
|
184
|
+
* Прогнать `before`-хуки по очереди (await на async): сначала глобальные,
|
|
185
|
+
* затем поименные для ключа — так общая подготовка (напр. заголовок
|
|
186
|
+
* авторизации) ложится раньше, а точечный хук может её переопределить.
|
|
187
|
+
*/
|
|
151
188
|
runBefore(args: {
|
|
152
189
|
key: string;
|
|
153
190
|
request: HookRequest;
|
|
154
191
|
ctx: unknown;
|
|
155
192
|
}): Promise<void>;
|
|
156
|
-
/**
|
|
193
|
+
/**
|
|
194
|
+
* Прогнать `after`-хуки по очереди (await на async): сначала поименные
|
|
195
|
+
* для ключа, затем глобальные — так глобальный «оборачивает» точечные
|
|
196
|
+
* (видит итоговый эффект, удобно для логирования/обработки статуса).
|
|
197
|
+
*/
|
|
157
198
|
runAfter(args: {
|
|
158
199
|
key: string;
|
|
159
200
|
request: HookRequest;
|
|
160
201
|
response: HookResponse;
|
|
161
202
|
ctx: unknown;
|
|
162
203
|
}): Promise<void>;
|
|
204
|
+
/**
|
|
205
|
+
* Спросить retry-хуки, нужно ли переиграть запрос. Прогон по очереди
|
|
206
|
+
* регистрации; ПЕРВЫЙ хук, вернувший истину, выигрывает (он уже сделал
|
|
207
|
+
* подготовку — напр. refresh — поэтому остальные не дёргаем). Если ни
|
|
208
|
+
* один не вернул истину — `false` (повтора нет). Цикл и лимит повторов
|
|
209
|
+
* — на стороне вызывающего (сгенерированный `request`).
|
|
210
|
+
*/
|
|
211
|
+
runRetry(args: {
|
|
212
|
+
key: string;
|
|
213
|
+
request: HookRequest;
|
|
214
|
+
response: HookResponse;
|
|
215
|
+
ctx: unknown;
|
|
216
|
+
}): Promise<boolean>;
|
|
163
217
|
}
|
|
164
218
|
|
|
165
219
|
/**
|
|
@@ -176,6 +230,57 @@ type SwaggerSource = {
|
|
|
176
230
|
pathToFile?: never;
|
|
177
231
|
};
|
|
178
232
|
|
|
233
|
+
/**
|
|
234
|
+
* Как кодируются ИМЕНА полей формы для МАССИВА файлов (для одного файла не
|
|
235
|
+
* важно — всегда одно поле без скобок). Сам wire-формат multipart (RFC 7578)
|
|
236
|
+
* везде одинаков; различается только именование при массиве:
|
|
237
|
+
* - 'repeat' — files, files, … (busboy: Express/Nest/Fastify, Go, Rust) — ДЕФОЛТ
|
|
238
|
+
* - 'bracket-index' — files[0], files[1] (oRPC OpenAPI-хендлер, PHP, Rails)
|
|
239
|
+
* - 'bracket-empty' — files[], files[] (PHP/Rails вариант)
|
|
240
|
+
* - функция — кастомная кодировка (escape hatch)
|
|
241
|
+
*/
|
|
242
|
+
type FileEncoding = 'repeat' | 'bracket-index' | 'bracket-empty' | ((args: {
|
|
243
|
+
form: FormData;
|
|
244
|
+
field: string;
|
|
245
|
+
blobs: Blob[];
|
|
246
|
+
}) => void);
|
|
247
|
+
/**
|
|
248
|
+
* Способы передать файл в типизированный метод клиента. В 90% случаев это
|
|
249
|
+
* строка-путь или URL (в тестах); во фронтенде — готовый File/Blob.
|
|
250
|
+
*/
|
|
251
|
+
type FileInput = string | URL | {
|
|
252
|
+
path: string;
|
|
253
|
+
name?: string;
|
|
254
|
+
type?: string;
|
|
255
|
+
} | {
|
|
256
|
+
content: Uint8Array | ArrayBuffer;
|
|
257
|
+
name: string;
|
|
258
|
+
type?: string;
|
|
259
|
+
} | Blob | File;
|
|
260
|
+
/** Плоское файловое поле тела: имя свойства + это массив файлов или один. */
|
|
261
|
+
type FileFieldMeta = {
|
|
262
|
+
name: string;
|
|
263
|
+
array: boolean;
|
|
264
|
+
};
|
|
265
|
+
/** mime по расширению имени/пути; дефолт — application/octet-stream. */
|
|
266
|
+
declare function mimeFromName(name: string): string;
|
|
267
|
+
/**
|
|
268
|
+
* Преобразует один {@link FileInput} в Blob (File). Путь читается с диска
|
|
269
|
+
* через `fs` (Node); готовый File/Blob возвращается как есть (браузер).
|
|
270
|
+
*/
|
|
271
|
+
declare function toBlob(input: FileInput): Promise<Blob>;
|
|
272
|
+
/**
|
|
273
|
+
* Собирает FormData из тела-объекта: файловые поля кодируются по конвенции
|
|
274
|
+
* (`encoding`), остальные (скалярные) поля добавляются строками (объекты —
|
|
275
|
+
* через JSON.stringify). Вызывается уже ПОСЛЕ before-хуков, когда тело —
|
|
276
|
+
* обычный объект.
|
|
277
|
+
*/
|
|
278
|
+
declare function toMultipartFormData(args: {
|
|
279
|
+
body: Record<string, unknown>;
|
|
280
|
+
fileFields: FileFieldMeta[];
|
|
281
|
+
encoding: FileEncoding;
|
|
282
|
+
}): Promise<FormData>;
|
|
283
|
+
|
|
179
284
|
/**
|
|
180
285
|
* Режим генерируемого клиента (решается на этапе генерации, влияет на
|
|
181
286
|
* форму сгенерированного файла):
|
|
@@ -218,6 +323,16 @@ type EbelyConfig = {
|
|
|
218
323
|
* @default 'test'
|
|
219
324
|
*/
|
|
220
325
|
mode?: ClientMode;
|
|
326
|
+
/**
|
|
327
|
+
* Максимальное число ПОВТОРОВ запроса, которые ebely сделает, если
|
|
328
|
+
* глобальный `retry`-хук (`h.globalRetry`) вернул `true`. Это потолок
|
|
329
|
+
* на повторы СВЕРХ первой попытки: при `maxRetries: 3` запрос уйдёт
|
|
330
|
+
* максимум 4 раза. Защита от бесконечного цикла, если хук упрямо просит
|
|
331
|
+
* повтор (напр. сервер стабильно отвечает 401). Без `globalRetry`-хуков
|
|
332
|
+
* не влияет ни на что.
|
|
333
|
+
* @default 3
|
|
334
|
+
*/
|
|
335
|
+
maxRetries?: number;
|
|
221
336
|
/**
|
|
222
337
|
* Регистратор хуков `before` / `after`. Объявляется в ОТДЕЛЬНОМ
|
|
223
338
|
* типизированном файле (тип `Hooks` экспортирует сгенерированный
|
|
@@ -242,6 +357,25 @@ type EbelyConfig = {
|
|
|
242
357
|
* @default undefined
|
|
243
358
|
*/
|
|
244
359
|
hooks?: HooksRegistrar;
|
|
360
|
+
/**
|
|
361
|
+
* Настройки отправки файлов (multipart/form-data). `encoding` задаёт, как
|
|
362
|
+
* кодируются ИМЕНА полей формы для МАССИВА файлов (для одного файла не
|
|
363
|
+
* важно — всегда одно поле без скобок):
|
|
364
|
+
* - `'repeat'` — files, files, … (busboy: Express/Nest/Fastify, Go, Rust) — ДЕФОЛТ
|
|
365
|
+
* - `'bracket-index'` — files[0], files[1] (oRPC OpenAPI-хендлер, PHP, Rails)
|
|
366
|
+
* - `'bracket-empty'` — files[], files[] (PHP/Rails вариант)
|
|
367
|
+
* - функция — кастомная кодировка (escape hatch)
|
|
368
|
+
*
|
|
369
|
+
* Дефолт `'repeat'` — мейнстрим (веб-стандарт). Проектам на oRPC нужно
|
|
370
|
+
* явно поставить `files: { encoding: 'bracket-index' }`.
|
|
371
|
+
*
|
|
372
|
+
* Рантайм-настройка (как `maxRetries`): читается в момент запроса, для
|
|
373
|
+
* смены перегенерация клиента НЕ нужна.
|
|
374
|
+
* @default 'repeat'
|
|
375
|
+
*/
|
|
376
|
+
files?: {
|
|
377
|
+
encoding?: FileEncoding;
|
|
378
|
+
};
|
|
245
379
|
/**
|
|
246
380
|
* Из какого модуля СГЕНЕРИРОВАННЫЙ файл импортирует `BaseStore`.
|
|
247
381
|
* По умолчанию `'ebely'` — имя npm-пакета библиотеки. Менять нужно
|
|
@@ -270,4 +404,4 @@ declare function generateClient(args: EbelyConfig): Promise<{
|
|
|
270
404
|
operations: number;
|
|
271
405
|
}>;
|
|
272
406
|
|
|
273
|
-
export { AfterHook, AfterHookArgs, ApiResponse, BaseStore, BeforeHook, BeforeHookArgs, ClientMode, DeepPartial, EbelyAssertionError, EbelyConfig, HookRegistry, HookRequest, HookResponse, HooksRegistrar, SwaggerSource, generateClient };
|
|
407
|
+
export { AfterHook, AfterHookArgs, ApiResponse, BaseStore, BeforeHook, BeforeHookArgs, ClientMode, DeepPartial, EbelyAssertionError, EbelyConfig, FileEncoding, FileFieldMeta, FileInput, HookRegistry, HookRequest, HookResponse, HooksRegistrar, RetryHook, SwaggerSource, generateClient, mimeFromName, toBlob, toMultipartFormData };
|
package/dist/index.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
+
var __create = Object.create;
|
|
2
3
|
var __defProp = Object.defineProperty;
|
|
3
4
|
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
5
|
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
6
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
5
7
|
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
8
|
var __export = (target, all) => {
|
|
7
9
|
for (var name in all)
|
|
@@ -15,6 +17,14 @@ var __copyProps = (to, from, except, desc) => {
|
|
|
15
17
|
}
|
|
16
18
|
return to;
|
|
17
19
|
};
|
|
20
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
|
21
|
+
// If the importer is in node compatibility mode or this is not an ESM
|
|
22
|
+
// file that has been converted to a CommonJS file using a Babel-
|
|
23
|
+
// compatible transform (i.e. "__esModule" has not been set), then set
|
|
24
|
+
// "default" to the CommonJS "module.exports" for node compatibility.
|
|
25
|
+
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
|
26
|
+
mod
|
|
27
|
+
));
|
|
18
28
|
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
29
|
|
|
20
30
|
// index.ts
|
|
@@ -24,7 +34,10 @@ __export(evely_exports, {
|
|
|
24
34
|
BaseStore: () => BaseStore,
|
|
25
35
|
EbelyAssertionError: () => EbelyAssertionError,
|
|
26
36
|
HookRegistry: () => HookRegistry,
|
|
27
|
-
generateClient: () => generateClient
|
|
37
|
+
generateClient: () => generateClient,
|
|
38
|
+
mimeFromName: () => mimeFromName,
|
|
39
|
+
toBlob: () => toBlob,
|
|
40
|
+
toMultipartFormData: () => toMultipartFormData
|
|
28
41
|
});
|
|
29
42
|
module.exports = __toCommonJS(evely_exports);
|
|
30
43
|
|
|
@@ -163,6 +176,15 @@ var ApiResponse = class {
|
|
|
163
176
|
var HookRegistry = class {
|
|
164
177
|
beforeMap = /* @__PURE__ */ new Map();
|
|
165
178
|
afterMap = /* @__PURE__ */ new Map();
|
|
179
|
+
// Глобальные хуки — срабатывают на КАЖДУЮ операцию (вне зависимости от
|
|
180
|
+
// ключа). Удобны для сквозных задач: подстановка `Authorization` из
|
|
181
|
+
// `ctx` во все запросы, логирование, обработка 401 и т.п.
|
|
182
|
+
globalBeforeHooks = [];
|
|
183
|
+
globalAfterHooks = [];
|
|
184
|
+
// Глобальные retry-хуки — решают, переигрывать ли запрос (напр. на 401
|
|
185
|
+
// сходить за refresh и вернуть `true`). Цикл повторов и его лимит живут
|
|
186
|
+
// в сгенерированном `request` (см. render.ts) — сам реестр сети не знает.
|
|
187
|
+
globalRetryHooks = [];
|
|
166
188
|
/** Зарегистрировать `before`-хук на ключ `"<группа>.<метод>"`. */
|
|
167
189
|
before(args) {
|
|
168
190
|
const list = this.beforeMap.get(args.key) ?? [];
|
|
@@ -175,18 +197,59 @@ var HookRegistry = class {
|
|
|
175
197
|
list.push(args.fn);
|
|
176
198
|
this.afterMap.set(args.key, list);
|
|
177
199
|
}
|
|
178
|
-
/**
|
|
200
|
+
/** Зарегистрировать ГЛОБАЛЬНЫЙ `before`-хук (на все операции). */
|
|
201
|
+
globalBefore(args) {
|
|
202
|
+
this.globalBeforeHooks.push(args.fn);
|
|
203
|
+
}
|
|
204
|
+
/** Зарегистрировать ГЛОБАЛЬНЫЙ `after`-хук (на все операции). */
|
|
205
|
+
globalAfter(args) {
|
|
206
|
+
this.globalAfterHooks.push(args.fn);
|
|
207
|
+
}
|
|
208
|
+
/** Зарегистрировать ГЛОБАЛЬНЫЙ `retry`-хук (решает про повтор запроса). */
|
|
209
|
+
globalRetry(args) {
|
|
210
|
+
this.globalRetryHooks.push(args.fn);
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* Прогнать `before`-хуки по очереди (await на async): сначала глобальные,
|
|
214
|
+
* затем поименные для ключа — так общая подготовка (напр. заголовок
|
|
215
|
+
* авторизации) ложится раньше, а точечный хук может её переопределить.
|
|
216
|
+
*/
|
|
179
217
|
async runBefore(args) {
|
|
180
|
-
|
|
218
|
+
const hooks = [...this.globalBeforeHooks, ...this.beforeMap.get(args.key) ?? []];
|
|
219
|
+
for (const fn of hooks) {
|
|
181
220
|
await fn({ request: args.request, ctx: args.ctx });
|
|
182
221
|
}
|
|
183
222
|
}
|
|
184
|
-
/**
|
|
223
|
+
/**
|
|
224
|
+
* Прогнать `after`-хуки по очереди (await на async): сначала поименные
|
|
225
|
+
* для ключа, затем глобальные — так глобальный «оборачивает» точечные
|
|
226
|
+
* (видит итоговый эффект, удобно для логирования/обработки статуса).
|
|
227
|
+
*/
|
|
185
228
|
async runAfter(args) {
|
|
186
|
-
|
|
229
|
+
const hooks = [...this.afterMap.get(args.key) ?? [], ...this.globalAfterHooks];
|
|
230
|
+
for (const fn of hooks) {
|
|
187
231
|
await fn({ request: args.request, response: args.response, ctx: args.ctx });
|
|
188
232
|
}
|
|
189
233
|
}
|
|
234
|
+
/**
|
|
235
|
+
* Спросить retry-хуки, нужно ли переиграть запрос. Прогон по очереди
|
|
236
|
+
* регистрации; ПЕРВЫЙ хук, вернувший истину, выигрывает (он уже сделал
|
|
237
|
+
* подготовку — напр. refresh — поэтому остальные не дёргаем). Если ни
|
|
238
|
+
* один не вернул истину — `false` (повтора нет). Цикл и лимит повторов
|
|
239
|
+
* — на стороне вызывающего (сгенерированный `request`).
|
|
240
|
+
*/
|
|
241
|
+
async runRetry(args) {
|
|
242
|
+
for (const fn of this.globalRetryHooks) {
|
|
243
|
+
const decision = await fn({
|
|
244
|
+
request: args.request,
|
|
245
|
+
response: args.response,
|
|
246
|
+
ctx: args.ctx
|
|
247
|
+
});
|
|
248
|
+
if (decision)
|
|
249
|
+
return true;
|
|
250
|
+
}
|
|
251
|
+
return false;
|
|
252
|
+
}
|
|
190
253
|
};
|
|
191
254
|
|
|
192
255
|
// src/generate-client.ts
|
|
@@ -194,6 +257,9 @@ var import_promises2 = require("fs/promises");
|
|
|
194
257
|
var import_node_path2 = require("path");
|
|
195
258
|
|
|
196
259
|
// src/generator/schema.ts
|
|
260
|
+
function isFileSchema(schema) {
|
|
261
|
+
return Boolean(schema) && schema.type === "string" && typeof schema.contentMediaType === "string";
|
|
262
|
+
}
|
|
197
263
|
function schemaToType(args) {
|
|
198
264
|
const { schema, spec, indent = 0 } = args;
|
|
199
265
|
if (!schema)
|
|
@@ -202,6 +268,8 @@ function schemaToType(args) {
|
|
|
202
268
|
const resolved = resolveRef({ ref: schema.$ref, spec });
|
|
203
269
|
return schemaToType({ schema: resolved, spec, indent });
|
|
204
270
|
}
|
|
271
|
+
if (isFileSchema(schema))
|
|
272
|
+
return "FileInput";
|
|
205
273
|
for (const key of ["allOf", "oneOf", "anyOf"]) {
|
|
206
274
|
if (Array.isArray(schema[key])) {
|
|
207
275
|
const joiner = key === "allOf" ? " & " : " | ";
|
|
@@ -291,7 +359,10 @@ function collectOperations(args) {
|
|
|
291
359
|
const parameters = op.parameters ?? [];
|
|
292
360
|
const pathParams = parameters.filter((p) => p.in === "path").map((p) => p.name);
|
|
293
361
|
const queryParams = parameters.filter((p) => p.in === "query").map((p) => p.name);
|
|
294
|
-
const
|
|
362
|
+
const content = op.requestBody?.content ?? {};
|
|
363
|
+
const bodySchema = content["multipart/form-data"]?.schema ?? content["application/json"]?.schema;
|
|
364
|
+
const fileFields = collectFileFields({ schema: bodySchema, spec });
|
|
365
|
+
const isMultipart = "multipart/form-data" in content || fileFields.length > 0;
|
|
295
366
|
const responseSchema = pickResponseSchema({ responses: op.responses, spec });
|
|
296
367
|
const responseType = schemaToType({ schema: responseSchema, spec, indent: 3 });
|
|
297
368
|
const declared = collectResponseSchemas({ responses: op.responses, spec });
|
|
@@ -308,6 +379,8 @@ function collectOperations(args) {
|
|
|
308
379
|
queryParams,
|
|
309
380
|
bodyType: bodySchema ? schemaToType({ schema: bodySchema, spec, indent: 4 }) : null,
|
|
310
381
|
bodyRequired: Boolean(op.requestBody?.required),
|
|
382
|
+
isMultipart,
|
|
383
|
+
fileFields,
|
|
311
384
|
responseType,
|
|
312
385
|
responses,
|
|
313
386
|
summary: op.summary
|
|
@@ -317,6 +390,22 @@ function collectOperations(args) {
|
|
|
317
390
|
dedupeNames({ operations });
|
|
318
391
|
return operations;
|
|
319
392
|
}
|
|
393
|
+
function collectFileFields(args) {
|
|
394
|
+
const { schema, spec } = args;
|
|
395
|
+
const deref = (s) => s && typeof s.$ref === "string" ? resolveRef({ ref: s.$ref, spec }) : s;
|
|
396
|
+
const root = deref(schema);
|
|
397
|
+
const props = root?.properties ?? {};
|
|
398
|
+
const out = [];
|
|
399
|
+
for (const [name, raw] of Object.entries(props)) {
|
|
400
|
+
const prop = deref(raw);
|
|
401
|
+
if (isFileSchema(prop)) {
|
|
402
|
+
out.push({ name, array: false });
|
|
403
|
+
} else if (prop?.type === "array" && isFileSchema(deref(prop.items))) {
|
|
404
|
+
out.push({ name, array: true });
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
return out;
|
|
408
|
+
}
|
|
320
409
|
function prefixMethod(args) {
|
|
321
410
|
const { method, name } = args;
|
|
322
411
|
return method + name.charAt(0).toUpperCase() + name.slice(1);
|
|
@@ -408,11 +497,23 @@ function renderMethod(args) {
|
|
|
408
497
|
}
|
|
409
498
|
function renderImports(args) {
|
|
410
499
|
const { mode, userStoreImport, configImport } = args;
|
|
411
|
-
const
|
|
412
|
-
return `import { ${
|
|
413
|
-
import type { BeforeHook, AfterHook } from ${JSON.stringify(userStoreImport)}
|
|
500
|
+
const core = mode === "frontend" ? "BaseStore, HookRegistry" : "BaseStore, ApiResponse, HookRegistry";
|
|
501
|
+
return `import { ${core}, toMultipartFormData } from ${JSON.stringify(userStoreImport)}
|
|
502
|
+
import type { BeforeHook, AfterHook, RetryHook } from ${JSON.stringify(userStoreImport)}
|
|
503
|
+
import type { FileInput, FileEncoding, FileFieldMeta } from ${JSON.stringify(userStoreImport)}
|
|
414
504
|
import { ebely } from ${JSON.stringify(configImport)}`;
|
|
415
505
|
}
|
|
506
|
+
function renderFileOps(operations) {
|
|
507
|
+
const entries = operations.filter((op) => op.isMultipart && op.fileFields.length > 0).map((op) => {
|
|
508
|
+
const fields = op.fileFields.map((f) => `{ name: ${JSON.stringify(f.name)}, array: ${f.array} }`).join(", ");
|
|
509
|
+
return ` ${JSON.stringify(hookKey(op))}: [${fields}],`;
|
|
510
|
+
});
|
|
511
|
+
if (entries.length === 0)
|
|
512
|
+
return "const FILE_OPS: Record<string, FileFieldMeta[]> = {}";
|
|
513
|
+
return `const FILE_OPS: Record<string, FileFieldMeta[]> = {
|
|
514
|
+
${entries.join("\n")}
|
|
515
|
+
}`;
|
|
516
|
+
}
|
|
416
517
|
function renderRequestTail(mode) {
|
|
417
518
|
if (mode === "frontend") {
|
|
418
519
|
return {
|
|
@@ -469,7 +570,11 @@ function renderHookTreeType(groups) {
|
|
|
469
570
|
${leaves.join("\n")}
|
|
470
571
|
}`;
|
|
471
572
|
});
|
|
573
|
+
const global = ` globalBefore(fn: BeforeHook<Store>): void
|
|
574
|
+
globalAfter(fn: AfterHook<Store>): void
|
|
575
|
+
globalRetry(fn: RetryHook<Store>): void`;
|
|
472
576
|
return `type EbelyHookTree<Store extends BaseStore> = {
|
|
577
|
+
${global}
|
|
473
578
|
${blocks.join("\n")}
|
|
474
579
|
}`;
|
|
475
580
|
}
|
|
@@ -489,6 +594,9 @@ ${leaves.join("\n")}
|
|
|
489
594
|
return ` private buildHookTree(): EbelyHookTree<Store> {
|
|
490
595
|
const r = this.hookRegistry
|
|
491
596
|
return {
|
|
597
|
+
globalBefore: (fn: BeforeHook<Store>) => r.globalBefore({ fn }),
|
|
598
|
+
globalAfter: (fn: AfterHook<Store>) => r.globalAfter({ fn }),
|
|
599
|
+
globalRetry: (fn: RetryHook<Store>) => r.globalRetry({ fn }),
|
|
492
600
|
${blocks.join("\n")}
|
|
493
601
|
} as unknown as EbelyHookTree<Store>
|
|
494
602
|
}`;
|
|
@@ -513,6 +621,7 @@ function renderClient(args) {
|
|
|
513
621
|
const basePath = spec.servers?.[0]?.url ?? "";
|
|
514
622
|
const groups = groupOperations(operations);
|
|
515
623
|
const tail = renderRequestTail(mode);
|
|
624
|
+
const tailRet = tail.ret.split("\n").map((l) => l.trim() ? ` ${l}` : l).join("\n");
|
|
516
625
|
return `// AUTO-GENERATED by ebely (generateClient) \u2014 \u041D\u0415 \u0440\u0435\u0434\u0430\u043A\u0442\u0438\u0440\u043E\u0432\u0430\u0442\u044C \u0432\u0440\u0443\u0447\u043D\u0443\u044E.
|
|
517
626
|
// \u0418\u0441\u0442\u043E\u0447\u043D\u0438\u043A: ${spec.info?.title ?? "OpenAPI spec"} v${spec.info?.version ?? "?"}
|
|
518
627
|
// \u0420\u0435\u0436\u0438\u043C \u043A\u043B\u0438\u0435\u043D\u0442\u0430: ${mode}
|
|
@@ -520,6 +629,8 @@ function renderClient(args) {
|
|
|
520
629
|
|
|
521
630
|
${renderImports({ mode, userStoreImport, configImport })}
|
|
522
631
|
|
|
632
|
+
${renderFileOps(operations)}
|
|
633
|
+
|
|
523
634
|
type RequestInput = {
|
|
524
635
|
path?: Record<string, string>
|
|
525
636
|
query?: Record<string, string | number | boolean | undefined>
|
|
@@ -618,59 +729,102 @@ ${renderHookTreeBuilder(groups)}
|
|
|
618
729
|
const baseUrl = this.baseUrl()
|
|
619
730
|
const registry = this.hookRegistry
|
|
620
731
|
const { headers: baseHeaders, store } = cfg
|
|
732
|
+
// \u041F\u043E\u0442\u043E\u043B\u043E\u043A \u041F\u041E\u0412\u0422\u041E\u0420\u041E\u0412 (\u0441\u0432\u0435\u0440\u0445 \u043F\u0435\u0440\u0432\u043E\u0439 \u043F\u043E\u043F\u044B\u0442\u043A\u0438) \u0434\u043B\u044F globalRetry-\u0445\u0443\u043A\u043E\u0432 \u2014
|
|
733
|
+
// \u0437\u0430\u0449\u0438\u0442\u0430 \u043E\u0442 \u0431\u0435\u0441\u043A\u043E\u043D\u0435\u0447\u043D\u043E\u0433\u043E \u0446\u0438\u043A\u043B\u0430, \u0435\u0441\u043B\u0438 \u0445\u0443\u043A \u0443\u043F\u0440\u044F\u043C\u043E \u043F\u0440\u043E\u0441\u0438\u0442 \u043F\u043E\u0432\u0442\u043E\u0440.
|
|
734
|
+
const maxRetries = (ebely as { maxRetries?: number }).maxRetries ?? 3
|
|
621
735
|
|
|
622
736
|
return async (req): Promise<${tail.returnType}> => {
|
|
623
737
|
const { method, path, opKey, input } = req
|
|
624
738
|
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
739
|
+
// Retry-\u0446\u0438\u043A\u043B: \u043A\u0430\u0436\u0434\u0443\u044E \u043F\u043E\u043F\u044B\u0442\u043A\u0443 hookReq \u0441\u043E\u0431\u0438\u0440\u0430\u0435\u0442\u0441\u044F \u0417\u0410\u041D\u041E\u0412\u041E \u0438 \u0437\u0430\u043D\u043E\u0432\u043E
|
|
740
|
+
// \u043F\u0440\u043E\u0433\u043E\u043D\u044F\u044E\u0442\u0441\u044F before-\u0445\u0443\u043A\u0438, \u043F\u043E\u044D\u0442\u043E\u043C\u0443 \u043F\u0440\u0430\u0432\u043A\u0438 \u0438\u0437 retry-\u0445\u0443\u043A\u0430 (\u043D\u0430\u043F\u0440.
|
|
741
|
+
// \u0441\u0432\u0435\u0436\u0438\u0439 \u0442\u043E\u043A\u0435\u043D \u0432 ctx) \u043F\u043E\u0434\u0445\u0432\u0430\u0442\u044B\u0432\u0430\u044E\u0442\u0441\u044F \u043F\u043E\u0432\u0442\u043E\u0440\u043D\u044B\u043C before. \`for (;;)\`
|
|
742
|
+
// \u043A\u0440\u0443\u0442\u0438\u0442\u0441\u044F, \u043F\u043E\u043A\u0430 \u043D\u0435 \u0441\u0440\u0430\u0431\u043E\u0442\u0430\u0435\u0442 return/throw \u0432 \u0445\u0432\u043E\u0441\u0442\u0435 \u0438\u043B\u0438 \`continue\`.
|
|
743
|
+
let attempt = 0
|
|
744
|
+
for (;;) {
|
|
745
|
+
const hookReq = {
|
|
746
|
+
method,
|
|
747
|
+
path,
|
|
748
|
+
pathParams: { ...(input?.path ?? {}) },
|
|
749
|
+
query: { ...(input?.query ?? {}) },
|
|
750
|
+
body: input?.body,
|
|
751
|
+
headers: { ...baseHeaders },
|
|
752
|
+
}
|
|
753
|
+
await registry.runBefore({ key: opKey, request: hookReq, ctx: store })
|
|
754
|
+
|
|
755
|
+
let resolvedPath = path
|
|
756
|
+
for (const [k, v] of Object.entries(hookReq.pathParams)) {
|
|
757
|
+
resolvedPath = resolvedPath.replace(
|
|
758
|
+
\`{\${k}}\`,
|
|
759
|
+
encodeURIComponent(String(v)),
|
|
760
|
+
)
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
const url = new URL(baseUrl + resolvedPath)
|
|
764
|
+
for (const [k, v] of Object.entries(hookReq.query)) {
|
|
765
|
+
if (v !== undefined) url.searchParams.set(k, String(v))
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
const hasBody = hookReq.body !== undefined
|
|
769
|
+
// \u0424\u0430\u0439\u043B\u043E\u0432\u044B\u0435 (multipart) \u043E\u043F\u0435\u0440\u0430\u0446\u0438\u0438: FormData \u0441\u043E\u0431\u0438\u0440\u0430\u0435\u0442\u0441\u044F \u041F\u041E\u0421\u041B\u0415 before-\u0445\u0443\u043A\u043E\u0432
|
|
770
|
+
// (\u0442\u0435\u043B\u043E \u043A \u044D\u0442\u043E\u043C\u0443 \u043C\u043E\u043C\u0435\u043D\u0442\u0443 \u2014 \u043E\u0431\u044B\u0447\u043D\u044B\u0439 \u043E\u0431\u044A\u0435\u043A\u0442, \u0445\u0443\u043A\u0438 \u0432\u0438\u0434\u044F\u0442/\u043F\u0440\u0430\u0432\u044F\u0442 \u0435\u0433\u043E \u043A\u0430\u043A
|
|
771
|
+
// JSON). \u041A\u043E\u0434\u0438\u0440\u043E\u0432\u043A\u0430 \u0438\u043C\u0451\u043D \u043F\u043E\u043B\u0435\u0439 \u0434\u043B\u044F \u043C\u0430\u0441\u0441\u0438\u0432\u0430 \u0444\u0430\u0439\u043B\u043E\u0432 \u2014 \u0438\u0437 ebely.files
|
|
772
|
+
// (\u0434\u0435\u0444\u043E\u043B\u0442 'repeat': \u0432\u0435\u0431-\u0441\u0442\u0430\u043D\u0434\u0430\u0440\u0442 busboy/Go/Rust; oRPC \u0441\u0442\u0430\u0432\u0438\u0442
|
|
773
|
+
// 'bracket-index'). \u041D\u0430 retry FormData \u043F\u0435\u0440\u0435\u0441\u043E\u0431\u0438\u0440\u0430\u0435\u0442\u0441\u044F \u0437\u0430\u043D\u043E\u0432\u043E.
|
|
774
|
+
const fileFields = FILE_OPS[opKey]
|
|
775
|
+
const form =
|
|
776
|
+
fileFields && fileFields.length > 0 && hasBody
|
|
777
|
+
? await toMultipartFormData({
|
|
778
|
+
body: hookReq.body as Record<string, unknown>,
|
|
779
|
+
fileFields,
|
|
780
|
+
encoding:
|
|
781
|
+
(ebely as { files?: { encoding?: FileEncoding } }).files?.encoding ?? 'repeat',
|
|
782
|
+
})
|
|
783
|
+
: undefined
|
|
784
|
+
const isMultipart = form !== undefined
|
|
785
|
+
|
|
786
|
+
const response = await fetch(url, {
|
|
787
|
+
method,
|
|
788
|
+
headers: {
|
|
789
|
+
// multipart: content-type \u041D\u0415 \u0441\u0442\u0430\u0432\u0438\u043C \u2014 fetch \u0441\u0430\u043C \u0432\u044B\u0441\u0442\u0430\u0432\u0438\u0442 boundary.
|
|
790
|
+
...(isMultipart ? {} : hasBody ? { 'content-type': 'application/json' } : {}),
|
|
791
|
+
...hookReq.headers,
|
|
792
|
+
},
|
|
793
|
+
body: isMultipart ? form : hasBody ? JSON.stringify(hookReq.body) : undefined,
|
|
794
|
+
})
|
|
795
|
+
|
|
796
|
+
const text = await response.text()
|
|
797
|
+
let data: unknown
|
|
798
|
+
try {
|
|
799
|
+
data = text ? JSON.parse(text) : undefined
|
|
800
|
+
} catch {
|
|
801
|
+
data = text
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
await registry.runAfter({
|
|
805
|
+
key: opKey,
|
|
806
|
+
request: hookReq,
|
|
807
|
+
response: { status: response.status, body: data },
|
|
808
|
+
ctx: store,
|
|
809
|
+
})
|
|
810
|
+
|
|
811
|
+
// \u041F\u043E\u043A\u0430 \u0435\u0441\u0442\u044C \u0431\u044E\u0434\u0436\u0435\u0442 \u043F\u043E\u0432\u0442\u043E\u0440\u043E\u0432 \u2014 \u0441\u043F\u0440\u0430\u0448\u0438\u0432\u0430\u0435\u043C retry-\u0445\u0443\u043A\u0438. \u0412\u0435\u0440\u043D\u0443\u043B\u0438
|
|
812
|
+
// true \u2192 \u043D\u043E\u0432\u044B\u0439 \u0432\u0438\u0442\u043E\u043A (\u0437\u0430\u043D\u043E\u0432\u043E before \u2192 fetch); \u0438\u043D\u0430\u0447\u0435 \u043E\u0442\u0434\u0430\u0451\u043C \u043E\u0442\u0432\u0435\u0442.
|
|
813
|
+
if (attempt < maxRetries) {
|
|
814
|
+
const shouldRetry = await registry.runRetry({
|
|
815
|
+
key: opKey,
|
|
816
|
+
request: hookReq,
|
|
817
|
+
response: { status: response.status, body: data },
|
|
818
|
+
ctx: store,
|
|
819
|
+
})
|
|
820
|
+
if (shouldRetry) {
|
|
821
|
+
attempt++
|
|
822
|
+
continue
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
${tailRet}
|
|
632
827
|
}
|
|
633
|
-
await registry.runBefore({ key: opKey, request: hookReq, ctx: store })
|
|
634
|
-
|
|
635
|
-
let resolvedPath = path
|
|
636
|
-
for (const [k, v] of Object.entries(hookReq.pathParams)) {
|
|
637
|
-
resolvedPath = resolvedPath.replace(
|
|
638
|
-
\`{\${k}}\`,
|
|
639
|
-
encodeURIComponent(String(v)),
|
|
640
|
-
)
|
|
641
|
-
}
|
|
642
|
-
|
|
643
|
-
const url = new URL(baseUrl + resolvedPath)
|
|
644
|
-
for (const [k, v] of Object.entries(hookReq.query)) {
|
|
645
|
-
if (v !== undefined) url.searchParams.set(k, String(v))
|
|
646
|
-
}
|
|
647
|
-
|
|
648
|
-
const hasBody = hookReq.body !== undefined
|
|
649
|
-
const response = await fetch(url, {
|
|
650
|
-
method,
|
|
651
|
-
headers: {
|
|
652
|
-
...(hasBody ? { 'content-type': 'application/json' } : {}),
|
|
653
|
-
...hookReq.headers,
|
|
654
|
-
},
|
|
655
|
-
body: hasBody ? JSON.stringify(hookReq.body) : undefined,
|
|
656
|
-
})
|
|
657
|
-
|
|
658
|
-
const text = await response.text()
|
|
659
|
-
let data: unknown
|
|
660
|
-
try {
|
|
661
|
-
data = text ? JSON.parse(text) : undefined
|
|
662
|
-
} catch {
|
|
663
|
-
data = text
|
|
664
|
-
}
|
|
665
|
-
|
|
666
|
-
await registry.runAfter({
|
|
667
|
-
key: opKey,
|
|
668
|
-
request: hookReq,
|
|
669
|
-
response: { status: response.status, body: data },
|
|
670
|
-
ctx: store,
|
|
671
|
-
})
|
|
672
|
-
|
|
673
|
-
${tail.ret}
|
|
674
828
|
}
|
|
675
829
|
}
|
|
676
830
|
|
|
@@ -742,11 +896,125 @@ Endpoints: ${operations.length}
|
|
|
742
896
|
`);
|
|
743
897
|
return { outPath, operations: operations.length };
|
|
744
898
|
}
|
|
899
|
+
|
|
900
|
+
// src/files.ts
|
|
901
|
+
var MIME_BY_EXT = {
|
|
902
|
+
jpg: "image/jpeg",
|
|
903
|
+
jpeg: "image/jpeg",
|
|
904
|
+
png: "image/png",
|
|
905
|
+
gif: "image/gif",
|
|
906
|
+
webp: "image/webp",
|
|
907
|
+
avif: "image/avif",
|
|
908
|
+
bmp: "image/bmp",
|
|
909
|
+
svg: "image/svg+xml",
|
|
910
|
+
pdf: "application/pdf",
|
|
911
|
+
txt: "text/plain",
|
|
912
|
+
csv: "text/csv",
|
|
913
|
+
json: "application/json",
|
|
914
|
+
html: "text/html",
|
|
915
|
+
xml: "application/xml",
|
|
916
|
+
mp4: "video/mp4",
|
|
917
|
+
webm: "video/webm",
|
|
918
|
+
mp3: "audio/mpeg",
|
|
919
|
+
wav: "audio/wav",
|
|
920
|
+
zip: "application/zip"
|
|
921
|
+
};
|
|
922
|
+
function basenameOf(p) {
|
|
923
|
+
const parts = p.split(/[\\/]/);
|
|
924
|
+
return parts[parts.length - 1] || p;
|
|
925
|
+
}
|
|
926
|
+
function mimeFromName(name) {
|
|
927
|
+
const base = basenameOf(name);
|
|
928
|
+
const dot = base.lastIndexOf(".");
|
|
929
|
+
const ext = dot <= 0 ? "" : base.slice(dot + 1).toLowerCase();
|
|
930
|
+
return MIME_BY_EXT[ext] ?? "application/octet-stream";
|
|
931
|
+
}
|
|
932
|
+
async function readPathToBlob(args) {
|
|
933
|
+
const { readFile: readFile2 } = await import("fs/promises");
|
|
934
|
+
let filePath;
|
|
935
|
+
if (args.url) {
|
|
936
|
+
const { fileURLToPath } = await import("url");
|
|
937
|
+
filePath = fileURLToPath(args.url);
|
|
938
|
+
} else {
|
|
939
|
+
const { resolve: resolve3 } = await import("path");
|
|
940
|
+
filePath = resolve3(process.cwd(), args.path ?? "");
|
|
941
|
+
}
|
|
942
|
+
const bytes = await readFile2(filePath);
|
|
943
|
+
const name = args.name ?? basenameOf(filePath);
|
|
944
|
+
const type = args.type ?? mimeFromName(filePath);
|
|
945
|
+
return new File([bytes], name, { type });
|
|
946
|
+
}
|
|
947
|
+
async function toBlob(input) {
|
|
948
|
+
if (input instanceof Blob)
|
|
949
|
+
return input;
|
|
950
|
+
if (input instanceof URL)
|
|
951
|
+
return readPathToBlob({ url: input });
|
|
952
|
+
if (typeof input === "object" && "content" in input) {
|
|
953
|
+
const { content, name, type } = input;
|
|
954
|
+
return new File([content], name, { type: type ?? mimeFromName(name) });
|
|
955
|
+
}
|
|
956
|
+
if (typeof input === "object" && "path" in input) {
|
|
957
|
+
return readPathToBlob({ path: input.path, name: input.name, type: input.type });
|
|
958
|
+
}
|
|
959
|
+
if (input.startsWith("file://"))
|
|
960
|
+
return readPathToBlob({ url: new URL(input) });
|
|
961
|
+
return readPathToBlob({ path: input });
|
|
962
|
+
}
|
|
963
|
+
function appendArray(args) {
|
|
964
|
+
const { form, field, blobs, encoding } = args;
|
|
965
|
+
if (typeof encoding === "function") {
|
|
966
|
+
encoding({ form, field, blobs });
|
|
967
|
+
return;
|
|
968
|
+
}
|
|
969
|
+
switch (encoding) {
|
|
970
|
+
case "bracket-index":
|
|
971
|
+
blobs.forEach((b, i) => form.append(`${field}[${i}]`, b));
|
|
972
|
+
return;
|
|
973
|
+
case "bracket-empty":
|
|
974
|
+
for (const b of blobs)
|
|
975
|
+
form.append(`${field}[]`, b);
|
|
976
|
+
return;
|
|
977
|
+
case "repeat":
|
|
978
|
+
default:
|
|
979
|
+
for (const b of blobs)
|
|
980
|
+
form.append(field, b);
|
|
981
|
+
return;
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
async function toMultipartFormData(args) {
|
|
985
|
+
const { body, fileFields, encoding } = args;
|
|
986
|
+
const form = new FormData();
|
|
987
|
+
const fileFieldNames = new Set(fileFields.map((f) => f.name));
|
|
988
|
+
for (const field of fileFields) {
|
|
989
|
+
const value = body[field.name];
|
|
990
|
+
if (value === void 0 || value === null)
|
|
991
|
+
continue;
|
|
992
|
+
if (field.array) {
|
|
993
|
+
const inputs = Array.isArray(value) ? value : [value];
|
|
994
|
+
const blobs = await Promise.all(inputs.map((i) => toBlob(i)));
|
|
995
|
+
appendArray({ form, field: field.name, blobs, encoding });
|
|
996
|
+
} else {
|
|
997
|
+
const blob = await toBlob(value);
|
|
998
|
+
form.append(field.name, blob);
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
for (const [key, value] of Object.entries(body)) {
|
|
1002
|
+
if (fileFieldNames.has(key))
|
|
1003
|
+
continue;
|
|
1004
|
+
if (value === void 0)
|
|
1005
|
+
continue;
|
|
1006
|
+
form.append(key, typeof value === "object" ? JSON.stringify(value) : String(value));
|
|
1007
|
+
}
|
|
1008
|
+
return form;
|
|
1009
|
+
}
|
|
745
1010
|
// Annotate the CommonJS export names for ESM import in node:
|
|
746
1011
|
0 && (module.exports = {
|
|
747
1012
|
ApiResponse,
|
|
748
1013
|
BaseStore,
|
|
749
1014
|
EbelyAssertionError,
|
|
750
1015
|
HookRegistry,
|
|
751
|
-
generateClient
|
|
1016
|
+
generateClient,
|
|
1017
|
+
mimeFromName,
|
|
1018
|
+
toBlob,
|
|
1019
|
+
toMultipartFormData
|
|
752
1020
|
});
|
package/dist/index.mjs
CHANGED
|
@@ -133,6 +133,15 @@ var ApiResponse = class {
|
|
|
133
133
|
var HookRegistry = class {
|
|
134
134
|
beforeMap = /* @__PURE__ */ new Map();
|
|
135
135
|
afterMap = /* @__PURE__ */ new Map();
|
|
136
|
+
// Глобальные хуки — срабатывают на КАЖДУЮ операцию (вне зависимости от
|
|
137
|
+
// ключа). Удобны для сквозных задач: подстановка `Authorization` из
|
|
138
|
+
// `ctx` во все запросы, логирование, обработка 401 и т.п.
|
|
139
|
+
globalBeforeHooks = [];
|
|
140
|
+
globalAfterHooks = [];
|
|
141
|
+
// Глобальные retry-хуки — решают, переигрывать ли запрос (напр. на 401
|
|
142
|
+
// сходить за refresh и вернуть `true`). Цикл повторов и его лимит живут
|
|
143
|
+
// в сгенерированном `request` (см. render.ts) — сам реестр сети не знает.
|
|
144
|
+
globalRetryHooks = [];
|
|
136
145
|
/** Зарегистрировать `before`-хук на ключ `"<группа>.<метод>"`. */
|
|
137
146
|
before(args) {
|
|
138
147
|
const list = this.beforeMap.get(args.key) ?? [];
|
|
@@ -145,18 +154,59 @@ var HookRegistry = class {
|
|
|
145
154
|
list.push(args.fn);
|
|
146
155
|
this.afterMap.set(args.key, list);
|
|
147
156
|
}
|
|
148
|
-
/**
|
|
157
|
+
/** Зарегистрировать ГЛОБАЛЬНЫЙ `before`-хук (на все операции). */
|
|
158
|
+
globalBefore(args) {
|
|
159
|
+
this.globalBeforeHooks.push(args.fn);
|
|
160
|
+
}
|
|
161
|
+
/** Зарегистрировать ГЛОБАЛЬНЫЙ `after`-хук (на все операции). */
|
|
162
|
+
globalAfter(args) {
|
|
163
|
+
this.globalAfterHooks.push(args.fn);
|
|
164
|
+
}
|
|
165
|
+
/** Зарегистрировать ГЛОБАЛЬНЫЙ `retry`-хук (решает про повтор запроса). */
|
|
166
|
+
globalRetry(args) {
|
|
167
|
+
this.globalRetryHooks.push(args.fn);
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Прогнать `before`-хуки по очереди (await на async): сначала глобальные,
|
|
171
|
+
* затем поименные для ключа — так общая подготовка (напр. заголовок
|
|
172
|
+
* авторизации) ложится раньше, а точечный хук может её переопределить.
|
|
173
|
+
*/
|
|
149
174
|
async runBefore(args) {
|
|
150
|
-
|
|
175
|
+
const hooks = [...this.globalBeforeHooks, ...this.beforeMap.get(args.key) ?? []];
|
|
176
|
+
for (const fn of hooks) {
|
|
151
177
|
await fn({ request: args.request, ctx: args.ctx });
|
|
152
178
|
}
|
|
153
179
|
}
|
|
154
|
-
/**
|
|
180
|
+
/**
|
|
181
|
+
* Прогнать `after`-хуки по очереди (await на async): сначала поименные
|
|
182
|
+
* для ключа, затем глобальные — так глобальный «оборачивает» точечные
|
|
183
|
+
* (видит итоговый эффект, удобно для логирования/обработки статуса).
|
|
184
|
+
*/
|
|
155
185
|
async runAfter(args) {
|
|
156
|
-
|
|
186
|
+
const hooks = [...this.afterMap.get(args.key) ?? [], ...this.globalAfterHooks];
|
|
187
|
+
for (const fn of hooks) {
|
|
157
188
|
await fn({ request: args.request, response: args.response, ctx: args.ctx });
|
|
158
189
|
}
|
|
159
190
|
}
|
|
191
|
+
/**
|
|
192
|
+
* Спросить retry-хуки, нужно ли переиграть запрос. Прогон по очереди
|
|
193
|
+
* регистрации; ПЕРВЫЙ хук, вернувший истину, выигрывает (он уже сделал
|
|
194
|
+
* подготовку — напр. refresh — поэтому остальные не дёргаем). Если ни
|
|
195
|
+
* один не вернул истину — `false` (повтора нет). Цикл и лимит повторов
|
|
196
|
+
* — на стороне вызывающего (сгенерированный `request`).
|
|
197
|
+
*/
|
|
198
|
+
async runRetry(args) {
|
|
199
|
+
for (const fn of this.globalRetryHooks) {
|
|
200
|
+
const decision = await fn({
|
|
201
|
+
request: args.request,
|
|
202
|
+
response: args.response,
|
|
203
|
+
ctx: args.ctx
|
|
204
|
+
});
|
|
205
|
+
if (decision)
|
|
206
|
+
return true;
|
|
207
|
+
}
|
|
208
|
+
return false;
|
|
209
|
+
}
|
|
160
210
|
};
|
|
161
211
|
|
|
162
212
|
// src/generate-client.ts
|
|
@@ -164,6 +214,9 @@ import { writeFile } from "fs/promises";
|
|
|
164
214
|
import { resolve as resolve2 } from "path";
|
|
165
215
|
|
|
166
216
|
// src/generator/schema.ts
|
|
217
|
+
function isFileSchema(schema) {
|
|
218
|
+
return Boolean(schema) && schema.type === "string" && typeof schema.contentMediaType === "string";
|
|
219
|
+
}
|
|
167
220
|
function schemaToType(args) {
|
|
168
221
|
const { schema, spec, indent = 0 } = args;
|
|
169
222
|
if (!schema)
|
|
@@ -172,6 +225,8 @@ function schemaToType(args) {
|
|
|
172
225
|
const resolved = resolveRef({ ref: schema.$ref, spec });
|
|
173
226
|
return schemaToType({ schema: resolved, spec, indent });
|
|
174
227
|
}
|
|
228
|
+
if (isFileSchema(schema))
|
|
229
|
+
return "FileInput";
|
|
175
230
|
for (const key of ["allOf", "oneOf", "anyOf"]) {
|
|
176
231
|
if (Array.isArray(schema[key])) {
|
|
177
232
|
const joiner = key === "allOf" ? " & " : " | ";
|
|
@@ -261,7 +316,10 @@ function collectOperations(args) {
|
|
|
261
316
|
const parameters = op.parameters ?? [];
|
|
262
317
|
const pathParams = parameters.filter((p) => p.in === "path").map((p) => p.name);
|
|
263
318
|
const queryParams = parameters.filter((p) => p.in === "query").map((p) => p.name);
|
|
264
|
-
const
|
|
319
|
+
const content = op.requestBody?.content ?? {};
|
|
320
|
+
const bodySchema = content["multipart/form-data"]?.schema ?? content["application/json"]?.schema;
|
|
321
|
+
const fileFields = collectFileFields({ schema: bodySchema, spec });
|
|
322
|
+
const isMultipart = "multipart/form-data" in content || fileFields.length > 0;
|
|
265
323
|
const responseSchema = pickResponseSchema({ responses: op.responses, spec });
|
|
266
324
|
const responseType = schemaToType({ schema: responseSchema, spec, indent: 3 });
|
|
267
325
|
const declared = collectResponseSchemas({ responses: op.responses, spec });
|
|
@@ -278,6 +336,8 @@ function collectOperations(args) {
|
|
|
278
336
|
queryParams,
|
|
279
337
|
bodyType: bodySchema ? schemaToType({ schema: bodySchema, spec, indent: 4 }) : null,
|
|
280
338
|
bodyRequired: Boolean(op.requestBody?.required),
|
|
339
|
+
isMultipart,
|
|
340
|
+
fileFields,
|
|
281
341
|
responseType,
|
|
282
342
|
responses,
|
|
283
343
|
summary: op.summary
|
|
@@ -287,6 +347,22 @@ function collectOperations(args) {
|
|
|
287
347
|
dedupeNames({ operations });
|
|
288
348
|
return operations;
|
|
289
349
|
}
|
|
350
|
+
function collectFileFields(args) {
|
|
351
|
+
const { schema, spec } = args;
|
|
352
|
+
const deref = (s) => s && typeof s.$ref === "string" ? resolveRef({ ref: s.$ref, spec }) : s;
|
|
353
|
+
const root = deref(schema);
|
|
354
|
+
const props = root?.properties ?? {};
|
|
355
|
+
const out = [];
|
|
356
|
+
for (const [name, raw] of Object.entries(props)) {
|
|
357
|
+
const prop = deref(raw);
|
|
358
|
+
if (isFileSchema(prop)) {
|
|
359
|
+
out.push({ name, array: false });
|
|
360
|
+
} else if (prop?.type === "array" && isFileSchema(deref(prop.items))) {
|
|
361
|
+
out.push({ name, array: true });
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
return out;
|
|
365
|
+
}
|
|
290
366
|
function prefixMethod(args) {
|
|
291
367
|
const { method, name } = args;
|
|
292
368
|
return method + name.charAt(0).toUpperCase() + name.slice(1);
|
|
@@ -378,11 +454,23 @@ function renderMethod(args) {
|
|
|
378
454
|
}
|
|
379
455
|
function renderImports(args) {
|
|
380
456
|
const { mode, userStoreImport, configImport } = args;
|
|
381
|
-
const
|
|
382
|
-
return `import { ${
|
|
383
|
-
import type { BeforeHook, AfterHook } from ${JSON.stringify(userStoreImport)}
|
|
457
|
+
const core = mode === "frontend" ? "BaseStore, HookRegistry" : "BaseStore, ApiResponse, HookRegistry";
|
|
458
|
+
return `import { ${core}, toMultipartFormData } from ${JSON.stringify(userStoreImport)}
|
|
459
|
+
import type { BeforeHook, AfterHook, RetryHook } from ${JSON.stringify(userStoreImport)}
|
|
460
|
+
import type { FileInput, FileEncoding, FileFieldMeta } from ${JSON.stringify(userStoreImport)}
|
|
384
461
|
import { ebely } from ${JSON.stringify(configImport)}`;
|
|
385
462
|
}
|
|
463
|
+
function renderFileOps(operations) {
|
|
464
|
+
const entries = operations.filter((op) => op.isMultipart && op.fileFields.length > 0).map((op) => {
|
|
465
|
+
const fields = op.fileFields.map((f) => `{ name: ${JSON.stringify(f.name)}, array: ${f.array} }`).join(", ");
|
|
466
|
+
return ` ${JSON.stringify(hookKey(op))}: [${fields}],`;
|
|
467
|
+
});
|
|
468
|
+
if (entries.length === 0)
|
|
469
|
+
return "const FILE_OPS: Record<string, FileFieldMeta[]> = {}";
|
|
470
|
+
return `const FILE_OPS: Record<string, FileFieldMeta[]> = {
|
|
471
|
+
${entries.join("\n")}
|
|
472
|
+
}`;
|
|
473
|
+
}
|
|
386
474
|
function renderRequestTail(mode) {
|
|
387
475
|
if (mode === "frontend") {
|
|
388
476
|
return {
|
|
@@ -439,7 +527,11 @@ function renderHookTreeType(groups) {
|
|
|
439
527
|
${leaves.join("\n")}
|
|
440
528
|
}`;
|
|
441
529
|
});
|
|
530
|
+
const global = ` globalBefore(fn: BeforeHook<Store>): void
|
|
531
|
+
globalAfter(fn: AfterHook<Store>): void
|
|
532
|
+
globalRetry(fn: RetryHook<Store>): void`;
|
|
442
533
|
return `type EbelyHookTree<Store extends BaseStore> = {
|
|
534
|
+
${global}
|
|
443
535
|
${blocks.join("\n")}
|
|
444
536
|
}`;
|
|
445
537
|
}
|
|
@@ -459,6 +551,9 @@ ${leaves.join("\n")}
|
|
|
459
551
|
return ` private buildHookTree(): EbelyHookTree<Store> {
|
|
460
552
|
const r = this.hookRegistry
|
|
461
553
|
return {
|
|
554
|
+
globalBefore: (fn: BeforeHook<Store>) => r.globalBefore({ fn }),
|
|
555
|
+
globalAfter: (fn: AfterHook<Store>) => r.globalAfter({ fn }),
|
|
556
|
+
globalRetry: (fn: RetryHook<Store>) => r.globalRetry({ fn }),
|
|
462
557
|
${blocks.join("\n")}
|
|
463
558
|
} as unknown as EbelyHookTree<Store>
|
|
464
559
|
}`;
|
|
@@ -483,6 +578,7 @@ function renderClient(args) {
|
|
|
483
578
|
const basePath = spec.servers?.[0]?.url ?? "";
|
|
484
579
|
const groups = groupOperations(operations);
|
|
485
580
|
const tail = renderRequestTail(mode);
|
|
581
|
+
const tailRet = tail.ret.split("\n").map((l) => l.trim() ? ` ${l}` : l).join("\n");
|
|
486
582
|
return `// AUTO-GENERATED by ebely (generateClient) \u2014 \u041D\u0415 \u0440\u0435\u0434\u0430\u043A\u0442\u0438\u0440\u043E\u0432\u0430\u0442\u044C \u0432\u0440\u0443\u0447\u043D\u0443\u044E.
|
|
487
583
|
// \u0418\u0441\u0442\u043E\u0447\u043D\u0438\u043A: ${spec.info?.title ?? "OpenAPI spec"} v${spec.info?.version ?? "?"}
|
|
488
584
|
// \u0420\u0435\u0436\u0438\u043C \u043A\u043B\u0438\u0435\u043D\u0442\u0430: ${mode}
|
|
@@ -490,6 +586,8 @@ function renderClient(args) {
|
|
|
490
586
|
|
|
491
587
|
${renderImports({ mode, userStoreImport, configImport })}
|
|
492
588
|
|
|
589
|
+
${renderFileOps(operations)}
|
|
590
|
+
|
|
493
591
|
type RequestInput = {
|
|
494
592
|
path?: Record<string, string>
|
|
495
593
|
query?: Record<string, string | number | boolean | undefined>
|
|
@@ -588,59 +686,102 @@ ${renderHookTreeBuilder(groups)}
|
|
|
588
686
|
const baseUrl = this.baseUrl()
|
|
589
687
|
const registry = this.hookRegistry
|
|
590
688
|
const { headers: baseHeaders, store } = cfg
|
|
689
|
+
// \u041F\u043E\u0442\u043E\u043B\u043E\u043A \u041F\u041E\u0412\u0422\u041E\u0420\u041E\u0412 (\u0441\u0432\u0435\u0440\u0445 \u043F\u0435\u0440\u0432\u043E\u0439 \u043F\u043E\u043F\u044B\u0442\u043A\u0438) \u0434\u043B\u044F globalRetry-\u0445\u0443\u043A\u043E\u0432 \u2014
|
|
690
|
+
// \u0437\u0430\u0449\u0438\u0442\u0430 \u043E\u0442 \u0431\u0435\u0441\u043A\u043E\u043D\u0435\u0447\u043D\u043E\u0433\u043E \u0446\u0438\u043A\u043B\u0430, \u0435\u0441\u043B\u0438 \u0445\u0443\u043A \u0443\u043F\u0440\u044F\u043C\u043E \u043F\u0440\u043E\u0441\u0438\u0442 \u043F\u043E\u0432\u0442\u043E\u0440.
|
|
691
|
+
const maxRetries = (ebely as { maxRetries?: number }).maxRetries ?? 3
|
|
591
692
|
|
|
592
693
|
return async (req): Promise<${tail.returnType}> => {
|
|
593
694
|
const { method, path, opKey, input } = req
|
|
594
695
|
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
696
|
+
// Retry-\u0446\u0438\u043A\u043B: \u043A\u0430\u0436\u0434\u0443\u044E \u043F\u043E\u043F\u044B\u0442\u043A\u0443 hookReq \u0441\u043E\u0431\u0438\u0440\u0430\u0435\u0442\u0441\u044F \u0417\u0410\u041D\u041E\u0412\u041E \u0438 \u0437\u0430\u043D\u043E\u0432\u043E
|
|
697
|
+
// \u043F\u0440\u043E\u0433\u043E\u043D\u044F\u044E\u0442\u0441\u044F before-\u0445\u0443\u043A\u0438, \u043F\u043E\u044D\u0442\u043E\u043C\u0443 \u043F\u0440\u0430\u0432\u043A\u0438 \u0438\u0437 retry-\u0445\u0443\u043A\u0430 (\u043D\u0430\u043F\u0440.
|
|
698
|
+
// \u0441\u0432\u0435\u0436\u0438\u0439 \u0442\u043E\u043A\u0435\u043D \u0432 ctx) \u043F\u043E\u0434\u0445\u0432\u0430\u0442\u044B\u0432\u0430\u044E\u0442\u0441\u044F \u043F\u043E\u0432\u0442\u043E\u0440\u043D\u044B\u043C before. \`for (;;)\`
|
|
699
|
+
// \u043A\u0440\u0443\u0442\u0438\u0442\u0441\u044F, \u043F\u043E\u043A\u0430 \u043D\u0435 \u0441\u0440\u0430\u0431\u043E\u0442\u0430\u0435\u0442 return/throw \u0432 \u0445\u0432\u043E\u0441\u0442\u0435 \u0438\u043B\u0438 \`continue\`.
|
|
700
|
+
let attempt = 0
|
|
701
|
+
for (;;) {
|
|
702
|
+
const hookReq = {
|
|
703
|
+
method,
|
|
704
|
+
path,
|
|
705
|
+
pathParams: { ...(input?.path ?? {}) },
|
|
706
|
+
query: { ...(input?.query ?? {}) },
|
|
707
|
+
body: input?.body,
|
|
708
|
+
headers: { ...baseHeaders },
|
|
709
|
+
}
|
|
710
|
+
await registry.runBefore({ key: opKey, request: hookReq, ctx: store })
|
|
711
|
+
|
|
712
|
+
let resolvedPath = path
|
|
713
|
+
for (const [k, v] of Object.entries(hookReq.pathParams)) {
|
|
714
|
+
resolvedPath = resolvedPath.replace(
|
|
715
|
+
\`{\${k}}\`,
|
|
716
|
+
encodeURIComponent(String(v)),
|
|
717
|
+
)
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
const url = new URL(baseUrl + resolvedPath)
|
|
721
|
+
for (const [k, v] of Object.entries(hookReq.query)) {
|
|
722
|
+
if (v !== undefined) url.searchParams.set(k, String(v))
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
const hasBody = hookReq.body !== undefined
|
|
726
|
+
// \u0424\u0430\u0439\u043B\u043E\u0432\u044B\u0435 (multipart) \u043E\u043F\u0435\u0440\u0430\u0446\u0438\u0438: FormData \u0441\u043E\u0431\u0438\u0440\u0430\u0435\u0442\u0441\u044F \u041F\u041E\u0421\u041B\u0415 before-\u0445\u0443\u043A\u043E\u0432
|
|
727
|
+
// (\u0442\u0435\u043B\u043E \u043A \u044D\u0442\u043E\u043C\u0443 \u043C\u043E\u043C\u0435\u043D\u0442\u0443 \u2014 \u043E\u0431\u044B\u0447\u043D\u044B\u0439 \u043E\u0431\u044A\u0435\u043A\u0442, \u0445\u0443\u043A\u0438 \u0432\u0438\u0434\u044F\u0442/\u043F\u0440\u0430\u0432\u044F\u0442 \u0435\u0433\u043E \u043A\u0430\u043A
|
|
728
|
+
// JSON). \u041A\u043E\u0434\u0438\u0440\u043E\u0432\u043A\u0430 \u0438\u043C\u0451\u043D \u043F\u043E\u043B\u0435\u0439 \u0434\u043B\u044F \u043C\u0430\u0441\u0441\u0438\u0432\u0430 \u0444\u0430\u0439\u043B\u043E\u0432 \u2014 \u0438\u0437 ebely.files
|
|
729
|
+
// (\u0434\u0435\u0444\u043E\u043B\u0442 'repeat': \u0432\u0435\u0431-\u0441\u0442\u0430\u043D\u0434\u0430\u0440\u0442 busboy/Go/Rust; oRPC \u0441\u0442\u0430\u0432\u0438\u0442
|
|
730
|
+
// 'bracket-index'). \u041D\u0430 retry FormData \u043F\u0435\u0440\u0435\u0441\u043E\u0431\u0438\u0440\u0430\u0435\u0442\u0441\u044F \u0437\u0430\u043D\u043E\u0432\u043E.
|
|
731
|
+
const fileFields = FILE_OPS[opKey]
|
|
732
|
+
const form =
|
|
733
|
+
fileFields && fileFields.length > 0 && hasBody
|
|
734
|
+
? await toMultipartFormData({
|
|
735
|
+
body: hookReq.body as Record<string, unknown>,
|
|
736
|
+
fileFields,
|
|
737
|
+
encoding:
|
|
738
|
+
(ebely as { files?: { encoding?: FileEncoding } }).files?.encoding ?? 'repeat',
|
|
739
|
+
})
|
|
740
|
+
: undefined
|
|
741
|
+
const isMultipart = form !== undefined
|
|
742
|
+
|
|
743
|
+
const response = await fetch(url, {
|
|
744
|
+
method,
|
|
745
|
+
headers: {
|
|
746
|
+
// multipart: content-type \u041D\u0415 \u0441\u0442\u0430\u0432\u0438\u043C \u2014 fetch \u0441\u0430\u043C \u0432\u044B\u0441\u0442\u0430\u0432\u0438\u0442 boundary.
|
|
747
|
+
...(isMultipart ? {} : hasBody ? { 'content-type': 'application/json' } : {}),
|
|
748
|
+
...hookReq.headers,
|
|
749
|
+
},
|
|
750
|
+
body: isMultipart ? form : hasBody ? JSON.stringify(hookReq.body) : undefined,
|
|
751
|
+
})
|
|
752
|
+
|
|
753
|
+
const text = await response.text()
|
|
754
|
+
let data: unknown
|
|
755
|
+
try {
|
|
756
|
+
data = text ? JSON.parse(text) : undefined
|
|
757
|
+
} catch {
|
|
758
|
+
data = text
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
await registry.runAfter({
|
|
762
|
+
key: opKey,
|
|
763
|
+
request: hookReq,
|
|
764
|
+
response: { status: response.status, body: data },
|
|
765
|
+
ctx: store,
|
|
766
|
+
})
|
|
767
|
+
|
|
768
|
+
// \u041F\u043E\u043A\u0430 \u0435\u0441\u0442\u044C \u0431\u044E\u0434\u0436\u0435\u0442 \u043F\u043E\u0432\u0442\u043E\u0440\u043E\u0432 \u2014 \u0441\u043F\u0440\u0430\u0448\u0438\u0432\u0430\u0435\u043C retry-\u0445\u0443\u043A\u0438. \u0412\u0435\u0440\u043D\u0443\u043B\u0438
|
|
769
|
+
// true \u2192 \u043D\u043E\u0432\u044B\u0439 \u0432\u0438\u0442\u043E\u043A (\u0437\u0430\u043D\u043E\u0432\u043E before \u2192 fetch); \u0438\u043D\u0430\u0447\u0435 \u043E\u0442\u0434\u0430\u0451\u043C \u043E\u0442\u0432\u0435\u0442.
|
|
770
|
+
if (attempt < maxRetries) {
|
|
771
|
+
const shouldRetry = await registry.runRetry({
|
|
772
|
+
key: opKey,
|
|
773
|
+
request: hookReq,
|
|
774
|
+
response: { status: response.status, body: data },
|
|
775
|
+
ctx: store,
|
|
776
|
+
})
|
|
777
|
+
if (shouldRetry) {
|
|
778
|
+
attempt++
|
|
779
|
+
continue
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
${tailRet}
|
|
602
784
|
}
|
|
603
|
-
await registry.runBefore({ key: opKey, request: hookReq, ctx: store })
|
|
604
|
-
|
|
605
|
-
let resolvedPath = path
|
|
606
|
-
for (const [k, v] of Object.entries(hookReq.pathParams)) {
|
|
607
|
-
resolvedPath = resolvedPath.replace(
|
|
608
|
-
\`{\${k}}\`,
|
|
609
|
-
encodeURIComponent(String(v)),
|
|
610
|
-
)
|
|
611
|
-
}
|
|
612
|
-
|
|
613
|
-
const url = new URL(baseUrl + resolvedPath)
|
|
614
|
-
for (const [k, v] of Object.entries(hookReq.query)) {
|
|
615
|
-
if (v !== undefined) url.searchParams.set(k, String(v))
|
|
616
|
-
}
|
|
617
|
-
|
|
618
|
-
const hasBody = hookReq.body !== undefined
|
|
619
|
-
const response = await fetch(url, {
|
|
620
|
-
method,
|
|
621
|
-
headers: {
|
|
622
|
-
...(hasBody ? { 'content-type': 'application/json' } : {}),
|
|
623
|
-
...hookReq.headers,
|
|
624
|
-
},
|
|
625
|
-
body: hasBody ? JSON.stringify(hookReq.body) : undefined,
|
|
626
|
-
})
|
|
627
|
-
|
|
628
|
-
const text = await response.text()
|
|
629
|
-
let data: unknown
|
|
630
|
-
try {
|
|
631
|
-
data = text ? JSON.parse(text) : undefined
|
|
632
|
-
} catch {
|
|
633
|
-
data = text
|
|
634
|
-
}
|
|
635
|
-
|
|
636
|
-
await registry.runAfter({
|
|
637
|
-
key: opKey,
|
|
638
|
-
request: hookReq,
|
|
639
|
-
response: { status: response.status, body: data },
|
|
640
|
-
ctx: store,
|
|
641
|
-
})
|
|
642
|
-
|
|
643
|
-
${tail.ret}
|
|
644
785
|
}
|
|
645
786
|
}
|
|
646
787
|
|
|
@@ -712,10 +853,124 @@ Endpoints: ${operations.length}
|
|
|
712
853
|
`);
|
|
713
854
|
return { outPath, operations: operations.length };
|
|
714
855
|
}
|
|
856
|
+
|
|
857
|
+
// src/files.ts
|
|
858
|
+
var MIME_BY_EXT = {
|
|
859
|
+
jpg: "image/jpeg",
|
|
860
|
+
jpeg: "image/jpeg",
|
|
861
|
+
png: "image/png",
|
|
862
|
+
gif: "image/gif",
|
|
863
|
+
webp: "image/webp",
|
|
864
|
+
avif: "image/avif",
|
|
865
|
+
bmp: "image/bmp",
|
|
866
|
+
svg: "image/svg+xml",
|
|
867
|
+
pdf: "application/pdf",
|
|
868
|
+
txt: "text/plain",
|
|
869
|
+
csv: "text/csv",
|
|
870
|
+
json: "application/json",
|
|
871
|
+
html: "text/html",
|
|
872
|
+
xml: "application/xml",
|
|
873
|
+
mp4: "video/mp4",
|
|
874
|
+
webm: "video/webm",
|
|
875
|
+
mp3: "audio/mpeg",
|
|
876
|
+
wav: "audio/wav",
|
|
877
|
+
zip: "application/zip"
|
|
878
|
+
};
|
|
879
|
+
function basenameOf(p) {
|
|
880
|
+
const parts = p.split(/[\\/]/);
|
|
881
|
+
return parts[parts.length - 1] || p;
|
|
882
|
+
}
|
|
883
|
+
function mimeFromName(name) {
|
|
884
|
+
const base = basenameOf(name);
|
|
885
|
+
const dot = base.lastIndexOf(".");
|
|
886
|
+
const ext = dot <= 0 ? "" : base.slice(dot + 1).toLowerCase();
|
|
887
|
+
return MIME_BY_EXT[ext] ?? "application/octet-stream";
|
|
888
|
+
}
|
|
889
|
+
async function readPathToBlob(args) {
|
|
890
|
+
const { readFile: readFile2 } = await import("fs/promises");
|
|
891
|
+
let filePath;
|
|
892
|
+
if (args.url) {
|
|
893
|
+
const { fileURLToPath } = await import("url");
|
|
894
|
+
filePath = fileURLToPath(args.url);
|
|
895
|
+
} else {
|
|
896
|
+
const { resolve: resolve3 } = await import("path");
|
|
897
|
+
filePath = resolve3(process.cwd(), args.path ?? "");
|
|
898
|
+
}
|
|
899
|
+
const bytes = await readFile2(filePath);
|
|
900
|
+
const name = args.name ?? basenameOf(filePath);
|
|
901
|
+
const type = args.type ?? mimeFromName(filePath);
|
|
902
|
+
return new File([bytes], name, { type });
|
|
903
|
+
}
|
|
904
|
+
async function toBlob(input) {
|
|
905
|
+
if (input instanceof Blob)
|
|
906
|
+
return input;
|
|
907
|
+
if (input instanceof URL)
|
|
908
|
+
return readPathToBlob({ url: input });
|
|
909
|
+
if (typeof input === "object" && "content" in input) {
|
|
910
|
+
const { content, name, type } = input;
|
|
911
|
+
return new File([content], name, { type: type ?? mimeFromName(name) });
|
|
912
|
+
}
|
|
913
|
+
if (typeof input === "object" && "path" in input) {
|
|
914
|
+
return readPathToBlob({ path: input.path, name: input.name, type: input.type });
|
|
915
|
+
}
|
|
916
|
+
if (input.startsWith("file://"))
|
|
917
|
+
return readPathToBlob({ url: new URL(input) });
|
|
918
|
+
return readPathToBlob({ path: input });
|
|
919
|
+
}
|
|
920
|
+
function appendArray(args) {
|
|
921
|
+
const { form, field, blobs, encoding } = args;
|
|
922
|
+
if (typeof encoding === "function") {
|
|
923
|
+
encoding({ form, field, blobs });
|
|
924
|
+
return;
|
|
925
|
+
}
|
|
926
|
+
switch (encoding) {
|
|
927
|
+
case "bracket-index":
|
|
928
|
+
blobs.forEach((b, i) => form.append(`${field}[${i}]`, b));
|
|
929
|
+
return;
|
|
930
|
+
case "bracket-empty":
|
|
931
|
+
for (const b of blobs)
|
|
932
|
+
form.append(`${field}[]`, b);
|
|
933
|
+
return;
|
|
934
|
+
case "repeat":
|
|
935
|
+
default:
|
|
936
|
+
for (const b of blobs)
|
|
937
|
+
form.append(field, b);
|
|
938
|
+
return;
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
async function toMultipartFormData(args) {
|
|
942
|
+
const { body, fileFields, encoding } = args;
|
|
943
|
+
const form = new FormData();
|
|
944
|
+
const fileFieldNames = new Set(fileFields.map((f) => f.name));
|
|
945
|
+
for (const field of fileFields) {
|
|
946
|
+
const value = body[field.name];
|
|
947
|
+
if (value === void 0 || value === null)
|
|
948
|
+
continue;
|
|
949
|
+
if (field.array) {
|
|
950
|
+
const inputs = Array.isArray(value) ? value : [value];
|
|
951
|
+
const blobs = await Promise.all(inputs.map((i) => toBlob(i)));
|
|
952
|
+
appendArray({ form, field: field.name, blobs, encoding });
|
|
953
|
+
} else {
|
|
954
|
+
const blob = await toBlob(value);
|
|
955
|
+
form.append(field.name, blob);
|
|
956
|
+
}
|
|
957
|
+
}
|
|
958
|
+
for (const [key, value] of Object.entries(body)) {
|
|
959
|
+
if (fileFieldNames.has(key))
|
|
960
|
+
continue;
|
|
961
|
+
if (value === void 0)
|
|
962
|
+
continue;
|
|
963
|
+
form.append(key, typeof value === "object" ? JSON.stringify(value) : String(value));
|
|
964
|
+
}
|
|
965
|
+
return form;
|
|
966
|
+
}
|
|
715
967
|
export {
|
|
716
968
|
ApiResponse,
|
|
717
969
|
BaseStore,
|
|
718
970
|
EbelyAssertionError,
|
|
719
971
|
HookRegistry,
|
|
720
|
-
generateClient
|
|
972
|
+
generateClient,
|
|
973
|
+
mimeFromName,
|
|
974
|
+
toBlob,
|
|
975
|
+
toMultipartFormData
|
|
721
976
|
};
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ebely",
|
|
3
3
|
"license": "MIT",
|
|
4
|
-
"version": "0.0
|
|
4
|
+
"version": "0.1.0",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"module": "dist/index.mjs",
|
|
7
7
|
"types": "dist/index.d.ts",
|
|
@@ -35,6 +35,6 @@
|
|
|
35
35
|
"build": "tsup index.ts --format cjs,esm --dts",
|
|
36
36
|
"release": "pnpm run build && changeset publish",
|
|
37
37
|
"lint": "tsc",
|
|
38
|
-
"test": "tsx --test src/response.test.ts src/hooks.test.ts src/generator/render.test.ts src/generator/operations.test.ts"
|
|
38
|
+
"test": "tsx --test src/response.test.ts src/hooks.test.ts src/files.test.ts src/generator/render.test.ts src/generator/operations.test.ts src/generator/schema.test.ts"
|
|
39
39
|
}
|
|
40
40
|
}
|