Skip to content

Commit c4feac4

Browse files
authored
Merge pull request github#36677 from github/repo-sync
Repo sync
2 parents 284d837 + 0e6c2d9 commit c4feac4

File tree

4 files changed

+155
-71
lines changed

4 files changed

+155
-71
lines changed
+90-54
Original file line numberDiff line numberDiff line change
@@ -1,68 +1,104 @@
11
import express from 'express'
2-
import type { Response } from 'express'
2+
import type { Response, RequestHandler } from 'express'
33

44
import type { ExtendedRequest } from '@/types'
55
import { defaultCacheControl } from '@/frame/middleware/cache-control.js'
66
import { getProductStringFromPath, getVersionStringFromPath } from '#src/frame/lib/path-utils.js'
7-
import { latest } from '#src/versions/lib/enterprise-server-releases.js'
7+
import { getLanguageCodeFromPath } from '#src/languages/middleware/detect-language.js'
8+
import { pagelistValidationMiddleware } from './validation'
9+
import catchMiddlewareError from '#src/observability/middleware/catch-middleware-error.js'
810

911
const router = express.Router()
1012

11-
router.get('/v1/enterprise-server@latest', (req, res) => {
12-
res.redirect(
13-
307,
14-
req.originalUrl.replace(
15-
'/pagelist/v1/enterprise-server@latest',
16-
`/pagelist/v1/enterprise-server@${latest}`,
17-
),
18-
)
19-
})
20-
21-
router.get('/v1/:product@:version', (req: ExtendedRequest, res: Response) => {
22-
const { product, version } = req.params
23-
24-
if (!req.context || !req.context.pages) throw new Error('Request not contextualized.')
25-
26-
const pages = req.context.pages
27-
28-
// the keys of `context.pages` are permalinks
29-
const keys = Object.keys(pages)
30-
31-
// we filter the permalinks to get only our target version
32-
const filteredPermalinks = keys.filter((key) => versionMatcher(key, `${product}@${version}`))
33-
const expression = /^\/en/
34-
35-
if (!filteredPermalinks.length) {
36-
res.status(400).type('text').send('Invalid version')
37-
return
38-
}
39-
40-
//right now we only need english permalinks perhaps we can use the language from the request in the future
41-
const englishPermalinks = filteredPermalinks.filter((permalink) => expression.test(permalink))
42-
43-
defaultCacheControl(res)
44-
45-
// new line added at the end so `wc` works as expected with `-l` and `-w`.
46-
res.type('text').send(englishPermalinks.join('\n').concat('\n'))
47-
})
48-
49-
router.get('/:product@:version', (req, res) => {
50-
res.redirect(307, req.originalUrl.replace('/pagelist', '/pagelist/v1'))
51-
})
52-
53-
// If no version is provided we'll assume API v1 and Docs version FPT
54-
router.get('/', (req, res) => {
55-
res.redirect(307, req.originalUrl.replace('/pagelist', '/pagelist/v1/free-pro-team@latest'))
56-
})
57-
58-
function versionMatcher(key: string, targetVersion: string) {
59-
const versionFromPath = getVersionStringFromPath(key)
60-
61-
if (!versionFromPath) {
13+
// pagelistValidationMiddleware is used for every route to normalize the lang and version from the path
14+
15+
// If no version or lang is provided we'll assume english and fpt and redirect there
16+
router.get(
17+
'/',
18+
pagelistValidationMiddleware as RequestHandler,
19+
catchMiddlewareError(async function (req: ExtendedRequest, res: Response) {
20+
res.redirect(
21+
308,
22+
req.originalUrl.replace(
23+
'/pagelist',
24+
`/pagelist/${req.context!.currentLanguage}/${req.context!.currentVersion}`,
25+
),
26+
)
27+
}),
28+
)
29+
30+
// handles paths with fragments that could be the language or the version
31+
router.get(
32+
'/:someParam',
33+
pagelistValidationMiddleware as RequestHandler,
34+
catchMiddlewareError(async function (req: ExtendedRequest, res: Response) {
35+
const { someParam } = req.params
36+
res.redirect(
37+
308,
38+
req.originalUrl.replace(
39+
`/pagelist/${someParam}`,
40+
`/pagelist/${req.context!.currentLanguage}/${req.context!.currentVersion}`,
41+
),
42+
)
43+
}),
44+
)
45+
46+
// for a fully qualified path with language and product version, we'll serve up the pagelist
47+
router.get(
48+
'/:lang/:productVersion',
49+
pagelistValidationMiddleware as RequestHandler,
50+
catchMiddlewareError(async function (req: ExtendedRequest, res: Response) {
51+
if (!req.context || !req.context.pages) throw new Error('Request not contextualized.')
52+
53+
const pages = req.context.pages
54+
55+
// the keys of `context.pages` are permalinks
56+
const keys = Object.keys(pages)
57+
58+
// we filter the permalinks to get only our target version and language
59+
const filteredPermalinks = keys.filter((key) =>
60+
versionMatcher(key, req.context!.currentVersion!, req.context!.currentLanguage!),
61+
)
62+
63+
// if we've filtered it out of existence, there's no articles to return so we must've
64+
// gotten a bad language or version
65+
if (!filteredPermalinks.length) {
66+
const { lang, productVersion } = req.params
67+
68+
res
69+
.status(400)
70+
.type('application/json')
71+
.send(
72+
JSON.stringify({
73+
error: 'Invalid version or language code',
74+
language: lang,
75+
version: productVersion,
76+
}),
77+
)
78+
return
79+
}
80+
81+
defaultCacheControl(res)
82+
83+
// new line added at the end so `wc` works as expected with `-l` and `-w`.
84+
res.type('text').send(filteredPermalinks.join('\n').concat('\n'))
85+
}),
86+
)
87+
88+
function versionMatcher(key: string, targetVersion: string, targetLang: string) {
89+
const versionFromPermalink = getVersionStringFromPath(key)
90+
91+
if (!versionFromPermalink) {
6292
throw new Error(`Couldn't get version from the permalink ${key} when generating the pagelist.`)
6393
}
6494
if (getProductStringFromPath(key) === 'early-access') return null
65-
if (versionFromPath === targetVersion) return key
95+
96+
const langFromPermalink = getLanguageCodeFromPath(key)
97+
if (!langFromPermalink) {
98+
throw new Error(`Couldn't get language from the permalink ${key} when generating the pagelist.`)
99+
}
100+
101+
if (versionFromPermalink === targetVersion && langFromPermalink === targetLang) return key
66102
}
67103

68104
export default router

src/article-api/middleware/validation.ts

+34-1
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,41 @@
11
import { ExtendedRequestWithPageInfo } from '../types'
22
import type { NextFunction, Response } from 'express'
3+
4+
import { ExtendedRequest, Page } from '#src/types.js'
35
import { isArchivedVersionByPath } from '@/archives/lib/is-archived-version'
46
import getRedirect from '@/redirects/lib/get-redirect.js'
5-
import { Page } from '#src/types.js'
7+
import { getVersionStringFromPath, getLangFromPath } from '#src/frame/lib/path-utils.js'
8+
import nonEnterpriseDefaultVersion from '#src/versions/lib/non-enterprise-default-version.js'
9+
10+
// validates the path for pagelist endpoint
11+
// specifically, defaults to `/en/free-pro-team@latest` when those values are missing
12+
// when they're provided, checks and cleans them up so we don't just lookup bad lang codes or versions
13+
export const pagelistValidationMiddleware = (
14+
req: ExtendedRequest,
15+
res: Response,
16+
next: NextFunction,
17+
) => {
18+
// get version from path, fallback to default version if it can't be resolved
19+
const versionFromPath = getVersionStringFromPath(req.path) || nonEnterpriseDefaultVersion
20+
21+
// in the rare case that this failed, probably won't be reached
22+
if (!versionFromPath)
23+
return res.status(400).json({ error: `Couldn't get version from the given path.` })
24+
25+
// get the language from path, fallback to english if it can't be resolved
26+
const langFromPath = getLangFromPath(req.path) || 'en'
27+
28+
// in the rare case that the language fallback failed
29+
if (!langFromPath)
30+
return res.status(400).json({
31+
error: `Couldn't get language from the from the given path.`,
32+
})
33+
34+
// set the version and language in the context, we'll use it later
35+
req.context!.currentVersion = versionFromPath
36+
req.context!.currentLanguage = langFromPath
37+
return next()
38+
}
639

740
export const pathValidationMiddleware = (
841
req: ExtendedRequestWithPageInfo,

src/article-api/tests/pagelist.ts

+23-16
Original file line numberDiff line numberDiff line change
@@ -4,19 +4,6 @@ import { get } from '#src/tests/helpers/e2etest.js'
44

55
import { allVersionKeys } from '#src/versions/lib/all-versions.js'
66
import nonEnterpriseDefaultVersion from '#src/versions/lib/non-enterprise-default-version.js'
7-
import { latest } from '#src/versions/lib/enterprise-server-releases.js'
8-
9-
test('redirects without version suffix', async () => {
10-
const res = await get(`/api/pagelist`)
11-
expect(res.statusCode).toBe(307)
12-
expect(res.headers.location).toBe(`/api/pagelist/v1/${nonEnterpriseDefaultVersion}`)
13-
})
14-
15-
test('redirects for ghes@latest', async () => {
16-
const res = await get(`/api/pagelist/v1/enterprise-server@latest`)
17-
expect(res.statusCode).toBe(307)
18-
expect(res.headers.location).toBe(`/api/pagelist/v1/enterprise-server@${latest}`)
19-
})
207

218
describe.each(allVersionKeys)('pagelist api for %s', async (versionKey) => {
229
beforeAll(() => {
@@ -37,15 +24,15 @@ describe.each(allVersionKeys)('pagelist api for %s', async (versionKey) => {
3724
})
3825

3926
// queries the pagelist API for each version
40-
const res = await get(`/api/pagelist/v1/${versionKey}`)
27+
const res = await get(`/api/pagelist/en/${versionKey}`)
4128

4229
test('is reachable, returns 200 OK', async () => {
4330
expect(res.statusCode).toBe(200)
4431
})
4532

4633
// there's a large assortment of possible URLs,
4734
// even "/en" is an acceptable URL, so regexes capture lots
48-
test('contains valid urls', async () => {
35+
test('contains valid urls matching the requested version', async () => {
4936
let expression
5037

5138
// if we're testing the default version, it may be missing
@@ -62,7 +49,7 @@ describe.each(allVersionKeys)('pagelist api for %s', async (versionKey) => {
6249
})
6350
})
6451

65-
test('only returns urls that contain /en', async () => {
52+
test('English requests only returns urls that contain /en', async () => {
6653
const expression = new RegExp(`^/en(/${nonEnterpriseDefaultVersion})?/?.*`)
6754
res.body
6855
.trim()
@@ -72,3 +59,23 @@ describe.each(allVersionKeys)('pagelist api for %s', async (versionKey) => {
7259
})
7360
})
7461
})
62+
63+
describe('Redirect Tests', () => {
64+
test('redirects without version suffix', async () => {
65+
const res = await get(`/api/pagelist`)
66+
expect(res.statusCode).toBe(308)
67+
expect(res.headers.location).toBe(`/api/pagelist/en/${nonEnterpriseDefaultVersion}`)
68+
})
69+
70+
test('should redirect to /pagelist/en/:product@:version when URL does not include /en', async () => {
71+
const res = await get('/api/pagelist/free-pro-team@latest')
72+
expect(res.statusCode).toBe(308)
73+
expect(res.headers.location).toBe('/api/pagelist/en/free-pro-team@latest')
74+
})
75+
76+
test('should redirect to /pagelist/en/free-pro-team@lateset when URL does not include version', async () => {
77+
const res = await get('/api/pagelist/en')
78+
expect(res.statusCode).toBe(308)
79+
expect(res.headers.location).toBe('/api/pagelist/en/free-pro-team@latest')
80+
})
81+
})

src/frame/lib/path-utils.js

+8
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,14 @@ import { allVersions } from '#src/versions/lib/all-versions.js'
77
import nonEnterpriseDefaultVersion from '#src/versions/lib/non-enterprise-default-version.js'
88
const supportedVersions = new Set(Object.keys(allVersions))
99

10+
// Extracts the language code from the path
11+
// if href is '/en/something', returns 'en'
12+
export function getLangFromPath(href) {
13+
// first remove the version from the path so we don't match, say, `/free-pro-team` as `/fr/`
14+
const match = getPathWithoutVersion(href).match(patterns.getLanguageCode)
15+
return match ? match[1] : null
16+
}
17+
1018
// Add the language to the given HREF
1119
// /articles/foo -> /en/articles/foo
1220
export function getPathWithLanguage(href, languageCode) {

0 commit comments

Comments
 (0)