Skip to content

Commit 96b22da

Browse files
committed
refactor(apig-validation): switch to Joi for common proxies properties
1 parent da2959c commit 96b22da

File tree

3 files changed

+197
-184
lines changed

3 files changed

+197
-184
lines changed

lib/apiGateway/schema.js

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
'use strict'
2+
3+
const Joi = require('@hapi/joi')
4+
5+
const path = Joi.string().required()
6+
7+
const method = Joi.string()
8+
.required()
9+
.valid(['get', 'post', 'put', 'patch', 'options', 'head', 'delete', 'any'])
10+
.insensitive()
11+
12+
const cors = Joi.alternatives().try(
13+
Joi.boolean(),
14+
Joi.object({
15+
headers: Joi.array().items(Joi.string()),
16+
origin: Joi.string(),
17+
origins: Joi.array().items(Joi.string()),
18+
methods: Joi.array().items(method),
19+
maxAge: Joi.number().min(1),
20+
cacheControl: Joi.string(),
21+
allowCredentials: Joi.boolean()
22+
})
23+
.oxor('origin', 'origins') // can have one of them, but not required
24+
.error((errors) => {
25+
for (const error of errors) {
26+
switch (error.type) {
27+
case 'object.oxor':
28+
error.message = '"cors" can have "origin" or "origins" but not both'
29+
break
30+
default:
31+
break
32+
}
33+
}
34+
return errors
35+
})
36+
)
37+
38+
const authorizerId = Joi.alternatives().try(
39+
Joi.string(),
40+
Joi.object().keys({
41+
Ref: Joi.string().required()
42+
})
43+
)
44+
45+
const authorizationScopes = Joi.array()
46+
47+
// https://hapi.dev/family/joi/?v=15.1.0#anywhencondition-options
48+
const authorizationType = Joi.alternatives().when('authorizerId', {
49+
is: authorizerId.required(),
50+
then: Joi.string()
51+
.valid('CUSTOM')
52+
.required(),
53+
otherwise: Joi.alternatives().when('authorizationScopes', {
54+
is: authorizationScopes.required(),
55+
then: Joi.string()
56+
.valid('COGNITO_USER_POOLS')
57+
.required(),
58+
otherwise: Joi.string().valid('NONE', 'AWS_IAM', 'CUSTOM', 'COGNITO_USER_POOLS')
59+
})
60+
})
61+
62+
// https://hapi.dev/family/joi/?v=15.1.0#objectpatternpattern-schema
63+
const requestParameters = Joi.object().pattern(Joi.string(), Joi.string().required())
64+
65+
const proxy = Joi.object({
66+
path,
67+
method,
68+
cors,
69+
authorizationType,
70+
authorizerId,
71+
authorizationScopes,
72+
requestParameters
73+
})
74+
.oxor('authorizerId', 'authorizationScopes') // can have one of them, but not required
75+
.error((errors) => {
76+
for (const error of errors) {
77+
switch (error.type) {
78+
case 'object.oxor':
79+
error.message = 'cannot set both "authorizerId" and "authorizationScopes"'
80+
break
81+
default:
82+
break
83+
}
84+
}
85+
return errors
86+
})
87+
.required()
88+
89+
const allowedProxies = ['kinesis', 'sqs', 's3', 'sns']
90+
91+
const proxiesSchemas = {
92+
kinesis: Joi.object({ kinesis: proxy.forbiddenKeys('requestParameters') }),
93+
s3: Joi.object({ s3: proxy.forbiddenKeys('requestParameters') }),
94+
sns: Joi.object({ sns: proxy.forbiddenKeys('requestParameters') }),
95+
sqs: Joi.object({ sqs: proxy.optionalKeys('requestParameters') })
96+
}
97+
98+
const schema = Joi.array()
99+
.items(...allowedProxies.map((proxyKey) => proxiesSchemas[proxyKey]))
100+
.error((errors) => {
101+
for (const error of errors) {
102+
switch (error.type) {
103+
case 'array.includes':
104+
// get a detailed error why the proxy object failed the schema validation
105+
// Joi default message is `"value" at position <i> does not match any of the allowed types`
106+
const proxyKey = Object.keys(error.context.value)[0]
107+
if (proxiesSchemas[proxyKey]) {
108+
// e.g. value is { kinesis: { path: '/kinesis', method: 'xxxx' } }
109+
const { error: proxyError } = Joi.validate(
110+
error.context.value,
111+
proxiesSchemas[proxyKey]
112+
)
113+
error.message = proxyError.message
114+
} else {
115+
// e.g. value is { xxxxx: { path: '/kinesis', method: 'post' } }
116+
error.message = `Invalid APIG proxy "${proxyKey}". This plugin supported Proxies are: ${allowedProxies.join(
117+
', '
118+
)}.`
119+
}
120+
break
121+
default:
122+
break
123+
}
124+
}
125+
return errors
126+
})
127+
128+
module.exports = schema

