|
| 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