Skip to content

Commit 5e9034e

Browse files
committed
init
0 parents  commit 5e9034e

File tree

4 files changed

+351
-0
lines changed

4 files changed

+351
-0
lines changed

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
node_modules/
2+
.git

README.md

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
#### Installation
2+
3+
`npm i jest-doc`
4+
5+
#### Configuration
6+
7+
Creates [apidoc.js](https://apidocjs.com/) documentation from jest tests
8+
9+
Create a jest-apidoc.json at project root with the following structure
10+
```
11+
{
12+
"outFolder": string, // path to write apidoc to
13+
"ignoreRoutes": string[], // array of routes to ignore
14+
}
15+
```
16+
17+
From your tests, call `setApiResponse({ method, params, status, response })`. I recommend writing an api request wrapper like so (uses supertest):
18+
```
19+
const request = require('supertest');
20+
const { setApiResponse } = require('jest-apidoc');
21+
const server = require('./app');
22+
23+
const apiRequest = async (
24+
method,
25+
url,
26+
params = {},
27+
) => {
28+
const loadedServer = request(server);
29+
let requestBuilder;
30+
31+
switch (method) {
32+
case GET:
33+
requestBuilder = loadedServer.get(url).query(params);
34+
break;
35+
case POST:
36+
requestBuilder = loadedServer.post(url).send(params);
37+
break;
38+
case DELETE:
39+
requestBuilder = loadedServer.delete(url).send(params);
40+
break;
41+
case PUT:
42+
requestBuilder = loadedServer.put(url).send(params);
43+
break;
44+
case PATCH:
45+
requestBuilder = loadedServer.patch(url).send(params);
46+
break;
47+
default:
48+
throw new Error(`No method for: ${method}`);
49+
}
50+
51+
return requestBuilder
52+
.set('Content-Type', 'application/json')
53+
.then((res) => {
54+
setApiResponse({ method, params, status: res.statusCode, response });
55+
return res;
56+
});
57+
};
58+
```
59+
60+
Expects tests to be structured like:
61+
```
62+
describe('some-route.routes.js`, () => {
63+
describe('GET /some-route', () => {
64+
test('some functionality', async () => {
65+
...
66+
});
67+
});
68+
});
69+
```
70+
71+
In your [test setup file](https://jestjs.io/docs/en/configuration#setupfilesafterenv-array), add the `writeApiDoc()` in the `afterAll()`
72+
73+
```
74+
const { writeApiDoc } = require('jest-apidoc');
75+
76+
afterAll(() => {
77+
writeApiDoc();
78+
})
79+
```
80+
Above will write all response set through `writeApiResponse()` to the `outFolder` of `jest-apidoc.json`

index.js

Lines changed: 254 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,254 @@
1+
const fs = require("fs");
2+
const { outFolder, ignoreRoutes } = require("../../jest-apidoc.json");
3+
4+
// prettier-ignore
5+
const baseSuccessfulExample =
6+
`@apiSuccessExample Success-Response:
7+
HTTP/1.1 {{status}}{{response}}`;
8+
9+
// prettier-ignore
10+
const baseErrorExample =
11+
`@apiErrorExample {json} Error-Response:
12+
HTTP/1.1 {{status}}{{response}}`;
13+
14+
// prettier-ignore
15+
const baseApiDoc =
16+
`/**
17+
@api {{{method}}} {{route}} {{routeTitle}}
18+
@apiName {{apiName}}
19+
@apiGroup {{apiGroup}}
20+
{{params}}
21+
{{successResponses}}{{errorResponses}}*/
22+
`;
23+
24+
let lastTestName = "";
25+
let lastDescribeName = "";
26+
27+
const originalTest = global.test;
28+
global.test = function (name, callback, timeout) {
29+
lastTestName = name;
30+
originalTest(name, callback, timeout);
31+
};
32+
33+
const originalIt = global.it;
34+
global.it = function (name, callback, timeout) {
35+
lastTestName = name;
36+
originalIt(name, callback, timeout);
37+
};
38+
39+
const originalDescribe = global.describe;
40+
global.describe = function (name, callback) {
41+
lastDescribeName = name;
42+
originalDescribe(name, callback);
43+
};
44+
45+
/**
46+
* current test name to be structured like "admin-session.routes PUT /admin/vendors/:vendorId updates and returns vendor"
47+
* parse out PUT /admin/vendors/:vendorId
48+
*/
49+
const getDocNameFromTestName = (currentTestName) => {
50+
const parts = currentTestName.split(" ");
51+
return `${parts[2]}`;
52+
};
53+
54+
const getGroupNameFromTestName = (currentTestName) => {
55+
const parts = currentTestName.split(" ");
56+
return parts[0].replace(".routes", "").replace("-", " ");
57+
};
58+
59+
const getParamType = (paramValue) => {
60+
if (Array.isArray(paramValue) && paramValue.length > 0) {
61+
return `${typeof paramValue[0]}[]`;
62+
}
63+
64+
if (paramValue && paramValue.constructor) {
65+
const { name } = paramValue.constructor;
66+
return name.toLowerCase();
67+
}
68+
69+
return typeof paramValue;
70+
};
71+
72+
const createParams = (params) => {
73+
const paramsKeys = Object.keys(params);
74+
75+
if (paramsKeys.length === 0) {
76+
return "";
77+
}
78+
79+
let paramString = "";
80+
81+
paramsKeys.forEach((key) => {
82+
const paramType = getParamType(params[key]);
83+
paramString += ` @apiParam {${paramType}} ${key}\n`;
84+
});
85+
86+
return `\n${paramString}`;
87+
};
88+
89+
const createResponse = (response) => {
90+
if (!response) {
91+
return "";
92+
}
93+
94+
delete response.status;
95+
const jsonResponse = JSON.stringify(response, null, 2);
96+
return `\n ${jsonResponse}\n`;
97+
};
98+
99+
/**
100+
* apiCalls: {
101+
* [route]: {
102+
* [method]: {
103+
* docName,
104+
* apiGroup,
105+
* apiName,
106+
* routeTitle,
107+
* lastDescribeName,
108+
* lastTestName,
109+
* params: {},
110+
* statuses: {
111+
* [status]: {
112+
* response: {},
113+
* }
114+
* },
115+
* },
116+
* },
117+
* }
118+
*/
119+
const apiCalls = {};
120+
121+
const populateUndefined = ({ method, route, status }) => {
122+
if (!apiCalls[route]) {
123+
apiCalls[route] = {};
124+
}
125+
126+
if (!apiCalls[route][method]) {
127+
apiCalls[route][method] = {
128+
params: {},
129+
statuses: {},
130+
};
131+
apiCalls[route][method].statuses = {};
132+
}
133+
134+
if (!apiCalls[route][method].statuses[status]) {
135+
apiCalls[route][method].statuses[status] = {
136+
response: {},
137+
};
138+
}
139+
};
140+
141+
const setApiResponse = ({ method, params, status, response }) => {
142+
const jestState = expect.getState();
143+
144+
const { currentTestName } = jestState;
145+
const apiGroup = getGroupNameFromTestName(currentTestName);
146+
const apiName = `${method.toUpperCase()} ${apiGroup}`;
147+
const routeToWrite = lastDescribeName.split(" ")[1] || lastDescribeName;
148+
149+
if (!ignoreRoutes.includes(routeToWrite)) {
150+
populateUndefined({ method, route: routeToWrite, status });
151+
152+
const { params: currentParams } = apiCalls[routeToWrite][method];
153+
const { response: currentResponse } = apiCalls[routeToWrite][
154+
method
155+
].statuses[status];
156+
157+
const paramsToWrite =
158+
currentParams &&
159+
Object.keys(currentParams).length > Object.keys(params).length
160+
? currentParams
161+
: params;
162+
const responseToWrite =
163+
currentResponse &&
164+
Object.keys(currentResponse).length > Object.keys(response).length
165+
? currentResponse
166+
: response;
167+
168+
// @todo check response size too
169+
170+
apiCalls[routeToWrite][method] = {
171+
docName: getDocNameFromTestName(currentTestName),
172+
apiGroup,
173+
apiName,
174+
routeTitle: apiName,
175+
lastDescribeName,
176+
lastTestName,
177+
params: paramsToWrite,
178+
statuses: {
179+
...apiCalls[routeToWrite][method].statuses,
180+
[status]: {
181+
response: responseToWrite,
182+
},
183+
},
184+
};
185+
}
186+
};
187+
188+
const writeApiDoc = () => {
189+
// Create apidoc if non-existent
190+
if (!fs.existsSync(outFolder)) {
191+
fs.mkdirSync(outFolder);
192+
}
193+
194+
const routes = Object.keys(apiCalls);
195+
196+
routes.forEach((apiRoute) => {
197+
const methods = Object.keys(apiCalls[apiRoute]);
198+
methods.forEach((apiMethod) => {
199+
const { docName, apiGroup, apiName, routeTitle, params } = apiCalls[
200+
apiRoute
201+
][apiMethod];
202+
203+
const statuses = Object.keys(apiCalls[apiRoute][apiMethod].statuses);
204+
205+
const successResponseDocs = [];
206+
const errorResponseDocs = [];
207+
const paramsString = createParams(params);
208+
209+
statuses.forEach((apiStatus) => {
210+
const { response } = apiCalls[apiRoute][apiMethod].statuses[apiStatus];
211+
212+
const responseString = createResponse(response);
213+
214+
if (apiStatus < 400) {
215+
successResponseDocs.push(
216+
baseSuccessfulExample
217+
.replace(/{{status}}/g, apiStatus)
218+
.replace(/{{response}}/g, responseString)
219+
);
220+
} else {
221+
errorResponseDocs.push(
222+
baseErrorExample
223+
.replace(/{{status}}/g, apiStatus)
224+
.replace(/{{response}}/g, responseString)
225+
);
226+
}
227+
});
228+
229+
const apiDoc = baseApiDoc
230+
.replace(/{{routeTitle}}/g, routeTitle)
231+
.replace(/{{params}}/g, paramsString)
232+
.replace(/{{apiName}}/g, apiName)
233+
.replace(/{{apiGroup}}/g, apiGroup)
234+
.replace(/{{method}}/g, apiMethod.toUpperCase())
235+
.replace(/{{route}}/g, docName)
236+
.replace(/{{successResponses}}/g, successResponseDocs.join("\n"))
237+
.replace(/{{errorResponses}}/g, errorResponseDocs.join("\n"));
238+
239+
const path = (docName.charAt(0) === "/" ? docName : `/${docName}`)
240+
.replace(/\//g, "-")
241+
.replace(/:/g, "");
242+
243+
fs.writeFileSync(
244+
`${outFolder}/${apiMethod.toUpperCase()}-${path}.js`,
245+
apiDoc
246+
);
247+
});
248+
});
249+
};
250+
251+
module.exports = {
252+
setApiResponse,
253+
writeApiDoc,
254+
};

package.json

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
{
2+
"name": "jest-apidoc",
3+
"version": "1.0.0",
4+
"description": "create apidoc from jest route tests",
5+
"main": "index.js",
6+
"scripts": {
7+
"test": "echo \"Error: no test specified\" && exit 1"
8+
},
9+
"repository": {
10+
"type": "git",
11+
"url": "git+bitbucket.org:Kyle-Richardson/jest-apidoc.git"
12+
},
13+
"author": "Kyle Richardson",
14+
"license": "ISC"
15+
}

0 commit comments

Comments
 (0)