kwhitley / itty-router-extras

An assortment of delicious extras for the calorie-light itty-router.
MIT License
84 stars 12 forks source link

withParams #1

Closed kwhitley closed 3 years ago

kwhitley commented 3 years ago

Currently withParams cannot be injected upstream as global middleware, because at this point, params would have not been set (to be spread into the request).

import { Router } from 'itty-router'
import { withParams } from 'itty-router-extras'

const router = Router()

// currently works
router
  .get('/:id', withParams, ({ id }) => new Response(id))

// currently fails
router
  .all('*', withParams)
  .get('/:id', ({ id }) => new Response(id))

Discussion continues on itty-router: https://github.com/kwhitley/itty-router/issues/24

kwhitley commented 3 years ago

@mvasigh Tag!

kwhitley commented 3 years ago

Itty is wrapping up its changes, leaving the real discussion to continue here... saving the (removed) tests from itty in this thread to carry forth the topics of:

require('isomorphic-fetch')

const { Router } = require('./itty-router')

const ERROR_MESSAGE = 'Error Message'

const notProxy = prop => prop !== 'proxy'

// silence the log
console.log = v => v

// similar to watch, but takes a predicate function (receives prop, value, and request)
// to determine if fn should fire (with value and request)
const dynamicWatch = (fn, predicate = notProxy) => request => {
  request.proxy = new Proxy(request, {
    set: (obj, prop, value) => {
      obj[prop] = value
      predicate(prop, obj) && fn(value, prop, request)

      return true
    }
  })
}

// 1. this executes a watcher function when the prop changes on request
// 2. curried signature to allow the product to BE middleware, if desired
// (would export this from itty-router-extras)
const watch = (prop, fn) => dynamicWatch(fn, key => key === prop)

// predicate comes last again, to default to all things flowing through this, if not defined,
// rather than the awkward () => true syntax for using everywhere
const dynamicRetrieve = (fn, predicate = notProxy) => request => {
  request.proxy = new Proxy(request, {
    get: (obj, prop) => predicate(prop, obj)
                        ? fn(prop, request)
                        : obj[prop]
  })
}

// 1. this allows for modifying reads from the request
// 2. curried signature to allow the product to BE middleware, if desired
// (would export this from itty-router-extras)
const retrieve = (prop, fn) => dynamicRetrieve(key => key === prop, fn)

