ebely 0.0.5 → 0.0.7

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
  /**
@@ -218,6 +272,16 @@ type EbelyConfig = {
218
272
  * @default 'test'
219
273
  */
220
274
  mode?: ClientMode;
275
+ /**
276
+ * Максимальное число ПОВТОРОВ запроса, которые ebely сделает, если
277
+ * глобальный `retry`-хук (`h.globalRetry`) вернул `true`. Это потолок
278
+ * на повторы СВЕРХ первой попытки: при `maxRetries: 3` запрос уйдёт
279
+ * максимум 4 раза. Защита от бесконечного цикла, если хук упрямо просит
280
+ * повтор (напр. сервер стабильно отвечает 401). Без `globalRetry`-хуков
281
+ * не влияет ни на что.
282
+ * @default 3
283
+ */
284
+ maxRetries?: number;
221
285
  /**
222
286
  * Регистратор хуков `before` / `after`. Объявляется в ОТДЕЛЬНОМ
223
287
  * типизированном файле (тип `Hooks` экспортирует сгенерированный
@@ -270,4 +334,4 @@ declare function generateClient(args: EbelyConfig): Promise<{
270
334
  operations: number;
271
335
  }>;
272
336
 
273
- export { AfterHook, AfterHookArgs, ApiResponse, BaseStore, BeforeHook, BeforeHookArgs, ClientMode, DeepPartial, EbelyAssertionError, EbelyConfig, HookRegistry, HookRequest, HookResponse, HooksRegistrar, SwaggerSource, generateClient };
337
+ export { AfterHook, AfterHookArgs, ApiResponse, BaseStore, BeforeHook, BeforeHookArgs, ClientMode, DeepPartial, EbelyAssertionError, EbelyConfig, HookRegistry, HookRequest, HookResponse, HooksRegistrar, RetryHook, SwaggerSource, generateClient };
package/dist/index.js CHANGED
@@ -163,6 +163,15 @@ var ApiResponse = class {
163
163
  var HookRegistry = class {
164
164
  beforeMap = /* @__PURE__ */ new Map();
165
165
  afterMap = /* @__PURE__ */ new Map();
166
+ // Глобальные хуки — срабатывают на КАЖДУЮ операцию (вне зависимости от
167
+ // ключа). Удобны для сквозных задач: подстановка `Authorization` из
168
+ // `ctx` во все запросы, логирование, обработка 401 и т.п.
169
+ globalBeforeHooks = [];
170
+ globalAfterHooks = [];
171
+ // Глобальные retry-хуки — решают, переигрывать ли запрос (напр. на 401
172
+ // сходить за refresh и вернуть `true`). Цикл повторов и его лимит живут
173
+ // в сгенерированном `request` (см. render.ts) — сам реестр сети не знает.
174
+ globalRetryHooks = [];
166
175
  /** Зарегистрировать `before`-хук на ключ `"<группа>.<метод>"`. */
