ty-fetch 0.1.1 → 0.2.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/base.d.ts CHANGED
@@ -3,9 +3,9 @@ export class HTTPError extends Error {
3
3
  }
4
4
 
5
5
  export interface Options<
6
- TBody = never,
7
- TPathParams = never,
8
- TQueryParams = never,
6
+ TBody = unknown,
7
+ TPathParams = Record<string, string>,
8
+ TQueryParams = Record<string, string | number | boolean>,
9
9
  THeaders extends Record<string, string> = Record<string, string>,
10
10
  > extends Omit<RequestInit, 'body' | 'headers'> {
11
11
  body?: TBody;
@@ -31,19 +31,19 @@ export interface StreamResult<T = unknown> extends AsyncIterable<T> {
31
31
  }
32
32
 
33
33
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
34
- type BaseOptions = Options<any, Record<string, any>, Record<string, any>>;
34
+ type Untyped = any;
35
35
 
36
36
  export interface TyFetch {
37
- (url: string, options?: BaseOptions): Promise<FetchResult<any>>;
38
- get(url: string, options?: BaseOptions): Promise<FetchResult<any>>;
39
- post(url: string, options?: BaseOptions): Promise<FetchResult<any>>;
40
- put(url: string, options?: BaseOptions): Promise<FetchResult<any>>;
41
- patch(url: string, options?: BaseOptions): Promise<FetchResult<any>>;
42
- delete(url: string, options?: BaseOptions): Promise<FetchResult<any>>;
43
- head(url: string, options?: BaseOptions): Promise<FetchResult<any>>;
44
- stream(url: string, options?: BaseOptions): StreamResult;
45
- create(defaults?: BaseOptions): TyFetch;
46
- extend(defaults?: BaseOptions): TyFetch;
37
+ <T = Untyped>(url: string, options?: Options): Promise<FetchResult<T>>;
38
+ get<T = Untyped>(url: string, options?: Options<never>): Promise<FetchResult<T>>;
39
+ post<T = Untyped>(url: string, options?: Options): Promise<FetchResult<T>>;
40
+ put<T = Untyped>(url: string, options?: Options): Promise<FetchResult<T>>;
41
+ patch<T = Untyped>(url: string, options?: Options): Promise<FetchResult<T>>;
42
+ delete<T = Untyped>(url: string, options?: Options<never>): Promise<FetchResult<T>>;
43
+ head<T = Untyped>(url: string, options?: Options<never>): Promise<FetchResult<T>>;
44
+ stream<T = Untyped>(url: string, options?: Options): StreamResult<T>;
45
+ create(defaults?: Options): TyFetch;
46
+ extend(defaults?: Options): TyFetch;
47
47
  use(middleware: Middleware): TyFetch;
48
48
  HTTPError: typeof HTTPError;
49
49
  }
@@ -36,6 +36,10 @@ function findFetchCalls(ts, sourceFile) {
36
36
  const urlStart = nodeStart(arg) + 1; // skip opening quote
37
37
  const urlLength = nodeLen(arg) - 2; // exclude quotes
38
38
  let jsonBody = null;
39
+ let queryParams = null;
40
+ let pathParams = null;
41
+ let queryObjRange = null;
42
+ let pathObjRange = null;
39
43
  if (node.arguments.length >= 2) {
40
44
  const optionsArg = node.arguments[1];
41
45
  if (ts.isObjectLiteralExpression(optionsArg)) {
@@ -43,6 +47,22 @@ function findFetchCalls(ts, sourceFile) {
43
47
  if (jsonProp && ts.isObjectLiteralExpression(jsonProp.initializer)) {
44
48
  jsonBody = extractJsonProperties(ts, sourceFile, jsonProp.initializer);
45
49
  }
50
+ // Extract params.query and params.path
51
+ const paramsProp = optionsArg.properties.find((p) => ts.isPropertyAssignment(p) && ts.isIdentifier(p.name) && p.name.text === "params");
52
+ if (paramsProp && ts.isObjectLiteralExpression(paramsProp.initializer)) {
53
+ for (const sub of paramsProp.initializer.properties) {
54
+ if (!ts.isPropertyAssignment(sub) || !ts.isIdentifier(sub.name))
55
+ continue;
56
+ if (sub.name.text === "query" && ts.isObjectLiteralExpression(sub.initializer)) {
57
+ queryParams = extractParamNames(ts, sourceFile, sub.initializer);
58
+ queryObjRange = { start: nodeStart(sub.initializer), end: sub.initializer.getEnd() };
59
+ }
60
+ else if (sub.name.text === "path" && ts.isObjectLiteralExpression(sub.initializer)) {
61
+ pathParams = extractParamNames(ts, sourceFile, sub.initializer);
62
+ pathObjRange = { start: nodeStart(sub.initializer), end: sub.initializer.getEnd() };
63
+ }
64
+ }
65
+ }
46
66
  }
47
67
  }
48
68
  results.push({
@@ -53,6 +73,10 @@ function findFetchCalls(ts, sourceFile) {
53
73
  callStart: nodeStart(node),
54
74
  callLength: nodeLen(node),
55
75
  jsonBody,
76
+ queryParams,
77
+ pathParams,
78
+ queryObjRange,
79
+ pathObjRange,
56
80
  });
57
81
  }
58
82
  }
@@ -111,3 +135,21 @@ function extractJsonProperties(ts, sf, obj) {
111
135
  }
112
136
  return props;
113
137
  }
138
+ function extractParamNames(ts, sf, obj) {
139
+ function nodeStart(n) {
140
+ return n.getStart(sf);
141
+ }
142
+ function nodeLen(n) {
143
+ return n.getEnd() - nodeStart(n);
144
+ }
145
+ const params = [];
146
+ for (const prop of obj.properties) {
147
+ if (!ts.isPropertyAssignment(prop))
148
+ continue;
149
+ const name = ts.isIdentifier(prop.name) ? prop.name.text : ts.isStringLiteral(prop.name) ? prop.name.text : null;
150
+ if (!name)
151
+ continue;
152
+ params.push({ name, nameStart: nodeStart(prop.name), nameLength: nodeLen(prop.name) });
153
+ }
154
+ return params;
155
+ }
@@ -7,3 +7,8 @@ export declare function getRequestBodySchema(operation: any): any | null;
7
7
  export declare function getResponseSchema(operation: any): any | null;
8
8
  /** Return true if the operation's request body is marked as required. */
9
9
  export declare function isRequestBodyRequired(operation: any): boolean;
10
+ /** Extract parameter definitions from an operation and its parent path item. */
11
+ export declare function getOperationParams(operation: any, pathItem: any, spec: OpenAPISpec, filterIn: "query" | "path"): Array<{
12
+ name: string;
13
+ required: boolean;
14
+ }>;
@@ -4,6 +4,7 @@ exports.resolveSchemaRef = resolveSchemaRef;
4
4
  exports.getRequestBodySchema = getRequestBodySchema;
5
5
  exports.getResponseSchema = getResponseSchema;
6
6
  exports.isRequestBodyRequired = isRequestBodyRequired;
7
+ exports.getOperationParams = getOperationParams;
7
8
  /** Resolve a $ref pointer to its target schema, or return the schema as-is if not a ref. */
8
9
  function resolveSchemaRef(schema, spec) {
9
10
  if (!schema)
@@ -27,7 +28,29 @@ function getResponseSchema(operation) {
27
28
  const resp = operation?.responses?.["200"] ?? operation?.responses?.["201"];
28
29
  return resp?.content?.["application/json"]?.schema ?? null;
29
30
  }
31
+ /** Resolve a $ref pointer to a parameter definition. */
32
+ function resolveParamRef(ref, spec) {
33
+ const match = ref.match(/^#\/components\/parameters\/(.+)$/);
34
+ if (match)
35
+ return spec.components?.parameters?.[match[1]] ?? null;
36
+ // Also try schemas ref as fallback
37
+ return resolveSchemaRef({ $ref: ref }, spec);
38
+ }
30
39
  /** Return true if the operation's request body is marked as required. */
31
40
  function isRequestBodyRequired(operation) {
32
41
  return !!operation?.requestBody?.required;
33
42
  }
43
+ /** Extract parameter definitions from an operation and its parent path item. */
44
+ function getOperationParams(operation, pathItem, spec, filterIn) {
45
+ const allParams = [...(operation?.parameters ?? []), ...(pathItem?.parameters ?? [])];
46
+ const result = [];
47
+ const seen = new Set();
48
+ for (const param of allParams) {
49
+ const resolved = param.$ref ? resolveParamRef(param.$ref, spec) ?? param : param;
50
+ if (resolved?.in === filterIn && resolved?.name && !seen.has(resolved.name)) {
51
+ seen.add(resolved.name);
52
+ result.push({ name: resolved.name, required: !!resolved.required });
53
+ }
54
+ }
55
+ return result;
56
+ }
@@ -35,6 +35,25 @@ export interface FetchCallInfo {
35
35
  callLength: number;
36
36
  /** JSON body properties if present: [{ name, nameStart, nameLength, valueStart, valueLength, valueText, valueKind }] */
37
37
  jsonBody: JsonBodyProperty[] | null;
38
+ /** params.query properties */
39
+ queryParams: ParamProperty[] | null;
40
+ /** params.path properties */
41
+ pathParams: ParamProperty[] | null;
42
+ /** Position range of the query object literal (for completions) */
43
+ queryObjRange: {
44
+ start: number;
45
+ end: number;
46
+ } | null;
47
+ /** Position range of the path object literal (for completions) */
48
+ pathObjRange: {
49
+ start: number;
50
+ end: number;
51
+ } | null;
52
+ }
53
+ export interface ParamProperty {
54
+ name: string;
55
+ nameStart: number;
56
+ nameLength: number;
38
57
  }
39
58
  export interface JsonBodyProperty {
40
59
  name: string;
@@ -127,13 +127,7 @@ function generateDtsContent(domainSpecs) {
127
127
  const paramSchema = resolved.schema?.$ref
128
128
  ? (resolveRef(spec, resolved.schema.$ref) ?? resolved.schema)
129
129
  : (resolved.schema ?? null);
130
- const tsType = paramSchema?.type === "integer"
131
- ? "number"
132
- : paramSchema?.type === "number"
133
- ? "number"
134
- : paramSchema?.type === "boolean"
135
- ? "boolean"
136
- : "string";
130
+ const tsType = inferParamType(paramSchema);
137
131
  queryParams.push({
138
132
  name: resolved.name,
139
133
  type: tsType,
@@ -203,6 +197,27 @@ function generateDtsContent(domainSpecs) {
203
197
  lines.push("}");
204
198
  return lines.join("\n");
205
199
  }
200
+ /** Infer a TypeScript type string from an OpenAPI parameter schema, including enum unions and arrays. */
201
+ function inferParamType(paramSchema) {
202
+ if (!paramSchema)
203
+ return "string";
204
+ // Enum → string literal union
205
+ if (paramSchema.enum && paramSchema.enum.length > 0) {
206
+ return paramSchema.enum
207
+ .map((v) => (typeof v === "string" ? `"${v}"` : String(v)))
208
+ .join(" | ");
209
+ }
210
+ // Array with items
211
+ if (paramSchema.type === "array" && paramSchema.items) {
212
+ const itemType = inferParamType(paramSchema.items);
213
+ return `(${itemType})[]`;
214
+ }
215
+ if (paramSchema.type === "integer" || paramSchema.type === "number")
216
+ return "number";
217
+ if (paramSchema.type === "boolean")
218
+ return "boolean";
219
+ return "string";
220
+ }
206
221
  function resolveRef(spec, ref) {
207
222
  // Handle #/components/schemas/Foo
208
223
  const match = ref.match(/^#\/components\/schemas\/(.+)$/);
@@ -123,35 +123,39 @@ function init(modules) {
123
123
  source: "ty-fetch",
124
124
  });
125
125
  }
126
+ const specPath = (0, core_1.findSpecPath)(apiPath, entry.spec);
126
127
  // Body validation
127
- if (call.httpMethod && call.jsonBody) {
128
- const specPath = (0, core_1.findSpecPath)(apiPath, entry.spec);
129
- if (specPath) {
130
- const operation = entry.spec.paths[specPath]?.[call.httpMethod];
131
- const reqSchema = operation?.requestBody?.content?.["application/json"]?.schema ??
132
- operation?.requestBody?.content?.["application/x-www-form-urlencoded"]?.schema;
133
- if (reqSchema) {
134
- const resolved = (0, core_1.resolveSchemaRef)(reqSchema, entry.spec);
135
- if (resolved?.properties) {
136
- // Find jsonObj start from the call expression in the AST
137
- const callNode = sourceFile.statements.length > 0 ? findCallAtPosition(ts, sourceFile, call.callStart) : null;
138
- const jsonObjStart = callNode ? getJsonObjStart(ts, callNode) : call.callStart;
139
- const bodyDiags = (0, core_1.validateJsonBody)(call.jsonBody, resolved, entry.spec, jsonObjStart);
140
- for (const d of bodyDiags) {
141
- extra.push({
142
- file: sourceFile,
143
- start: d.start,
144
- length: d.length,
145
- messageText: d.message,
146
- category: ts.DiagnosticCategory.Error,
147
- code: d.code,
148
- source: "ty-fetch",
149
- });
150
- }
128
+ if (call.httpMethod && call.jsonBody && specPath) {
129
+ const operation = entry.spec.paths[specPath]?.[call.httpMethod];
130
+ const reqSchema = operation?.requestBody?.content?.["application/json"]?.schema ??
131
+ operation?.requestBody?.content?.["application/x-www-form-urlencoded"]?.schema;
132
+ if (reqSchema) {
133
+ const resolved = (0, core_1.resolveSchemaRef)(reqSchema, entry.spec);
134
+ if (resolved?.properties) {
135
+ const callNode = sourceFile.statements.length > 0 ? findCallAtPosition(ts, sourceFile, call.callStart) : null;
136
+ const jsonObjStart = callNode ? getJsonObjStart(ts, callNode) : call.callStart;
137
+ const bodyDiags = (0, core_1.validateJsonBody)(call.jsonBody, resolved, entry.spec, jsonObjStart);
138
+ for (const d of bodyDiags) {
139
+ extra.push({
140
+ file: sourceFile,
141
+ start: d.start,
142
+ length: d.length,
143
+ messageText: d.message,
144
+ category: ts.DiagnosticCategory.Error,
145
+ code: d.code,
146
+ source: "ty-fetch",
147
+ });
151
148
  }
152
149
  }
153
150
  }
154
151
  }
152
+ // Params validation (query + path)
153
+ if (call.httpMethod && specPath) {
154
+ const operation = entry.spec.paths[specPath]?.[call.httpMethod];
155
+ const pathItem = entry.spec.paths[specPath];
156
+ validateParams(call.queryParams, "query", operation, pathItem, entry.spec, sourceFile, extra);
157
+ validateParams(call.pathParams, "path", operation, pathItem, entry.spec, sourceFile, extra);
158
+ }
155
159
  }
156
160
  // Regenerate types when URLs change
157
161
  const currentUrls = calls
@@ -181,38 +185,76 @@ function init(modules) {
181
185
  return prior;
182
186
  const calls = (0, core_1.findFetchCalls)(ts, sourceFile);
183
187
  for (const call of calls) {
184
- if (position < call.urlStart || position > call.urlStart + call.urlLength)
185
- continue;
186
- const parsed = (0, core_1.parseFetchUrl)(call.url);
187
- if (!parsed)
188
- continue;
189
- const entry = (0, core_1.ensureSpec)(parsed.domain, logger, onSpecLoaded);
190
- if (entry.status !== "loaded" || !entry.spec)
191
- continue;
192
- const basePath = (0, core_1.getBasePath)(entry.spec);
193
- const urlPrefix = `https://${parsed.domain}${basePath}`;
194
- const pathEntries = [];
195
- for (const [specPath, methods] of Object.entries(entry.spec.paths)) {
196
- const available = Object.keys(methods).filter((m) => !["parameters", "summary", "description"].includes(m));
197
- if (call.httpMethod && !available.includes(call.httpMethod))
188
+ // URL completions
189
+ if (position >= call.urlStart && position <= call.urlStart + call.urlLength) {
190
+ const parsed = (0, core_1.parseFetchUrl)(call.url);
191
+ if (!parsed)
198
192
  continue;
199
- const fullUrl = `${urlPrefix}${specPath}`;
200
- pathEntries.push({
201
- name: fullUrl,
202
- kind: ts.ScriptElementKind.string,
203
- sortText: `0${specPath}`,
204
- replacementSpan: { start: call.urlStart, length: call.urlLength },
205
- insertText: fullUrl,
206
- labelDetails: { description: available.map((m) => m.toUpperCase()).join(", ") },
207
- });
193
+ const entry = (0, core_1.ensureSpec)(parsed.domain, logger, onSpecLoaded);
194
+ if (entry.status !== "loaded" || !entry.spec)
195
+ continue;
196
+ const basePath = (0, core_1.getBasePath)(entry.spec);
197
+ const urlPrefix = `https://${parsed.domain}${basePath}`;
198
+ const pathEntries = [];
199
+ for (const [specPath, methods] of Object.entries(entry.spec.paths)) {
200
+ const available = Object.keys(methods).filter((m) => !["parameters", "summary", "description"].includes(m));
201
+ if (call.httpMethod && !available.includes(call.httpMethod))
202
+ continue;
203
+ const fullUrl = `${urlPrefix}${specPath}`;
204
+ pathEntries.push({
205
+ name: fullUrl,
206
+ kind: ts.ScriptElementKind.string,
207
+ sortText: `0${specPath}`,
208
+ replacementSpan: { start: call.urlStart, length: call.urlLength },
209
+ insertText: fullUrl,
210
+ labelDetails: { description: available.map((m) => m.toUpperCase()).join(", ") },
211
+ });
212
+ }
213
+ const filtered = pathEntries.filter((e) => e.name.startsWith(call.url) || call.url.endsWith("/"));
214
+ return {
215
+ isGlobalCompletion: false,
216
+ isMemberCompletion: false,
217
+ isNewIdentifierLocation: false,
218
+ entries: filtered.length > 0 ? filtered : pathEntries,
219
+ };
220
+ }
221
+ // Param completions (query / path)
222
+ const inQuery = call.queryObjRange && position > call.queryObjRange.start && position < call.queryObjRange.end;
223
+ const inPath = call.pathObjRange && position > call.pathObjRange.start && position < call.pathObjRange.end;
224
+ if ((inQuery || inPath) && call.httpMethod) {
225
+ const parsed = (0, core_1.parseFetchUrl)(call.url);
226
+ if (!parsed)
227
+ continue;
228
+ const entry = (0, core_1.ensureSpec)(parsed.domain, logger, onSpecLoaded);
229
+ if (entry.status !== "loaded" || !entry.spec)
230
+ continue;
231
+ const apiPath = (0, core_1.stripBasePath)(parsed.path, entry.spec);
232
+ const specPath = (0, core_1.findSpecPath)(apiPath, entry.spec);
233
+ if (!specPath)
234
+ continue;
235
+ const operation = entry.spec.paths[specPath]?.[call.httpMethod];
236
+ const pathItem = entry.spec.paths[specPath];
237
+ const filterIn = inQuery ? "query" : "path";
238
+ const specParams = (0, core_1.getOperationParams)(operation, pathItem, entry.spec, filterIn);
239
+ const existing = (inQuery ? call.queryParams : call.pathParams) ?? [];
240
+ const existingNames = new Set(existing.map((p) => p.name));
241
+ const entries = specParams
242
+ .filter((p) => !existingNames.has(p.name))
243
+ .map((p) => ({
244
+ name: p.name,
245
+ kind: ts.ScriptElementKind.memberVariableElement,
246
+ sortText: p.required ? `0${p.name}` : `1${p.name}`,
247
+ labelDetails: { description: p.required ? "(required)" : "(optional)" },
248
+ }));
249
+ if (entries.length > 0) {
250
+ return {
251
+ isGlobalCompletion: false,
252
+ isMemberCompletion: true,
253
+ isNewIdentifierLocation: false,
254
+ entries: [...entries, ...(prior?.entries ?? [])],
255
+ };
256
+ }
208
257
  }
209
- const filtered = pathEntries.filter((e) => e.name.startsWith(call.url) || call.url.endsWith("/"));
210
- return {
211
- isGlobalCompletion: false,
212
- isMemberCompletion: false,
213
- isNewIdentifierLocation: false,
214
- entries: filtered.length > 0 ? filtered : pathEntries,
215
- };
216
258
  }
217
259
  return prior;
218
260
  };
@@ -270,6 +312,27 @@ function init(modules) {
270
312
  }
271
313
  return { create };
272
314
  }
315
+ function validateParams(params, filterIn, operation, pathItem, spec, sourceFile, extra) {
316
+ if (!params || params.length === 0)
317
+ return;
318
+ const ts = require("typescript");
319
+ const specParams = (0, core_1.getOperationParams)(operation, pathItem, spec, filterIn);
320
+ const validNames = new Set(specParams.map((p) => p.name));
321
+ for (const param of params) {
322
+ if (!validNames.has(param.name)) {
323
+ const suggestions = specParams.map((p) => `'${p.name}'`).join(", ");
324
+ extra.push({
325
+ file: sourceFile,
326
+ start: param.nameStart,
327
+ length: param.nameLength,
328
+ messageText: `Unknown ${filterIn} parameter '${param.name}'.${suggestions ? ` Available: ${suggestions}` : ""}`,
329
+ category: ts.DiagnosticCategory.Error,
330
+ code: 99005,
331
+ source: "ty-fetch",
332
+ });
333
+ }
334
+ }
335
+ }
273
336
  // Helper to find a CallExpression at a position in the AST
274
337
  function findCallAtPosition(ts, sourceFile, pos) {
275
338
  let found = null;
package/index.d.ts CHANGED
@@ -3,9 +3,9 @@ export class HTTPError extends Error {
3
3
  }
4
4
 
5
5
  export interface Options<
6
- TBody = never,
7
- TPathParams = never,
8
- TQueryParams = never,
6
+ TBody = unknown,
7
+ TPathParams = Record<string, string>,
8
+ TQueryParams = Record<string, string | number | boolean>,
9
9
  THeaders extends Record<string, string> = Record<string, string>,
10
10
  > extends Omit<RequestInit, 'body' | 'headers'> {
11
11
  body?: TBody;
@@ -31,19 +31,19 @@ export interface StreamResult<T = unknown> extends AsyncIterable<T> {
31
31
  }
32
32
 
33
33
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
34
- type BaseOptions = Options<any, Record<string, any>, Record<string, any>>;
34
+ type Untyped = any;
35
35
 
36
36
  export interface TyFetch {
37
- (url: string, options?: BaseOptions): Promise<FetchResult<any>>;
38
- get(url: string, options?: BaseOptions): Promise<FetchResult<any>>;
39
- post(url: string, options?: BaseOptions): Promise<FetchResult<any>>;
40
- put(url: string, options?: BaseOptions): Promise<FetchResult<any>>;
41
- patch(url: string, options?: BaseOptions): Promise<FetchResult<any>>;
42
- delete(url: string, options?: BaseOptions): Promise<FetchResult<any>>;
43
- head(url: string, options?: BaseOptions): Promise<FetchResult<any>>;
44
- stream(url: string, options?: BaseOptions): StreamResult;
45
- create(defaults?: BaseOptions): TyFetch;
46
- extend(defaults?: BaseOptions): TyFetch;
37
+ <T = Untyped>(url: string, options?: Options): Promise<FetchResult<T>>;
38
+ get<T = Untyped>(url: string, options?: Options<never>): Promise<FetchResult<T>>;
39
+ post<T = Untyped>(url: string, options?: Options): Promise<FetchResult<T>>;
40
+ put<T = Untyped>(url: string, options?: Options): Promise<FetchResult<T>>;
41
+ patch<T = Untyped>(url: string, options?: Options): Promise<FetchResult<T>>;
42
+ delete<T = Untyped>(url: string, options?: Options<never>): Promise<FetchResult<T>>;
43
+ head<T = Untyped>(url: string, options?: Options<never>): Promise<FetchResult<T>>;
44
+ stream<T = Untyped>(url: string, options?: Options): StreamResult<T>;
45
+ create(defaults?: Options): TyFetch;
46
+ extend(defaults?: Options): TyFetch;
47
47
  use(middleware: Middleware): TyFetch;
48
48
  HTTPError: typeof HTTPError;
49
49
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ty-fetch",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "Automatic TypeScript types for any REST API. No codegen, no manual types — just fetch.",
5
5
  "license": "MIT",
6
6
  "keywords": [