Skip to content

Commit

Permalink
Feat: Allow query params to return all values (#48)
Browse files Browse the repository at this point in the history
* Feat: Allow query params to return all values

* chore(Query): apply feedback from review

* fix: lint

Co-authored-by: Thomas Cruveilher <38007824+Sorikairox@users.noreply.github.com>
  • Loading branch information
rgoupil and Sorikairox committed Oct 7, 2022
1 parent 7c6a7b5 commit 4728f54
Show file tree
Hide file tree
Showing 5 changed files with 241 additions and 55 deletions.
3 changes: 2 additions & 1 deletion deno.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
"emitDecoratorMetadata": true
},
"tasks": {
"test": "NO_LOG=true deno test --allow-env --allow-net --allow-read -A --unstable --coverage=coverage spec"
"test": "NO_LOG=true deno test --allow-env --allow-net --allow-read -A --unstable --coverage=coverage spec",
"start:example": "deno run --allow-net --allow-env --watch example/run.ts"
}
}
36 changes: 9 additions & 27 deletions doc/overview/controllers.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,33 +85,15 @@ not necessary to grab these properties manually. We can use dedicated decorators
instead, such as `@Body()` or `@Query()`, which are available out of the box.
Below is a list of the provided decorators and the plain platform-specific
objects they represent.