167
176
  before(args) {
168
177
  const list = this.beforeMap.get(args.key) ?? [];
@@ -175,18 +184,59 @@ var HookRegistry = class {
175
184
  list.push(args.fn);
176
185
  this.afterMap.set(args.key, list);
177
186
  }
178
- /** Прогнать все `before`-хуки ключа по очереди (await на async). */
187
+ /** Зарегистрировать ГЛОБАЛЬНЫЙ `before`-хук (на все операции). */
188
+ globalBefore(args) {
189
+ this.globalBeforeHooks.push(args.fn);
190
+ }
191
+ /** Зарегистрировать ГЛОБАЛЬНЫЙ `after`-хук (на все операции). */
192
+ globalAfter(args) {
193
+ this.globalAfterHooks.push(args.fn);
194
+ }
195
+ /** Зарегистрировать ГЛОБАЛЬНЫЙ `retry`-хук (решает про повтор запроса). */
196
+ globalRetry(args) {
197
+ this.globalRetryHooks.push(args.fn);
198
+ }
199
+ /**
200
+ * Прогнать `before`-хуки по очереди (await на async): сначала глобальные,
201
+ * затем поименные для ключа — так общая подготовка (напр. заголовок
202
+ * авторизации) ложится раньше, а точечный хук может её переопределить.
203
+ */
179
204
  async runBefore(args) {
180
- for (const fn of this.beforeMap.get(args.key) ?? []) {
205
+ const hooks = [...this.globalBeforeHooks, ...this.beforeMap.get(args.key) ?? []];
206
+ for (const fn of hooks) {
181
207
  await fn({ request: args.request, ctx: args.ctx });
182
208
  }
183
209
  }
184
- /** Прогнать все `after`-хуки ключа по очереди (await на async). */
210
+ /**
211
+ * Прогнать `after`-хуки по очереди (await на async): сначала поименные
212
+ * для ключа, затем глобальные — так глобальный «оборачивает» точечные
213
+ * (видит итоговый эффект, удобно для логирования/обработки статуса).
214
+ */
185
215
  async runAfter(args) {
186
- for (const fn of this.afterMap.get(args.key) ?? []) {
216
+ const hooks = [...this.afterMap.get(args.key) ?? [], ...this.globalAfterHooks];
217
+ for (const fn of hooks) {
187
218
  await fn({ request: args.request, response: args.response, ctx: args.ctx });
188
219
  }
189
220
  }
221
+ /**
222
+ * Спросить retry-хуки, нужно ли переиграть запрос. Прогон по очереди
223
+ * регистрации; ПЕРВЫЙ хук, вернувший истину, выигрывает (он уже сделал
224
+ * подготовку — напр. refresh — поэтому остальные не дёргаем). Если ни
225
+ * один не вернул истину — `false` (повтора нет). Цикл и лимит повторов
226
+ * — на стороне вызывающего (сгенерированный `request`).
227
+ */
228
+ async runRetry(args) {
229
+ for (const fn of this.globalRetryHooks) {
230
+ const decision = await fn({
231
+ request: args.request,
232
+ response: args.response,
233
+ ctx: args.ctx
234
+ });
235
+ if (decision)
236
+ return true;
237
+ }
238
+ return false;
239
+ }
190
240
  };
191
241
 
192
242
  // src/generate-client.ts
@@ -314,8 +364,38 @@ function collectOperations(args) {
314
364
  });
315
365
  }
316
366
  }
367
+ dedupeNames({ operations });
317
368
  return operations;
318
369
  }
370
+ function prefixMethod(args) {
371
+ const { method, name } = args;
372
+ return method + name.charAt(0).toUpperCase() + name.slice(1);
373
+ }
374
+ function dedupeNames(args) {
375
+ const { operations } = args;
376
+ const counts = /* @__PURE__ */ new Map();
377
+ for (const op of operations) {
378
+ const byName = counts.get(op.group) ?? /* @__PURE__ */ new Map();
379
+ byName.set(op.name, (byName.get(op.name) ?? 0) + 1);
380
+ counts.set(op.group, byName);
381
+ }
382
+ const taken = /* @__PURE__ */ new Map();
383
+ const claim = (group, name) => {
384
+ const used = taken.get(group) ?? /* @__PURE__ */ new Set();
385
+ let candidate = name;
386
+ let i = 2;
387
+ while (used.has(candidate))
388
+ candidate = `${name}${i++}`;
389
+ used.add(candidate);
390
+ taken.set(group, used);
391
+ return candidate;
392
+ };
393
+ for (const op of operations) {
394
+ const collides = (counts.get(op.group)?.get(op.name) ?? 0) > 1;
395
+ const base = collides ? prefixMethod({ method: op.method, name: op.name }) : op.name;
396
+ op.name = claim(op.group, base);
397
+ }
398
+ }
319
399
 
320
400
  // src/generator/render.ts
