Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions src/client/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,23 @@ export type UrlFor<Routes extends LookupList, Options extends any = URLOptions>
>
): { method: 'DELETE'; url: string; form: { action: string; method: 'DELETE' } }

/**
* Make URL for a QUERY route. An error will be raised if the route doesn't
* exist.
*
* ```ts
* urlFor.query('users.search') // { method: 'QUERY', url: '/users/search' }
* urlFor.query('users.show', [1]) // Error: Route not found QUERY@users.show
* ```
*/
query<RouteIdentifier extends keyof Routes['QUERY'] & string>(
...[identifier, params, options]: RouteBuilderArguments<
RouteIdentifier,
Routes['QUERY'][RouteIdentifier],
Options
>
): { method: 'QUERY'; url: string; form: { action: string; method: 'QUERY' } }

/**
* Make URL for a custom route method. An error will be raised if the route doesn't
* exist for the same method.
Expand Down
17 changes: 17 additions & 0 deletions src/client/url_builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,23 @@ export function createUrlBuilder<Routes extends LookupList>(
}
}

urlFor.query = function urlForMethodQuery(...[identifier, params, options]) {
const method = 'QUERY'
const url = createUrlForRoute(identifier, params, options, method)

return {
url,
method,
toString() {
return url
},
form: {
action: url,
method,
},
}
}

urlFor.method = function urlForCustomMethod(method, ...[identifier, params, options]) {
const url = createUrlForRoute(identifier, params, options, method)
return {
Expand Down
20 changes: 20 additions & 0 deletions src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,26 @@ export const E_CANNOT_LOOKUP_ROUTE = createError<[routeIdentifier: string]>(
500
)

/**
* Thrown when a QUERY request is received without a valid Content-Type header.
*
* RFC 10008 requires servers to fail QUERY requests when the Content-Type
* request header is missing, since the request content and its media type
* together define the query.
*
* @see https://www.rfc-editor.org/rfc/rfc10008.html#section-2
*
* @example
* ```ts
* throw new E_INVALID_QUERY_CONTENT_TYPE()
* ```
*/
export const E_INVALID_QUERY_CONTENT_TYPE = createError(
'QUERY requests must have a valid Content-Type header',
'E_INVALID_QUERY_CONTENT_TYPE',
400
)

/**
* A generic HTTP exception for converting errors to HTTP responses.
*
Expand Down
13 changes: 13 additions & 0 deletions src/router/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -333,6 +333,19 @@ export class Router extends Macroable {
return this.route(pattern, ['DELETE'], handler)
}

/**
* Define `QUERY` route
* @param pattern - The route pattern
* @param handler - Route handler (function, string, or controller tuple)
* @returns The created route instance
*/
query<T extends Constructor<any>>(
pattern: string,
handler: string | RouteFn | [LazyImport<T> | T, GetControllerHandlers<T>?]
) {
return this.route(pattern, ['QUERY'], handler)
}

/**
* Creates a group of routes. A route group can apply transforms
* to routes in bulk
Expand Down
17 changes: 17 additions & 0 deletions src/router/signed_url_builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,23 @@ export function createSignedUrlBuilder<Routes extends LookupList>(
}
}

signedRoute.query = function routeQuery(...[identifier, params, options]) {
const method = 'QUERY'
const url = createSignedUrlForRoute(identifier, params, options, method)

return {
url,
method,
toString() {
return url
},
form: {
action: url,
method,
},
}
}

signedRoute.method = function routeGet(method, ...[identifier, params, options]) {
const url = createSignedUrlForRoute(identifier, params, options, method)

Expand Down
12 changes: 12 additions & 0 deletions src/server/factories/route_finder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
* file that was distributed with this source code.
*/

import typeIs from 'type-is'
import type { ContainerResolver } from '@adonisjs/fold'

