Skip to content

Commit d727001

Browse files
committedAug 24, 2022
first version that works
1 parent de8726d commit d727001

13 files changed

+312
-113
lines changed
 

‎.eslintignore

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

‎.npmignore

+3-1
Original file line numberDiff line numberDiff line change
@@ -1 +1,3 @@
1-
src/
1+
src/
2+
test/
3+
tsconfig.json

‎bin/outdoc.js

+27
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
#!/usr/bin/env node
2+
3+
'use strict';
4+
5+
const { Command } = require('commander');
6+
const { runner } = require('../lib/index');
7+
8+
const main = async () => {
9+
const program = new Command();
10+
// TODO: more options or config in package.json
11+
program
12+
.name('outdoc')
13+
.description('Generate OpenAPI document from local testing')
14+
.usage("[command running test] [options]")
15+
.option('-o, --output', 'file path of the generated doc, format supports json and yaml, default: api.yaml')
16+
.option('-t, --title <string>', 'title of the api document, default: API Document')
17+
.option('-v, --version <string>', 'version of the api document, default: 1.0.0')
18+
.option('-e, --email <string>', 'contact information')
19+
.parse();
20+
21+
const args = program.args;
22+
const opts = program.opts();
23+
24+
await runner(args, opts);
25+
};
26+
27+
main();

‎package.json

