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 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
- /** Прогнать все `before`-хуки ключа по очереди (await на async). */
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
- /** Прогнать все `after`-хуки ключа по очереди (await на async). */
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
- /** Прогнать все `before`-хуки ключа по очереди (await на async). */
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
- for (const fn of this.beforeMap.get(args.key) ?? []) {
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
- /** Прогнать все `after`-хуки ключа по очереди (await на async). */
223
+ /**
224
+ * Прогнать `after`-хуки по очереди (await на async): сначала поименные
225
+ * для ключа, затем глобальные — так глобальный «оборачивает» точечные
226
+ * (видит итоговый эффект, удобно для логирования/обработки статуса).
227
+ */
185
228
  async runAfter(args) {
186
- for (const fn of this.afterMap.get(args.key) ?? []) {
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 bodySchema = op.requestBody?.content?.["application/json"]?.schema;
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 values = mode === "frontend" ? "BaseStore, HookRegistry" : "BaseStore, ApiResponse, HookRegistry";
412
- return `import { ${values} } from ${JSON.stringify(userStoreImport)}
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
- const hookReq = {
626
- method,
627
- path,
628
- pathParams: { ...(input?.path ?? {}) },
629
- query: { ...(input?.query ?? {}) },
630
- body: input?.body,
631
- headers: { ...baseHeaders },
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
- /** Прогнать все `before`-хуки ключа по очереди (await на async). */
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
- for (const fn of this.beforeMap.get(args.key) ?? []) {
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
- /** Прогнать все `after`-хуки ключа по очереди (await на async). */
180
+ /**
181
+ * Прогнать `after`-хуки по очереди (await на async): сначала поименные
182
+ * для ключа, затем глобальные — так глобальный «оборачивает» точечные
183
+ * (видит итоговый эффект, удобно для логирования/обработки статуса).
184
+ */
155
185
  async runAfter(args) {
156
- for (const fn of this.afterMap.get(args.key) ?? []) {
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 bodySchema = op.requestBody?.content?.["application/json"]?.schema;
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 values = mode === "frontend" ? "BaseStore, HookRegistry" : "BaseStore, ApiResponse, HookRegistry";
382
- return `import { ${values} } from ${JSON.stringify(userStoreImport)}
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
- const hookReq = {
596
- method,
597
- path,
598
- pathParams: { ...(input?.path ?? {}) },
599
- query: { ...(input?.query ?? {}) },
600
- body: input?.body,
601
- headers: { ...baseHeaders },
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.6",
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
  }