Skip to content

Commit 2f4c6a4

Browse files
authored
Add routes for /article, /article/body, and /article/meta (#54652)
1 parent ce9bd49 commit 2f4c6a4

File tree

7 files changed

+131
-7
lines changed

7 files changed

+131
-7
lines changed

src/article-api/middleware/article.ts

+106
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
import type { RequestHandler, Response } from 'express'
2+
import express from 'express'
3+
4+
import { defaultCacheControl } from '@/frame/middleware/cache-control.js'
5+
import { Context } from '#src/types.js'
6+
import catchMiddlewareError from '@/observability/middleware/catch-middleware-error.js'
7+
import { ExtendedRequestWithPageInfo } from '../types'
8+
import { pageValidationMiddleware, pathValidationMiddleware } from './validation'
9+
import contextualize from '#src/frame/middleware/context/context.js'
10+
11+
/** START helper functions */
12+
13+
// for now, we're just querying pageinfo, we'll likely replace /api/pageinfo
14+
// with /api/meta and move or reference that code here
15+
async function getArticleMetadata(req: ExtendedRequestWithPageInfo) {
16+
const queryString = new URLSearchParams(req.query as Record<string, string>).toString()
17+
const apiUrl = `${req.protocol}://${req.get('host')}/api/pageinfo${queryString ? `?${queryString}` : ''}`
18+
19+
// Fetch the data from the pageinfo API
20+
const response = await fetch(apiUrl)
21+
22+
// Check if the response is OK
23+
if (!response.ok) {
24+
const errorText = await response.text()
25+
throw new Error(`Failed to fetch metadata: ${response.status} ${errorText}`)
26+
}
27+
28+
return await response.json()
29+
}
30+
31+
async function getArticleBody(req: ExtendedRequestWithPageInfo) {
32+
// req.pageinfo is set from pageValidationMiddleware and pathValidationMiddleware
33+
// and is in the ExtendedRequestWithPageInfo
34+
const { page, pathname } = req.pageinfo
35+
36+
// these parts allow us to render the page
37+
const mockedContext: Context = {}
38+
const renderingReq = {
39+
path: pathname,
40+
language: page.languageCode,
41+
pagePath: pathname,
42+
cookies: {},
43+
context: mockedContext,
44+
headers: {
45+
'content-type': 'text/markdown',
46+
},
47+
}
48+
49+
// contextualize and render the page
50+
await contextualize(renderingReq as ExtendedRequestWithPageInfo, {} as Response, () => {})
51+
renderingReq.context.page = page
52+
renderingReq.context.markdownRequested = true
53+
return await page.render(renderingReq.context)
54+
}
55+
56+
/** END helper functions */
57+
58+
/** START routes */
59+
const router = express.Router()
60+
61+
// For all these routes:
62+
// - pathValidationMiddleware ensures the path is properly structured and handles errors when it's not
63+
// - pageValidationMiddleware fetches the page from the pagelist, returns 404 to the user if not found
64+
65+
router.get(
66+
'/',
67+
pathValidationMiddleware as RequestHandler,
68+
pageValidationMiddleware as RequestHandler,
69+
catchMiddlewareError(async function (req: ExtendedRequestWithPageInfo, res: Response) {
70+
// First, fetch metadata
71+
const metaData = await getArticleMetadata(req)
72+
const bodyContent = await getArticleBody(req)
73+
74+
defaultCacheControl(res)
75+
return res.json({
76+
meta: metaData,
77+
body: bodyContent,
78+
})
79+
}),
80+
)
81+
82+
router.get(
83+
'/body',
84+
pathValidationMiddleware as RequestHandler,
85+
pageValidationMiddleware as RequestHandler,
86+
catchMiddlewareError(async function (req: ExtendedRequestWithPageInfo, res: Response) {
87+
const rendered = await getArticleBody(req)
88+
defaultCacheControl(res)
89+
return res.type('text/markdown').send(rendered)
90+
}),
91+
)
92+
93+
router.get(
94+
'/meta',
95+
pathValidationMiddleware as RequestHandler,
96+
pageValidationMiddleware as RequestHandler,
97+
catchMiddlewareError(async function (req: ExtendedRequestWithPageInfo, res: Response) {
98+
const metaData = await getArticleMetadata(req)
99+
defaultCacheControl(res)
100+
return res.json(metaData)
101+
}),
102+
)
103+
104+
/** END routes */
105+
106+
export default router

src/article-api/middleware/validation.ts

+10-1
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { ExtendedRequestWithPageInfo } from '../types'
22
import type { NextFunction, Response } from 'express'
33
import { isArchivedVersionByPath } from '@/archives/lib/is-archived-version'
44
import getRedirect from '@/redirects/lib/get-redirect.js'
5+
import { Page } from '#src/types.js'
56

67
export const pathValidationMiddleware = (
78
req: ExtendedRequestWithPageInfo,
@@ -24,7 +25,9 @@ export const pathValidationMiddleware = (
2425
if (/\s/.test(pathname)) {
2526
return res.status(400).json({ error: `'pathname' cannot contain whitespace` })
2627
}
27-
req.pageinfo = { pathname }
28+
29+
// req.pageinfo.page will be defined later or it will throw
30+
req.pageinfo = { pathname, page: {} as Page }
2831
return next()
2932
}
3033

@@ -59,6 +62,9 @@ export const pageValidationMiddleware = (
5962
pathname = `/${req.context.currentLanguage}`
6063
}
6164

65+
// Initialize archived property to avoid it being undefined
66+
req.pageinfo.archived = { isArchived: false }
67+
6268
if (!(pathname in req.context.pages)) {
6369
// If a pathname is not a known page, it might *either* be a redirect,
6470
// or an archived enterprise version, or both.
@@ -76,6 +82,9 @@ export const pageValidationMiddleware = (
7682

7783
// Remember this might yield undefined if the pathname is not a page
7884
req.pageinfo.page = req.context.pages[pathname]
85+
if (!req.pageinfo.page && !req.pageinfo.archived.isArchived) {
86+
return res.status(404).json({ error: `No page found for '${pathname}'` })
87+
}
7988
// The pathname might have changed if it was a redirect
8089
req.pageinfo.pathname = pathname
8190

src/article-api/tests/pageinfo.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ describe('pageinfo api', () => {
5050

5151
test('a pathname that does not exist', async () => {
5252
const res = await get(makeURL('/en/never/heard/of'))
53-
expect(res.statusCode).toBe(400)
53+
expect(res.statusCode).toBe(404)
5454
const { error } = JSON.parse(res.body)
5555
expect(error).toBe("No page found for '/en/never/heard/of'")
5656
})

src/article-api/types.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ export type ArchivedVersion = {
88
export type ExtendedRequestWithPageInfo = ExtendedRequest & {
99
pageinfo: {
1010
pathname: string
11-
page?: Page
11+
page: Page
1212
archived?: ArchivedVersion
1313
}
1414
}

src/frame/middleware/api.ts

+3-1
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,9 @@ import events from '@/events/middleware.js'
55
import anchorRedirect from '@/rest/api/anchor-redirect.js'
66
import aiSearch from '@/search/middleware/ai-search'
77
import search from '@/search/middleware/search-routes.js'
8-
import pageInfo from '#src/article-api/middleware/pageinfo.ts'
8+
import pageInfo from '@/article-api/middleware/pageinfo'
99
import pageList from '@/article-api/middleware/pagelist'
10+
import article from '@/article-api/middleware/article'
1011
import webhooks from '@/webhooks/middleware/webhooks.js'
1112
import { ExtendedRequest } from '@/types'
1213
import { noCacheControl } from './cache-control'
@@ -30,6 +31,7 @@ router.use('/webhooks', createAPIRateLimiter(internalRoutesRateLimit), webhooks)
3031
router.use('/anchor-redirect', createAPIRateLimiter(internalRoutesRateLimit), anchorRedirect)
3132
router.use('/pageinfo', createAPIRateLimiter(3), pageInfo)
3233
router.use('/pagelist', createAPIRateLimiter(publicRoutesRateLimit), pageList)
34+
router.use('/article', createAPIRateLimiter(publicRoutesRateLimit), article)
3335

3436
// The purpose of this is for convenience to everyone who runs this code
3537
// base locally but don't have an Elasticsearch server locally.

src/frame/middleware/context/context.ts

-2
Original file line numberDiff line numberDiff line change
@@ -39,8 +39,6 @@ export default async function contextualize(
3939
req.context.process = { env: {} }
4040

4141
if (req.pagePath && req.pagePath.endsWith('.md')) {
42-
req.context.markdownRequested = true
43-
4442
// req.pagePath is used later in the rendering pipeline to
4543
// locate the file in the tree so it cannot have .md
4644
req.pagePath = req.pagePath.replace(/\/index\.md$/, '').replace(/\.md$/, '')

src/shielding/middleware/handle-invalid-paths.ts

+10-1
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,16 @@ export default function handleInvalidPaths(
8989
// E.g. `/en/foo.md?bar=baz`
9090
const newUrl = req.originalUrl.replace(req.path, req.path.replace(/\/index\.md$/, ''))
9191
return res.redirect(newUrl)
92+
} else if (req.path.endsWith('.md')) {
93+
// encode the query params but also make them pretty so we can see
94+
// them as `/` and `@` in the address bar
95+
// e.g. /api/article/body?pathname=/en/[email protected]/admin...
96+
// NOT: /api/article/body?pathname=%2Fen%2Fenterprise-server%403.16%2Fadmin...
97+
const encodedPath = encodeURIComponent(req.path.replace(/\.md$/, ''))
98+
.replace(/%2F/g, '/')
99+
.replace(/%40/g, '@')
100+
const newUrl = `/api/article/body?pathname=${encodedPath}`
101+
return res.redirect(newUrl)
92102
}
93-
94103
return next()
95104
}

0 commit comments

Comments
 (0)