Skip to content

Commit

Permalink
Add routes for /article, /article/body, and /article/meta (#54652)
Browse files Browse the repository at this point in the history
  • Loading branch information
hectorsector authored Mar 4, 2025
1 parent ce9bd49 commit 2f4c6a4
Show file tree
Hide file tree
Showing 7 changed files with 131 additions and 7 deletions.
106 changes: 106 additions & 0 deletions src/article-api/middleware/article.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import type { RequestHandler, Response } from 'express'
import express from 'express'

import { defaultCacheControl } from '@/frame/middleware/cache-control.js'
import { Context } from '#src/types.js'
import catchMiddlewareError from '@/observability/middleware/catch-middleware-error.js'
import { ExtendedRequestWithPageInfo } from '../types'
import { pageValidationMiddleware, pathValidationMiddleware } from './validation'
import contextualize from '#src/frame/middleware/context/context.js'

/** START helper functions */

// for now, we're just querying pageinfo, we'll likely replace /api/pageinfo
// with /api/meta and move or reference that code here
async function getArticleMetadata(req: ExtendedRequestWithPageInfo) {
const queryString = new URLSearchParams(req.query as Record<string, string>).toString()
const apiUrl = `${req.protocol}://${req.get('host')}/api/pageinfo${queryString ? `?${queryString}` : ''}`

// Fetch the data from the pageinfo API
const response = await fetch(apiUrl)

// Check if the response is OK
if (!response.ok) {
const errorText = await response.text()
throw new Error(`Failed to fetch metadata: ${response.status} ${errorText}`)
}

return await response.json()
}

async function getArticleBody(req: ExtendedRequestWithPageInfo) {
// req.pageinfo is set from pageValidationMiddleware and pathValidationMiddleware
// and is in the ExtendedRequestWithPageInfo
const { page, pathname } = req.pageinfo

// these parts allow us to render the page
const mockedContext: Context = {}
const renderingReq = {
path: pathname,
language: page.languageCode,
pagePath: pathname,
cookies: {},
context: mockedContext,
headers: {
'content-type': 'text/markdown',
},
}

// contextualize and render the page
await contextualize(renderingReq as ExtendedRequestWithPageInfo, {} as Response, () => {})
renderingReq.context.page = page
renderingReq.context.markdownRequested = true
return await page.render(renderingReq.context)
}

/** END helper functions */

/** START routes */
const router = express.Router()

// For all these routes:
// - pathValidationMiddleware ensures the path is properly structured and handles errors when it's not
// - pageValidationMiddleware fetches the page from the pagelist, returns 404 to the user if not found

router.get(
'/',
pathValidationMiddleware as RequestHandler,
pageValidationMiddleware as RequestHandler,
catchMiddlewareError(async function (req: ExtendedRequestWithPageInfo, res: Response) {
// First, fetch metadata
const metaData = await getArticleMetadata(req)
const bodyContent = await getArticleBody(req)

defaultCacheControl(res)
return res.json({
meta: metaData,
body: bodyContent,
})
}),
)

router.get(
'/body',
pathValidationMiddleware as RequestHandler,
pageValidationMiddleware as RequestHandler,
catchMiddlewareError(async function (req: ExtendedRequestWithPageInfo, res: Response) {
const rendered = await getArticleBody(req)
defaultCacheControl(res)
return res.type('text/markdown').send(rendered)
}),
)

router.get(
'/meta',
pathValidationMiddleware as RequestHandler,
pageValidationMiddleware as RequestHandler,
catchMiddlewareError(async function (req: ExtendedRequestWithPageInfo, res: Response) {
const metaData = await getArticleMetadata(req)
defaultCacheControl(res)
return res.json(metaData)
}),
)

/** END routes */

export default router
11 changes: 10 additions & 1 deletion src/article-api/middleware/validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { ExtendedRequestWithPageInfo } from '../types'
import type { NextFunction, Response } from 'express'
import { isArchivedVersionByPath } from '@/archives/lib/is-archived-version'
import getRedirect from '@/redirects/lib/get-redirect.js'
import { Page } from '#src/types.js'

export const pathValidationMiddleware = (
req: ExtendedRequestWithPageInfo,
Expand All @@ -24,7 +25,9 @@ export const pathValidationMiddleware = (
if (/\s/.test(pathname)) {
return res.status(400).json({ error: `'pathname' cannot contain whitespace` })
}
req.pageinfo = { pathname }

// req.pageinfo.page will be defined later or it will throw
req.pageinfo = { pathname, page: {} as Page }
return next()
}

Expand Down Expand Up @@ -59,6 +62,9 @@ export const pageValidationMiddleware = (
pathname = `/${req.context.currentLanguage}`
}

// Initialize archived property to avoid it being undefined
req.pageinfo.archived = { isArchived: false }

if (!(pathname in req.context.pages)) {
// If a pathname is not a known page, it might *either* be a redirect,
// or an archived enterprise version, or both.
Expand All @@ -76,6 +82,9 @@ export const pageValidationMiddleware = (

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

Expand Down
2 changes: 1 addition & 1 deletion src/article-api/tests/pageinfo.js
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ describe('pageinfo api', () => {

test('a pathname that does not exist', async () => {
const res = await get(makeURL('/en/never/heard/of'))
expect(res.statusCode).toBe(400)
expect(res.statusCode).toBe(404)
const { error } = JSON.parse(res.body)
expect(error).toBe("No page found for '/en/never/heard/of'")
})
Expand Down
2 changes: 1 addition & 1 deletion src/article-api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ export type ArchivedVersion = {
export type ExtendedRequestWithPageInfo = ExtendedRequest & {
pageinfo: {
pathname: string
page?: Page
page: Page
archived?: ArchivedVersion
}
}
4 changes: 3 additions & 1 deletion src/frame/middleware/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,9 @@ import events from '@/events/middleware.js'
import anchorRedirect from '@/rest/api/anchor-redirect.js'
import aiSearch from '@/search/middleware/ai-search'
import search from '@/search/middleware/search-routes.js'
import pageInfo from '#src/article-api/middleware/pageinfo.ts'
import pageInfo from '@/article-api/middleware/pageinfo'
import pageList from '@/article-api/middleware/pagelist'
import article from '@/article-api/middleware/article'
import webhooks from '@/webhooks/middleware/webhooks.js'
import { ExtendedRequest } from '@/types'
import { noCacheControl } from './cache-control'
Expand All @@ -30,6 +31,7 @@ router.use('/webhooks', createAPIRateLimiter(internalRoutesRateLimit), webhooks)
router.use('/anchor-redirect', createAPIRateLimiter(internalRoutesRateLimit), anchorRedirect)
router.use('/pageinfo', createAPIRateLimiter(3), pageInfo)
router.use('/pagelist', createAPIRateLimiter(publicRoutesRateLimit), pageList)
router.use('/article', createAPIRateLimiter(publicRoutesRateLimit), article)

// The purpose of this is for convenience to everyone who runs this code
// base locally but don't have an Elasticsearch server locally.
Expand Down
2 changes: 0 additions & 2 deletions src/frame/middleware/context/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,6 @@ export default async function contextualize(
req.context.process = { env: {} }

if (req.pagePath && req.pagePath.endsWith('.md')) {
req.context.markdownRequested = true

// req.pagePath is used later in the rendering pipeline to
// locate the file in the tree so it cannot have .md
req.pagePath = req.pagePath.replace(/\/index\.md$/, '').replace(/\.md$/, '')
Expand Down
11 changes: 10 additions & 1 deletion src/shielding/middleware/handle-invalid-paths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,16 @@ export default function handleInvalidPaths(
// E.g. `/en/foo.md?bar=baz`
const newUrl = req.originalUrl.replace(req.path, req.path.replace(/\/index\.md$/, ''))
return res.redirect(newUrl)
} else if (req.path.endsWith('.md')) {
// encode the query params but also make them pretty so we can see
// them as `/` and `@` in the address bar
// e.g. /api/article/body?pathname=/en/[email protected]/admin...
// NOT: /api/article/body?pathname=%2Fen%2Fenterprise-server%403.16%2Fadmin...
const encodedPath = encodeURIComponent(req.path.replace(/\.md$/, ''))
.replace(/%2F/g, '/')
.replace(/%40/g, '@')
const newUrl = `/api/article/body?pathname=${encodedPath}`
return res.redirect(newUrl)
}

return next()
}

0 comments on commit 2f4c6a4

Please sign in to comment.