<table>
<tbody>
<tr>
<td><code>@Req()</code></td>
<td><code>ctx.request</code></td></tr>
<tr>
<td><code>@Res()</code><span class='table-code-asterisk'>*</span></td>
<td><code>ctx.response</code></td>
</tr>
<tr>
<td><code>@Param(key: string)</code></td>
<td><code>getQuery(context, { mergeParams: true })[key]</code></td>
</tr>
<tr>
<td><code>@Header(key? : string)</code></td>
<td><code>ctx.request.headers</code> / <code>ctx.request.headers.get(key)</code></td></tr>
<tr>
<td><code>@Body(key?: string)</code></td>
<td><code>ctx.request.body</code> / <code>ctx.request.body[key]</code></td>
</tr>
<tr>
<td><code>@Query(key: string)</code></td>
<td><code>getQuery(context, { mergeParams: true })[key]</code></td>
</tr>
</tbody>
</table>
| Decorator | Type | Value |
|-----------|------|-------|
| `@Req()` | [oak.Request](https://deno.land/x/oak@v10.5.1/request.ts) | `ctx.request` |
| `@Res()` | [oak.Response](https://deno.land/x/oak@v10.5.1/response.ts) | `ctx.response` |
| `@Param(key: string)` | `string` | `context.params[key]` |
| `@Header(key? : string)` | `string \| undefined` | `ctx.request.headers` / `ctx.request.headers.get(key)` |
| `@Body(key?: string)` | `any` | `ctx.request.body` / `ctx.request.body[key]` |
| `@Query(key: string, options?: { value?: 'first' \| 'last' \| 'array' })` | `string \| string[]` | Get the `first`, the `last` or `all` the values for the query parameter named `key` |
| `@Query(options?: { value?: 'first' \| 'last' \| 'array' })` | `{ [key: string]: string \| string[] }` | Get the `first`, the `last` or `all` the values for all the query parameters |

### Resources

Expand Down
71 changes: 57 additions & 14 deletions example/run.ts
Original file line number Diff line number Diff line change
@@ -1,38 +1,77 @@
import { DanetApplication } from '../src/mod.ts';
import { Injectable, SCOPE } from '../src/injector/injectable/mod.ts';
import { Module } from '../src/module/mod.ts';
import {
All,
Body,
Controller,
DanetApplication,
Get,
Injectable,
Module,
Param,
Post,
Query,
Req,
} from '../src/router/controller/mod.ts';
SCOPE,
} from '../src/mod.ts';

@Injectable()
class SharedService {
sum(nums: number[]): number {
return nums.reduce((sum, n) => sum + n, 0);
}
}

@Injectable({ scope: SCOPE.REQUEST })
class Child2 {
class ScopedService2 {
getWorldHello() {
return 'World Hello';
}
}

@Injectable({ scope: SCOPE.REQUEST })
class Child1 {
constructor(public child: Child2) {
class ScopedService1 {
constructor(public child: ScopedService2) {
}
getHelloWorld() {
return 'Hello World';
getHelloWorld(name: string) {
return `Hello World ${name}`;
}
}

@Controller('/my-controller-path')
@Controller('')
class FirstController {
constructor() {
constructor(
private sharedService: SharedService,
private scopedService1: ScopedService1,
private scopedService2: ScopedService2,
) {
}

@Get('')
getMethod() {
return 'OK';
}

@Get('hello-world/:name')
getHelloWorld(
@Param('name') name: string,
) {
return this.scopedService1.getHelloWorld(name);
}

@Get('world-hello')
getWorldHello() {
return this.scopedService2.getWorldHello();
}

@Get('sum')
getSum(
@Query('num') numParams: string | string[],
) {
const numString = Array.isArray(numParams) ? numParams : [numParams];
return this.sharedService.sum(numString.map((n) => Number(n)));
}

@Post('post')
// deno-lint-ignore no-explicit-any
postMethod(@Body() body: any) {
return body;
}
Expand All @@ -45,11 +84,15 @@ class FirstController {

@Module({
controllers: [FirstController],
injectables: [SharedService, ScopedService1, ScopedService2],
})
class FirstModule {}

const app = new DanetApplication();
await app.init(FirstModule);
const serve = app.listen(3000);
console.log('listening on port 3000');
await serve;

let port = Number(Deno.env.get('PORT'));
if (isNaN(port)) {
port = 3000;
}
app.listen(port);
130 changes: 125 additions & 5 deletions spec/method-param-decorator.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { assertEquals } from '../src/deps_test.ts';
import { Response } from '../src/deps.ts';
import { DanetApplication } from '../src/app.ts';
import { Module } from '../src/module/decorator.ts';
import { Controller, Get, Post } from '../src/router/controller/decorator.ts';
Expand All @@ -8,16 +7,40 @@ import {
Header,
Param,
Query,
Res,
} from '../src/router/controller/params/decorators.ts';

@Controller('')
class SimpleController {
@Get('/')
simpleGet(@Res() res: Response, @Query('myvalue') myvalue: string) {
@Get('/query/myvalue/all')
simpleGetMyValueAll(@Query('myvalue', { value: 'array' }) myvalue: string[]) {
return myvalue;
}

@Get('/query/myvalue/last')
simpleGetMyValueLast(@Query('myvalue', { value: 'last' }) myvalue: string) {
return myvalue;
}

@Get('/query/myvalue/first')
simpleGetMyValueFirst(@Query('myvalue', { value: 'first' }) myvalue: string) {
return myvalue;
}

@Get('/query/all')
simpleGetAll(@Query({ value: 'array' }) queryParams: Record<string, string[]>) {
return queryParams;
}

@Get('/query/last')
simpleGetLast(@Query({ value: 'last' }) queryParams: Record<string, string>) {
return queryParams;
}

@Get('/query/first')
simpleGetFirst(@Query({ value: 'first' }) queryParams: Record<string, string>) {
return queryParams;
}

@Get('/lambda')
headerParamWithAttribute(@Header('New-Header') acceptHeader: string) {
if (!acceptHeader) return 'No "New-Header" header';
Expand Down Expand Up @@ -57,11 +80,108 @@ Deno.test('@Res and @Query decorator', async () => {
await app.init(MyModule);
const listenEvent = await app.listen(0);

const res = await fetch(`http://localhost:${listenEvent.port}?myvalue=foo`, {
const res = await fetch(`http://localhost:${listenEvent.port}/query/myvalue/all?myvalue=foo`, {
method: 'GET',
});
const text = await res.text();
assertEquals(text, `["foo"]`);

await app.close();
});

Deno.test(`@Query decorator with value 'array' to return all values for a given query parameter`, async () => {
await app.init(MyModule);
const listenEvent = await app.listen(0);

const res = await fetch(
`http://localhost:${listenEvent.port}/query/myvalue/all?myvalue=foo&myvalue=bar`,
{
method: 'GET',
},
);
const json = await res.json();
assertEquals(json, ['foo','bar']);

await app.close();
});

Deno.test(`@Query decorator with value 'last' to return the last value for a given query parameter`, async () => {
await app.init(MyModule);
const listenEvent = await app.listen(0);

const res = await fetch(
`http://localhost:${listenEvent.port}/query/myvalue/last?myvalue=foo&myvalue=bar`,
{
method: 'GET',
},
);
const text = await res.text();
assertEquals(text, `bar`);

await app.close();
});

Deno.test(`@Query decorator with value 'first' to return the first value for a given query parameter`, async () => {
await app.init(MyModule);
const listenEvent = await app.listen(0);

const res = await fetch(
`http://localhost:${listenEvent.port}/query/myvalue/first?myvalue=foo&myvalue=bar`,
{
method: 'GET',
},
);
const text = await res.text();
assertEquals(text, `foo`);

await app.close();
});

Deno.test(`@Query decorator with no key and value 'array' to return all values of all query parameters`, async () => {
await app.init(MyModule);
const listenEvent = await app.listen(0);

const res = await fetch(
`http://localhost:${listenEvent.port}/query/all?myvalue=foo&myvalue=bar`,
{
method: 'GET',
},
);
const json = await res.json();
assertEquals(json, {myvalue: ['foo','bar']});

await app.close();
});

Deno.test(`@Query decorator with no key and value 'last' to return the last value of all query parameters`, async () => {
await app.init(MyModule);
const listenEvent = await app.listen(0);

const res = await fetch(
`http://localhost:${listenEvent.port}/query/last?myvalue=foo&myvalue=bar`,
{
method: 'GET',
},
);
const json = await res.json();
assertEquals(json, {myvalue: 'bar'});

await app.close();
});

Deno.test(`@Query decorator with no key and value 'first' to return the first value of all query parameters`, async () => {
await app.init(MyModule);
const listenEvent = await app.listen(0);

const res = await fetch(
`http://localhost:${listenEvent.port}/query/first?myvalue=foo&myvalue=bar`,
{
method: 'GET',
},
);
const json = await res.json();
assertEquals(json, {myvalue: 'foo'});

await app.close();
});

Expand Down
56 changes: 48 additions & 8 deletions src/router/controller/params/decorators.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { getQuery } from '../../../deps.ts';
import { MetadataHelper } from '../../../metadata/helper.ts';
import { HttpContext } from '../../router.ts';
import { validateObject } from '../../../deps.ts';
Expand Down Expand Up @@ -103,14 +102,55 @@ export const Body = (prop?: string) =>
return body;
})();

export const Query = (prop?: string) =>
function formatQueryValue(queryValue: string[] | undefined, value: 'first' | 'last' | 'array' | undefined) {
if (!queryValue || !value) {
return undefined;
}

switch (value || 'last') {
case 'first':
return queryValue[0];
case 'last':
return queryValue[queryValue.length - 1];
case 'array':
return queryValue;
default:
return undefined;
}
}

export interface QueryOption {
value?: 'first' | 'last' | 'array';
}
export function Query(options?: QueryOption): ReturnType<ReturnType<typeof createParamDecorator>>;
export function Query(param: string, options?: QueryOption): ReturnType<ReturnType<typeof createParamDecorator>>;
export function Query(pParamOrOptions?: string | QueryOption, pOptions?: QueryOption) {
return (createParamDecorator((context: HttpContext) => {
const param = typeof pParamOrOptions === 'string' ? pParamOrOptions : undefined;
const options = typeof pParamOrOptions === 'string' ? pOptions : pParamOrOptions;

if (param) {
return formatQueryValue(context.request.url.searchParams.getAll(param), options?.value);
} else {
return Object.fromEntries(
Array.from(context.request.url.searchParams.keys())
.map(key => [
key,
formatQueryValue(context.request.url.searchParams.getAll(key), options?.value),
])
);
}
}))();
}

export const Param = (paramName: string) =>
createParamDecorator((context: HttpContext) => {
const query = getQuery(context, { mergeParams: true });
if (prop) {
return query?.[prop];
// not sure why params is not exposed, but it definitely is the right way to do this
// deno-lint-ignore no-explicit-any
const params = (context as any).params;
if (paramName) {
return params?.[paramName];
} else {
return query;
return params;
}
})();

export const Param = (paramName: string) => Query(paramName);

0 comments on commit 4728f54

Please sign in to comment.