describe('Router', () => {
  const router = Router()
  const buildRequest = ({
    method = 'GET',
    path,
    url = `https://example.com${path}`,
    ...other
  }) => ({ method, path, url, ...other })
  const extract = ({ params, query }) => ({ params, query })

  const routes = [
    { path: '/', callback: jest.fn(extract), method: 'get' },
    { path: '/foo/first', callback: jest.fn(extract), method: 'get' },
    { path: '/foo/:id', callback: jest.fn(extract), method: 'get' },
    { path: '/foo', callback: jest.fn(extract), method: 'post' },
    { path: '/optional/:id?', callback: jest.fn(extract), method: 'get' },
    {
      path: '/passthrough',
      callback: jest.fn(({ path, name }) => ({ path, name })),
      method: 'get',
    },
    {
      path: '/passthrough',
      callback: jest.fn(({ path, name }) => ({ path, name })),
    },
    { path: '/wildcards/*', callback: jest.fn(), method: 'get' },
    { path: '*', callback: jest.fn(), method: 'get' },
  ]

  const applyRoutes = (router, routes) => {
    for (const route of routes) {
      router[route.method](route.path, route.callback)
    }

    return router
  }

  applyRoutes(router, routes)

  it('is exported as { Router } from module', () => {
    expect(typeof Router).toBe('function')
  })

  describe('.{method}(route: string, handler1: function, ..., handlerN: function)', () => {
    it('can accept multiple handlers (each mutates request)', async () => {
      const r = Router()
      const handler1 = jest.fn(req => { req.a = 1 })
      const handler2 = jest.fn(req => {
        req.b = 2

        return req
      })
      const handler3 = jest.fn(req => ({ c: 3, ...req }))
      r.get('/multi/:id', handler1, handler2, handler3)

      await r.handle(buildRequest({ path: '/multi/foo' }))

      // expect(handler1).toHaveBeenCalled()
      expect(handler2).toHaveBeenCalled()
      expect(handler3).not.toHaveBeenCalled()
    })
  })

  describe('.handle({ method = \'GET\', url })', () => {

    it('returns { path, query } from match', () => {
      const route = routes.find(r => r.path === '/foo/:id')
      router.handle(buildRequest({ path: '/foo/13?foo=bar&cat=dog' }))

      expect(route.callback).toHaveReturnedWith({
        params: { id: '13' },
        query: { foo: 'bar', cat: 'dog' },
      })
    })

    it('requires exact route match', () => {
      const route = routes.find(r => r.path === '/')

      router.handle(buildRequest({ path: '/foo' }))

      expect(route.callback).not.toHaveBeenCalled()
    })

    it('path of "" works with works with route ending in slash or not', async () => {
      const r = Router()
      const handler = jest.fn()

      r.get('', handler)

      await r.handle(buildRequest({ path: '/' }))
      expect(handler).toHaveBeenCalled()

      await r.handle(buildRequest({ path: '' }))
      expect(handler).toHaveBeenCalledTimes(2)
    })

    it('path of "/" works with route ending in slash or not', async () => {
      const r = Router()
      const handler = jest.fn()

      r.get('/', handler)

      await r.handle(buildRequest({ path: '/' }))
      expect(handler).toHaveBeenCalled()

      await r.handle(buildRequest({ path: '' }))
      expect(handler).toHaveBeenCalledTimes(2)
    })

    it('path of "/:id" works without leading slash', async () => {
      const r = Router()
      const handler = jest.fn(req => req.params.id)

      r.get('/:id?', handler)

      await r.handle(buildRequest({ path: '/13' }))
      expect(handler).toHaveReturnedWith('13')

      await r.handle(buildRequest({ path: '' }))
      expect(handler).toHaveBeenCalledTimes(2)
    })

    it('path of "/:id.:format?" works', async () => {
      const r = Router()
      const handler = jest.fn(req => req.params)

      r.get('/:id.:format?', handler)

      await r.handle(buildRequest({ path: '/13' }))
      expect(handler).toHaveReturnedWith({ id: '13', format: undefined })

      await r.handle(buildRequest({ path: '/13.jpg' }))
      expect(handler).toHaveReturnedWith({ id: '13', format: 'jpg' })
    })

    it('can accept dot notations (e.g. domains) as a route param', async () => {
      const r = Router()
      const handler = jest.fn(req => req.params.url)

      r.get('/:url/etc', handler)

      await r.handle(buildRequest({ path: '/domain.dev/etc' }))
      expect(handler).toHaveReturnedWith('domain.dev')
    })

    it('match earliest routes that match', () => {
      const route = routes.find(r => r.path === '/foo/first')
      router.handle(buildRequest({ path: '/foo/first' }))

      expect(route.callback).toHaveBeenCalled()
    })

    it('honors correct method (e.g. GET, POST, etc)', () => {
      const route = routes.find(r => r.path === '/foo' && r.method === 'post')
      router.handle(buildRequest({ method: 'POST', path: '/foo' }))

      expect(route.callback).toHaveBeenCalled()
    })

    it('handles optional params (e.g. /foo/:id?)', () => {
      const route = routes.find(r => r.path === '/optional/:id?')

      router.handle(buildRequest({ path: '/optional' }))
      expect(route.callback).toHaveBeenCalled()

      router.handle(buildRequest({ path: '/optional/13' }))
      expect(route.callback).toHaveBeenCalledTimes(2)
    })

    it('passes the entire original request through to the handler', () => {
      const route = routes.find(r => r.path === '/passthrough')
      router.handle(buildRequest({ path: '/passthrough', name: 'miffles' }))

      expect(route.callback).toHaveReturnedWith({
        path: '/passthrough',
        name: 'miffles',
      })
    })

    it('accepts * as a wildcard route (e.g. for use in 404)', () => {
      const route = routes.find(r => r.path === '*')
      router.handle(buildRequest({ path: '/missing' }))

      expect(route.callback).toHaveBeenCalled()

      const route2 = routes.find(r => r.path === '/wildcards/*')
      router.handle(buildRequest({ path: '/wildcards/missing' }))

      expect(route2.callback).toHaveBeenCalled()
    })

    it('allows missing handler later in flow with "all" channel', () => {
      const missingHandler = jest.fn()
      const matchHandler = jest.fn()

      const router1 = Router()
      const router2 = Router({ base: '/nested' })

      router2.get('/foo', matchHandler)
      router1
        .all('/nested/*', router2.handle)
        .all('*', missingHandler)

      router1.handle(buildRequest({ path: '/foo' }))
      expect(missingHandler).toHaveBeenCalled()

      router1.handle(buildRequest({ path: '/nested/foo' }))
      expect(matchHandler).toHaveBeenCalled()
    })

    it('defaults to GET assumption when handling new requests without { method: \'METHOD\' }', () => {
      const route = routes.find(r => r.path === '/foo')
      router.handle({ url: 'https://example.com/foo' }) // no method listed

      expect(route.callback).toHaveBeenCalled()
    })

    it('won\'t throw on unknown method', () => {
      expect(() => router.handle({ method: 'CUSTOM', url: 'https://example.com/foo' })).not.toThrow()
    })

    it('can match multiple routes if earlier handlers do not return (as middleware)', async () => {
      const r = Router()

      const middleware = req => {
        req.user = { id: 13 }
      }

      const handler = jest.fn(req => req.user.id)

      r.get('/middleware/*', middleware)
      r.get('/middleware/:id', handler)

      await r.handle(buildRequest({ path: '/middleware/foo' }))

      expect(handler).toHaveBeenCalled()
      expect(handler).toHaveReturnedWith(13)
    })

    it('can accept a basepath for routes', async () => {
      const router = Router({ base: '/api' })
      const handler = jest.fn()
      router.get('/foo/:id?', handler)

      router.handle(buildRequest({ path: '/api/foo' }))
      expect(handler).toHaveBeenCalled()

      await router.handle(buildRequest({ path: '/api/foo/13' }))
      expect(handler).toHaveBeenCalledTimes(2)
    })

    it('can pull route params from the basepath as well', async () => {
      const router = Router({ base: '/:collection' })
      const handler = jest.fn(req => req.params)
      router.get('/:id', handler)

      await router.handle(buildRequest({ path: '/todos/13' }))
      expect(handler).toHaveBeenCalled()
      expect(handler).toHaveReturnedWith({ collection: 'todos', id: '13' })
    })

    it('gracefully handles trailing slashes', async () => {
      const r = Router()

      const middleware = req => {
        req.user = { id: 13 }
      }

      const handler = jest.fn(req => req.user.id)

      r.get('/middleware/*', middleware)
      r.get('/middleware', handler)

      await r.handle(buildRequest({ path: '/middleware' }))

      expect(handler).toHaveBeenCalled()
      expect(handler).toHaveReturnedWith(13)

      await r.handle(buildRequest({ path: '/middleware/' }))

      expect(handler).toHaveBeenCalledTimes(2)
    })

    it('allow wildcards in the middle of paths', async () => {
      const r = Router()
      const handler = jest.fn()

      r.get('/foo/*/end', handler)

      await r.handle(buildRequest({ path: '/foo/bar/baz/13/end' }))

      expect(handler).toHaveBeenCalled()
    })

    it('can handle nested routers', async () => {
      const router1 = Router()
      const router2 = Router({ base: '/nested' })
      const handler1 = jest.fn()
      const handler2 = jest.fn()
      const handler3 = jest.fn()
      router1.get('/pet', handler1)
      router1.get('/nested/*', router2.handle)
      router2.get('/', handler3)
      router2.get('/bar/:id?', handler2)

      await router1.handle(buildRequest({ path: '/pet' }))
      expect(handler1).toHaveBeenCalled()

      await router1.handle(buildRequest({ path: '/nested/bar' }))
      expect(handler2).toHaveBeenCalled()

      await router1.handle(buildRequest({ path: '/nested' }))
      expect(handler3).toHaveBeenCalled()
    })

    it('allows any method to match an "all" route', async () => {
      const router = Router()
      const handler = jest.fn()
      router.all('/crud/*', handler)

      await router.handle(buildRequest({ path: '/crud/foo' }))
      expect(handler).toHaveBeenCalled()

      await router.handle(buildRequest({ method: 'POST', path: '/crud/bar' }))
      expect(handler).toHaveBeenCalledTimes(2)

      await router.handle(buildRequest({ method: 'UPDATE', path: '/crud/baz' }))
      expect(handler).toHaveBeenCalledTimes(3)
    })

    it('stops at a handler that throws', async () => {
      const router = Router()
      const handler1 = jest.fn(() => {})
      const handler2 = jest.fn(() => { throw new Error() })
      const handler3 = jest.fn(() => {})
      router.get('/foo', handler1, handler2, handler3)

      const escape = err => err

      await router
        .handle(buildRequest({ path: '/foo' }))
        .catch(escape)

      expect(handler1).toHaveBeenCalled()
      expect(handler2).toHaveBeenCalled()
      expect(handler3).not.toHaveBeenCalled()
    })

    it('can throw an error and still handle if using catch', async () => {
      const router = Router()
      const handlerWithError = jest.fn(() => { throw new Error(ERROR_MESSAGE) })
      const errorHandler = jest.fn(err => err.message)

      router.get('/foo', handlerWithError)

      await router
        .handle(buildRequest({ path: '/foo' }))
        .catch(errorHandler)

      expect(handlerWithError).toHaveBeenCalled()
      expect(errorHandler).toHaveBeenCalled()
      expect(errorHandler).toHaveReturnedWith(ERROR_MESSAGE)
    })

    it('can easily create a ThrowableRouter', async () => {
      const error = (status, message) => new Response(message, { status })
      const errorResponse = err => error(err.status || 500, err.message)

      const ThrowableRouter = options => new Proxy(Router(options), {
        get: (obj, prop) => (...args) =>
            prop === 'handle'
            ? obj[prop](...args).catch(err => error(err.status || 500, err.message))
            : obj[prop](...args)
      })

      const router = ThrowableRouter()
      const handlerWithError = jest.fn(() => { throw new Error(ERROR_MESSAGE) })

      router.get('/foo', handlerWithError)

      const response = await router.handle(buildRequest({ path: '/foo' }))

      expect(response instanceof Response).toBe(true)
      expect(response.status).toBe(500)
      expect(await response.text()).toBe(ERROR_MESSAGE)
    })

    it('requires exact path match unless wildcard', async () => {
      const router = Router()
      const handler = jest.fn()
      router.get('/foo', handler)

      await router.handle(buildRequest({ path: '/a/foo' })) // test prefix
      expect(handler).not.toHaveBeenCalled()

      await router.handle(buildRequest({ path: '/foo/d' })) // test suffix
      expect(handler).not.toHaveBeenCalled()

      await router.handle(buildRequest({ path: '/foo' })) // test exact
      expect(handler).toHaveBeenCalled()
    })

    it('allows chaining', () => {
      const router = Router()

      expect(() => {
        router
          .get('/foo', jest.fn())
          .get('/foo', jest.fn())

      }).not.toThrow()
    })
  })

  describe('.handle({ method = \'GET\', url }, ...args)', () => {
    it('passes extra args to each handler', async () => {
      const r = Router()
      const h = (req, a, b) => { req.a = a; req.b = b }
      const originalA = 'A'
      const originalB = {}
      r.get('*', h)
      const req = buildRequest({ path: '/foo', })

      await r.handle(req, originalA, originalB)

      expect(req.a).toBe(originalA)
      expect(req.b).toBe(originalB)
    })
  })

  describe('request.proxy embedding', () => {
    it('allows for upstream request-watching', async () => {
      const router = Router()
      const handler = jest.fn(req => req.id)
      const logger = jest.fn(v => v)
      const simplelogger = jest.fn(user => user.id)
      const dynamicLogger = jest.fn(user => `${user.id}*`)
      const everythingLogger = jest.fn((value, prop) => console.log(prop, 'changed to', value))

      // without a predicate, a dynamic watcher will fire on any request update
      const watchEverything = dynamicWatch(everythingLogger)

      // longhand for watch('user', fn), using the predicate to target a single prop
      const withDynamicWatch = dynamicWatch(
        dynamicLogger,
        prop => prop === 'user',
      )

      // if you need access to the request, you could do it this way
      const withUserTracking = request => {
        request.foo = 'bar' // this is here to see if the watchEverything middleware fires twice
        watch('user', user => logger({ user, url: request.url }))(request)
      }

      // but this is simpler when watching a single prop
      const withSimpleUserTracking = watch('user', simplelogger)

      // similar syntax for retrieving [dynamic] props.  No predicate 2nd param means everything
      // passes through this function.
      const withParams = dynamicRetrieve((prop, request) => request?.params[prop]
                                                            ? request.params[prop]
                                                            : request[prop])

      // just embeds user in the request... other middleware will fire as a result!
      const withUser = request => {
        request.user = { id: '15' }
      }

      router
        .all('*', watchEverything, withUserTracking, withDynamicWatch, withSimpleUserTracking, withUser, withParams)
        .get('/:id', handler)

      await router.handle(buildRequest({ path: '/13' }))

      expect(handler).toHaveReturnedWith('13')
      expect(logger).toHaveReturnedWith({ url: 'https://example.com/13', user: { id: '15' } })
      expect(simplelogger).toHaveReturnedWith('15')
      expect(logger).toHaveBeenCalledTimes(1)
      expect(everythingLogger).toHaveBeenCalledTimes(2)
      expect(dynamicLogger).toHaveReturnedWith('15*')
    })
  })
})
kwhitley commented 3 years ago

going live with v0.3.x

mvasigh commented 3 years ago

Totally didn't see this tag lol oops