Skip to content

Commit

Permalink
feat: support context.params + config.method in functions + edge …
Browse files Browse the repository at this point in the history
…functions (#5970)

* feat: support context.params in functions

* feat: support config.methods in functions

* feat: support `method` matching in edge functions

* Update src/lib/edge-functions/registry.mjs

Co-authored-by: Eduardo Bouças <mail@eduardoboucas.com>

* chore: use ts syntax of arrays

* fix: oops, method isn't an array!

* refactor: return matched route

---------

Co-authored-by: Eduardo Bouças <mail@eduardoboucas.com>
  • Loading branch information
Skn0tt and eduardoboucas committed Sep 5, 2023
1 parent 8441878 commit 6afe7bd
Show file tree
Hide file tree
Showing 12 changed files with 76 additions and 19 deletions.
2 changes: 1 addition & 1 deletion src/lib/edge-functions/proxy.mjs
Expand Up @@ -146,7 +146,7 @@ export const initializeProxy = async ({
await registry.initialize()

const url = new URL(req.url, `http://${LOCAL_HOST}:${mainPort}`)
const { functionNames, invocationMetadata, orphanedDeclarations } = registry.matchURLPath(url.pathname)
const { functionNames, invocationMetadata, orphanedDeclarations } = registry.matchURLPath(url.pathname, req.method)

// If the request matches a config declaration for an Edge Function without
// a matching function file, we warn the user.
Expand Down
7 changes: 6 additions & 1 deletion src/lib/edge-functions/registry.mjs
Expand Up @@ -302,8 +302,9 @@ export class EdgeFunctionsRegistry {

/**
* @param {string} urlPath
* @param {string} method
*/
matchURLPath(urlPath) {
matchURLPath(urlPath, method) {
const declarations = this.#bundler.mergeDeclarations(
this.#declarationsFromTOML,
this.#userFunctionConfigs,
Expand All @@ -330,6 +331,10 @@ export class EdgeFunctionsRegistry {
const routeIndexes = []

routes.forEach((route, index) => {
if (route.methods && route.methods.length !== 0 && !route.methods.includes(method)) {
return
}

if (!route.pattern.test(urlPath)) {
return
}
Expand Down
16 changes: 12 additions & 4 deletions src/lib/functions/netlify-function.mjs
Expand Up @@ -158,12 +158,22 @@ export default class NetlifyFunction {
}
}

async matchURLPath(rawPath) {
/**
* Matches all routes agains the incoming request. If a match is found, then the matched route is returned.
* @param {string} rawPath
* @param {string} method
* @returns matched route
*/
async matchURLPath(rawPath, method) {
await this.buildQueue

const path = (rawPath.endsWith('/') ? rawPath.slice(0, -1) : rawPath).toLowerCase()
const { routes = [] } = this.buildData
const isMatch = routes.some(({ expression, literal }) => {
return routes.find(({ expression, literal, methods }) => {
if (methods.length !== 0 && !methods.includes(method)) {
return false
}

if (literal !== undefined) {
return path === literal
}
Expand All @@ -176,8 +186,6 @@ export default class NetlifyFunction {

return false
})

return isMatch
}

get url() {
Expand Down
8 changes: 4 additions & 4 deletions src/lib/functions/registry.mjs
Expand Up @@ -122,12 +122,12 @@ export class FunctionsRegistry {
return this.functions.get(name)
}

async getFunctionForURLPath(urlPath) {
async getFunctionForURLPath(urlPath, method) {
for (const func of this.functions.values()) {
const isMatch = await func.matchURLPath(urlPath)
const route = await func.matchURLPath(urlPath, method)

if (isMatch) {
return func
if (route) {
return { func, route }
}
}
}
Expand Down
7 changes: 5 additions & 2 deletions src/lib/functions/server.mjs
Expand Up @@ -7,7 +7,7 @@ import jwtDecode from 'jwt-decode'

import { NETLIFYDEVERR, NETLIFYDEVLOG, error as errorExit, log } from '../../utils/command-helpers.mjs'
import { CLOCKWORK_USERAGENT, getFunctionsDistPath, getInternalFunctionsDir } from '../../utils/functions/index.mjs'
import { NFFunctionName } from '../../utils/headers.mjs'
import { NFFunctionName, NFFunctionRoute } from '../../utils/headers.mjs'
import { headers as efHeaders } from '../edge-functions/headers.mjs'
import { getGeoLocation } from '../geo-location.mjs'

Expand Down Expand Up @@ -56,11 +56,13 @@ export const createHandler = function (options) {
const { functionsRegistry } = options

return async function handler(request, response) {
// If this header is set, it means we've already matched a function and we
// If these headers are set, it means we've already matched a function and we
// can just grab its name directly. We delete the header from the request
// because we don't want to expose it to user code.
let functionName = request.header(NFFunctionName)
delete request.headers[NFFunctionName]
const functionRoute = request.header(NFFunctionRoute)
delete request.headers[NFFunctionRoute]

// If we didn't match a function with a custom route, let's try to match
// using the fixed URL format.
Expand Down Expand Up @@ -148,6 +150,7 @@ export const createHandler = function (options) {
isBase64Encoded,
rawUrl,
rawQuery,
route: functionRoute,
}

const clientContext = buildClientContext(request.headers) || {}
Expand Down
1 change: 1 addition & 0 deletions src/utils/headers.mjs
Expand Up @@ -47,4 +47,5 @@ const getErrorMessage = function ({ message }) {
}

export const NFFunctionName = 'x-nf-function-name'
export const NFFunctionRoute = 'x-nf-function-route'
export const NFRequestID = 'x-nf-request-id'
13 changes: 8 additions & 5 deletions src/utils/proxy.mjs
Expand Up @@ -31,7 +31,7 @@ import renderErrorTemplate from '../lib/render-error-template.mjs'

import { NETLIFYDEVLOG, NETLIFYDEVWARN, log, chalk } from './command-helpers.mjs'
import createStreamPromise from './create-stream-promise.mjs'
import { headersForPath, parseHeaders, NFFunctionName, NFRequestID } from './headers.mjs'
import { headersForPath, parseHeaders, NFFunctionName, NFRequestID, NFFunctionRoute } from './headers.mjs'
import { generateRequestID } from './request-id.mjs'
import { createRewriter, onChanges } from './rules-proxy.mjs'
import { signRedirect } from './sign-redirect.mjs'
Expand Down Expand Up @@ -328,7 +328,8 @@ const serveRedirect = async function ({ env, functionsRegistry, match, options,
return proxy.web(req, res, { target: options.functionsServer })
}

const functionWithCustomRoute = functionsRegistry && (await functionsRegistry.getFunctionForURLPath(destURL))
const functionWithCustomRoute =
functionsRegistry && (await functionsRegistry.getFunctionForURLPath(destURL, req.method))
const destStaticFile = await getStatic(dest.pathname, options.publicFolder)
let statusValue
if (
Expand All @@ -342,7 +343,9 @@ const serveRedirect = async function ({ env, functionsRegistry, match, options,
}

if (isFunction(options.functionsPort, req.url) || functionWithCustomRoute) {
const functionHeaders = functionWithCustomRoute ? { [NFFunctionName]: functionWithCustomRoute.name } : {}
const functionHeaders = functionWithCustomRoute
? { [NFFunctionName]: functionWithCustomRoute.func.name, [NFFunctionRoute]: functionWithCustomRoute.route }
: {}
const url = reqToURL(req, originalURL)
req.headers['x-netlify-original-pathname'] = url.pathname
req.headers['x-netlify-original-search'] = url.search
Expand Down Expand Up @@ -600,12 +603,12 @@ const onRequest = async (
}

// Does the request match a function on a custom URL path?
const functionMatch = functionsRegistry ? await functionsRegistry.getFunctionForURLPath(req.url) : null
const functionMatch = functionsRegistry ? await functionsRegistry.getFunctionForURLPath(req.url, req.method) : null

if (functionMatch) {
// Setting an internal header with the function name so that we don't
// have to match the URL again in the functions server.
const headers = { [NFFunctionName]: functionMatch.name }
const headers = { [NFFunctionName]: functionMatch.func.name, [NFFunctionRoute]: functionMatch.route.pattern }

return proxy.web(req, res, { headers, target: functionsServer })
}
Expand Down
@@ -0,0 +1,6 @@
export default (_, context) => new Response(`Deleted item successfully: ${context.params.sku}`)

export const config = {
path: '/products/:sku',
method: 'DELETE',
}
@@ -1,4 +1,4 @@
export default async (req) => new Response(`With expression path: ${req.url}`)
export default async (req, context) => new Response(`With expression path: ${JSON.stringify(context.params)}`)

export const config = {
path: '/products/:sku',
Expand Down
@@ -0,0 +1,6 @@
export default async (req, context) => new Response(`Deleted item successfully: ${context.params.sku}`)

export const config = {
path: '/products/:sku',
method: "DELETE"
}
18 changes: 18 additions & 0 deletions tests/integration/commands/dev/edge-functions.test.ts
Expand Up @@ -45,6 +45,24 @@ describe('edge functions', () => {
product: 'bar',
})
})

test<FixtureTestContext>('should respect config.methods field', async ({ devServer }) => {
const responseGet = await got(`http://localhost:${devServer.port}/products/really-bad-product`, {
method: "GET",
throwHttpErrors: false,
retry: { limit: 0 },
})

expect(responseGet.statusCode).toBe(404)

const responseDelete = await got(`http://localhost:${devServer.port}/products/really-bad-product`, {
method: "DELETE",
throwHttpErrors: false,
retry: { limit: 0 },
})

expect(responseDelete.body).toEqual('Deleted item successfully: really-bad-product')
})
})

setupFixtureTests('dev-server-with-edge-functions', { devServer: true }, () => {
Expand Down
9 changes: 8 additions & 1 deletion tests/integration/commands/dev/v2-api.test.ts
Expand Up @@ -112,11 +112,18 @@ describe.runIf(gte(version, '18.13.0'))('v2 api', () => {
expect(await response.text()).toBe(`With literal path: ${url}`)
})

test<FixtureTestContext>('supports custom URLs with method matching', async ({ devServer }) => {
const url = `http://localhost:${devServer.port}/products/really-bad-product`
const response = await fetch(url, { method: 'DELETE' })
expect(response.status).toBe(200)
expect(await response.text()).toBe(`Deleted item successfully: really-bad-product`)
})

test<FixtureTestContext>('supports custom URLs using an expression path', async ({ devServer }) => {
const url = `http://localhost:${devServer.port}/products/netlify`
const response = await fetch(url)
expect(response.status).toBe(200)
expect(await response.text()).toBe(`With expression path: ${url}`)
expect(await response.text()).toBe(`With expression path: {"sku":"netlify"}`)
})

describe('handles rewrites to a function', () => {
Expand Down

2 comments on commit 6afe7bd

@github-actions
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📊 Benchmark results

  • Dependency count: 1,331
  • Package size: 295 MB

@github-actions
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📊 Benchmark results

  • Dependency count: 1,331
  • Package size: 295 MB

Please sign in to comment.