+7-2
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,19 @@
11
{
22
"name": "outdoc",
3-
"version": "0.0.1",
3+
"version": "0.0.19",
44
"description": "Auto-generate OpenAPI document for Node.js service from the local testing",
55
"main": "lib/index.js",
66
"typings": "lib/index.d.ts",
7+
"bin": {
8+
"outdoc": "./bin/outdoc.js"
9+
},
710
"scripts": {
811
"lint": "eslint .",
912
"lint-fix": "eslint . --fix",
1013
"test": "env TS_NODE_COMPILER_OPTIONS='{\"module\": \"commonjs\" }' mocha -r ts-node/register '**/*.test.*'",
1114
"prebuild": "rimraf lib",
12-
"build": "tsc --build"
15+
"build": "tsc --build",
16+
"prepublishOnly": "npm run build"
1317
},
1418
"repository": {
1519
"type": "git",
@@ -45,6 +49,7 @@
4549
"dependencies": {
4650
"commander": "^9.4.0",
4751
"content-type": "^1.0.4",
52+
"js-yaml": "^4.1.0",
4853
"qs": "^6.11.0"
4954
}
5055
}

‎src/APICollector.interface.ts

+13
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import type { OpenAPIV3_1 } from 'openapi-types';
2+
import type { ServerResponseArg, ObjectForResBodyBufferItem } from './types/asyncEventTypes';
3+
4+
export type API_URL = string
5+
6+
export interface APICollectorInterface {
7+
addAPIItem(
8+
serverResponse: ServerResponseArg,
9+
resBody?: ObjectForResBodyBufferItem
10+
): void;
11+
12+
getItems(): Record<API_URL, OpenAPIV3_1.PathItemObject>;
13+
}

‎src/APICollector.ts

+6-10
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,9 @@ import qs from 'qs';
33
import contentType from 'content-type';
44
import type { OpenAPIV3_1 } from 'openapi-types';
55
import type { ServerResponseArg, ObjectForResBodyBufferItem } from './types/asyncEventTypes';
6+
import type { API_URL, APICollectorInterface } from './APICollector.interface';
67

7-
type API_URL = string
8-
9-
export default class APICollector {
8+
export default class APICollector implements APICollectorInterface {
109
private items: Record<API_URL, OpenAPIV3_1.PathItemObject>;
1110

1211
constructor () {
@@ -60,9 +59,9 @@ export default class APICollector {
6059
let path = parsedURL.pathname;
6160
if (!path) return;
6261
if (serverRes.req.params) {
63-
// convert /path/xxx-xxx-xxx-xxx => /path/:id
62+
// convert /path/xxx-xxx-xxx-xxx => /path/{id}
6463
Object.entries(serverRes.req.params).forEach(([key, value]) => {
65-
path = path!.replace(value, `:${key}`);
64+
path = path!.replace(value, `{${key}}`);
6665
});
6766
}
6867
return path;
@@ -83,16 +82,12 @@ export default class APICollector {
8382
}
8483

8584
private extractHeaders (serverRes: ServerResponseArg): Array<OpenAPIV3_1.ParameterObject> {
86-
const excludeHeaders = ['host', 'user-agent', 'content-length'];
85+
const excludeHeaders = ['host', 'user-agent', 'content-length', 'content-type', 'accept'];
8786
const headers = this.genHeaderObjFromRawHeaders(
8887
serverRes.req.rawHeaders,
8988
{ exclude: excludeHeaders }
9089
);
9190
return Object.entries(headers)
92-
.filter(([key, value]) => {
93-
if (key.toLowerCase() === 'accept' && value === "*/*") return false;
94-
return true;
95-
})
9691
.map(([key, value]) => ({
9792
in: 'header',
9893
name: key,
@@ -126,6 +121,7 @@ export default class APICollector {
126121
return Object.entries(serverRes.req.params).map(([key, value]) => ({
127122
in: 'path',
128123
name: key,
124+
required: true,
129125
example: value,
130126
schema: {
131127
type: 'string'

‎src/APIGenerator.ts

+43-2
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,46 @@
1+
import { mkdir, writeFile } from 'fs/promises';
2+
import path from 'path';
3+
4+
import type { OpenAPIV3_1 } from 'openapi-types';
5+
import type { API_URL, APICollectorInterface } from './APICollector.interface';
6+
7+
type GenDocOpts = {
8+
output?: string,
9+
title?: string,
10+
version?: string,
11+
email?: string
12+
}
13+
14+
const SUPPORTED_FORMAT = ['json', 'yaml'];
15+
116
export default class APIGenerator {
2-
public generate (): void {
3-
console.log('here')
17+
public static async generate (
18+
apiCollector: APICollectorInterface,
19+
opts: GenDocOpts
20+
): Promise<void> {
21+
const apiItems = apiCollector.getItems();
22+
const paths = Object.entries(apiItems)
23+
.reduce((acc, cur) => {
24+
const key: API_URL = cur[0];
25+
const value: OpenAPIV3_1.PathItemObject = cur[1];
26+
acc[key] = value;
27+
return acc;
28+
}, {} as Record<string, any>);
29+
const info = {
30+
title: opts.title || 'API Document',
31+
version: opts.version || '1.0.0',
32+
... opts.email && { contact: { email: opts.email } }
33+
};
34+
const apiDoc: OpenAPIV3_1.Document = {
35+
openapi: '3.0.1',
36+
info,
37+
paths
38+
};
39+
40+
const output = opts.output || 'api.json';
41+
const fileFormat = path.extname(output);
42+
// TODO: change format based on fileFormat
43+
await mkdir(path.dirname(output), { recursive: true });
44+
await writeFile(output, JSON.stringify(apiDoc, null, 2));
445
}
546
}

‎src/RequestHook.ts

+93-48
Original file line numberDiff line numberDiff line change
@@ -1,71 +1,116 @@
1-
import async_hooks, { AsyncHook } from 'async_hooks';
1+
import {
2+
PREFIX_RESPONSE_BODY_DATA,
3+
PREFIX_SERVER_RESPONSE
4+
} from './constants';
25
import type {
3-
ObjectForResBodyArg,
46
ObjectForResBodyBufferItem,
57
ServerResponseArg
68
} from './types/asyncEventTypes';
7-
import APICollector from './APICollector';
9+
import type { APICollectorInterface } from './APICollector.interface';
810

911
/**
1012
* The response body data comes before ServerResponse event
1113
* The ServerResponse event will tell which asyncId it triggered
12-
* By this way we can get the full API data including req and res
14+
* By this way we can get the full response
1315
*/
1416
type EventMap = Record<number, ObjectForResBodyBufferItem | undefined>
1517

18+
type ResponseBodyDataFromChildProcess = {
19+
asyncId: number,
20+
data: ObjectForResBodyBufferItem
21+
}
22+
23+
type ServerResponseFromChildProcess = {
24+
triggerAsyncId: number,
25+
data: ServerResponseArg
26+
}
27+
1628
export default class RequestHook {
17-
private asyncHook: AsyncHook | null;
1829
private eventMap: EventMap;
19-
private apiCollector: APICollector;
2030

21-
constructor () {
22-
this.asyncHook = null;
23-
this.eventMap = {};
24-
this.apiCollector = new APICollector();
25-
}
31+
public static getInjectedCodes (): string {
32+
return `
33+
const async_hooks = require('async_hooks')
34+
const asyncHook = async_hooks.createHook({ init });
35+
asyncHook.enable();
2636
27-
public enable (): void {
28-
this.asyncHook = async_hooks.createHook({ init: this.init });
29-
this.asyncHook.enable();
30-
}
37+
function init(asyncId, type, triggerAsyncId, resource) {
38+
if (type === "TickObject" && resource.args) {
39+
const className = resource.args?.[0]?.constructor.name;
3140
32-
public disable (): void {
33-
if (this.asyncHook) {
34-
this.asyncHook.disable();
35-
this.asyncHook = null;
36-
}
37-
}
41+
// Response body data
42+
if (className === "Object") {
43+
const arg = resource.args[0]
44+
if (arg?.stream?.server && arg?.state?.buffered) {
45+
const dataItem = arg.state.buffered.find(item => {
46+
if (!item) return false
47+
return ['buffer', 'utf-8'].includes(item.encoding)
48+
})
49+
if (dataItem) {
50+
const chunk = dataItem.encoding === 'buffer'
51+
? dataItem.chunk.toString()
52+
: dataItem.chunk
53+
const res = {
54+
asyncId,
55+
data: {
56+
encoding: dataItem.encoding,
57+
chunk
58+
}
59+
}
60+
console.log("${PREFIX_RESPONSE_BODY_DATA}" + JSON.stringify(res))
61+
}
62+
}
63+
}
3864
39-
private extractResBody (arg: ObjectForResBodyArg): ObjectForResBodyBufferItem | undefined {
40-
return arg.state.buffered.find(item => ['buffer', 'utf-8'].includes(item.encoding));
65+
// Server response
66+
if (className === "ServerResponse") {
67+
const arg = resource.args[0];
68+
const res = {
69+
triggerAsyncId,
70+
data: {
71+
_header: arg._header,
72+
statusCode: arg.statusCode,
73+
statusMessage: arg.statusMessage,
74+
req: {
75+
rawHeaders: arg.req.rawHeaders,
76+
url: arg.req.url,
77+
method: arg.req.method,
78+
params: arg.req.params,
79+
query: arg.req.query,
80+
baseUrl: arg.req.baseUrl,
81+
originalUrl: arg.req.originalUrl,
82+
body: arg.req.body
83+
}
84+
}
85+
}
86+
if (arg.req._readableState?.buffer?.head?.data) {
87+
res.data.req._readableState = {
88+
buffer: {
89+
head: {
90+
data: arg.req._readableState.buffer.head.data.toString()
91+
}
92+
}
93+
}
94+
}
95+
console.log("${PREFIX_SERVER_RESPONSE}" + JSON.stringify(res))
96+
}
97+
}
98+
}
99+
`;
41100
}
42101

43-
private init (
44-
asyncId: number,
45-
type: string,
46-
triggerAsyncId: number,
47-
resource: {
48-
args: Array<ObjectForResBodyArg | ServerResponseArg>
49-
}
50-
): void {
51-
if (type === "TickObject" && resource.args) {
52-
const className = resource.args?.[0]?.constructor.name;
102+
constructor (private apiCollector: APICollectorInterface) {
103+
this.eventMap = {};
104+
}
53105

54-
// Check if it is response data, save it into eventMap
55-
if (className === "Object") {
56-
const myArg = resource.args[0] as ObjectForResBodyArg;
57-
if (myArg?.stream?.server) {
58-
this.eventMap[asyncId] = this.extractResBody(myArg);
59-
}
60-
}
106+
public handleResponseBodyData (res: ResponseBodyDataFromChildProcess): void {
107+
this.eventMap[res.asyncId] = res.data;
108+
}
61109

62-
// Check if it is server response, pass it to APICollector
63-
if (className === "ServerResponse") {
64-
const myArg = resource.args[0] as ServerResponseArg;
65-
const responseBodyData = this.eventMap[triggerAsyncId];
66-
this.apiCollector.addAPIItem(myArg, responseBodyData);
67-
delete this.eventMap[triggerAsyncId];
68-
}
69-
}
110+
public handleServerResponse (res: ServerResponseFromChildProcess): void {
111+
const triggerAsyncId = res.triggerAsyncId;
112+
const responseBodyData = this.eventMap[triggerAsyncId];
113+
this.apiCollector.addAPIItem(res.data, responseBodyData);
114+
delete this.eventMap[triggerAsyncId];
70115
}
71116
}

‎src/constants.ts

+2
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export const PREFIX_RESPONSE_BODY_DATA = 'outdoc response body data -';
2+
export const PREFIX_SERVER_RESPONSE = 'outdoc server response -';

0 commit comments

Comments
 (0)
Please sign in to comment.