-
Notifications
You must be signed in to change notification settings - Fork 1
/
Copy pathparse.ts
402 lines (369 loc) · 11.3 KB
/
parse.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
import { URL } from "url";
import { readFile } from "fs/promises";
import path from "path";
import * as Router from "find-my-way-ts";
import { Model, Request } from "./metamodel";
const isBrowser = typeof window !== "undefined";
const httpMethods = ["HEAD", "GET", "POST", "PUT", "PATCH", "DELETE"];
export type JSONValue = string | number | boolean | JSONArray | JSONObject;
interface JSONArray extends Array<JSONValue> {}
interface JSONObject {
[x: string]: JSONValue;
}
export type ParseOptions = {
/** Wether to ignore errors and continue parsing */
ignoreErrors?: boolean;
};
export type ParsedRequest = {
/** The source request. */
source: string;
/** The target service code. This is "es" for Elasticsearch, "kbn" for Kibana. */
service: string;
/** The name of the Elasticsearch API this request refers to. */
api?: string;
/** The request definition from the Elasticsearch specification that applies to this request. */
request?: Request;
/** The dynamic parameters that are part of the request's URL. */
params: Record<string, string | undefined>;
/** The request method. */
method: string;
/** The complete request URL, including query string. */
url: string;
/** The path portion of the request URL, with URL decoding applied. */
path: string;
/** The path portion of the request URL, without URL decoding applied. */
rawPath: string;
/** An object with the arguments passed in the query string of the request. */
query?: Record<string, string>;
/** The body of the request, given as an object for a JSON body, or an array of
* objects for the ndjson bodies used in bulk requests. */
body?: JSONObject | JSONObject[] | string;
};
type ESRoute = {
name: string;
request: Request;
};
let router = Router.make<ESRoute>({
ignoreTrailingSlash: true,
maxParamLength: 1000,
});
// split Dev Console source code into individual commands
export function splitSource(source: string): string[] {
source = source.replace(/^#.*$/gm, "\n"); // remove comments
source = source.trim();
const len = source.length;
const sources = [];
let index = 0;
let prev = 0;
while (index < len) {
// Beginning of a new command, we should find the method and proceede to the url.
for (const method of httpMethods) {
if (source.slice(index, len).startsWith(method)) {
index += method.length;
break;
}
}
nextCommand();
sources.push(source.slice(prev, index).trim());
prev = index;
}
return sources;
function nextCommand() {
if (index == len) return;
let brackets = 0;
// If we found an http method, then we have found a new command.
for (const method of httpMethods) {
if (source.slice(index, len).startsWith(method)) {
return;
}
}
// If we didn't find an http method, we should increment the index.
// If we find an open curly bracket, we should also find the closing one
// before to checking for the http method.
if (source[index] == "{") {
for (; index < len; ) {
if (source[index] == "{") {
brackets += 1;
} else if (source[index] == "}") {
brackets -= 1;
}
if (brackets == 0) {
break;
}
index += 1;
}
} else {
index += 1;
}
nextCommand();
}
}
// parse a single console command
function parseCommand(source: string, options: ParseOptions) {
source = source
// removes comments tags, such as `<1>`
.replace(/<([\S\s])>/g, "")
// removes comments, such as `// optional`
.replace(/\s*\/\/\s.+/g, "")
// trimp whitespace
.trim();
const data: ParsedRequest = {
source: source,
service: "es",
params: {},
method: "",
url: "",
path: "",
rawPath: "",
};
const len = source.length;
let index = 0;
// identify the method
for (const method of httpMethods) {
if (source.slice(index, len).startsWith(method)) {
data.method = method;
index += method.length;
break;
}
}
/* istanbul ignore if */
if (!data.method) {
if (options?.ignoreErrors) {
return data;
}
throw new Error("Invalid request method");
}
// identify the url and query
skip(" ", "\n");
const urlStart = index;
until("{", "\n");
if (source[index] == "{" && source[index + 1].match(/[a-z]/i)) {
// this is a placeholder element inside the URL (as used in doc examples),
// so we continue scanning
index++;
until("{", "\n");
}
data.url = source.slice(urlStart, index).trim();
if (data.url.indexOf(":/") >= 0) {
[data.service, data.url] = data.url.split(":/", 2);
data.url = "/" + data.url;
}
if (data.url[0] != "/") {
data.url = "/" + data.url;
}
data.url = data.url
// replaces { with %7B (braces in many doc examples are not URIencoded)
.replace(/{/g, "%7B")
// replaces } with %7D
.replace(/}/g, "%7D");
const parsedUrl = new URL(`http://localhost${data.url}`);
data.rawPath =
parsedUrl.pathname != "/"
? parsedUrl.pathname.replace(/\/$/, "")
: parsedUrl.pathname;
data.path = decodeURIComponent(data.rawPath);
if (parsedUrl.search.length) {
const parsedQuery = new URLSearchParams(parsedUrl.search.slice(1));
data.query = {};
for (const [key, value] of parsedQuery) {
data.query[key] = value || "true";
}
}
// TODO: this should be an issue in the docs,
// the correct url is `<index/_mapping`
if (data.path.endsWith("_mappings")) {
data.path = data.path.slice(0, -1);
data.rawPath = data.rawPath.slice(0, -1);
data.url = data.url.replace(data.path + "s", data.path);
}
// identify the body
const body = removeTrailingCommas(
collapseLiteralStrings(source.slice(index)),
);
if (body != "") {
try {
// json body
data.body = JSON.parse(body) as JSONObject;
} catch (err) {
try {
// ndjson body
const ndbody = body.split("\n").filter(Boolean) as string[];
data.body = ndbody.map((b) => JSON.parse(b));
} catch (err) {
if (options?.ignoreErrors) {
data.body = body;
} else {
throw new Error("body cannot be parsed");
}
}
}
}
return data;
// some commands have three double quotes `"""` in the body
// this utility removes them and makes the string a valid json
function collapseLiteralStrings(data: string) {
const splitData = data.split('"""');
for (let idx = 1; idx < splitData.length - 1; idx += 2) {
splitData[idx] = JSON.stringify(splitData[idx]);
}
return splitData.join("");
}
// remove any trailing commas to prevent JSON parser failures
function removeTrailingCommas(data: string) {
return data.replace(/,([ |\t|\n]+[}|\]|)])/g, "$1");
}
// proceeds until it finds a character not present
// in the list passed as input
function skip(...args: string[]) {
if (index == len) return;
if (!args.includes(source[index])) {
return;
}
index += 1;
skip(...args);
}
// proceeds until it finds a character present
// in the list passed as input
function until(...args: string[]) {
if (index == len) return;
if (args.includes(source[index])) {
return;
}
index += 1;
until(...args);
}
}
/** Load a schema.json file with the Elasticsearch specification.
*
* This function is used internally to load the Elasticsearch specification to
* use to categorize requests. It is normally not necessary to invoke this
* function directly, but it can be used to load a different version of the
* specification than the one bundled with this package.
*
* @param filename_or_object The path to the schema.json file to load, or an
* object with a loaded schema.
*/
export async function loadSchema(filename_or_object: string | object) {
let spec: Model;
if (typeof filename_or_object === "string") {
spec = JSON.parse(
await readFile(filename_or_object, { encoding: "utf-8" }),
) as Model;
} else {
spec = filename_or_object as Model;
}
if (router.find("GET", "/") != undefined) {
// start from a clean router
router = Router.make<ESRoute>({
ignoreTrailingSlash: true,
maxParamLength: 1000,
});
}
for (const endpoint of spec.endpoints) {
for (const url of endpoint.urls) {
const { path, methods } = url;
let formattedPath = path
.split("/")
.map((p) => (p.startsWith("{") ? `:${p.slice(1, -1)}` : p))
.join("/");
/* istanbul ignore next */
if (!formattedPath.startsWith("/")) {
formattedPath = "/" + formattedPath;
}
// find the request in the spec
try {
let req: Request | undefined;
for (const type of spec.types) {
if (
type.name.namespace == endpoint.request?.namespace &&
type.name.name == endpoint.request?.name
) {
if (type.kind != "request") {
/* istanbul ignore next */
throw new Error(
`Unexpected request type ${type.kind} for URL ${url}`,
);
}
req = type as Request;
break;
}
}
const r = {
name: endpoint.name,
request: req as Request,
};
router.on(methods, formattedPath as Router.PathInput, r);
} catch (err) {
// in some cases there are routes that have the same url but different
// dynamic parameters, which causes find-my-way to fail
}
}
}
}
// use a router to figure out the API name
async function getAPI(
method: string,
endpointPath: string,
): Promise<Router.FindResult<ESRoute>> {
if (router.find("GET", "/") == undefined) {
if (!isBrowser) {
// load the Elasticsearch spec
await loadSchema(path.join(__dirname, "./schema.json"));
} else {
throw new Error("Specification is missing");
}
}
const formattedPath = endpointPath.startsWith("/")
? endpointPath
: `/${endpointPath}`;
const route = router.find(method, formattedPath);
if (!route) {
/* istanbul ignore next */
throw new Error(
`There is no handler for method '${method}' and url '${formattedPath}'`,
);
}
return route;
}
export async function parseRequest(
source: string,
options?: ParseOptions,
): Promise<ParsedRequest> {
source = source.replace(/^\s+|\s+$/g, ""); // trim whitespace
const req = parseCommand(source, options ?? {});
if (req.service == "es") {
// for Elasticsearch URLs we can get API details
try {
const route = await getAPI(req.method, req.rawPath);
req.api = route.handler.name;
req.request = route.handler.request;
if (Object.keys(route.params).length > 0) {
req.params = route.params;
}
} catch (error) {
if (!options?.ignoreErrors) {
throw error;
}
}
}
return req;
}
/** Parse a Dev Console script.
*
* This function is used internally by the `convertRequests()` function, so in
* general it does not need to be called directly.
*
* @param source The source code to parse in Dev Console syntax. Multiple requests
* can be separated with an empty line.
* @returns The function returns an array of `ParsedRequest` objects, each describing
* a request.
*/
export async function parseRequests(
source: string,
options?: ParseOptions,
): Promise<ParsedRequest[]> {
const sources = splitSource(source);
return await Promise.all(
sources.map((source) => parseRequest(source, options)),
);
}