diff --git a/packages/contracts/src/http/query-parameter-bag.ts b/packages/contracts/src/http/query-parameter-bag.ts new file mode 100644 index 00000000..a8fd1060 --- /dev/null +++ b/packages/contracts/src/http/query-parameter-bag.ts @@ -0,0 +1,21 @@ + +import { ParameterBag } from '../index.js' + +export interface QueryParameterBag extends ParameterBag { + /** + * Returns the querystring created from all items in this query parameter bag, + * without the leading question mark `?`. + * + * **Notice:** the returned querystring is encoded. Node.js automatically + * encodes the querystring to ensure a valid URL. Some characters would + * break the URL string otherwise. This way ensures the valid string. + */ + toQuerystring (): string + + /** + * Returns the decoded querystring by running the result of `toQuerystring` + * through `decodeURIComponent`. This method is useful to debug during + * development. It’s recommended to use `toQuerystring` in production. + */ + toQuerystringDecoded (): string +} diff --git a/packages/contracts/src/http/request.ts b/packages/contracts/src/http/request.ts index aa2014eb..554741f9 100644 --- a/packages/contracts/src/http/request.ts +++ b/packages/contracts/src/http/request.ts @@ -11,6 +11,7 @@ import { IncomingHttpHeaders, IncomingMessage } from 'node:http' import { InteractsWithState } from './concerns/interacts-with-state.js' import { RequestCookieBuilderCallback } from './cookie-options-builder.js' import { InteractsWithContentTypes } from './concerns/interacts-with-content-types.js' +import { QueryParameterBag } from './query-parameter-bag.js' export interface HttpRequestCtor extends MacroableCtor { /** @@ -66,7 +67,7 @@ export interface HttpRequest extends InteractsWithState, InteractsWithContentTyp /** * Returns the query parameter bag. */ - query(): ParameterBag + query(): QueryParameterBag /** * Returns the plain query string, without the leading ?. diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index d2c7e565..93957a8f 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -37,6 +37,7 @@ export { StateBag, HttpStateData } from './http/concerns/state-bag.js' export { FileBag } from './http/file-bag.js' export { InputBag } from './http/input-bag.js' export { ParameterBag } from './http/parameter-bag.js' +export { QueryParameterBag } from './http/query-parameter-bag.js' export { HttpKernel } from './http/kernel.js' export { HttpMethods } from './http/methods.js' export { Middleware, MiddlewareCtor, InlineMiddlewareHandler } from './http/middleware.js' diff --git a/packages/http/src/server/input-bag.ts b/packages/http/src/server/input-bag.ts index 199bec91..5d1ada81 100644 --- a/packages/http/src/server/input-bag.ts +++ b/packages/http/src/server/input-bag.ts @@ -11,7 +11,7 @@ export class InputBag implements InputBagContract { /** * Create a new instance. */ - constructor (attributes: Dict) { + constructor (attributes: Dict | undefined) { this.attributes = attributes ?? {} } diff --git a/packages/http/src/server/query-parameter-bag.ts b/packages/http/src/server/query-parameter-bag.ts new file mode 100644 index 00000000..32b3196e --- /dev/null +++ b/packages/http/src/server/query-parameter-bag.ts @@ -0,0 +1,31 @@ +'use strict' + +import { ParameterBag } from './parameter-bag.js' +import { QueryParameterBag as QueryParameterBagContract } from '@supercharge/contracts' + +export class QueryParameterBag extends ParameterBag implements QueryParameterBagContract { + /** + * Returns the query string created from all items in this query parameter bag, + * without the leading question mark `?`. + * + * **Notice:** the returned querystring is encoded. Node.js automatically + * encodes the querystring to ensure a valid URL. Some characters would + * break the URL string otherwise. This way ensures the valid string. + */ + toQuerystring (): string { + return new URLSearchParams( + this.all() as Record + ).toString() + } + + /** + * Returns the decoded querystring by running the result of `toQuerystring` + * through `decodeURIComponent`. This method is useful to debug during + * development. It’s recommended to use `toQuerystring` in production. + */ + toQuerystringDecoded (): string { + return decodeURIComponent( + this.toQuerystring() + ) + } +} diff --git a/packages/http/src/server/request.ts b/packages/http/src/server/request.ts index d7fcb253..6b2fffec 100644 --- a/packages/http/src/server/request.ts +++ b/packages/http/src/server/request.ts @@ -10,6 +10,7 @@ import { CookieBag } from './cookie-bag.js' import { ParameterBag } from './parameter-bag.js' import { Macroable } from '@supercharge/macroable' import { RequestHeaderBag } from './request-header-bag.js' +import { QueryParameterBag } from './query-parameter-bag.js' import { InteractsWithState } from './interacts-with-state.js' import { IncomingHttpHeaders, IncomingMessage } from 'node:http' import { CookieOptions, HttpContext, HttpMethods, HttpRequest, InteractsWithContentTypes, Protocol, RequestCookieBuilderCallback } from '@supercharge/contracts' @@ -88,8 +89,8 @@ export class Request extends Many(Macroable, InteractsWithState) implements Http /** * Returns the request’s query parameters. */ - query (): ParameterBag { - return new ParameterBag(this.koaCtx.query) + query (): QueryParameterBag { + return new QueryParameterBag(this.koaCtx.query) } /** diff --git a/packages/http/test/request.js b/packages/http/test/request.js index d3ff8c6a..2bb9d5a0 100644 --- a/packages/http/test/request.js +++ b/packages/http/test/request.js @@ -37,6 +37,60 @@ test('request.query() returns the querystring', async () => { .expect(200, { name: 'Supercharge', marcus: 'isCool' }) }) +test('request.query().toQuerystring() returns the querystring', async () => { + const server = app + .make(Server) + .use(({ request, response }) => { + if (request.query().has('addFoo')) { + request.query().set('foo', 'bar') + } + return response.payload( + request.query().toQuerystring() + ) + }) + + await Supertest(server.callback()) + .get('/') + .expect(200, '') + + await Supertest(server.callback()) + .get('/?name=Supercharge&marcus=isCool') + .expect(200, 'name=Supercharge&marcus=isCool') + + await Supertest(server.callback()) + .get('/?addFoo=1&name=Supercharge') + .expect(200, 'addFoo=1&name=Supercharge&foo=bar') +}) + +test('request.query().toQuerystringDecoded()', async () => { + const server = app + .make(Server) + .use(({ request, response }) => { + if (request.query().has('addFoo')) { + request.query().set('foo', 'bar') + } + return response.payload( + request.query().toQuerystringDecoded() + ) + }) + + await Supertest(server.callback()) + .get('/') + .expect(200, '') + + await Supertest(server.callback()) + .get('/?name=Supercharge&marcus=isCool') + .expect(200, 'name=Supercharge&marcus=isCool') + + await Supertest(server.callback()) + .get('/?name=Supercharge&marcus[]=isCool&marcus[]=isQuery') + .expect(200, 'name=Supercharge&marcus[]=isCool,isQuery') + + await Supertest(server.callback()) + .get('/?addFoo=1&name=Supercharge') + .expect(200, 'addFoo=1&name=Supercharge&foo=bar') +}) + test('request.all() returns merged query params, payload, and files', async () => { const server = app .make(Server) @@ -651,6 +705,10 @@ test('querystring', async () => { await Supertest(server.callback()) .get('/?name=Supercharge') .expect(200, { querystring: 'name=Supercharge' }) + + await Supertest(server.callback()) + .get('/?name=Supercharge&foo=bar') + .expect(200, { querystring: 'name=Supercharge&foo=bar' }) }) test('fullUrl', async () => {