-
Notifications
You must be signed in to change notification settings - Fork 66.9k
Expand file tree
/
Copy pathmiddleware.ts
More file actions
138 lines (123 loc) · 4.86 KB
/
middleware.ts
File metadata and controls
138 lines (123 loc) · 4.86 KB
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
import express from 'express'
import { omit, without, mapValues } from 'lodash-es'
import QuickLRU from 'quick-lru'
import { ErrorObject } from 'ajv'
import type { ExtendedRequest } from '@/types'
import type { Response } from 'express'
import { schemas, hydroNames } from './lib/schema.js'
import catchMiddlewareError from 'src/observability/middleware/catch-middleware-error'
import { noCacheControl } from 'src/frame/middleware/cache-control'
import { getJsonValidator } from 'src/tests/lib/validate-json-schema'
import { formatErrors } from './lib/middleware-errors.js'
import { publish as _publish } from './lib/hydro.js'
import { analyzeComment, getGuessedLanguage } from './lib/analyze-comment.js'
import { EventType, EventProps, EventPropsByType } from './types'
const router = express.Router()
const OMIT_FIELDS = ['type']
const allowedTypes = new Set(without(Object.keys(schemas), 'validation'))
const isProd = process.env.NODE_ENV === 'production'
const validators = mapValues(schemas, (schema) => getJsonValidator(schema))
// In production, fire and not wait to respond.
// _publish will send an error to failbot,
// so we don't get alerts but we still track it.
// This ends up being the same as try > await > catch > (do nothing).
async function publish(...args: Parameters<typeof _publish>) {
if (isProd) {
_publish(...args)
return
}
return await _publish(...args)
}
const sentValidationErrors = new QuickLRU({
maxSize: 10_000,
maxAge: 1000 * 60,
})
router.post(
'/',
catchMiddlewareError(async function postEvents(req: ExtendedRequest, res: Response) {
noCacheControl(res)
const eventsToProcess = Array.isArray(req.body) ? req.body : [req.body]
const validEvents: any[] = []
const validationErrors: any[] = []
// We use a LRU cache & a hash of the request IP + error message
// to prevent sending multiple validation errors per user that can spam requests to Hydro
const getValidationErrorHash = (validateErrors: ErrorObject[]) =>
`${req.ip}:${(validateErrors || [])
.map(
(error: ErrorObject) => error.message + error.instancePath + JSON.stringify(error.params),
)
.join(':')}`
for (const eventBody of eventsToProcess) {
try {
// Skip event if it doesn't have a type or if the type is not in the allowed types
if (!eventBody.type || !allowedTypes.has(eventBody.type)) {
continue
}
const type: EventType = eventBody.type
const body: EventProps & EventPropsByType[EventType] = eventBody
if (isSurvey(body) && body.survey_comment) {
body.survey_rating = await getSurveyCommentRating({
comment: body.survey_comment,
language: body.context.path_language || 'en',
})
body.survey_comment_language = await getGuessedLanguage(body.survey_comment)
}
if (body.context) {
// Add dotcom_user to the context if it's available
// JSON.stringify removes `undefined` values but not `null`, and we don't want to send `null` to Hydro
body.context.dotcom_user = req.cookies?.dotcom_user ? req.cookies.dotcom_user : undefined
body.context.is_staff = Boolean(req.cookies?.staffonly)
}
const validate = validators[type]
if (!validate(body)) {
const hash = getValidationErrorHash(validate.errors || [])
if (!sentValidationErrors.has(hash)) {
sentValidationErrors.set(hash, true)
formatErrors(validate.errors || [], body).map((error) => {
validationErrors.push({ schema: hydroNames.validation, value: error })
})
}
continue
}
validEvents.push({
schema: hydroNames[type],
value: omit(body, OMIT_FIELDS),
})
} catch (eventError) {
console.error('Error validating event:', eventError)
}
}
if (validEvents.length > 0) {
await publish(validEvents)
}
if (validationErrors.length > 0) {
await publish(validationErrors)
}
const statusCode = validationErrors.length > 0 ? 400 : 200
return res.status(statusCode).json(
isProd
? undefined
: {
success_count: validEvents.length,
failure_count: validationErrors.length,
details: validationErrors,
},
)
}),
)
// https://www.typescriptlang.org/docs/handbook/2/narrowing.html#using-type-predicates
function isSurvey(
body: EventProps & EventPropsByType[EventType],
): body is EventProps & EventPropsByType[EventType.survey] {
return body.type === EventType.survey
}
type GetSurveyCommentRatingArgs = {
comment: string
language: string
}
async function getSurveyCommentRating({ comment, language }: GetSurveyCommentRatingArgs) {
if (!comment || !comment.trim()) return
const { rating } = await analyzeComment(comment, language)
return rating
}
export default router