Skip to content

Commit

Permalink
feat: add express adapter for handler-kit
Browse files Browse the repository at this point in the history
Reviewed-by: @lucacavallaro 
Refs: SFEQS-1550 #18
  • Loading branch information
silvicir authored Apr 20, 2023
1 parent 07ab60d commit 4d780d5
Show file tree
Hide file tree
Showing 13 changed files with 760 additions and 571 deletions.
5 changes: 5 additions & 0 deletions .changeset/orange-starfishes-check.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@pagopa/handler-kit-express": major
---

Add handler-kit adapter for express
Binary file added packages/handler-kit-express/.DS_Store
Binary file not shown.
13 changes: 13 additions & 0 deletions packages/handler-kit-express/.eslintrc.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
require("@rushstack/eslint-patch/modern-module-resolution");

module.exports = {
env: {
es2021: true,
node: true,
},
extends: ["@pagopa/eslint-config/recommended"],
ignorePatterns: ["*.yaml", "**/*.spec.ts"],
rules: {
"max-classes-per-file": "off",
},
};
2 changes: 2 additions & 0 deletions packages/handler-kit-express/.prettierignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
dist
coverage
68 changes: 68 additions & 0 deletions packages/handler-kit-express/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
# @pagopa/handler-kit-express

`@pagopa/handler-kit-express` adapter for `Express`

### How to use it

```typescript
import { expressHandler } from "@pagopa/handler-kit-express";

// Given an Handler
// (from the @pagopa/handler README example)

const GetMovies = H.of((req: H.HttpRequest) =>
pipe(
req.body,
// perform a refinement with io-ts, and returns a ValidationError
// that represents a 422 HTTP response
H.parse(GetMoviesBody),
E.map(({ genre }) => genre),
RTE.fromEither,
RTE.chainTaskEither(getMoviesByGenre),
RTE.map((movies) => ({ items: movies })),
// wrap in a 200 HTTP response, with content-type JSON
RTE.map(H.successJson),
// convert Error instances to problem json (RFC 7808) objects
RTE.orElseW(flow(H.toProblemJson, H.problemJson))
)
);

// instead of wiring manually the dependencies

/*
GetMovies({
input: ...,
inputDecoder: ...,
logger: ...,
movies: ...
})*/

// just use "expressHandler"
const GetMoviesExpress = expressHandler(GetMovies)({
movies,
});

const ConsoleLogger: L.Logger = {
log: (r) => () => console.log(r),
format: L.format.json,
};

// now GetMoviesRoute can be called by the Express runtime
const app = express.default();
app.use(express.json());

// decorate "req" with "log" function
app.use(logger(ConsoleLogger));

// enable HTTP request logging
app.use(access());

app.post("/", GetMoviesExpress);

app.listen(3001, () => {
// eslint-disable-next-line no-console
console.log("Server ready on port 3001");
});
```

See the unit tests for other examples
47 changes: 47 additions & 0 deletions packages/handler-kit-express/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
{
"name": "@pagopa/handler-kit-express",
"version": "1.0.0",
"homepage": "https://github.com/pagopa/io-std#readme",
"bugs": {
"url": "https://github.com/pagopa/io-std/issues"
},
"repository": {
"type": "git",
"url": "git+https://github.com/pagopa/io-std.git"
},
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"files": [
"dist"
],
"scripts": {
"build": "tsup-node",
"format": "prettier --write .",
"test": "vitest run",
"coverage": "vitest run --coverage",
"lint": "eslint \"src/**\"",
"lint:fix": "eslint --fix \"src/**\"",
"typecheck": "tsc"
},
"dependencies": {
"@pagopa/handler-kit": "^1.0.1",
"@pagopa/logger": "^1.0.1",
"@pagopa/logger-express": "^1.1.0",
"express": "^4.18.2",
"fp-ts": "^2.13.1",
"io-ts": "^2.2.20"
},
"devDependencies": {
"@pagopa/eslint-config": "^3.0.0",
"@rushstack/eslint-patch": "^1.2.0",
"@types/express": "^4.17.17",
"@types/supertest": "^2.0.12",
"@vitest/coverage-c8": "^0.29.2",
"eslint": "^8.36.0",
"prettier": "2.8.4",
"supertest": "^6.3.3",
"tsup": "^6.6.3",
"typescript": "^5.0.2",
"vitest": "^0.29.2"
}
}
64 changes: 64 additions & 0 deletions packages/handler-kit-express/src/__test__/express.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { describe, it, expect, vi } from "vitest";

