Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(httpfunction): add result mapping #19

Merged
merged 10 commits into from
Dec 20, 2022
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [v0.5.0] - 2022-12-19

- feat!: http result mapping

## [v0.4.2] - 2022-12-15

- fix: response body JSON is malformed
Expand Down
2 changes: 1 addition & 1 deletion packages/decorators/jest.config.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
/** @type {import('ts-jest').JestConfigWithTsJest} */
/** @type {import("ts-jest").JestConfigWithTsJest} */
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
Expand Down
4 changes: 2 additions & 2 deletions packages/decorators/src/__test__/decorators/azure-function.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Context } from '@azure/functions';
import { Context, HttpResponse } from '@azure/functions';

export async function callAzureFunction<T>(func: (...args: any[]) => T, context: Context): Promise<T> {
export async function callAzureFunction(func: (...args: any[]) => any, context: Context): Promise<HttpResponse> {
return func(context);
}
7 changes: 4 additions & 3 deletions packages/decorators/src/__test__/decorators/context.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,9 @@ describe('@Context decorator', () => {
},
});

const result: ContextEchoResponse = await callAzureFunction(ContextEcho.httpTrigger, context);
expect(result.page).toEqual(page);
expect(result.context).toBe(context);
const result = await callAzureFunction(ContextEcho.httpTrigger, context);
const resultBody: ContextEchoResponse = JSON.parse(result.body);
expect(resultBody.page).toEqual(page);
expect(resultBody.context).toEqual(context);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -180,4 +180,27 @@ describe('HTTP function decorators', () => {
);
expect(response.headers?.['Content-Type']).toEqual('application/json');
});

