diff --git a/src/client/types.ts b/src/client/types.ts index 35daf8f..233715c 100644 --- a/src/client/types.ts +++ b/src/client/types.ts @@ -223,6 +223,23 @@ export type UrlFor > ): { 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( + ...[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. diff --git a/src/client/url_builder.ts b/src/client/url_builder.ts index 5224bc8..d33b8ed 100644 --- a/src/client/url_builder.ts +++ b/src/client/url_builder.ts @@ -160,6 +160,23 @@ export function createUrlBuilder( } } + 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 { diff --git a/src/errors.ts b/src/errors.ts index f3a6414..33441c1 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -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. * diff --git a/src/router/main.ts b/src/router/main.ts index 12dd74e..924e0da 100644 --- a/src/router/main.ts +++ b/src/router/main.ts @@ -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>( + pattern: string, + handler: string | RouteFn | [LazyImport | T, GetControllerHandlers?] + ) { + return this.route(pattern, ['QUERY'], handler) + } + /** * Creates a group of routes. A route group can apply transforms * to routes in bulk diff --git a/src/router/signed_url_builder.ts b/src/router/signed_url_builder.ts index d59f7ea..c01f315 100644 --- a/src/router/signed_url_builder.ts +++ b/src/router/signed_url_builder.ts @@ -162,6 +162,23 @@ export function createSignedUrlBuilder( } } + 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) diff --git a/src/server/factories/route_finder.ts b/src/server/factories/route_finder.ts index 2c4206d..f5260cb 100644 --- a/src/server/factories/route_finder.ts +++ b/src/server/factories/route_finder.ts @@ -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' @@ -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 @@ -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 diff --git a/tests/router/router.spec.ts b/tests/router/router.spec.ts index 8f2e398..cd3d01c 100644 --- a/tests/router/router.spec.ts +++ b/tests/router/router.spec.ts @@ -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(), { @@ -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'], @@ -1192,6 +1202,7 @@ test.group('Router | handler', () => { async update() {} async updatePatch() {} async destroy() {} + async search() {} async handle() {} } @@ -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') @@ -1234,6 +1249,7 @@ test.group('Router | handler', () => { async update() {} async updatePatch() {} async destroy() {} + async search() {} async handle() {} } @@ -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') diff --git a/tests/router/url_builder.spec.ts b/tests/router/url_builder.spec.ts index 63f8059..7e3897a 100644 --- a/tests/router/url_builder.spec.ts +++ b/tests/router/url_builder.spec.ts @@ -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' }) @@ -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', @@ -251,6 +269,12 @@ test.group('URLBuilder', () => { paramsTuple: [string] } } + QUERY: { + 'users.search': { + params?: {} + paramsTuple: [string] + } + } }>() .merge({ router, encryption }) .create() @@ -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) { @@ -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' })}`) }) diff --git a/tests/server.spec.ts b/tests/server.spec.ts index 492a65e..46ffae7 100644 --- a/tests/server.spec.ts +++ b/tests/server.spec.ts @@ -23,6 +23,7 @@ import { } from '../src/tracing_channels.ts' import { Router } from '../src/router/main.ts' +import { httpServer as httpServerFactory } from '../factories/http_server.ts' import { HttpContext } from '../src/http_context/main.ts' import { ServerFactory } from '../factories/server_factory.ts' import { defineNamedMiddleware } from '../src/define_middleware.ts' @@ -178,6 +179,175 @@ test.group('Server | Response handling', () => { assert.equal(text, 'handled') }) + test('invoke router handler for QUERY method requests', async ({ assert }) => { + const app = new AppFactory().create(BASE_URL, () => {}) + const server = new ServerFactory().merge({ app }).create() + + await app.init() + + server.use([]) + server.getRouter().query('/users/search', async ({ request }) => request.method()) + await server.boot() + + const { url } = await httpServerFactory.create(server.handle.bind(server)) + const response = await fetch(`${url}/users/search`, { + method: 'QUERY', + body: 'name = "virk"', + }) + + assert.equal(response.status, 200) + assert.equal(await response.text(), 'QUERY') + }) + + test('reject QUERY requests without a Content-Type header', async ({ assert }) => { + const app = new AppFactory().create(BASE_URL, () => {}) + const server = new ServerFactory().merge({ app }).create() + + await app.init() + + let routeHandlerInvoked = false + server.use([]) + server.getRouter().query('/users/search', async () => { + routeHandlerInvoked = true + return 'handled' + }) + await server.boot() + + const { url } = await httpServerFactory.create(server.handle.bind(server)) + + /** + * Fetch does not set a Content-Type header for binary bodies + */ + const response = await fetch(`${url}/users/search`, { + method: 'QUERY', + body: new TextEncoder().encode('name = "virk"'), + }) + + assert.equal(response.status, 400) + assert.equal(await response.text(), 'QUERY requests must have a valid Content-Type header') + assert.isFalse(routeHandlerInvoked) + }) + + test('reject QUERY requests with a malformed Content-Type header', async ({ assert }) => { + const app = new AppFactory().create(BASE_URL, () => {}) + const server = new ServerFactory().merge({ app }).create() + + await app.init() + + server.use([]) + server.getRouter().query('/users/search', async () => 'handled') + await server.boot() + + const { url } = await httpServerFactory.create(server.handle.bind(server)) + + for (const contentType of ['', 'json', 'application/', '/json', 'application json']) { + const response = await fetch(`${url}/users/search`, { + method: 'QUERY', + headers: { 'content-type': contentType }, + body: '{"name":"virk"}', + }) + + assert.equal(response.status, 400, `expected 400 for content-type "${contentType}"`) + } + }) + + test('accept QUERY requests with a valid Content-Type header', async ({ assert }) => { + const app = new AppFactory().create(BASE_URL, () => {}) + const server = new ServerFactory().merge({ app }).create() + + await app.init() + + server.use([]) + server.getRouter().query('/users/search', async ({ request }) => request.header('content-type')) + await server.boot() + + const { url } = await httpServerFactory.create(server.handle.bind(server)) + + for (const contentType of [ + 'application/json', + 'application/json; charset=utf-8', + 'application/x-www-form-urlencoded', + 'application/sql', + ]) { + const response = await fetch(`${url}/users/search`, { + method: 'QUERY', + headers: { 'content-type': contentType }, + body: '{"name":"virk"}', + }) + + assert.equal(response.status, 200) + assert.equal(await response.text(), contentType) + } + }) + + test('pass QUERY Content-Type errors to the error handler', async ({ assert }) => { + const app = new AppFactory().create(BASE_URL, () => {}) + const server = new ServerFactory().merge({ app }).create() + + await app.init() + + server.use([]) + server.getRouter().query('/users/search', async () => 'handled') + server.errorHandler(async () => { + return { + default: class ErrorHandler { + report() {} + handle(error: any, { response }: HttpContext) { + response.status(error.status).send(error.code) + } + }, + } + }) + await server.boot() + + const { url } = await httpServerFactory.create(server.handle.bind(server)) + const response = await fetch(`${url}/users/search`, { + method: 'QUERY', + body: new TextEncoder().encode('name = "virk"'), + }) + + assert.equal(response.status, 400) + assert.equal(await response.text(), 'E_INVALID_QUERY_CONTENT_TYPE') + }) + + test('do not enforce QUERY Content-Type rules on other methods', async ({ assert }) => { + const app = new AppFactory().create(BASE_URL, () => {}) + const server = new ServerFactory().merge({ app }).create() + + await app.init() + + server.use([]) + server.getRouter().post('/users', async () => 'handled') + await server.boot() + + const { url } = await httpServerFactory.create(server.handle.bind(server)) + const response = await fetch(`${url}/users`, { + method: 'POST', + body: new TextEncoder().encode('name = "virk"'), + }) + + assert.equal(response.status, 200) + assert.equal(await response.text(), 'handled') + }) + + test('return 404 for QUERY requests to unknown routes', async ({ assert }) => { + const app = new AppFactory().create(BASE_URL, () => {}) + const server = new ServerFactory().merge({ app }).create() + + await app.init() + + server.use([]) + await server.boot() + + const { url } = await httpServerFactory.create(server.handle.bind(server)) + const response = await fetch(`${url}/users/search`, { + method: 'QUERY', + body: new TextEncoder().encode('name = "virk"'), + }) + + assert.equal(response.status, 404) + }) + test('use route handler return value when response.send is not called', async ({ assert }) => { const app = new AppFactory().create(BASE_URL, () => {}) const server = new ServerFactory().merge({ app }).create()