import * as O from "fp-ts/Option";
import * as RTE from "fp-ts/ReaderTaskEither";

import { pipe, flow } from "fp-ts/function";
import { lookup } from "fp-ts/Record";

import * as H from "@pagopa/handler-kit";
import { expressHandler } from "../express";
import express from "express";
import request from "supertest";
import { logger } from "@pagopa/logger-express";
import * as L from "@pagopa/logger";

describe("expressHandler", () => {
const GreetHandler = H.of((req: H.HttpRequest) =>
pipe(
req.query,
lookup("name"),
O.getOrElse(() => "Test"),
RTE.right,
RTE.chainW((name) =>
RTE.asks<{ lang: "it" | "en" }, string>((r) =>
r.lang === "it" ? `Ciao ${name}` : `Hello ${name}`
)
),
RTE.map((message) => ({ message })),
RTE.map(H.successJson),
RTE.orElseW(flow(H.toProblemJson, H.problemJson, RTE.right))
)
);
it("wires the http request correctly and returns the correct response", async () => {
const GreetFunction = expressHandler(GreetHandler)({
lang: "it",
});
const app = express();
app.use(express.json());
app.use(
logger({
log: (r) => () => console.log(r),
})
);
app.get("/greet", GreetFunction);
await request(app).get("/greet?name=Silvia").expect(200, {
message: "Ciao Silvia",
});
});
it("recovers from uncaught errors", async () => {
const ConsoleLogger: L.Logger = {
log: vi.fn((r) => () => {}),
};
const ErrorFunction = expressHandler(
H.of((_) => RTE.left(new Error("unhandled error")))
)({});
const app = express();
app.use(express.json());
app.use(logger(ConsoleLogger));
app.get("/error", ErrorFunction);
const response = await request(app).get("/error");
expect(response.statusCode).toBe(500);
expect(ConsoleLogger.log).toHaveBeenCalled();
});
});
139 changes: 139 additions & 0 deletions packages/handler-kit-express/src/express.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
import * as t from "io-ts";

import * as T from "fp-ts/Task";
import * as RE from "fp-ts/ReaderEither";
import * as TE from "fp-ts/TaskEither";
import * as E from "fp-ts/Either";

import { sequenceS } from "fp-ts/Apply";
import { pipe, flow } from "fp-ts/function";

import * as H from "@pagopa/handler-kit";
import * as L from "@pagopa/logger";

import * as express from "express";
import { getLogger } from "./logger";

const hasLogger = <R, I>(u: unknown): u is R & H.HandlerEnvironment<I> =>
typeof u === "object" && u !== null && "logger" in u;

// Populates Handler dependencies reading from express.Request
const expressHandlerTE = <I, A, R>(
h: H.Handler<I, A, R>,
deps: Omit<R, "logger"> & { inputDecoder: t.Decoder<unknown, I> }
) =>
flow(
sequenceS(RE.Apply)({
logger: getLogger,
input: (req: express.Request) => E.right(req),
}),
TE.fromEither,
TE.map(({ input, logger }) => ({ input, logger, ...deps })),
TE.filterOrElse(hasLogger<R, I>, () => new Error("Unmeet dependencies")),
TE.chainW(h)
);