321
401
  function buildInputType(op) {
@@ -380,7 +460,7 @@ function renderImports(args) {
380
460
  const { mode, userStoreImport, configImport } = args;
381
461
  const values = mode === "frontend" ? "BaseStore, HookRegistry" : "BaseStore, ApiResponse, HookRegistry";
382
462
  return `import { ${values} } from ${JSON.stringify(userStoreImport)}
383
- import type { BeforeHook, AfterHook } from ${JSON.stringify(userStoreImport)}
463
+ import type { BeforeHook, AfterHook, RetryHook } from ${JSON.stringify(userStoreImport)}
384
464
  import { ebely } from ${JSON.stringify(configImport)}`;
385
465
  }
386
466
  function renderRequestTail(mode) {
@@ -439,7 +519,11 @@ function renderHookTreeType(groups) {
439
519
  ${leaves.join("\n")}
440
520
  }`;
441
521
  });
522
+ const global = ` globalBefore(fn: BeforeHook<Store>): void
523
+ globalAfter(fn: AfterHook<Store>): void
524
+ globalRetry(fn: RetryHook<Store>): void`;
442
525
  return `type EbelyHookTree<Store extends BaseStore> = {
526
+ ${global}
443
527
  ${blocks.join("\n")}
444
528
  }`;
445
529
  }
@@ -459,6 +543,9 @@ ${leaves.join("\n")}
459
543
  return ` private buildHookTree(): EbelyHookTree<Store> {
460
544
  const r = this.hookRegistry
461
545
  return {
546
+ globalBefore: (fn: BeforeHook<Store>) => r.globalBefore({ fn }),
547
+ globalAfter: (fn: AfterHook<Store>) => r.globalAfter({ fn }),
548
+ globalRetry: (fn: RetryHook<Store>) => r.globalRetry({ fn }),
462
549
  ${blocks.join("\n")}
463
550
  } as unknown as EbelyHookTree<Store>
464
551
  }`;
@@ -483,6 +570,7 @@ function renderClient(args) {
483
570
  const basePath = spec.servers?.[0]?.url ?? "";
484
571
  const groups = groupOperations(operations);
485
572
  const tail = renderRequestTail(mode);
573
+ const tailRet = tail.ret.split("\n").map((l) => l.trim() ? ` ${l}` : l).join("\n");
486
574
  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
575
  // \u0418\u0441\u0442\u043E\u0447\u043D\u0438\u043A: ${spec.info?.title ?? "OpenAPI spec"} v${spec.info?.version ?? "?"}
488
576
  // \u0420\u0435\u0436\u0438\u043C \u043A\u043B\u0438\u0435\u043D\u0442\u0430: ${mode}
@@ -588,59 +676,84 @@ ${renderHookTreeBuilder(groups)}
588
676
  const baseUrl = this.baseUrl()
589
677
  const registry = this.hookRegistry
590
678
  const { headers: baseHeaders, store } = cfg
679
+ // \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
680
+ // \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.
681
+ const maxRetries = (ebely as { maxRetries?: number }).maxRetries ?? 3
591
682
 
592
683
  return async (req): Promise<${tail.returnType}> => {
593
684
  const { method, path, opKey, input } = req
594
685
 
595
- const hookReq = {
596
- method,
597
- path,
598
- pathParams: { ...(input?.path ?? {}) },
599
- query: { ...(input?.query ?? {}) },
600
- body: input?.body,
601
- headers: { ...baseHeaders },
602
- }
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
- )
686
+ // 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
687
+ // \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.
688
+ // \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 (;;)\`
689
+ // \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\`.
690
+ let attempt = 0
691
+ for (;;) {
692
+ const hookReq = {
693
+ method,
694
+ path,
695
+ pathParams: { ...(input?.path ?? {}) },
696
+ query: { ...(input?.query ?? {}) },
697
+ body: input?.body,
698
+ headers: { ...baseHeaders },
699
+ }
700
+ await registry.runBefore({ key: opKey, request: hookReq, ctx: store })
701
+
702
+ let resolvedPath = path
703
+ for (const [k, v] of Object.entries(hookReq.pathParams)) {
704
+ resolvedPath = resolvedPath.replace(
705
+ \`{\${k}}\`,
706
+ encodeURIComponent(String(v)),
707
+ )
708
+ }
709
+
710
+ const url = new URL(baseUrl + resolvedPath)
711
+ for (const [k, v] of Object.entries(hookReq.query)) {
712
+ if (v !== undefined) url.searchParams.set(k, String(v))
713
+ }
714
+
715
+ const hasBody = hookReq.body !== undefined
716
+ const response = await fetch(url, {
717
+ method,
718
+ headers: {
719
+ ...(hasBody ? { 'content-type': 'application/json' } : {}),
720
+ ...hookReq.headers,
721
+ },
722
+ body: hasBody ? JSON.stringify(hookReq.body) : undefined,
723
+ })
724
+
725
+ const text = await response.text()
726
+ let data: unknown
727
+ try {
728
+ data = text ? JSON.parse(text) : undefined
729
+ } catch {
730
+ data = text
731
+ }
732
+
733
+ await registry.runAfter({
734
+ key: opKey,
735
+ request: hookReq,
736
+ response: { status: response.status, body: data },
737
+ ctx: store,
738
+ })
739
+
740
+ // \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
741
+ // 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.
742
+ if (attempt < maxRetries) {
743
+ const shouldRetry = await registry.runRetry({
744
+ key: opKey,
745
+ request: hookReq,
746
+ response: { status: response.status, body: data },
747
+ ctx: store,
748
+ })
749
+ if (shouldRetry) {
750
+ attempt++
751
+ continue
752
+ }
753
+ }
754
+
755
+ ${tailRet}
611
756
  }
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
757
  }
645
758
  }
646
759
 
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
@@ -284,8 +334,38 @@ function collectOperations(args) {
284
334
  });
285
335
  }
286
336
  }
337
+ dedupeNames({ operations });
287
338
  return operations;
288
339
  }
340
+ function prefixMethod(args) {
341
+ const { method, name } = args;
342
+ return method + name.charAt(0).toUpperCase() + name.slice(1);
343
+ }
344
+ function dedupeNames(args) {
345
+ const { operations } = args;
346
+ const counts = /* @__PURE__ */ new Map();
347
+ for (const op of operations) {
348
+ const byName = counts.get(op.group) ?? /* @__PURE__ */ new Map();
349
+ byName.set(op.name, (byName.get(op.name) ?? 0) + 1);
350
+ counts.set(op.group, byName);
351
+ }
352
+ const taken = /* @__PURE__ */ new Map();
353
+ const claim = (group, name) => {
354
+ const used = taken.get(group) ?? /* @__PURE__ */ new Set();
355
+ let candidate = name;
356
+ let i = 2;
357
+ while (used.has(candidate))
358
+ candidate = `${name}${i++}`;
359
+ used.add(candidate);
360
+ taken.set(group, used);
361
+ return candidate;
362
+ };
363
+ for (const op of operations) {
364
+ const collides = (counts.get(op.group)?.get(op.name) ?? 0) > 1;
365
+ const base = collides ? prefixMethod({ method: op.method, name: op.name }) : op.name;
366
+ op.name = claim(op.group, base);
367
+ }
368
+ }
289
369
 
290
370
  // src/generator/render.ts
291
371
  function buildInputType(op) {
@@ -350,7 +430,7 @@ function renderImports(args) {
350
430
  const { mode, userStoreImport, configImport } = args;
351
431
  const values = mode === "frontend" ? "BaseStore, HookRegistry" : "BaseStore, ApiResponse, HookRegistry";
352
432
  return `import { ${values} } from ${JSON.stringify(userStoreImport)}
353
- import type { BeforeHook, AfterHook } from ${JSON.stringify(userStoreImport)}
433
+ import type { BeforeHook, AfterHook, RetryHook } from ${JSON.stringify(userStoreImport)}
354
434
  import { ebely } from ${JSON.stringify(configImport)}`;
355
435
  }
356
436
  function renderRequestTail(mode) {
@@ -409,7 +489,11 @@ function renderHookTreeType(groups) {
409
489
  ${leaves.join("\n")}
410
490
  }`;
411
491
  });
