ty-fetch 0.1.2 → 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/dist/core/ast-helpers.js +42 -0
- package/dist/core/schema-utils.d.ts +5 -0
- package/dist/core/schema-utils.js +23 -0
- package/dist/core/types.d.ts +19 -0
- package/dist/generate-types.js +22 -7
- package/dist/plugin/index.js +117 -54
- package/package.json +1 -1
package/dist/core/ast-helpers.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/core/types.d.ts
CHANGED
|
@@ -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;
|
package/dist/generate-types.js
CHANGED
|
@@ -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
|
|
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\/(.+)$/);
|
package/dist/plugin/index.js
CHANGED
|
@@ -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
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
if (
|
|
134
|
-
const
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
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;
|