it('applies the result transformer correctly', async () => {
const message = 'Hello World!';

class ResultTransformer {
@HttpFunction({
ResultMapper: (result: string) => {
return {
body: { message: result },
};
},
})
static async httpTrigger(): Promise<string> {
return message;
}
}

const context = createContextWithHttpRequest();
const response = await callAzureFunction(ResultTransformer.httpTrigger, context);
expect(response.body).toEqual({
message: message,
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ describe('@PathParameter decorator', () => {
});

const result = await callAzureFunction(PathParameterEcho.httpTrigger, context);
expect(result).toEqual(page);
expect(result.body).toEqual(page);
});

it('passes multiple path parameters correctly', async () => {
Expand All @@ -43,7 +43,7 @@ describe('@PathParameter decorator', () => {
});

const result = await callAzureFunction(Echo.httpTrigger, context);
expect(result).toEqual([size, token]);
expect(JSON.parse(result.body)).toEqual([size, token]);
});

it('should pass undefined when path parameter is not present', async () => {
Expand All @@ -52,13 +52,17 @@ describe('@PathParameter decorator', () => {
});

const result = await callAzureFunction(PathParameterEcho.httpTrigger, context);
expect(result).toEqual(undefined);
expect(result.status).toEqual(204);
expect(result.statusCode).toEqual(204);
expect(result.body).toEqual(undefined);
});

it('should pass undefined when params are not present', async () => {
const context = createContextWithHttpRequest();

const result = await callAzureFunction(PathParameterEcho.httpTrigger, context);
expect(result).toEqual(undefined);
expect(result.status).toEqual(204);
expect(result.statusCode).toEqual(204);
expect(result.body).toEqual(undefined);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ describe('@QueryParameter decorator', () => {
});

const result = await callAzureFunction(QueryParameterEcho.httpTrigger, context);
expect(result).toEqual(page);
expect(result.body).toEqual(page);
});

it('passes multiple query parameters correctly', async () => {
Expand All @@ -43,7 +43,7 @@ describe('@QueryParameter decorator', () => {
});

const result = await callAzureFunction(Echo.httpTrigger, context);
expect(result).toEqual([size, token]);
expect(JSON.parse(result.body)).toEqual([size, token]);
});

it('should pass undefined when query parameter is not present', async () => {
Expand All @@ -52,13 +52,17 @@ describe('@QueryParameter decorator', () => {
});

const result = await callAzureFunction(QueryParameterEcho.httpTrigger, context);
expect(result).toEqual(undefined);
expect(result.status).toEqual(204);
expect(result.statusCode).toEqual(204);
expect(result.body).toEqual(undefined);
});

it('should pass undefined when query parameter are not present', async () => {
const context = createContextWithHttpRequest();

const result = await callAzureFunction(QueryParameterEcho.httpTrigger, context);
expect(result).toEqual(undefined);
expect(result.status).toEqual(204);
expect(result.statusCode).toEqual(204);
expect(result.body).toEqual(undefined);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ describe('@RequestBody decorator', () => {
});

const result = await callAzureFunction(EchoBody.httpTrigger, context);
expect(result).toEqual(input);
expect(JSON.parse(result.body)).toEqual(input);
});

it('passes undefined when not JSON', async () => {
Expand All @@ -30,6 +30,8 @@ describe('@RequestBody decorator', () => {
});

const result = await callAzureFunction(EchoBody.httpTrigger, context);
expect(result).toEqual(undefined);
expect(result.status).toEqual(204);
expect(result.statusCode).toEqual(204);
expect(result.body).toEqual(undefined);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,6 @@ describe('@Request decorator', () => {
const context = createContextWithHttpRequest();

const result = await callAzureFunction(RequestEcho.httpTrigger, context);
expect(result).toEqual(context.req);
expect(JSON.parse(result.body)).toEqual(context.req);
});
});
49 changes: 46 additions & 3 deletions packages/decorators/src/decorators/http/http-function.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,47 @@
import { handleRequestBodyParameter } from './request-body';
import { handleQueryParameters } from './query-parameter';
import { handlePathParameter } from './path-parameter';
import { isContext, isFunction, isHttpRequest } from './type-guards';
import { isContext, isFunction, isHttpRequest, isHttpResponse } from './type-guards';
import { handleContextParameter } from '../context';
import { handleRequestParameter } from './http-request';
import { handleError } from './http-status';
import { HttpResponse } from '@azure/functions';

type ResultMapper<T> = (result: T) => HttpResponse;

type FullHttpFunctionOptions = {
ResultMapper: ResultMapper<any>;
};

type HttpFunctionOptions = Partial<FullHttpFunctionOptions>;

const defaultResultMapper: ResultMapper<unknown> = (result: unknown): HttpResponse => {
if (result === undefined || result === null) {
// is undefined, return empty success response
return { status: 204, statusCode: 204 };
}

if (isHttpResponse(result)) {
// is already a HttpResponse so return it
return result;
}

if (typeof result === 'object') {
// is an object, serialize it in the body
return {
body: JSON.stringify(result),
};
}

return {
// is probably a primitive, return it as body
body: result,
};
};

const defaultOptions = {
ResultMapper: defaultResultMapper,
} as FullHttpFunctionOptions;

/**
* The {@link HttpFunction @HttpFunction} decorator marks a static class function as a httpTrigger function.
Expand All @@ -23,7 +60,12 @@ import { handleError } from './http-status';
* {@link HttpStatus @HttpStatus}
* {@link Context @Context}
*/
export function HttpFunction(): MethodDecorator {
export function HttpFunction(options?: HttpFunctionOptions): MethodDecorator {
const mergedOptions = {
...defaultOptions,
...options,
};

return (target: object, propertyName: string | symbol, descriptor: TypedPropertyDescriptor<any>) => {
const method = descriptor?.value;
if (!isFunction(method)) {
Expand Down Expand Up @@ -55,7 +97,8 @@ export function HttpFunction(): MethodDecorator {
handlePathParameter(target, propertyName, req, args);

try {
return await method.apply(this, args);
const result = await method.apply(this, args);
return mergedOptions.ResultMapper(result);
} catch (e) {
return handleError(e);
}
Expand Down
10 changes: 9 additions & 1 deletion packages/decorators/src/decorators/http/type-guards.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Context, HttpRequest } from '@azure/functions';
import { Context, HttpRequest, HttpResponse } from '@azure/functions';

export function isFunction(f: unknown): f is (...args: unknown[]) => unknown {
return !!f && f instanceof Function;
Expand All @@ -11,3 +11,11 @@ export function isContext(c: unknown): c is Context {
export function isHttpRequest(req: unknown): req is HttpRequest {
return !!req && typeof req === 'object' && 'method' in req;
}

export function isHttpResponse(resp: unknown): resp is HttpResponse {
return (
!!resp &&
typeof resp === 'object' &&
('body' in resp || 'status' in resp || 'statusCode' in resp || 'headers' in resp)
);
}
15 changes: 15 additions & 0 deletions packages/examples/__test__/function-http-custom-mapper.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import fetch from 'node-fetch';
import { BASE_URL } from './test-constants';

describe('function-http-custom-mapper', () => {
it('should return transformed HTTP response', async () => {
const page = '42';

const resp = await fetch(BASE_URL + '/custom-mapper?page=' + page);

expect(resp.status).toBe(201);
expect(await resp.json()).toEqual({
message: page,
});
});
});
13 changes: 3 additions & 10 deletions packages/examples/__test__/function-http-path.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,24 +4,17 @@ import { BASE_URL } from './test-constants';
describe('function-http-path', () => {
it('should return correct path parameter', async () => {
const id = 'abc:123';
const expectedResponse = {
parameter: id,
};

const resp = await fetch(BASE_URL + '/path/' + id);

expect(resp.status).toBe(200);
expect(await resp.json()).toEqual(expectedResponse);
expect(await resp.text()).toEqual(id);
});

it('should return undefined on missing path parameter', async () => {
const expectedResponse = {
parameter: undefined,
};

const resp = await fetch(BASE_URL + '/path');

expect(resp.status).toBe(200);
expect(await resp.json()).toEqual(expectedResponse);
expect(resp.status).toBe(204);
expect(await resp.text()).toEqual('');
});
});
13 changes: 3 additions & 10 deletions packages/examples/__test__/function-http-query.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,24 +4,17 @@ import { BASE_URL } from './test-constants';
describe('function-http-query', () => {
it('should return correct query parameter', async () => {
const page = 42;
const expectedResponse = {
page: String(page),
};

const resp = await fetch(BASE_URL + '/query?page=' + page);

expect(resp.status).toBe(200);
expect(await resp.json()).toEqual(expectedResponse);
expect(await resp.text()).toEqual(String(page));
});

it('should return undefined on missing path parameter', async () => {
const expectedResponse = {
page: undefined,
};

const resp = await fetch(BASE_URL + '/query');

expect(resp.status).toBe(200);
expect(await resp.json()).toEqual(expectedResponse);
expect(resp.status).toBe(204);
expect(await resp.text()).toEqual('');
});
});
16 changes: 16 additions & 0 deletions packages/examples/__test__/function-http-raw-response.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import fetch from 'node-fetch';
import { BASE_URL } from './test-constants';
import { constants } from 'http2';

describe('function-http-raw-response', () => {
it('should return untransformed raw http response', async () => {
const page = '42';

const resp = await fetch(BASE_URL + '/raw?page=' + page);

expect(resp.status).toBe(constants.HTTP_STATUS_TEAPOT);
expect(await resp.json()).toEqual({
message: page,
});
});
});
5 changes: 2 additions & 3 deletions packages/examples/function-http-body/index.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
import { HttpFunction, RequestBody } from 'azure-functions-decorators';
import { HttpResponse } from '@azure/functions';

class Example {
@HttpFunction()
static async echoBody(@RequestBody() body: unknown): Promise<HttpResponse> {
return { body: body };
static async echoBody(@RequestBody() body: unknown): Promise<unknown> {
return body;
}
}

Expand Down
18 changes: 18 additions & 0 deletions packages/examples/function-http-custom-mapper/function.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
{
"bindings": [
{
"authLevel": "anonymous",
"type": "httpTrigger",
"direction": "in",
"name": "req",
"methods": ["get"],
"route": "custom-mapper"
},
{
"type": "http",
"direction": "out",
"name": "$return"
}
],
"scriptFile": "../dist/function-http-custom-mapper/index.js"
}
Loading