lib/apiGateway/validate.js

Lines changed: 28 additions & 146 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,36 @@
11
'use strict'
22
const NOT_FOUND = -1
33
const _ = require('lodash')
4+
const Joi = require('@hapi/joi')
5+
const schema = require('./schema')
46

57
module.exports = {
68
validateServiceProxies() {
9+
const proxies = this.getAllServiceProxies()
10+
11+
const { error } = Joi.validate(proxies, schema)
12+
if (error) {
13+
throw new this.serverless.classes.Error(error.message)
14+
}
15+
716
const corsPreflight = {}
8-
const events = this.getAllServiceProxies().map((serviceProxy) => {
17+
18+
const events = proxies.map((serviceProxy) => {
919
const serviceName = this.getServiceName(serviceProxy)
10-
this.checkAllowedService(serviceName)
1120
const http = serviceProxy[serviceName]
12-
http.path = this.getProxyPath(serviceProxy[serviceName], serviceName)
13-
http.method = this.getProxyMethod(serviceProxy[serviceName], serviceName)
14-
http.auth = this.getAuth(serviceProxy[serviceName], serviceName)
21+
http.path = http.path.replace(/^\//, '').replace(/\/$/, '')
22+
http.method = http.method.toLowerCase()
23+
http.auth = {
24+
authorizationType: http.authorizationType || 'NONE'
25+
}
1526

16-
this.validateRequestParameters(serviceProxy[serviceName], serviceName)
27+
if (_.has(http, 'authorizerId')) {
28+
http.auth.authorizerId = http.authorizerId
29+
}
30+
31+
if (_.has(http, 'authorizationScopes')) {
32+
http.auth.authorizationScopes = http.authorizationScopes
33+
}
1734

1835
if (serviceProxy[serviceName].cors) {
1936
http.cors = this.getCors(serviceProxy[serviceName])
@@ -47,49 +64,8 @@ module.exports = {
4764
}
4865
},
4966

50-
checkAllowedService(serviceName) {
51-
const allowedProxies = ['kinesis', 'sqs', 's3', 'sns']
52-
if (allowedProxies.indexOf(serviceName) === NOT_FOUND) {
53-
const errorMessage = [
54-
`Invalid APIG proxy "${serviceName}".`,
55-
` This plugin supported Proxies are: ${allowedProxies.join(', ')}.`
56-
].join('')
57-
throw new this.serverless.classes.Error(errorMessage)
58-
}
59-
},
60-
61-
getProxyPath(proxy, serviceName) {
62-
if (proxy.path && _.isString(proxy.path)) {
63-
return proxy.path.replace(/^\//, '').replace(/\/$/, '')
64-
}
65-
66-
throw new this.serverless.classes.Error(
67-
`Missing or invalid "path" property in ${serviceName} proxy`
68-
)
69-
},
70-
71-
getProxyMethod(proxy, serviceName) {
72-
if (proxy.method && _.isString(proxy.method)) {
73-
const method = proxy.method.toLowerCase()
74-
75-
const allowedMethods = ['get', 'post', 'put', 'patch', 'options', 'head', 'delete', 'any']
76-
if (allowedMethods.indexOf(method) === NOT_FOUND) {
77-
const errorMessage = [
78-
`Invalid APIG method "${proxy.method}" in AWS service proxy.`,
79-
` AWS supported methods are: ${allowedMethods.join(', ')}.`
80-
].join('')
81-
throw new this.serverless.classes.Error(errorMessage)
82-
}
83-
return method
84-
}
85-
86-
throw new this.serverless.classes.Error(
87-
`Missing or invalid "method" property in ${serviceName} proxy`
88-
)
89-
},
90-
9167
getCors(proxy) {
92-
const headers = [
68+
const defaultHeaders = [
9369
'Content-Type',
9470
'X-Amz-Date',
9571
'Authorization',
@@ -102,34 +78,17 @@ module.exports = {
10278
origins: ['*'],
10379
origin: '*',
10480
methods: ['OPTIONS'],
105-
headers,
81+
headers: defaultHeaders,
10682
allowCredentials: false
10783
}
10884

109-
if (typeof proxy.cors === 'object') {
85+
if (_.isPlainObject(proxy.cors)) {
11086
cors = proxy.cors
11187
cors.methods = cors.methods || []
11288
cors.allowCredentials = Boolean(cors.allowCredentials)
11389

114-
if (cors.origins && cors.origin) {
115-
const errorMessage = [
116-
'You can only use "origin" or "origins",',
117-
' but not both at the same time to configure CORS.',
118-
' Please check the docs for more info.'
119-
].join('')
120-
throw new this.serverless.classes.Error(errorMessage)
121-
}
122-
123-
if (cors.headers) {
124-
if (!Array.isArray(cors.headers)) {
125-
const errorMessage = [
126-
'CORS header values must be provided as an array.',
127-
' Please check the docs for more info.'
128-
].join('')
129-
throw new this.serverless.classes.Error(errorMessage)
130-
}
131-
} else {
132-
cors.headers = headers
90+
if (!cors.headers) {
91+
cors.headers = defaultHeaders
13392
}
13493

13594
if (cors.methods.indexOf('OPTIONS') === NOT_FOUND) {
@@ -139,87 +98,10 @@ module.exports = {
13998
if (cors.methods.indexOf(proxy.method.toUpperCase()) === NOT_FOUND) {
14099
cors.methods.push(proxy.method.toUpperCase())
141100
}
142-
143-
if (_.has(cors, 'maxAge')) {
144-
if (!_.isInteger(cors.maxAge) || cors.maxAge < 1) {
145-
const errorMessage = 'maxAge should be an integer over 0'
146-
throw new this.serverless.classes.Error(errorMessage)
147-
}
148-
}
149101
} else {
150102
cors.methods.push(proxy.method.toUpperCase())
151103
}
152104

153105
return cors
154-
},
155-
156-
getAuth(proxy, serviceName) {
157-
const auth = {
158-
authorizationType: 'NONE'
159-
}
160-
161-
if (!_.isUndefined(proxy.authorizationType)) {
162-
if (_.isString(proxy.authorizationType)) {
163-
const allowedTypes = ['NONE', 'AWS_IAM', 'CUSTOM', 'COGNITO_USER_POOLS']
164-
if (allowedTypes.indexOf(proxy.authorizationType) === NOT_FOUND) {
165-
const errorMessage = [
166-
`Invalid APIG authorization type "${proxy.authorizationType}" in AWS service proxy.`,
167-
` AWS supported types are: ${allowedTypes.join(', ')}.`
168-
].join('')
169-
throw new this.serverless.classes.Error(errorMessage)
170-
}
171-
172-
auth.authorizationType = proxy.authorizationType
173-
} else {
174-
throw new this.serverless.classes.Error(
175-
`Invalid "authorizationType" property in ${serviceName} proxy`
176-
)
177-
}
178-
}
179-
180-
if (!_.isUndefined(proxy.authorizerId)) {
181-
if (auth.authorizationType !== 'CUSTOM') {
182-
const errorMessage = `Expecting 'CUSTOM' authorization type when 'authorizerId' is set in service ${serviceName}`
183-
throw new this.serverless.classes.Error(errorMessage)
184-
}
185-
186-
auth.authorizerId = proxy.authorizerId
187-
}
188-
189-
if (!_.isUndefined(proxy.authorizationScopes)) {
190-
if (_.isArray(proxy.authorizationScopes)) {
191-
if (auth.authorizationType !== 'COGNITO_USER_POOLS') {
192-
const errorMessage = `Expecting 'COGNITO_USER_POOLS' authorization type when 'authorizationScopes' is set in service ${serviceName}`
193-
throw new this.serverless.classes.Error(errorMessage)
194-
}
195-
196-
auth.authorizationScopes = proxy.authorizationScopes
197-
} else {
198-
throw new this.serverless.classes.Error(
199-
`Invalid "authorizationScopes" property in ${serviceName} proxy`
200-
)
201-
}
202-
}
203-
204-
return auth
205-
},
206-
207-
validateRequestParameters(proxy, serviceName) {
208-
if (!_.isUndefined(proxy.requestParameters)) {
209-
if (serviceName !== 'sqs') {
210-
throw new this.serverless.classes.Error(
211-
'requestParameters property is only valid for "sqs" service proxy'
212-
)
213-
}
214-
215-
if (
216-
!_.isPlainObject(proxy.requestParameters) ||
217-
_.some(_.values(proxy.requestParameters), (v) => !_.isString(v))
218-
) {
219-
throw new this.serverless.classes.Error(
220-
'requestParameters property must be a string to string mapping'
221-
)
222-
}
223-
}
224106
}
225107
}

0 commit comments

Comments
 (0)