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 +71 -1
- package/dist/index.js +192 -7
- package/dist/index.mjs +178 -6
- package/package.json +2 -2
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
|
|
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
|
|
462
|
-
return `import { ${
|
|
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
|
-
|
|
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
|
|
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
|
|
432
|
-
return `import { ${
|
|
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
|
-
|
|
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
|
|
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
|
}
|