Skip to content

Commit 79e7425

Browse files
committed
add class api collector
1 parent 9145c12 commit 79e7425

13 files changed

+620
-9
lines changed

.eslintignore

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
lib/

.eslintrc.js

+14
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
module.exports = {
2+
extends: ['eslint:recommended', 'plugin:@typescript-eslint/recommended'],
3+
parser: '@typescript-eslint/parser',
4+
plugins: ['@typescript-eslint'],
5+
root: true,
6+
env: {
7+
node: true
8+
},
9+
rules: {
10+
semi: ["error", "always"],
11+
'@typescript-eslint/no-explicit-any': 'off',
12+
'@typescript-eslint/no-non-null-assertion': 'off'
13+
}
14+
};

package.json

+22-1
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,11 @@
55
"main": "lib/index.js",
66
"typings": "lib/index.d.ts",
77
"scripts": {
8-
"test": "echo \"Error: no test specified\" && exit 1"
8+
"lint": "eslint .",
9+
"lint-fix": "eslint . --fix",
10+
"test": "env TS_NODE_COMPILER_OPTIONS='{\"module\": \"commonjs\" }' mocha -r ts-node/register '**/*.test.*'",
11+
"prebuild": "rimraf lib",
12+
"build": "tsc --build"
913
},
1014
"repository": {
1115
"type": "git",
@@ -21,6 +25,23 @@
2125
"node": ">=16"
2226
},
2327
"devDependencies": {
28+
"@types/chai": "^4.3.3",
29+
"@types/content-type": "^1.1.5",
30+
"@types/mocha": "^9.1.1",
31+
"@types/node": "^18.6.5",
32+
"@types/qs": "^6.9.7",
33+
"@typescript-eslint/eslint-plugin": "^5.33.0",
34+
"@typescript-eslint/parser": "^5.33.0",
35+
"chai": "^4.3.6",
36+
"eslint": "^8.21.0",
37+
"mocha": "^10.0.0",
38+
"openapi-types": "^12.0.0",
39+
"rimraf": "^3.0.2",
40+
"ts-node": "^10.9.1",
2441
"typescript": "^4.7.4"
42+
},
43+
"dependencies": {
44+
"content-type": "^1.0.4",
45+
"qs": "^6.11.0"
2546
}
2647
}

src/APICollector.ts

