-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: add express adapter for handler-kit
Reviewed-by: @lucacavallaro Refs: SFEQS-1550 #18
- Loading branch information
Showing
13 changed files
with
760 additions
and
571 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 not shown.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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", | ||
}, | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,2 @@ | ||
dist | ||
coverage |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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" | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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(); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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)) | ||
)(); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
export * from "./express"; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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")); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,6 @@ | ||
{ | ||
"extends": "../../tsconfig.json", | ||
"compilerOptions": { | ||
"rootDir": "src" | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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", | ||
}); |
Oops, something went wrong.