Skip to content

Commit 2bc837f

Browse files
authored
Merge pull request #33 from erezrokah/refactor/use_hapi_joi_for_validation
Refactor: use hapi joi for validation
2 parents 6d3679b + accc9af commit 2bc837f

38 files changed

+1959
-1505
lines changed

lib/apiGateway/schema.js

Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
'use strict'
2+
const _ = require('lodash')
3+
4+
const customErrorBuilder = (type, message) => (errors) => {
5+
for (const error of errors) {
6+
switch (error.type) {
7+
case type:
8+
error.message = _.isFunction(message) ? message(error) : message
9+
break
10+
default:
11+
break
12+
}
13+
}
14+
return errors
15+
}
16+
17+
const Joi = require('@hapi/joi')
18+
19+
const path = Joi.string().required()
20+
21+
const method = Joi.string()
22+
.required()
23+
.valid(['get', 'post', 'put', 'patch', 'options', 'head', 'delete', 'any'])
24+
.insensitive()
25+
26+
const cors = Joi.alternatives().try(
27+
Joi.boolean(),
28+
Joi.object({
29+
headers: Joi.array().items(Joi.string()),
30+
origin: Joi.string(),
31+
origins: Joi.array().items(Joi.string()),
32+
methods: Joi.array().items(method),
33+
maxAge: Joi.number().min(1),
34+
cacheControl: Joi.string(),
35+
allowCredentials: Joi.boolean()
36+
})
37+
.oxor('origin', 'origins') // can have one of them, but not required
38+
.error(customErrorBuilder('object.oxor', '"cors" can have "origin" or "origins" but not both'))
39+
)
40+
41+
const authorizerId = Joi.alternatives().try(
42+
Joi.string(),
43+
Joi.object().keys({
44+
Ref: Joi.string().required()
45+
})
46+
)
47+
48+
const authorizationScopes = Joi.array()
49+
50+
// https://hapi.dev/family/joi/?v=15.1.0#anywhencondition-options
51+
const authorizationType = Joi.alternatives().when('authorizerId', {
52+
is: authorizerId.required(),
53+
then: Joi.string()
54+
.valid('CUSTOM')
55+
.required(),
56+
otherwise: Joi.alternatives().when('authorizationScopes', {
57+
is: authorizationScopes.required(),
58+
then: Joi.string()
59+
.valid('COGNITO_USER_POOLS')
60+
.required(),
61+
otherwise: Joi.string().valid('NONE', 'AWS_IAM', 'CUSTOM', 'COGNITO_USER_POOLS')
62+
})
63+
})
64+
65+
// https://hapi.dev/family/joi/?v=15.1.0#objectpatternpattern-schema
66+
const requestParameters = Joi.object().pattern(Joi.string(), Joi.string().required())
67+
68+
const proxy = Joi.object({
69+
path,
70+
method,
71+
cors,
72+
authorizationType,
73+
authorizerId,
74+
authorizationScopes
75+
})
76+
.oxor('authorizerId', 'authorizationScopes') // can have one of them, but not required
77+
.error(
78+
customErrorBuilder('object.oxor', 'cannot set both "authorizerId" and "authorizationScopes"')
79+
)
80+
.required()
81+
82+
const stringOrRef = Joi.alternatives().try([
83+
Joi.string(),
84+
Joi.object().keys({
85+
Ref: Joi.string().required()
86+
})
87+
])
88+
89+
const key = Joi.alternatives().try([
90+
Joi.string(),
91+
Joi.object()
92+
.keys({
93+
pathParam: Joi.string(),
94+
queryStringParam: Joi.string()
95+
})
96+
.xor('pathParam', 'queryStringParam')
97+
.error(
98+
customErrorBuilder(
99+
'object.xor',
100+
'key must contain "pathParam" or "queryStringParam" but not both'
101+
)
102+
)
103+
])
104+
105+
const partitionKey = Joi.alternatives().try([
106+
Joi.string(),
107+
Joi.object()
108+
.keys({
109+
pathParam: Joi.string(),
110+
queryStringParam: Joi.string(),
111+
bodyParam: Joi.string()
112+
})
113+
.xor('pathParam', 'queryStringParam', 'bodyParam')
114+
.error(
115+
customErrorBuilder(
116+
'object.xor',
117+
'key must contain "pathParam" or "queryStringParam" or "bodyParam" and only one'
118+
)
119+
)
120+
])
121+
122+
const stringOrGetAtt = (propertyName, attributeName) => {
123+
return Joi.alternatives().try([
124+
Joi.string(),
125+
Joi.object({
126+
'Fn::GetAtt': Joi.array()
127+
.length(2)
128+
.ordered(
129+
Joi.string().required(),
130+
Joi.string()
131+
.valid(attributeName)
132+
.required()
133+
)
134+
.required()
135+
}).error(
136+
customErrorBuilder(
137+
'object.child',
138+
`"${propertyName}" must be in the format "{ 'Fn::GetAtt': ['<ResourceId>', '${attributeName}'] }"`
139+
)
140+
)
141+
])
142+
}
143+
144+
const request = Joi.object({
145+
template: Joi.object().required()
146+
})
147+
148+
const allowedProxies = ['kinesis', 'sqs', 's3', 'sns']
149+
150+
const proxiesSchemas = {
151+
kinesis: Joi.object({
152+
kinesis: proxy.append({ streamName: stringOrRef.required(), partitionKey, request })
153+
}),
154+
s3: Joi.object({
155+
s3: proxy.append({
156+
action: Joi.string()
157+
.valid('GetObject', 'PutObject', 'DeleteObject')
158+
.required(),
159+
bucket: stringOrRef.required(),
160+
key: key.required()
161+
})
162+
}),
163+
sns: Joi.object({
164+
sns: proxy.append({ topicName: stringOrGetAtt('topicName', 'TopicName').required(), request })
165+
}),
166+
sqs: Joi.object({
167+
sqs: proxy.append({
168+
queueName: stringOrGetAtt('queueName', 'QueueName').required(),
169+
requestParameters
170+
})
171+
})
172+
}
173+
174+
const schema = Joi.array()
175+
.items(...allowedProxies.map((proxyKey) => proxiesSchemas[proxyKey]))
176+
.error(
177+
customErrorBuilder('array.includes', (error) => {
178+
// get a detailed error why the proxy object failed the schema validation
179+
// Joi default message is `"value" at position <i> does not match any of the allowed types`
180+
const proxyKey = Object.keys(error.context.value)[0]
181+
182+
let message = ''
183+
if (proxiesSchemas[proxyKey]) {
184+
// e.g. value is { kinesis: { path: '/kinesis', method: 'xxxx' } }
185+
const { error: proxyError } = Joi.validate(error.context.value, proxiesSchemas[proxyKey])
186+
message = proxyError.message
187+
} else {
188+
// e.g. value is { xxxxx: { path: '/kinesis', method: 'post' } }
189+
message = `Invalid APIG proxy "${proxyKey}". This plugin supported Proxies are: ${allowedProxies.join(
190+
', '
191+
)}.`
192+
}
193+
return message
194+
})
195+
)
196+
197+
module.exports = schema

0 commit comments

Comments
 (0)