-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathindex.js
220 lines (199 loc) · 8.5 KB
/
index.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
const _ = require('@sailshq/lodash')
const accepts = require('accepts')
const VERSION_CONFIG_KEY = 'versionConfig'
const VERSION_ARRAY_KEY = 'versions'
const VENDOR_PREFIX_KEY = 'vendorPrefix'
const PLACEHOLDER_VERSION = '%version%'
const ACTION_NAME_FIND = 'Find'
const ACTION_NAME_FIND_ONE = 'FindOne'
const ACTION_NAME_CREATE = 'Create'
const ACTION_NAME_UPDATE = 'Update'
const ACTION_NAME_DESTROY = 'Destroy'
const ALL_ACTIONS = [ACTION_NAME_FIND, ACTION_NAME_FIND_ONE, ACTION_NAME_CREATE, ACTION_NAME_UPDATE, ACTION_NAME_DESTROY]
module.exports = function (sails) {
let hook
return {
initialize: function(cb) {
hook = this
sails.on('router:before', hook.bindBlueprintHooks)
return cb()
},
bindBlueprintHooks: function () {
var defaultArchiveInUse = _.any(sails.models, model => { return model.archiveModelIdentity === 'archive' })
_.each(sails.models, (model, modelIdentity) => {
if (modelIdentity === 'archive' && defaultArchiveInUse) {
return
}
const versionConfig = model[VERSION_CONFIG_KEY]
if (typeof(versionConfig) === 'undefined') {
sails.log.debug(`Model '${modelIdentity}' doesn't define the '${VERSION_CONFIG_KEY}' key, *not* enabling API versioning for this model.`)
return
}
// verify required helpers exist at server startup
_.each(versionConfig[VERSION_ARRAY_KEY], versionTag => {
_.each(ALL_ACTIONS, actionName => {
const mime = buildMime(versionConfig[VENDOR_PREFIX_KEY], versionTag)
if (isLatestVersionMime(mime, model, modelIdentity)) {
// latest version doesn't need a helper
return
}
const helperName = buildHelperName(versionTag, modelIdentity, actionName)
if (!sails.helpers[helperName]) {
sails.log.warn(`'${helperName.toLowerCase()}' helper is *not* defined. Calls to '${actionName} ${modelIdentity}' with 'Accept: ${mime}' WILL FAIL! Create the helper to fix this.`)
}
})
})
function bind (shortcutRoute, actionName) {
const helperTemplate = buildHelperName(PLACEHOLDER_VERSION, modelIdentity, actionName)
sails.router.bind(shortcutRoute, buildGetWrapper(model, modelIdentity, helperTemplate))
sails.log.debug(`Applying API versioning to '${shortcutRoute}' route, handled by '${helperTemplate.replace(PLACEHOLDER_VERSION, '*')}'`)
}
// TODO handle URL prefix
bind(`get /${modelIdentity}`, 'Find')
bind(`get /${modelIdentity}/:id`, 'FindOne')
bind(`post /${modelIdentity}`, 'Create')
bind(`patch /${modelIdentity}/:id`, 'Update')
bind(`delete /${modelIdentity}/:id`, 'Destroy')
// TODO: bind "to-many" relationship blueprint actions (Add, Remove, Replace)
})
// TODO verify we aren't overwriting a custom .ok() response handler
sails.middleware.responses.ok = customOkResponse
},
_testonly: {
getRequestedVersion
}
}
function buildGetWrapper (model, modelIdentity, helperTemplate) {
return async function (req, res, proceed) {
let acceptHeader = req.headers.accept
sails.log.silly(`${req.method} ${req.path} request has Accept header='${acceptHeader}'`)
const isAcceptAnything = isMimeAccepted(req, '*/*')
const latestVersionMime = getLatestVersionMime(model, modelIdentity)
if (isAcceptAnything) {
res.forceMime = latestVersionMime
return proceed()
}
const validVersionMimeTypes = getValidVersionedMimeTypes()
const selectedMime = determineSelectedMime(validVersionMimeTypes, req)
if (!selectedMime) {
return res.status(406).json({
status: 406,
message: `We have no representation to satisfy '${acceptHeader}'`,
supportedTypes: validVersionMimeTypes,
})
}
if (isLatestVersionMime(selectedMime, model, modelIdentity)) {
res.forceMime = latestVersionMime
return proceed()
}
const requestedVersion = getRequestedVersion(selectedMime, model[VERSION_CONFIG_KEY])
const helperName = helperTemplate.replace(PLACEHOLDER_VERSION, requestedVersion)
const handler = sails.helpers[helperName]
if (!handler) {
const msg = `No helper defined '${helperName.toLowerCase()}'. You need to create and implement that helper!`
throw new Error(msg)
}
let respBody
if (_.isEmpty(req.params)) {
respBody = await handler()
} else {
respBody = await handler.with(req.params)
}
res.set('Content-type', selectedMime)
return res.ok(respBody)
}
function getValidVersionedMimeTypes () {
const versionConfig = getVersionConfig(model, modelIdentity)
const result = versionConfig[VERSION_ARRAY_KEY].reduce((accum, curr) => {
accum.push(buildMime(versionConfig[VENDOR_PREFIX_KEY], curr))
return accum
}, [])
return result
}
}
function determineSelectedMime (validVersionMimeTypes, req) {
for (const currMime of validVersionMimeTypes) {
if (isMimeAccepted(req, currMime)) {
return currMime
}
}
return false
}
function isLatestVersionMime (mime, model, modelIdentity) {
const latestVersionMime = getLatestVersionMime(model, modelIdentity)
return latestVersionMime === mime
}
function getLatestVersionMime (model, modelIdentity) {
const versionConfig = getVersionConfig(model, modelIdentity)
const versions = versionConfig[VERSION_ARRAY_KEY]
const latestVersion = _.last(versions)
return buildMime(versionConfig[VENDOR_PREFIX_KEY], latestVersion)
}
function getVersionConfig (model, modelIdentity) {
const versionConfig = model[VERSION_CONFIG_KEY]
failIfTrue(!versionConfig,
`model '${modelIdentity}' needs the '${VERSION_CONFIG_KEY}' field defined.`)
failIfTrue(!_.isPlainObject(versionConfig),
`${modelIdentity}.${VERSION_CONFIG_KEY} must be a plain object/dict`)
const versionList = versionConfig[VERSION_ARRAY_KEY]
failIfTrue(!versionList,
`${modelIdentity}.${VERSION_CONFIG_KEY} needs the '${VERSION_ARRAY_KEY}' field defined as string[].`)
failIfTrue(!_.every(versionList, String),
`${modelIdentity}.${VERSION_CONFIG_KEY} should have all elements of type 'string'`)
failIfTrue(versionList.length < 1,
`${modelIdentity}.${VERSION_CONFIG_KEY} should have at least one element`)
// TODO validate that versions are in order, perhaps add a flag to force acceptance of any order
const vendorPrefix = versionConfig[VENDOR_PREFIX_KEY]
failIfTrue(!vendorPrefix,
`${modelIdentity}.${VENDOR_PREFIX_KEY} needs the '${VENDOR_PREFIX_KEY}' field defined as string.`)
failIfTrue(!_.isString(vendorPrefix),
`${modelIdentity}.${VENDOR_PREFIX_KEY} should be of type 'string'`)
return versionConfig
}
/**
* Custom OK (200) response handler to force our MIME type, if required.
* @param {*} optionalData response body to send
*/
function customOkResponse(optionalData) {
const res = this.res
const ok = 200
if (res.forceMime) {
sails.log.silly(`Forcing custom MIME: ${res.forceMime}`)
res.set('content-type', res.forceMime) // TODO should we add charset?
}
if (optionalData === undefined) {
return res.sendStatus(ok)
}
// work around for unit testing, so we don't get overwritten by https://github.com/balderdashy/sails/blob/635ec44316f797237019dfc5b1e14b8085eb960f/lib/router/res.js#L264
const respBody = JSON.stringify(optionalData)
return res.status(ok).send(respBody)
}
function isMimeAccepted (req, mime) {
// because req.accents doesn't work in unit tests
const acceptor = accepts(req)
return acceptor.type([mime])
}
}
const MIME_PREFIX = 'application/'
const MIME_SEPARATOR = '.'
const MIME_SUFFIX = '+json'
function getRequestedVersion (mime, versionConfig) {
const result = mime
.replace(MIME_PREFIX, '')
.replace(MIME_SUFFIX, '')
.replace(versionConfig[VENDOR_PREFIX_KEY], '')
.replace(MIME_SEPARATOR, '')
// TODO verify is a valid version
return result
}
function buildMime (vendorPrefix, versionFragment) {
return `${MIME_PREFIX}${vendorPrefix}${MIME_SEPARATOR}${versionFragment}${MIME_SUFFIX}`
}
function buildHelperName (versionTag, modelIdentity, actionName) {
return `${versionTag}${_.capitalize(modelIdentity.toLowerCase())}${actionName.toLowerCase()}`
}
function failIfTrue (failureIfTrueCondition, msg) {
if (failureIfTrueCondition) {
throw new Error(`Config problem: ${msg}`)
}
}