-
Notifications
You must be signed in to change notification settings - Fork 66.9k
Expand file tree
/
Copy pathgeneral-search-middleware.ts
More file actions
176 lines (161 loc) · 6.22 KB
/
general-search-middleware.ts
File metadata and controls
176 lines (161 loc) · 6.22 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
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
/*
This file & middleware is for when a user requests our /search page e.g. 'docs.github.com/search?query=foo'
We make whatever search is in the ?query= parameter and attach it to req.search
req.search is then consumed by the search component in 'src/search/pages/search.tsx'
When a user directly hits our API e.g. /api/search/v1?query=foo, they will hit the routes in ./search-routes.ts
*/
import got from 'got'
import { Request, Response, NextFunction } from 'express'
import { errors } from '@elastic/elasticsearch'
import statsd from '@/observability/lib/statsd'
import { getPathWithoutVersion, getPathWithoutLanguage } from '@/frame/lib/path-utils'
import { getGeneralSearchResults } from '@/search/lib/get-elasticsearch-results/general-search'
import { getSearchFromRequestParams } from '@/search/lib/search-request-params/get-search-from-request-params'
import type { ComputedSearchQueryParamsMap } from '@/search/lib/search-request-params/types'
import type {
GeneralSearchResponse,
SearchOnReqObject,
SearchTypes,
SearchValidationErrorEntry,
} from '@/search/types'
interface Context<Type extends SearchTypes> {
currentVersion: string
currentLanguage: string
search: SearchOnReqObject<Type>
}
interface CustomRequest<Type extends SearchTypes> extends Request {
pagePath: string
context: Context<Type>
}
export default async function contextualizeGeneralSearch(
req: CustomRequest<'generalSearch'>,
res: Response,
next: NextFunction,
): Promise<void> {
const { pagePath } = req
if (getPathWithoutLanguage(getPathWithoutVersion(pagePath)) !== '/search') {
return next()
}
// Since this is a middleware language & version are already set in req.context via a prior middleware
const { indexName, searchParams, validationErrors } = getSearchFromRequestParams(
req,
'generalSearch',
// Force the version and language keys to be set from the `req.context` object
{
version: req.context.currentVersion,
language: req.context.currentLanguage,
},
)
if (validationErrors.map((error: SearchValidationErrorEntry) => error.key).includes('query')) {
if (Array.isArray(searchParams.query)) {
searchParams.query = searchParams.query[0]
} else if (!searchParams.query) {
searchParams.query = '' // If 'undefined' we need to cast to string
}
}
searchParams.aggregate = ['toplevel']
req.context.search = {
searchParams,
validationErrors,
}
if (!validationErrors.length && searchParams.query) {
// In local dev ELASTICSEARCH_URL may not be set, so we proxy the search to prod
if (!process.env.ELASTICSEARCH_URL) {
if (searchParams.aggregate && searchParams.toplevel && searchParams.toplevel.length > 0) {
// Do 2 searches. One without filtering to get the aggregations
const searchWithoutFilter = Object.fromEntries(
Object.entries(searchParams).filter(([key]) => key !== 'topLevel'),
)
searchWithoutFilter.size = 0
const { aggregations } = await getProxySearch(
searchWithoutFilter as ComputedSearchQueryParamsMap['generalSearch'],
)
const searchWithoutAggregate = Object.fromEntries(
Object.entries(searchParams).filter(([key]) => key !== 'aggregate'),
)
req.context.search.results = await getProxySearch(
searchWithoutAggregate as ComputedSearchQueryParamsMap['generalSearch'],
)
req.context.search.results.aggregations = aggregations
} else {
req.context.search.results = await getProxySearch(searchParams)
}
} else {
const tags: string[] = [`indexName:${indexName}`, `toplevels:${searchParams.toplevel.length}`]
const timed = statsd.asyncTimer(getGeneralSearchResults, 'contextualize.search', tags)
const getGeneralSearchArgs = {
indexName,
searchParams,
}
try {
if (searchParams.aggregate && searchParams.toplevel && searchParams.toplevel.length > 0) {
// Do 2 searches. One without filtering to get the aggregations
const searchWithoutFilter = Object.fromEntries(
Object.entries(searchParams).filter(([key]) => key !== 'topLevel'),
)
searchWithoutFilter.size = 0
const { aggregations } = await timed({
...getGeneralSearchArgs,
searchParams: searchWithoutFilter as ComputedSearchQueryParamsMap['generalSearch'],
})
req.context.search.results = await timed(getGeneralSearchArgs)
req.context.search.results.aggregations = aggregations
} else {
req.context.search.results = await timed(getGeneralSearchArgs)
}
} catch (error) {
// If the Elasticsearch sends a 4XX we want the user to see a 500
if (error instanceof errors.ResponseError) {
console.error(
'Error calling getSearchResults(%s):',
JSON.stringify({
indexName,
searchParams,
}),
error,
)
if (error?.meta?.body) {
console.error(`Meta:`, error.meta.body)
}
throw new Error(error.message)
} else {
throw error
}
}
}
}
return next()
}
const SEARCH_KEYS_TO_QUERY_STRING: (keyof ComputedSearchQueryParamsMap['generalSearch'])[] = [
'query',
'version',
'language',
'page',
'aggregate',
'toplevel',
'size',
]
// Proxy the API endpoint with the relevant search params
async function getProxySearch(
search: ComputedSearchQueryParamsMap['generalSearch'],
): Promise<GeneralSearchResponse> {
const url = new URL('https://docs.github.com/api/search/v1')
for (const key of SEARCH_KEYS_TO_QUERY_STRING) {
const value = search[key]
if (typeof value === 'boolean') {
url.searchParams.set(key, value ? 'true' : 'false')
} else if (Array.isArray(value)) {
for (const v of value) {
url.searchParams.append(key, v)
}
} else if (typeof value === 'number') {
url.searchParams.set(key, `${value}`)
} else if (value) {
url.searchParams.set(key, value)
}
}
// Add client_name for external API requests
url.searchParams.set('client_name', 'docs.github.com-client')
console.log(`Proxying search to ${url}`)
return got(url).json<GeneralSearchResponse>()
}