ebely 0.0.7 → 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
@@ -230,6 +230,57 @@ type SwaggerSource = {
230
230
  pathToFile?: never;
231
231
  };
232
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
+
233
284
  /**
234
285
  * Режим генерируемого клиента (решается на этапе генерации, влияет на
235
286
  * форму сгенерированного файла):
@@ -306,6 +357,25 @@ type EbelyConfig = {
306
357
  * @default undefined
307
358
  */
308
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
+ };
309
379
  /**
310
380
  * Из какого модуля СГЕНЕРИРОВАННЫЙ файл импортирует `BaseStore`.
311
381
  * По умолчанию `'ebely'` — имя npm-пакета библиотеки. Менять нужно
@@ -334,4 +404,4 @@ declare function generateClient(args: EbelyConfig): Promise<{
334
404
  operations: number;
335
405
  }>;
336
406
 
337
- export { AfterHook, AfterHookArgs, ApiResponse, BaseStore, BeforeHook, BeforeHookArgs, ClientMode, DeepPartial, EbelyAssertionError, EbelyConfig, HookRegistry, HookRequest, HookResponse, HooksRegistrar, RetryHook, 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
 
@@ -244,6 +257,9 @@ var import_promises2 = require("fs/promises");
244
257
  var import_node_path2 = require("path");
245
258
 
246
259
  // src/generator/schema.ts
260
+ function isFileSchema(schema) {
261
+ return Boolean(schema) && schema.type === "string" && typeof schema.contentMediaType === "string";
262
+ }
247
263
  function schemaToType(args) {
248
264
  const { schema, spec, indent = 0 } = args;
249
265
  if (!schema)
@@ -252,6 +268,8 @@ function schemaToType(args) {
252
268
  const resolved = resolveRef({ ref: schema.$ref, spec });
253
269
  return schemaToType({ schema: resolved, spec, indent });
254
270
  }
271
+ if (isFileSchema(schema))
272
+ return "FileInput";
255
273
  for (const key of ["allOf", "oneOf", "anyOf"]) {
256
274
  if (Array.isArray(schema[key])) {
257
275
  const joiner = key === "allOf" ? " & " : " | ";
@@ -341,7 +359,10 @@ function collectOperations(args) {
341
359
  const parameters = op.parameters ?? [];
342
360
  const pathParams = parameters.filter((p) => p.in === "path").map((p) => p.name);
343
361
  const queryParams = parameters.filter((p) => p.in === "query").map((p) => p.name);
344
- 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;
345
366
  const responseSchema = pickResponseSchema({ responses: op.responses, spec });
346
367
  const responseType = schemaToType({ schema: responseSchema, spec, indent: 3 });
347
368
  const declared = collectResponseSchemas({ responses: op.responses, spec });
@@ -358,6 +379,8 @@ function collectOperations(args) {
358
379
  queryParams,
359
380
  bodyType: bodySchema ? schemaToType({ schema: bodySchema, spec, indent: 4 }) : null,
360
381
  bodyRequired: Boolean(op.requestBody?.required),
382
+ isMultipart,
383
+ fileFields,
361
384
  responseType,
362
385
  responses,
363
386
  summary: op.summary
@@ -367,6 +390,22 @@ function collectOperations(args) {
367
390
  dedupeNames({ operations });
368
391
  return operations;
369
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
+ }
370
409
  function prefixMethod(args) {
371
410
  const { method, name } = args;
372
411
  return method + name.charAt(0).toUpperCase() + name.slice(1);
@@ -458,11 +497,23 @@ function renderMethod(args) {
458
497
  }
459
498
  function renderImports(args) {
460
499
  const { mode, userStoreImport, configImport } = args;
461
- const values = mode === "frontend" ? "BaseStore, HookRegistry" : "BaseStore, ApiResponse, HookRegistry";
462
- return `import { ${values} } from ${JSON.stringify(userStoreImport)}
500
+ const core = mode === "frontend" ? "BaseStore, HookRegistry" : "BaseStore, ApiResponse, HookRegistry";
501
+ return `import { ${core}, toMultipartFormData } from ${JSON.stringify(userStoreImport)}
463
502
  import type { BeforeHook, AfterHook, RetryHook } from ${JSON.stringify(userStoreImport)}
503
+ import type { FileInput, FileEncoding, FileFieldMeta } from ${JSON.stringify(userStoreImport)}
464
504
  import { ebely } from ${JSON.stringify(configImport)}`;
465
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
+ }
466
517
  function renderRequestTail(mode) {
467
518
  if (mode === "frontend") {
468
519
  return {
@@ -578,6 +629,8 @@ function renderClient(args) {
578
629
 
579
630
  ${renderImports({ mode, userStoreImport, configImport })}
580
631
 
632
+ ${renderFileOps(operations)}
633
+
581
634
  type RequestInput = {
582
635
  path?: Record<string, string>
583
636
  query?: Record<string, string | number | boolean | undefined>
@@ -713,13 +766,31 @@ ${renderHookTreeBuilder(groups)}
713
766
  }
714
767
 
715
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
+
716
786
  const response = await fetch(url, {
717
787
  method,
718
788
  headers: {
719
- ...(hasBody ? { 'content-type': 'application/json' } : {}),
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' } : {}),
720
791
  ...hookReq.headers,
721
792
  },
722
- body: hasBody ? JSON.stringify(hookReq.body) : undefined,
793
+ body: isMultipart ? form : hasBody ? JSON.stringify(hookReq.body) : undefined,
723
794
  })
724
795
 
725
796
  const text = await response.text()
@@ -825,11 +896,125 @@ Endpoints: ${operations.length}
825
896
  `);
826
897
  return { outPath, operations: operations.length };
827
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
+ }
828
1010
  // Annotate the CommonJS export names for ESM import in node:
829
1011
  0 && (module.exports = {
830
1012
  ApiResponse,
831
1013
  BaseStore,
832
1014
  EbelyAssertionError,
833
1015
  HookRegistry,
834
- generateClient
1016
+ generateClient,
1017
+ mimeFromName,
1018
+ toBlob,
1019
+ toMultipartFormData
835
1020
  });
package/dist/index.mjs CHANGED
@@ -214,6 +214,9 @@ import { writeFile } from "fs/promises";
214
214
  import { resolve as resolve2 } from "path";
215
215
 
216
216
  // src/generator/schema.ts
217
+ function isFileSchema(schema) {
218
+ return Boolean(schema) && schema.type === "string" && typeof schema.contentMediaType === "string";
219
+ }
217
220
  function schemaToType(args) {
218
221
  const { schema, spec, indent = 0 } = args;
219
222
  if (!schema)
@@ -222,6 +225,8 @@ function schemaToType(args) {
222
225
  const resolved = resolveRef({ ref: schema.$ref, spec });
223
226
  return schemaToType({ schema: resolved, spec, indent });
224
227
  }
228
+ if (isFileSchema(schema))
229
+ return "FileInput";
225
230
  for (const key of ["allOf", "oneOf", "anyOf"]) {
226
231
  if (Array.isArray(schema[key])) {
227
232
  const joiner = key === "allOf" ? " & " : " | ";
@@ -311,7 +316,10 @@ function collectOperations(args) {
311
316
  const parameters = op.parameters ?? [];
312
317
  const pathParams = parameters.filter((p) => p.in === "path").map((p) => p.name);
313
318
  const queryParams = parameters.filter((p) => p.in === "query").map((p) => p.name);
314
- 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;
315
323
  const responseSchema = pickResponseSchema({ responses: op.responses, spec });
316
324
  const responseType = schemaToType({ schema: responseSchema, spec, indent: 3 });
317
325
  const declared = collectResponseSchemas({ responses: op.responses, spec });
@@ -328,6 +336,8 @@ function collectOperations(args) {
328
336
  queryParams,
329
337
  bodyType: bodySchema ? schemaToType({ schema: bodySchema, spec, indent: 4 }) : null,
330
338
  bodyRequired: Boolean(op.requestBody?.required),
339
+ isMultipart,
340
+ fileFields,
331
341
  responseType,
332
342
  responses,
333
343
  summary: op.summary
@@ -337,6 +347,22 @@ function collectOperations(args) {
337
347
  dedupeNames({ operations });
338
348
  return operations;
339
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
+ }
340
366
  function prefixMethod(args) {
341
367
  const { method, name } = args;
342
368
  return method + name.charAt(0).toUpperCase() + name.slice(1);
@@ -428,11 +454,23 @@ function renderMethod(args) {
428
454
  }
429
455
  function renderImports(args) {
430
456
  const { mode, userStoreImport, configImport } = args;
431
- const values = mode === "frontend" ? "BaseStore, HookRegistry" : "BaseStore, ApiResponse, HookRegistry";
432
- return `import { ${values} } from ${JSON.stringify(userStoreImport)}
457
+ const core = mode === "frontend" ? "BaseStore, HookRegistry" : "BaseStore, ApiResponse, HookRegistry";
458
+ return `import { ${core}, toMultipartFormData } from ${JSON.stringify(userStoreImport)}
433
459
  import type { BeforeHook, AfterHook, RetryHook } from ${JSON.stringify(userStoreImport)}
460
+ import type { FileInput, FileEncoding, FileFieldMeta } from ${JSON.stringify(userStoreImport)}
434
461
  import { ebely } from ${JSON.stringify(configImport)}`;
435
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
+ }
436
474
  function renderRequestTail(mode) {
437
475
  if (mode === "frontend") {
438
476
  return {
@@ -548,6 +586,8 @@ function renderClient(args) {
548
586
 
549
587
  ${renderImports({ mode, userStoreImport, configImport })}
550
588
 
589
+ ${renderFileOps(operations)}
590
+
551
591
  type RequestInput = {
552
592
  path?: Record<string, string>
553
593
  query?: Record<string, string | number | boolean | undefined>
@@ -683,13 +723,31 @@ ${renderHookTreeBuilder(groups)}
683
723
  }
684
724
 
685
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
+
686
743
  const response = await fetch(url, {
687
744
  method,
688
745
  headers: {
689
- ...(hasBody ? { 'content-type': 'application/json' } : {}),
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' } : {}),
690
748
  ...hookReq.headers,
691
749
  },
692
- body: hasBody ? JSON.stringify(hookReq.body) : undefined,
750
+ body: isMultipart ? form : hasBody ? JSON.stringify(hookReq.body) : undefined,
693
751
  })
694
752
 
695
753
  const text = await response.text()
@@ -795,10 +853,124 @@ Endpoints: ${operations.length}
795
853
  `);
796
854
  return { outPath, operations: operations.length };
797
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
+ }
798
967
  export {
799
968
  ApiResponse,
800
969
  BaseStore,
801
970
  EbelyAssertionError,
802
971
  HookRegistry,
803
- generateClient
972
+ generateClient,
973
+ mimeFromName,
974
+ toBlob,
975
+ toMultipartFormData
804
976
  };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "ebely",
3
3
  "license": "MIT",
4
- "version": "0.0.7",
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
  }