Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add missing matcher support #42660

Merged
merged 2 commits into from Nov 9, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/next/build/analysis/get-page-static-info.ts
Expand Up @@ -25,6 +25,7 @@ export interface MiddlewareMatcher {
regexp: string
locale?: false
has?: RouteHas[]
missing?: RouteHas[]
}

export interface PageStaticInfo {
Expand Down
Expand Up @@ -178,8 +178,13 @@ export function getUtils({
)
let params = matcher(parsedUrl.pathname)

if (rewrite.has && params) {
const hasParams = matchHas(req, rewrite.has, parsedUrl.query)
if ((rewrite.has || rewrite.missing) && params) {
const hasParams = matchHas(
req,
parsedUrl.query,
rewrite.has,
rewrite.missing
)

if (hasParams) {
Object.assign(params, hasParams)
Expand Down
107 changes: 71 additions & 36 deletions packages/next/lib/load-custom-routes.ts
Expand Up @@ -24,6 +24,7 @@ export type Rewrite = {
basePath?: false
locale?: false
has?: RouteHas[]
missing?: RouteHas[]
}

export type Header = {
Expand All @@ -32,6 +33,7 @@ export type Header = {
locale?: false
headers: Array<{ key: string; value: string }>
has?: RouteHas[]
missing?: RouteHas[]
}

// internal type used for validation (not user facing)
Expand All @@ -41,6 +43,7 @@ export type Redirect = {
basePath?: false
locale?: false
has?: RouteHas[]
missing?: RouteHas[]
} & (
| {
statusCode?: never
Expand All @@ -56,6 +59,7 @@ export type Middleware = {
source: string
locale?: false
has?: RouteHas[]
missing?: RouteHas[]
}

const allowedHasTypes = new Set(['header', 'cookie', 'query', 'host'])
Expand Down Expand Up @@ -132,8 +136,9 @@ export function checkCustomRoutes(
let numInvalidRoutes = 0
let hadInvalidStatus = false
let hadInvalidHas = false
let hadInvalidMissing = false

const allowedKeys = new Set<string>(['source', 'locale', 'has'])
const allowedKeys = new Set<string>(['source', 'locale', 'has', 'missing'])

if (type === 'rewrite') {
allowedKeys.add('basePath')
Expand Down Expand Up @@ -198,48 +203,65 @@ export function checkCustomRoutes(
invalidParts.push('`locale` must be undefined or false')
}

if (typeof route.has !== 'undefined' && !Array.isArray(route.has)) {
invalidParts.push('`has` must be undefined or valid has object')
hadInvalidHas = true
} else if (route.has) {
const invalidHasItems = []
const checkInvalidHasMissing = (
items: any,
fieldName: 'has' | 'missing'
) => {
let hadInvalidItem = false

for (const hasItem of route.has) {
let invalidHasParts = []
if (typeof items !== 'undefined' && !Array.isArray(items)) {
invalidParts.push(
`\`${fieldName}\` must be undefined or valid has object`
)
hadInvalidItem = true
} else if (items) {
const invalidHasItems = []

if (!allowedHasTypes.has(hasItem.type)) {
invalidHasParts.push(`invalid type "${hasItem.type}"`)
}
if (typeof hasItem.key !== 'string' && hasItem.type !== 'host') {
invalidHasParts.push(`invalid key "${hasItem.key}"`)
}
if (
typeof hasItem.value !== 'undefined' &&
typeof hasItem.value !== 'string'
) {
invalidHasParts.push(`invalid value "${hasItem.value}"`)
}
if (typeof hasItem.value === 'undefined' && hasItem.type === 'host') {
invalidHasParts.push(`value is required for "host" type`)
}
for (const hasItem of items) {
let invalidHasParts = []

if (invalidHasParts.length > 0) {
invalidHasItems.push(
`${invalidHasParts.join(', ')} for ${JSON.stringify(hasItem)}`
)
if (!allowedHasTypes.has(hasItem.type)) {
invalidHasParts.push(`invalid type "${hasItem.type}"`)
}
if (typeof hasItem.key !== 'string' && hasItem.type !== 'host') {
invalidHasParts.push(`invalid key "${hasItem.key}"`)
}
if (
typeof hasItem.value !== 'undefined' &&
typeof hasItem.value !== 'string'
) {
invalidHasParts.push(`invalid value "${hasItem.value}"`)
}
if (typeof hasItem.value === 'undefined' && hasItem.type === 'host') {
invalidHasParts.push(`value is required for "host" type`)
}

if (invalidHasParts.length > 0) {
invalidHasItems.push(
`${invalidHasParts.join(', ')} for ${JSON.stringify(hasItem)}`
)
}
}
}

if (invalidHasItems.length > 0) {
hadInvalidHas = true
const itemStr = `item${invalidHasItems.length === 1 ? '' : 's'}`
if (invalidHasItems.length > 0) {
hadInvalidItem = true
const itemStr = `item${invalidHasItems.length === 1 ? '' : 's'}`

console.error(
`Invalid \`has\` ${itemStr}:\n` + invalidHasItems.join('\n')
)
console.error()
invalidParts.push(`invalid \`has\` ${itemStr} found`)
console.error(
`Invalid \`${fieldName}\` ${itemStr}:\n` +
invalidHasItems.join('\n')
)
console.error()
invalidParts.push(`invalid \`${fieldName}\` ${itemStr} found`)
}
}
return hadInvalidItem
}
if (checkInvalidHasMissing(route.has, 'has')) {
hadInvalidHas = true
}
if (checkInvalidHasMissing(route.missing, 'missing')) {
hadInvalidMissing = true
}

if (!route.source) {
Expand Down Expand Up @@ -421,6 +443,19 @@ export function checkCustomRoutes(
)}`
)
}
if (hadInvalidMissing) {
console.error(
`\nValid \`missing\` object shape is ${JSON.stringify(
{
type: [...allowedHasTypes].join(', '),
key: 'the key to check for',
value: 'undefined or a value string to match against',
},
null,
2
)}`
)
}
console.error()
console.error(
`Error: Invalid ${type}${numInvalidRoutes === 1 ? '' : 's'} found`
Expand Down
10 changes: 8 additions & 2 deletions packages/next/server/router.ts
Expand Up @@ -30,6 +30,7 @@ type RouteResult = {
export type Route = {
match: RouteMatch
has?: RouteHas[]
missing?: RouteHas[]
type: string
check?: boolean
statusCode?: number
Expand Down Expand Up @@ -416,8 +417,13 @@ export default class Router {
})

let params = route.match(matchPathname)
if (route.has && params) {
const hasParams = matchHas(req, route.has, parsedUrlUpdated.query)
if ((route.has || route.missing) && params) {
const hasParams = matchHas(
req,
parsedUrlUpdated.query,
route.has,
route.missing
)
if (hasParams) {
Object.assign(params, hasParams)
} else {
Expand Down
Expand Up @@ -25,8 +25,8 @@ export function getMiddlewareRouteMatcher(
continue
}

if (matcher.has) {
const hasParams = matchHas(req, matcher.has, query)
if (matcher.has || matcher.missing) {
const hasParams = matchHas(req, query, matcher.has, matcher.missing)
if (!hasParams) {
continue
}
Expand Down
13 changes: 9 additions & 4 deletions packages/next/shared/lib/router/utils/prepare-destination.ts
Expand Up @@ -42,12 +42,13 @@ function unescapeSegments(str: string) {

export function matchHas(
req: BaseNextRequest | IncomingMessage,
has: RouteHas[],
query: Params
query: Params,
has: RouteHas[] = [],
missing: RouteHas[] = []
): false | Params {
const params: Params = {}

const allMatch = has.every((hasItem) => {
const hasMatch = (hasItem: RouteHas) => {
let value: undefined | string
let key = hasItem.key

Expand Down Expand Up @@ -100,7 +101,11 @@ export function matchHas(
}
}
return false
})
}

const allMatch =
has.every((item) => hasMatch(item)) &&
!missing.some((item) => hasMatch(item))

if (allMatch) {
return params
Expand Down
5 changes: 3 additions & 2 deletions packages/next/shared/lib/router/utils/resolve-rewrites.ts
Expand Up @@ -44,7 +44,7 @@ export default function resolveRewrites(

let params = matcher(parsedAs.pathname)

if (rewrite.has && params) {
if ((rewrite.has || rewrite.missing) && params) {
const hasParams = matchHas(
{
headers: {
Expand All @@ -58,8 +58,9 @@ export default function resolveRewrites(
return acc
}, {}),
} as any,
parsedAs.query,
rewrite.has,
parsedAs.query
rewrite.missing
)

if (hasParams) {
Expand Down
20 changes: 20 additions & 0 deletions test/e2e/middleware-custom-matchers/app/middleware.js
Expand Up @@ -57,5 +57,25 @@ export const config = {
},
],
},
{
source: '/missing-match-1',
missing: [
{
type: 'header',
key: 'hello',
value: '(.*)',
},
],
},
{
source: '/missing-match-2',
missing: [
{
type: 'query',
key: 'test',
value: 'value',
},
],
},
],
}
22 changes: 22 additions & 0 deletions test/e2e/middleware-custom-matchers/test/index.test.ts
Expand Up @@ -21,6 +21,28 @@ describe('Middleware custom matchers', () => {
afterAll(() => next.destroy())

const runTests = () => {
it('should match missing header correctly', async () => {
const res = await fetchViaHTTP(next.url, '/missing-match-1')
expect(res.headers.get('x-from-middleware')).toBeDefined()

const res2 = await fetchViaHTTP(next.url, '/missing-match-1', undefined, {
headers: {
hello: 'world',
},
})
expect(res2.headers.get('x-from-middleware')).toBeFalsy()
})

it('should match missing query correctly', async () => {
const res = await fetchViaHTTP(next.url, '/missing-match-2')
expect(res.headers.get('x-from-middleware')).toBeDefined()

const res2 = await fetchViaHTTP(next.url, '/missing-match-2', {
test: 'value',
})
expect(res2.headers.get('x-from-middleware')).toBeFalsy()
})

it('should match source path', async () => {
const res = await fetchViaHTTP(next.url, '/source-match')
expect(res.status).toBe(200)
Expand Down
32 changes: 32 additions & 0 deletions test/integration/custom-routes/next.config.js
Expand Up @@ -204,6 +204,38 @@ module.exports = {
],
destination: '/blog-catchall/:post',
},
{
source: '/missing-rewrite-1',
missing: [
{
type: 'header',
key: 'x-my-header',
value: '(?<myHeader>.*)',
},
],
destination: '/with-params',
},
{
source: '/missing-rewrite-2',
missing: [
{
type: 'query',
key: 'my-query',
},
],
destination: '/with-params',
},
{
source: '/missing-rewrite-3',
missing: [
{
type: 'cookie',
key: 'loggedIn',
value: '(?<loggedIn>true)',
},
],
destination: '/with-params?authorized=1',
},
{
source: '/blog/about',
destination: '/hello',
Expand Down