const HttpRequestC = new t.Type<
H.HttpRequest,
ExpressHttpRequest,
ExpressHttpRequest
>(
"HttpRequestC",
H.HttpRequest.is,
(req) =>
t.success({
...req,
query: toHttp(req.query),
headers: toHttp(req.headers),
url: req.originalUrl,
path: req.params,
}),
(req) => ({
originalUrl: req.url,
method: req.method,
params: req.path,
query: req.query,
headers: req.headers,
body: req.body,
})
);

// Transforms objects of type Record<string, string | string[]> to Record<string, string> type
// Allows to adapt some ExpressHttpRequest's properties to H.HttpRequest's properties
const toHttp = (obj: Record<string, string | string[]>) =>
Object.entries(obj).reduce(
(acc, [key, value]) => ({
...acc,
[key]: Array.isArray(value) ? value.join(",") : value,
}),
{}
);

const ExpressHttpRequestC = t.type({
method: H.HttpRequest.props.method,
originalUrl: t.string,
params: t.record(t.string, t.string),
query: t.record(t.string, t.union([t.string, t.array(t.string)])),
headers: t.record(t.string, t.union([t.string, t.array(t.string)])),
body: t.unknown,
});

type ExpressHttpRequest = t.TypeOf<typeof ExpressHttpRequestC>;

const HttpRequestFromExpress = ExpressHttpRequestC.pipe(
HttpRequestC,
"HttpRequestFromExpress"
);

const toExpressResponse =
(res: express.Response) =>
(httpRes: H.HttpResponse<unknown, H.HttpStatusCode>): void => {
res.set(httpRes.headers).status(httpRes.statusCode).send(httpRes.body);
};

// Prevent and express handler from crashing
// If an handler returns with an error (RTE.left),
// logs it and show an Internal Server Error.
const logErrorAndReturnHttpResponse = (e: Error) =>
flow(
L.error("uncaught error from handler", { error: e }),
T.fromIO,
T.map(() =>
pipe(
new H.HttpError("Something went wrong."),
H.toProblemJson,
H.problemJson
)
)
);

// Adapts an HTTP Handler to an Azure Function that is triggered by HTTP,
// wiring automatically the HttpRequest inputDecoder and the logger
export const expressHandler =
<R>(
h: H.Handler<H.HttpRequest, H.HttpResponse<unknown, H.HttpStatusCode>, R>
) =>
(deps: Omit<R, "logger">): express.Handler =>
(req, res) =>
pipe(
req,
expressHandlerTE(h, {
...deps,
inputDecoder: HttpRequestFromExpress,
}),
TE.getOrElseW((e) =>
logErrorAndReturnHttpResponse(e)({
logger: pipe(
req,
getLogger,
E.getOrElse(() => ({
log: (s: string, _level: L.LogRecord["level"]) => () => {
// eslint-disable-next-line no-console
console.error(s);
},
}))
),
})
),
T.map(toExpressResponse(res))
)();
1 change: 1 addition & 0 deletions packages/handler-kit-express/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./express";
7 changes: 7 additions & 0 deletions packages/handler-kit-express/src/logger.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import * as E from "fp-ts/Either";
import * as L from "@pagopa/logger";
import type {} from "@pagopa/logger-express";

// Derive a concrete implementation L.Logger using Express.Request.log, if present, otherwise fails with an error
export const getLogger = (req: Express.Request): E.Either<Error, L.Logger> =>
req.logger ? E.right(req.logger) : E.left(new Error("There is no logger"));
6 changes: 6 additions & 0 deletions packages/handler-kit-express/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"rootDir": "src"
}
}
10 changes: 10 additions & 0 deletions packages/handler-kit-express/tsup.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { defineConfig } from "tsup";

export default defineConfig({
entry: ["./src/index.ts"],
splitting: false,
sourcemap: true,
dts: true,
clean: true,
target: "node18",
});
Loading

0 comments on commit 4d780d5

Please sign in to comment.