492
+ const global = ` globalBefore(fn: BeforeHook<Store>): void
493
+ globalAfter(fn: AfterHook<Store>): void
494
+ globalRetry(fn: RetryHook<Store>): void`;
412
495
  return `type EbelyHookTree<Store extends BaseStore> = {
496
+ ${global}
413
497
  ${blocks.join("\n")}
414
498
  }`;
415
499
  }
@@ -429,6 +513,9 @@ ${leaves.join("\n")}
429
513
  return ` private buildHookTree(): EbelyHookTree<Store> {
430
514
  const r = this.hookRegistry
431
515
  return {
516
+ globalBefore: (fn: BeforeHook<Store>) => r.globalBefore({ fn }),
517
+ globalAfter: (fn: AfterHook<Store>) => r.globalAfter({ fn }),
518
+ globalRetry: (fn: RetryHook<Store>) => r.globalRetry({ fn }),
432
519
  ${blocks.join("\n")}
433
520
  } as unknown as EbelyHookTree<Store>
434
521
  }`;
@@ -453,6 +540,7 @@ function renderClient(args) {
453
540
  const basePath = spec.servers?.[0]?.url ?? "";
454
541
  const groups = groupOperations(operations);
455
542
  const tail = renderRequestTail(mode);
543
+ const tailRet = tail.ret.split("\n").map((l) => l.trim() ? ` ${l}` : l).join("\n");
456
544
  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.
457
545
  // \u0418\u0441\u0442\u043E\u0447\u043D\u0438\u043A: ${spec.info?.title ?? "OpenAPI spec"} v${spec.info?.version ?? "?"}
458
546
  // \u0420\u0435\u0436\u0438\u043C \u043A\u043B\u0438\u0435\u043D\u0442\u0430: ${mode}
@@ -558,59 +646,84 @@ ${renderHookTreeBuilder(groups)}
558
646
  const baseUrl = this.baseUrl()
559
647
  const registry = this.hookRegistry
560
648
  const { headers: baseHeaders, store } = cfg
649
+ // \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
650
+ // \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.
651
+ const maxRetries = (ebely as { maxRetries?: number }).maxRetries ?? 3
561
652
 
562
653
  return async (req): Promise<${tail.returnType}> => {
563
654
  const { method, path, opKey, input } = req
564
655
 
565
- const hookReq = {
566
- method,
567
- path,
568
- pathParams: { ...(input?.path ?? {}) },
569
- query: { ...(input?.query ?? {}) },
570
- body: input?.body,
571
- headers: { ...baseHeaders },
572
- }
573
- await registry.runBefore({ key: opKey, request: hookReq, ctx: store })
574
-
575
- let resolvedPath = path
576
- for (const [k, v] of Object.entries(hookReq.pathParams)) {
577
- resolvedPath = resolvedPath.replace(
578
- \`{\${k}}\`,
579
- encodeURIComponent(String(v)),
580
- )
656
+ // 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
657
+ // \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.
658
+ // \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 (;;)\`
659
+ // \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\`.
660
+ let attempt = 0
661
+ for (;;) {
662
+ const hookReq = {
663
+ method,
664
+ path,
665
+ pathParams: { ...(input?.path ?? {}) },
666
+ query: { ...(input?.query ?? {}) },
667
+ body: input?.body,
668
+ headers: { ...baseHeaders },
669
+ }
670
+ await registry.runBefore({ key: opKey, request: hookReq, ctx: store })
671
+
672
+ let resolvedPath = path
673
+ for (const [k, v] of Object.entries(hookReq.pathParams)) {
674
+ resolvedPath = resolvedPath.replace(
675
+ \`{\${k}}\`,
676
+ encodeURIComponent(String(v)),
677
+ )
678
+ }
679
+
680
+ const url = new URL(baseUrl + resolvedPath)
681
+ for (const [k, v] of Object.entries(hookReq.query)) {
682
+ if (v !== undefined) url.searchParams.set(k, String(v))
683
+ }
684
+
685
+ const hasBody = hookReq.body !== undefined
686
+ const response = await fetch(url, {
687
+ method,
688
+ headers: {
689
+ ...(hasBody ? { 'content-type': 'application/json' } : {}),
690
+ ...hookReq.headers,
691
+ },
692
+ body: hasBody ? JSON.stringify(hookReq.body) : undefined,
693
+ })
694
+
695
+ const text = await response.text()
696
+ let data: unknown
697
+ try {
698
+ data = text ? JSON.parse(text) : undefined
699
+ } catch {
700
+ data = text
701
+ }
702
+
703
+ await registry.runAfter({
704
+ key: opKey,
705
+ request: hookReq,
706
+ response: { status: response.status, body: data },
707
+ ctx: store,
708
+ })
709
+
710
+ // \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
711
+ // 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.
712
+ if (attempt < maxRetries) {
713
+ const shouldRetry = await registry.runRetry({
714
+ key: opKey,
715
+ request: hookReq,
716
+ response: { status: response.status, body: data },
717
+ ctx: store,
718
+ })
719
+ if (shouldRetry) {
720
+ attempt++
721
+ continue
722
+ }
723
+ }
724
+
725
+ ${tailRet}
581
726
  }
582
-
583
- const url = new URL(baseUrl + resolvedPath)
584
- for (const [k, v] of Object.entries(hookReq.query)) {
585
- if (v !== undefined) url.searchParams.set(k, String(v))
586
- }
587
-
588
- const hasBody = hookReq.body !== undefined
589
- const response = await fetch(url, {
590
- method,
591
- headers: {
592
- ...(hasBody ? { 'content-type': 'application/json' } : {}),
593
- ...hookReq.headers,
594
- },
595
- body: hasBody ? JSON.stringify(hookReq.body) : undefined,
596
- })
597
-
598
- const text = await response.text()
599
- let data: unknown
600
- try {
601
- data = text ? JSON.parse(text) : undefined
602
- } catch {
603
- data = text
604
- }
605
-
606
- await registry.runAfter({
607
- key: opKey,
608
- request: hookReq,
609
- response: { status: response.status, body: data },
610
- ctx: store,
611
- })
612
-
613
- ${tail.ret}
614
727
  }
615
728
  }
616
729
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "ebely",
3
3
  "license": "MIT",
4
- "version": "0.0.5",
4
+ "version": "0.0.7",
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"
38
+ "test": "tsx --test src/response.test.ts src/hooks.test.ts src/generator/render.test.ts src/generator/operations.test.ts"
39
39
  }
40
40
  }