+300
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,300 @@
1+
import url from 'url';
2+
import qs from 'qs';
3+
import contentType from 'content-type';
4+
import type { OpenAPIV3_1 } from 'openapi-types';
5+
import type { ServerResponseArg, ObjectForResBodyBufferItem } from './types/asyncEventTypes';
6+
7+
type API_URL = string
8+
9+
export default class APICollector {
10+
private items: Record<API_URL, OpenAPIV3_1.PathItemObject>;
11+
12+
constructor () {
13+
this.items = {};
14+
}
15+
16+
/**
17+
* Using OpenAPIV3_1.SchemaObject as return type throws error
18+
* No idea how to deal with it
19+
* Reference:
20+
* https://github.com/kogosoftwarellc/open-api/blob/master/packages/openapi-types/index.ts#L138-L146
21+
*/
22+
private genSchema (data: any, withExample = true): Record<string, any> {
23+
if (!data) return {};
24+
switch (Object.prototype.toString.call(data)) {
25+
case "[object Object]": {
26+
const properties: Record<string, any> = {};
27+
Object.entries(data).forEach(([key, value]) => {
28+
properties[key] = this.genSchema(value, withExample);
29+
});
30+
return {
31+
type: 'object',
32+
properties
33+
};
34+
}
35+
case "[object Array]": {
36+
const items = this.genSchema(data[0], withExample);
37+
return {
38+
type: 'array',
39+
items
40+
};
41+
}
42+
default: {
43+
const res: Record<string, any> = {
44+
type: typeof data
45+
};
46+
if (withExample) {
47+
res.example = data;
48+
}
49+
return res;
50+
}
51+
}
52+
}
53+
54+
private extractMethod (serverRes: ServerResponseArg): OpenAPIV3_1.HttpMethods {
55+
return <OpenAPIV3_1.HttpMethods>serverRes.req.method.toLowerCase();
56+
}
57+
58+
private extractURL (serverRes: ServerResponseArg): string | undefined {
59+
const parsedURL = url.parse(serverRes.req.url);
60+
const path = parsedURL.pathname;
61+
if (!path) return;
62+
if (serverRes.req.params) {
63+
// convert /path/xxx-xxx-xxx-xxx => /path/:id
64+
Object.entries(serverRes.req.params).forEach(([key, value]) => {
65+
path.replace(value, `:${key}`);
66+
});
67+
}
68+
return path;
69+
}
70+
71+
private genHeaderObjFromRawHeaders (
72+
rawHeaders: Array<string>,
73+
{ exclude, lower }: { exclude?: Array<string>, lower?: boolean } = {}
74+
): Record<string, string> {
75+
const headers: Record<string, string> = {};
76+
for (let i = 0; i < rawHeaders.length; i += 2) {
77+
const headerName = lower && rawHeaders[i].toLowerCase() || rawHeaders[i];
78+
const headerValue = rawHeaders[i + 1];
79+
if (exclude?.includes(headerName.toLowerCase())) continue;
80+
headers[headerName] = headerValue;
81+
}
82+
return headers;
83+
}
84+
85+
private extractHeaders (serverRes: ServerResponseArg): Array<OpenAPIV3_1.ParameterObject> {
86+
const excludeHeaders = ['host', 'user-agent', 'content-length'];
87+
const headers = this.genHeaderObjFromRawHeaders(
88+
serverRes.req.rawHeaders,
89+
{ exclude: excludeHeaders }
90+
);
91+
return Object.entries(headers)
92+
.filter(([key, value]) => {
93+
if (key.toLowerCase() === 'accept' && value === "*/*") return false;
94+
return true;
95+
})
96+
.map(([key, value]) => ({
97+
in: 'header',
98+
name: key,
99+
example: value,
100+
schema: {
101+
type: 'string'
102+
}
103+
}));
104+
}
105+
106+
private extractQueries (serverRes: ServerResponseArg): Array<OpenAPIV3_1.ParameterObject> {
107+
let query: Record<string, any> | undefined = serverRes.req.query;
108+
if (!query) {
109+
const parsedURL = url.parse(serverRes.req.url);
110+
if (parsedURL.query) {
111+
query = qs.parse(parsedURL.query);
112+
}
113+
}
114+
115+
if (!query) return [];
116+
return Object.entries(query).map(([key, value]) => ({
117+
in: 'query',
118+
name: key,
119+
example: typeof value === "object" && JSON.stringify(value) || value,
120+
schema: this.genSchema(value, false)
121+
}));
122+
}
123+
124+
private extractParams (serverRes: ServerResponseArg): Array<OpenAPIV3_1.ParameterObject> {
125+
if (!serverRes.req.params) return [];
126+
return Object.entries(serverRes.req.params).map(([key, value]) => ({
127+
in: 'path',
128+
name: key,
129+
example: value,
130+
schema: {
131+
type: 'string'
132+
}
133+
}));
134+
}
135+
136+
private extractRequestBody (serverRes: ServerResponseArg): OpenAPIV3_1.RequestBodyObject | undefined {
137+
let requestBodyData = serverRes.req.body;
138+
if (!requestBodyData) {
139+
const headData = serverRes.req._readableState?.buffer.head.data;
140+
if (headData) {
141+
requestBodyData = JSON.parse(headData.toString());
142+
}
143+
}
144+
145+
if (!requestBodyData) return;
146+
const reqHeaders = this.genHeaderObjFromRawHeaders(serverRes.req.rawHeaders, { lower: true });
147+
const reqBodyContentType = reqHeaders['content-type']
148+
? contentType.parse(reqHeaders['content-type'].toLowerCase()).type
149+
: 'application/json';
150+
return {
151+
content: {
152+
[reqBodyContentType]: {
153+
schema: this.genSchema(requestBodyData)
154+
}
155+
}
156+
};
157+
}
158+
159+
private genHeaderObjFromResponse (resHeaderStr: string): Record<string, string> {
160+
const headers = resHeaderStr.split("\r\n\r\n")[0];
161+
return headers
162+
.split("\r\n")
163+
.slice(1) // The first http header is "HTTP/1.1 xxx xx"
164+
.reduce((headers, headerStr) => {
165+
const [key, value] = headerStr.split(":");
166+
headers[key.trim().toLowerCase()] = value.trim();
167+
return headers;
168+
}, {} as Record<string, string>);
169+
}
170+
171+
private genResBodyData (resBody: ObjectForResBodyBufferItem): Record<string, any> | string {
172+
let dataStr;
173+
switch (resBody.encoding) {
174+
case 'utf-8': {
175+
const chunk = resBody.chunk as string;
176+
dataStr = chunk.split("\r\n\r\n")[1];
177+
break;
178+
}
179+
case 'buffer': {
180+
const chunk = resBody.chunk as Buffer;
181+
dataStr = chunk.toString();
182+
break;
183+
}
184+
default: {
185+
dataStr = '';
186+
}
187+
}
188+
189+
try {
190+
return JSON.parse(dataStr);
191+
} catch (err) {
192+
return dataStr;
193+
}
194+
}
195+
196+
private extractResponseItem (
197+
serverRes: ServerResponseArg,
198+
resBody?: ObjectForResBodyBufferItem
199+
): OpenAPIV3_1.ResponsesObject {
200+
const statusCode = serverRes.statusCode;
201+
if (!resBody) {
202+
return {
203+
[String(statusCode)]: {
204+
description: 'No response data'
205+
}
206+
};
207+
}
208+
209+
const resHeaders = this.genHeaderObjFromResponse(serverRes._header);
210+
const resContentType = resHeaders['content-type']
211+
? contentType.parse(resHeaders['content-type'].toLowerCase()).type
212+
: 'application/json';
213+
const resData = this.genResBodyData(resBody);
214+
return {
215+
[String(statusCode)]: {
216+
description: serverRes.statusMessage,
217+
content: {
218+
[resContentType]: {
219+
schema: this.genSchema(resData)
220+
}
221+
}
222+
}
223+
};
224+
}
225+
226+
private insertNewAPIItem (
227+
url: API_URL,
228+
method: OpenAPIV3_1.HttpMethods,
229+
methodContent: OpenAPIV3_1.OperationObject,
230+
statusCode: number
231+
): void {
232+
// If the url method doesnt exist
233+
if (!this.items[url] || !this.items[url][method]) {
234+
this.items[url] = this.items[url] || {};
235+
Object.assign(
236+
this.items[url],
237+
{ [method]: methodContent }
238+
);
239+
return;
240+
}
241+
242+
// TODO: oneOf for parameters, requestBody and responses
243+
244+
// For invalid and proxy request, we only save its response
245+
if (statusCode >= 300) {
246+
Object.assign(
247+
this.items[url][method]!.responses,
248+
methodContent.responses
249+
);
250+
return;
251+
}
252+
253+
const newResponses = Object.assign(
254+
{},
255+
this.items[url][method]!.responses,
256+
methodContent.responses
257+
);
258+
this.items[url][method] = Object.assign(
259+
{},
260+
methodContent,
261+
{ responses: newResponses }
262+
);
263+
}
264+
265+
public addAPIItem(
266+
serverResponse: ServerResponseArg,
267+
resBody?: ObjectForResBodyBufferItem
268+
): void {
269+
const statusCode = serverResponse.statusCode;
270+
const url = this.extractURL(serverResponse);
271+
if (!url) throw new Error('No url found');
272+
const method = this.extractMethod(serverResponse);
273+
const operationObj: OpenAPIV3_1.OperationObject = {};
274+
275+
if (statusCode < 400) {
276+
const headers = this.extractHeaders(serverResponse);
277+
const queries = this.extractQueries(serverResponse);
278+
const params = this.extractParams(serverResponse);
279+
const parameters = headers.concat(queries).concat(params);
280+
if (parameters.length > 0) {
281+
operationObj.parameters = parameters;
282+
}
283+
284+
const requestBody = this.extractRequestBody(serverResponse);
285+
if (requestBody) {
286+
operationObj.requestBody = requestBody;
287+
}
288+
}
289+
290+
const responses = this.extractResponseItem(serverResponse, resBody);
291+
operationObj.responses = responses;
292+
293+
this.insertNewAPIItem(url, method, operationObj, statusCode);
294+
}
295+
296+
public getItems(): Record<API_URL, OpenAPIV3_1.PathItemObject> {
297+
return this.items;
298+
}
299+
}
300+

src/APIGenerator.js

Whitespace-only changes.
File renamed without changes.

0 commit comments

Comments
 (0)