import * as errors from '../../errors.ts'
Expand All @@ -22,6 +23,8 @@ import type { ServerErrorHandler } from '../../types/server.ts'
* - Extracts route parameters and subdomains
* - Executes the matched route's handler and middleware
* - Throws E_ROUTE_NOT_FOUND error for unmatched requests
* - Throws E_INVALID_QUERY_CONTENT_TYPE error for QUERY requests without
* a valid Content-Type header (RFC 10008, section 2)
*
* @param router - Router instance containing registered routes
* @param resolver - Container resolver for dependency injection
Expand Down Expand Up @@ -54,6 +57,15 @@ export function routeFinder(
const route = router.match(url, method, ctx.request.parsedUrl.shouldDecodeParam, hostname)

if (route) {
/**
* RFC 10008 requires servers to fail QUERY requests with a missing
* Content-Type header. The check runs before the route middleware
* (including the bodyparser) so the request content is never read.
*/
if (method === 'QUERY' && !typeIs.is(ctx.request.header('content-type') ?? '', ['*/*'])) {
return Promise.reject(new errors.E_INVALID_QUERY_CONTENT_TYPE())
}

ctx.params = route.params
ctx.subdomains = route.subdomains
ctx.route = route.route
Expand Down
20 changes: 20 additions & 0 deletions tests/router/router.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ test.group('Router | add', () => {
const putRoute = router.put('/', '#controllers/home.update')
const patchRoute = router.patch('/', '#controllers/home.updatePatch')
const deleteRoute = router.delete('/', '#controllers/home.destroy')
const queryRoute = router.query('/', '#controllers/home.search')
const anyRoute = router.any('/', '#controllers/home.handle')

assert.containSubset(getRoute.toJSON(), {
Expand Down Expand Up @@ -70,6 +71,15 @@ test.group('Router | add', () => {
name: 'home.destroy',
})

assert.containSubset(queryRoute.toJSON(), {
pattern: '/',
methods: ['QUERY'],
meta: {},
matchers: {},
domain: 'root',
name: 'home.search',
})

assert.containSubset(anyRoute.toJSON(), {
pattern: '/',
methods: ['HEAD', 'OPTIONS', 'GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
Expand Down Expand Up @@ -1192,6 +1202,7 @@ test.group('Router | handler', () => {
async update() {}
async updatePatch() {}
async destroy() {}
async search() {}
async handle() {}
}

Expand Down Expand Up @@ -1221,6 +1232,10 @@ test.group('Router | handler', () => {
assert.isObject(deleteRoute.toJSON().handler)
assert.property(deleteRoute.toJSON().handler, 'handle')

const queryRoute = router.query('/', [HomeController, 'search'])
assert.isObject(queryRoute.toJSON().handler)
assert.property(queryRoute.toJSON().handler, 'handle')

const anyRoute = router.any('/', [HomeController, 'handle'])
assert.isObject(anyRoute.toJSON().handler)
assert.property(anyRoute.toJSON().handler, 'handle')
Expand All @@ -1234,6 +1249,7 @@ test.group('Router | handler', () => {
async update() {}
async updatePatch() {}
async destroy() {}
async search() {}
async handle() {}
}

Expand All @@ -1257,6 +1273,10 @@ test.group('Router | handler', () => {
assert.isObject(deleteRoute.toJSON().handler)
assert.property(deleteRoute.toJSON().handler, 'handle')

const queryRoute = router.query('/', [HomeController, 'search'])
assert.isObject(queryRoute.toJSON().handler)
assert.property(queryRoute.toJSON().handler, 'handle')

const anyRoute = router.any('/', [HomeController, 'handle'])
assert.isObject(anyRoute.toJSON().handler)
assert.property(anyRoute.toJSON().handler, 'handle')
Expand Down
28 changes: 27 additions & 1 deletion tests/router/url_builder.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,12 +103,20 @@ test.group('URLBuilder', () => {
paramsTuple: [string]
}
}
QUERY: {
'users.show': {
params: { id: string }
paramsTuple: [string]
}
}
}>()
.merge({ router })
.create()

router.route('/users', ['POST'], () => {}).as('users.index')
router.route('/users/:id', ['GET', 'PUT', 'PATCH', 'DELETE'], () => {}).as('users.show')
router
.route('/users/:id', ['GET', 'PUT', 'PATCH', 'DELETE', 'QUERY'], () => {})
.as('users.show')
router.commit()

assert.containSubset(urlFor.get('users.show', { id: '1' }), { method: 'GET', url: '/users/1' })
Expand Down Expand Up @@ -158,6 +166,16 @@ test.group('URLBuilder', () => {
})
assert.equal(`${urlFor.delete('users.show', { id: '1' })}`, '/users/1')

assert.containSubset(urlFor.query('users.show', { id: '1' }), {
method: 'QUERY',
url: '/users/1',
})
assert.deepEqual(urlFor.query('users.show', { id: '1' }).form, {
method: 'QUERY',
action: '/users/1',
})
assert.equal(`${urlFor.query('users.show', { id: '1' })}`, '/users/1')

assert.containSubset(urlFor.method('GET', 'users.show', { id: '1' }), {
method: 'GET',
url: '/users/1',
Expand Down Expand Up @@ -251,6 +269,12 @@ test.group('URLBuilder', () => {
paramsTuple: [string]
}
}
QUERY: {
'users.search': {
params?: {}
paramsTuple: [string]
}
}
}>()
.merge({ router, encryption })
.create()
Expand All @@ -259,6 +283,7 @@ test.group('URLBuilder', () => {
router.route('/users/:id', ['GET'], () => {}).as('users.show')
router.route('/users/:id', ['PUT', 'PATCH'], () => {}).as('users.update')
router.route('/users/:id', ['DELETE'], () => {}).as('users.delete')
router.route('/users/search', ['QUERY'], () => {}).as('users.search')
router.commit()

function verifySignature(uri: string) {
Expand All @@ -276,6 +301,7 @@ test.group('URLBuilder', () => {
verifySignature(`${signedUrlFor.put('users.update', { id: '1' })}`)
verifySignature(`${signedUrlFor.patch('users.update', { id: '1' })}`)
verifySignature(`${signedUrlFor.delete('users.delete', { id: '1' })}`)
verifySignature(`${signedUrlFor.query('users.search')}`)
verifySignature(`${signedUrlFor.method('GET', 'users.show', { id: '1' })}`)
})

Expand Down
Loading