serverless-openapi-documenter 0.0.1

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.
@@ -0,0 +1,304 @@
1
+ 'use strict'
2
+
3
+ const { v4: uuid } = require('uuid')
4
+ const validator = require('oas-validator');
5
+ const SchemaConvertor = require('json-schema-for-openapi')
6
+
7
+ class DefinitionGenerator {
8
+ constructor(serverless, options = {}) {
9
+ this.version = options.v || '3.0.0'
10
+
11
+ this.serverless = serverless
12
+ this.httpKeys = {
13
+ http: 'http',
14
+ httpAPI: 'httpApi',
15
+ }
16
+
17
+ this.componentsSchemas = {
18
+ requestBody: 'requestBodies',
19
+ responses: 'responses',
20
+ }
21
+
22
+ this.openAPI = {
23
+ openapi: this.version,
24
+ }
25
+ }
26
+
27
+ parse() {
28
+ this.createInfo()
29
+ this.createPaths()
30
+ }
31
+
32
+ createInfo() {
33
+ const service = this.serverless.service
34
+ const documentation = this.serverless.service.custom.documentation;
35
+
36
+ const info = {
37
+ title: service.service,
38
+ description: documentation?.description || '',
39
+ version: documentation?.version || uuid(),
40
+ }
41
+ Object.assign(this.openAPI, {info})
42
+ }
43
+
44
+ createPaths() {
45
+ const paths = {}
46
+ const httpFunctions = this.getHTTPFunctions()
47
+
48
+ for (const httpFunction of httpFunctions) {
49
+ for (const event of httpFunction.event) {
50
+ if (event?.http?.documentation || event?.httpApi?.documentation) {
51
+ const documentation = event.http.documentation || event.httpApi.documentation
52
+
53
+ const path = this.createOperationObject(event.http.method || event.httpApi.method, documentation, httpFunction.functionInfo.name)
54
+ if (httpFunction.functionInfo?.summary)
55
+ path.summary = httpFunction.functionInfo.summary
56
+
57
+ if (httpFunction.functionInfo?.description)
58
+ path.description = httpFunction.functionInfo.description
59
+
60
+ Object.assign(paths, {[`/${event.http.path}`]: path})
61
+ }
62
+ }
63
+ }
64
+ Object.assign(this.openAPI, {paths})
65
+ }
66
+
67
+ createOperationObject(method, documentation, name = uuid()) {
68
+ const obj = {
69
+ summary: documentation.summary || '',
70
+ description: documentation.description || '',
71
+ operationId: documentation.operationId || name,
72
+ parameters: [],
73
+ tags: documentation.tags || []
74
+ }
75
+
76
+ if (documentation.pathParams) {
77
+ const paramObject = this.createParamObject('path', documentation)
78
+ obj.parameters = obj.parameters.concat(paramObject)
79
+ }
80
+
81
+ if (documentation.queryParams) {
82
+ const paramObject = this.createParamObject('query', documentation)
83
+ obj.parameters = obj.parameters.concat(paramObject)
84
+ }
85
+
86
+ if (documentation.headerParams) {
87
+ const paramObject = this.createParamObject('header', documentation)
88
+ obj.parameters = obj.parameters.concat(paramObject)
89
+ }
90
+
91
+ if (documentation.cookieParams) {
92
+ const paramObject = this.createParamObject('cookie', documentation)
93
+ obj.parameters = obj.parameters.concat(paramObject)
94
+ }
95
+
96
+ if (Object.keys(documentation).includes('deprecated'))
97
+ obj[method].deprecated = documentation.deprecated
98
+
99
+ if (documentation.requestBody)
100
+ obj.requestBody = this.createRequestBody(documentation)
101
+
102
+ if (documentation.methodResponses)
103
+ obj.responses = this.createResponses(documentation)
104
+
105
+ return {[method]: obj}
106
+ }
107
+
108
+ createResponses(documentation) {
109
+ const responses = {}
110
+ for (const response of documentation.methodResponses) {
111
+ const obj = {
112
+ description: response.responseBody.description || '',
113
+ }
114
+
115
+ obj.content = this.createMediaTypeObject(response.responseModels, 'responses')
116
+
117
+ Object.assign(responses,{[response.statusCode]: obj})
118
+ }
119
+
120
+ return responses
121
+ }
122
+
123
+ createRequestBody(documentation) {
124
+ const obj = {
125
+ description: documentation.requestBody.description,
126
+ required: documentation.requestBody.required || false,
127
+ }
128
+
129
+ obj.content = this.createMediaTypeObject(documentation.requestModels, 'requestBody')
130
+
131
+ return obj
132
+ }
133
+
134
+ createMediaTypeObject(models, type) {
135
+ const mediaTypeObj = {}
136
+ for (const mediaTypeDocumentation of this.serverless.service.custom.documentation.models) {
137
+ if (Object.values(models).includes(mediaTypeDocumentation.name)) {
138
+ let contentKey = ''
139
+ for (const [key, value] of Object.entries(models)) {
140
+ if (value === mediaTypeDocumentation.name)
141
+ contentKey = key;
142
+ }
143
+ const obj = {}
144
+
145
+ if (mediaTypeDocumentation.example)
146
+ obj.example = mediaTypeDocumentation.example
147
+
148
+ if (mediaTypeDocumentation.examples)
149
+ obj.examples = this.createExamples(mediaTypeDocumentation.examples)
150
+
151
+ if (mediaTypeDocumentation.content[contentKey].schema) {
152
+ const schema = SchemaConvertor.convert(mediaTypeDocumentation.content[contentKey].schema)
153
+ for (const key of Object.keys(schema.schemas)) {
154
+ if (key === 'main' || key.split('-')[0] === 'main') {
155
+ obj.schema = {
156
+ $ref: `#/components/schemas/${mediaTypeDocumentation.name}`
157
+ }
158
+
159
+ if (this.openAPI?.components) {
160
+ if (this.openAPI.components?.schemas) {
161
+ const schemaObj = {
162
+ [mediaTypeDocumentation.name]: schema.schemas[key]
163
+ }
164
+ Object.assign(this.openAPI.components.schemas, schemaObj)
165
+ } else {
166
+ const schemaObj = {
167
+ [mediaTypeDocumentation.name]: schema.schemas[key]
168
+ }
169
+ Object.assign(this.openAPI.components, {schemas: schemaObj})
170
+ }
171
+ } else {
172
+ const components = {
173
+ components: {
174
+ schemas: {
175
+ [mediaTypeDocumentation.name]: schema.schemas[key]
176
+ }
177
+ }
178
+ }
179
+ Object.assign(this.openAPI, components)
180
+ }
181
+ } else {
182
+ if (this.openAPI?.components) {
183
+ if (this.openAPI.components?.schemas) {
184
+ const schemaObj = {
185
+ [key]: schema.schemas[key]
186
+ }
187
+ Object.assign(this.openAPI.components.schemas, schemaObj)
188
+ } else {
189
+ const schemaObj = {
190
+ [key]: schema.schemas[key]
191
+ }
192
+ Object.assign(this.openAPI.components, {schemas: schemaObj})
193
+ }
194
+ } else {
195
+ const components = {
196
+ components: {
197
+ schemas: {
198
+ [key]: schema.schemas[key]
199
+ }
200
+ }
201
+ }
202
+ Object.assign(this.openAPI, components)
203
+ }
204
+ }
205
+ }
206
+ }
207
+
208
+ Object.assign(mediaTypeObj, {[contentKey]: obj})
209
+ }
210
+ }
211
+ return mediaTypeObj
212
+ }
213
+
214
+ createParamObject(paramIn, documentation) {
215
+ const params = []
216
+ for (const param of documentation[`${paramIn}Params`]) {
217
+ const obj = {
218
+ name: param.name,
219
+ in: paramIn,
220
+ description: param.description || '',
221
+ required: (paramIn === 'path') ? true : param.required || false,
222
+ }
223
+
224
+ if (Object.keys(param).includes('deprecated')) {
225
+ obj.deprecated = param.deprecated
226
+ }
227
+
228
+ if (paramIn === 'query' && Object.keys(param).includes('allowEmptyValue')) {
229
+ obj.allowEmptyValue = param.allowEmptyValue
230
+ }
231
+
232
+ if (param.style)
233
+ obj.style = param.style
234
+
235
+ if (param.explode)
236
+ obj.explode = param.explode
237
+
238
+ if (paramIn === 'query' && param.allowReserved)
239
+ obj.allowReserved = param.allowReserved
240
+
241
+ if (param.example)
242
+ obj.example = param.example
243
+
244
+ if (param.examples)
245
+ obj.examples = this.createExamples(param.examples)
246
+
247
+ if (param.schema) {
248
+ const schema = SchemaConvertor.convert(param.schema)
249
+ if (schema.schemas.main) {
250
+ Object.assign(obj,{schema: schema.schemas.main})
251
+
252
+ }
253
+ }
254
+
255
+ params.push(obj)
256
+ }
257
+ return params;
258
+ }
259
+
260
+ createExamples(examples) {
261
+ const examplesObj = {}
262
+
263
+ for(const example of examples) {
264
+ Object.assign(examplesObj, {[example.name]: example})
265
+ }
266
+
267
+ return examplesObj;
268
+ }
269
+
270
+ getHTTPFunctions() {
271
+ const isHttpFunction = (funcType) => {
272
+ const keys = Object.keys(funcType)
273
+ if (keys.includes(this.httpKeys.http) || keys.includes(this.httpKeys.httpAPI))
274
+ return true
275
+ }
276
+ const functionNames = this.serverless.service.getAllFunctions()
277
+
278
+ return functionNames.map(functionName => {
279
+ return this.serverless.service.getFunction(functionName)
280
+ })
281
+ .filter(functionType => {
282
+ if (functionType?.events.some(isHttpFunction))
283
+ return functionType
284
+ })
285
+ .map(functionType => {
286
+ const event = functionType.events.filter(isHttpFunction)
287
+ return {
288
+ functionInfo: functionType,
289
+ handler: functionType.handler,
290
+ name: functionType.name,
291
+ event
292
+ }
293
+ })
294
+ }
295
+
296
+ async validate() {
297
+ return await validator.validateInner(this.openAPI, {})
298
+ .catch(err => {
299
+ throw err
300
+ })
301
+ }
302
+ }
303
+
304
+ module.exports = DefinitionGenerator
@@ -0,0 +1,210 @@
1
+ 'use strict'
2
+
3
+ const fs = require('fs')
4
+ const yaml = require('js-yaml');
5
+ const chalk = require('chalk')
6
+
7
+ const DefinitionGenerator = require('./definitionGenerator')
8
+ const PostmanGenerator = require('openapi-to-postmanv2')
9
+
10
+ class OpenAPIGenerator {
11
+ constructor(serverless, options, {log = {}} = {}) {
12
+ this.logOutput = log;
13
+ this.serverless = serverless
14
+ this.options = options
15
+ this.defaultLog = 'notice';
16
+ this.commands = {
17
+ openapi: {
18
+ commands: {
19
+ generate: {
20
+ lifecycleEvents: [
21
+ 'serverless',
22
+ ],
23
+ usage: 'Generate OpenAPI v3 Documentation',
24
+ options: {
25
+ output: {
26
+ usage: 'Output file location [default: openapi.json|yml]',
27
+ shortcut: 'o',
28
+ type: 'string',
29
+ },
30
+ format: {
31
+ usage: 'OpenAPI file format (yml|json) [default: json]',
32
+ shortcut: 'f',
33
+ type: 'string',
34
+ },
35
+ indent: {
36
+ usage: 'File indentation in spaces [default: 2]',
37
+ shortcut: 'i',
38
+ type: 'string',
39
+ },
40
+ openApiVersion: {
41
+ usage: 'OpenAPI version number [default 3.0.0]',
42
+ shortcut: 'a',
43
+ type: 'string'
44
+ },
45
+ postmanCollection: {
46
+ usage: 'Output a postman collection and attach to openApi external documents [default: postman.json if passed]',
47
+ shortcut: 'p',
48
+ type: 'string'
49
+ }
50
+ },
51
+ },
52
+ },
53
+ },
54
+ }
55
+
56
+ this.hooks = {
57
+ // 'before:deploy': this.beforeDeploy.bind(this),
58
+ 'openapi:generate:serverless': this.generate.bind(this),
59
+ };
60
+
61
+ this.customVars = this.serverless.variables.service.custom;
62
+
63
+ this.serverless.configSchemaHandler.defineFunctionEventProperties('aws', 'http', {
64
+ properties: {
65
+ documentation: { type: 'object' },
66
+ },
67
+ required: ['documentation'],
68
+ });
69
+
70
+ this.serverless.configSchemaHandler.defineFunctionProperties('aws', {
71
+ properties: {
72
+ // description: {type: 'string'},
73
+ summary: {type: 'string'}
74
+ }
75
+ })
76
+ }
77
+
78
+ log(type = this.defaultLog, ...str) {
79
+ switch(this.serverless.version[0]) {
80
+ case '2':
81
+ this.serverless.cli.log(str)
82
+ break
83
+
84
+ case '3':
85
+ this.logOutput[type](str)
86
+ break
87
+
88
+ default:
89
+ process.stdout.write(str.join(' '))
90
+ break
91
+ }
92
+ }
93
+
94
+ async generate() {
95
+ this.log(this.defaultLog, chalk.bold.underline('OpenAPI v3 Document Generation'))
96
+ const config = this.processCliInput()
97
+ const generator = new DefinitionGenerator(this.serverless);
98
+
99
+ generator.parse();
100
+
101
+ const valid = await generator.validate()
102
+ .catch(err => {
103
+
104
+ this.log('error', chalk.bold.red(`ERROR: An error was thrown generation the OpenAPI v3 documentation`))
105
+ throw new this.serverless.classes.Error(err)
106
+ })
107
+
108
+ if (valid)
109
+ this.log(this.defaultLog, chalk.bold.green('OpenAPI v3 Documentation Successfully Generated'))
110
+
111
+ if (config.postmanCollection) {
112
+ const postmanGeneration = (err, result) => {
113
+ if (err) {
114
+ this.log('error', chalk.bold.red(`ERROR: An error was thrown when generating the postman collection`))
115
+ throw new this.serverless.classes.Error(err)
116
+ }
117
+
118
+ this.log(this.defaultLog, chalk.bold.green('postman collection v2 Documentation Successfully Generated'))
119
+ try {
120
+ fs.writeFileSync(config.postmanCollection, JSON.stringify(result.output[0].data))
121
+ this.log(this.defaultLog, chalk.bold.green('postman collection v2 Documentation Successfully Written'))
122
+ } catch (err) {
123
+ this.log('error', chalk.bold.red(`ERROR: An error was thrown whilst writing the postman collection`))
124
+ throw new this.serverless.classes.Error(err)
125
+ }
126
+ }
127
+
128
+ const postmanCollection = PostmanGenerator.convert(
129
+ {type: 'json', data: generator.openAPI},
130
+ {},
131
+ postmanGeneration
132
+ )
133
+ }
134
+
135
+ let output
136
+ switch (config.format.toLowerCase()) {
137
+ case 'json':
138
+ output = JSON.stringify(generator.openAPI, null, config.indent);
139
+ break;
140
+ case 'yaml':
141
+ default:
142
+ output = yaml.dump(generator.openAPI, { indent: config.indent });
143
+ break;
144
+ }
145
+ try {
146
+ fs.writeFileSync(config.file, output);
147
+ this.log(this.defaultLog, chalk.bold.green('OpenAPI v3 Documentation Successfully Written'))
148
+ } catch (err) {
149
+ this.log('error', chalk.bold.red(`ERROR: An error was thrown whilst writing the openAPI Documentation`))
150
+ throw new this.serverless.classes.Error(err)
151
+ }
152
+ }
153
+
154
+ processCliInput () {
155
+ const config = {
156
+ format: 'json',
157
+ file: 'openapi.json',
158
+ indent: 2,
159
+ openApiVersion: '3.0.0',
160
+ postmanCollection: 'postman.json'
161
+ };
162
+
163
+ config.indent = this.serverless.processedInput.options.indent || 2;
164
+ config.format = this.serverless.processedInput.options.format || 'json';
165
+ config.openApiVersion = this.serverless.processedInput.options.openApiVersion || '3.0.0';
166
+ config.postmanCollection = this.serverless.processedInput.options.postmanCollection || null
167
+
168
+ if (['yaml', 'json'].indexOf(config.format.toLowerCase()) < 0) {
169
+ throw new Error('Invalid Output Format Specified - must be one of "yaml" or "json"');
170
+ }
171
+
172
+ config.file = this.serverless.processedInput.options.output ||
173
+ ((config.format === 'yaml') ? 'openapi.yml' : 'openapi.json');
174
+
175
+ this.log(
176
+ this.defaultLog,
177
+ `${chalk.bold.green('[OPTIONS]')}`,
178
+ ` openApiVersion: "${chalk.bold.red(String(config.openApiVersion))}"`,
179
+ ` format: "${chalk.bold.red(config.format)}"`,
180
+ ` output file: "${chalk.bold.red(config.file)}"`,
181
+ ` indentation: "${chalk.bold.red(String(config.indent))}"`,
182
+ ` ${config.postmanCollection ? `postman collection: ${chalk.bold.red(config.postmanCollection)}`: `\n\n`}`
183
+ )
184
+
185
+ return config
186
+ }
187
+
188
+ validateDetails(validation) {
189
+ if (validation.valid) {
190
+ this.log(this.defaultLog, `${ chalk.bold.green('[VALIDATION]') } OpenAPI valid: ${chalk.bold.green('true')}\n\n`);
191
+ } else {
192
+ this.log(this.defaultLog, `${chalk.bold.red('[VALIDATION]')} Failed to validate OpenAPI document: \n\n`);
193
+ this.log(this.defaultLog, `${chalk.bold.green('Context:')} ${JSON.stringify(validation.context, null, 2)}\n`);
194
+ this.log(this.defaultLog, `${chalk.bold.green('Error Message:')} ${JSON.stringify(validation.error, null, 2)}\n`);
195
+ if (typeof validation.error === 'string') {
196
+ this.log(this.defaultLog, `${validation.error}\n\n`);
197
+ } else {
198
+ for (const info of validation.error) {
199
+ this.log(this.defaultLog, chalk.grey('\n\n--------\n\n'));
200
+ this.log(this.defaultLog, ' ', chalk.blue(info.dataPath), '\n');
201
+ this.log(this.defaultLog, ' ', info.schemaPath, chalk.bold.yellow(info.message));
202
+ this.log(this.defaultLog, chalk.grey('\n\n--------\n\n'));
203
+ this.log(this.defaultLog, `${inspect(info, { colors: true, depth: 2 })}\n\n`);
204
+ }
205
+ }
206
+ }
207
+ }
208
+ }
209
+
210
+ module.exports = OpenAPIGenerator
@@ -0,0 +1,118 @@
1
+ {
2
+ "$schema": "http://json-schema.org/draft-04/schema#",
3
+ "title": "JSON API Schema",
4
+ "description": "This is a schema for responses in the JSON API format. For more, see http://jsonapi.org",
5
+ "type": "object",
6
+ "required": [
7
+ "errors"
8
+ ],
9
+ "properties": {
10
+ "errors": {
11
+ "type": "array",
12
+ "items": {
13
+ "$ref": "#/definitions/error"
14
+ },
15
+ "uniqueItems": true
16
+ },
17
+ "meta": {
18
+ "$ref": "#/definitions/meta"
19
+ },
20
+ "links": {
21
+ "$ref": "#/definitions/links"
22
+ }
23
+ },
24
+ "additionalProperties": false,
25
+ "definitions": {
26
+ "meta": {
27
+ "description": "Non-standard meta-information that can not be represented as an attribute or relationship.",
28
+ "type": "object",
29
+ "additionalProperties": true
30
+ },
31
+ "links": {
32
+ "description": "A resource object **MAY** contain references to other resource objects (\"relationships\"). Relationships may be to-one or to-many. Relationships can be specified by including a member in a resource's links object.",
33
+ "type": "object",
34
+ "properties": {
35
+ "self": {
36
+ "description": "A `self` member, whose value is a URL for the relationship itself (a \"relationship URL\"). This URL allows the client to directly manipulate the relationship. For example, it would allow a client to remove an `author` from an `article` without deleting the people resource itself.",
37
+ "type": "string",
38
+ "format": "uri"
39
+ },
40
+ "related": {
41
+ "$ref": "#/definitions/link"
42
+ }
43
+ },
44
+ "additionalProperties": true
45
+ },
46
+ "link": {
47
+ "description": "A link **MUST** be represented as either: a string containing the link's URL or a link object.",
48
+ "oneOf": [
49
+ {
50
+ "description": "A string containing the link's URL.",
51
+ "type": "string",
52
+ "format": "uri"
53
+ },
54
+ {
55
+ "type": "object",
56
+ "required": [
57
+ "href"
58
+ ],
59
+ "properties": {
60
+ "href": {
61
+ "description": "A string containing the link's URL.",
62
+ "type": "string",
63
+ "format": "uri"
64
+ },
65
+ "meta": {
66
+ "$ref": "#/definitions/meta"
67
+ }
68
+ }
69
+ }
70
+ ]
71
+ },
72
+ "error": {
73
+ "type": "object",
74
+ "properties": {
75
+ "id": {
76
+ "description": "A unique identifier for this particular occurrence of the problem.",
77
+ "type": "string"
78
+ },
79
+ "links": {
80
+ "$ref": "#/definitions/links"
81
+ },
82
+ "status": {
83
+ "description": "The HTTP status code applicable to this problem, expressed as a string value.",
84
+ "type": "string"
85
+ },
86
+ "code": {
87
+ "description": "An application-specific error code, expressed as a string value.",
88
+ "type": "string"
89
+ },
90
+ "title": {
91
+ "description": "A short, human-readable summary of the problem. It **SHOULD NOT** change from occurrence to occurrence of the problem, except for purposes of localization.",
92
+ "type": "string"
93
+ },
94
+ "detail": {
95
+ "description": "A human-readable explanation specific to this occurrence of the problem.",
96
+ "type": "string"
97
+ },
98
+ "source": {
99
+ "type": "object",
100
+ "properties": {
101
+ "pointer": {
102
+ "description": "A JSON Pointer [RFC6901] to the associated entity in the request document [e.g. \"/data\" for a primary data object, or \"/data/attributes/title\" for a specific attribute].",
103
+ "type": "string"
104
+ },
105
+ "parameter": {
106
+ "description": "A string indicating which query parameter caused the error.",
107
+ "type": "string"
108
+ }
109
+ }
110
+ },
111
+ "meta": {
112
+ "$ref": "#/definitions/meta"
113
+ }
114
+ },
115
+ "additionalProperties": false
116
+ }
117
+ }
118
+ }
@@ -0,0 +1,5 @@
1
+ {
2
+ "$schema": "http://json-schema.org/draft-04/schema#",
3
+ "title" : "Empty Schema",
4
+ "type" : "object"
5
+ }