├── .circleci └── config.yml ├── .gitignore ├── .npmrc ├── .nvmrc ├── .vscode └── settings.json ├── README.md ├── docs ├── autocomplete_params.png ├── route_listing.png └── runtime_type_validations.png ├── jest.config.js ├── package.json ├── src ├── RouteDefiner.ts ├── e2e.spec.ts ├── express │ ├── Request.ts │ ├── RequestParamFetcher.ts │ ├── Response.ts │ ├── Route.ts │ ├── TypedExpress.spec.ts │ ├── TypedExpress.ts │ ├── __fixtures__ │ │ └── express-server.ts │ ├── __snapshots__ │ │ └── TypedExpress.spec.ts.snap │ ├── index.ts │ └── parseBody.ts ├── fetch.spec.ts ├── fetch.ts └── index.ts └── tsconfig.json /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | # Javascript Node CircleCI 2.0 configuration file 2 | # 3 | # Check https://circleci.com/docs/2.0/language-javascript/ for more details 4 | # 5 | version: 2 6 | jobs: 7 | build: 8 | docker: 9 | # specify the version you desire here 10 | - image: circleci/node:10 11 | 12 | # Specify service dependencies here if necessary 13 | # CircleCI maintains a library of pre-built images 14 | # documented at https://circleci.com/docs/2.0/circleci-images/ 15 | # - image: circleci/mongo:3.4.4 16 | 17 | working_directory: ~/repo 18 | 19 | steps: 20 | - checkout 21 | 22 | # Download and cache dependencies 23 | - restore_cache: 24 | keys: 25 | - v1-dependencies-{{ checksum "package.json" }} 26 | # fallback to using the latest cache if no exact match is found 27 | - v1-dependencies- 28 | 29 | - run: npm install 30 | 31 | - save_cache: 32 | paths: 33 | - node_modules 34 | key: v1-dependencies-{{ checksum "package.json" }} 35 | 36 | # - run: npm run build 37 | 38 | # run tests! 39 | - run: 40 | name: test 41 | command: npm test -- --ci --reporters=default --reporters=jest-junit 42 | environment: 43 | JEST_JUNIT_OUTPUT: ./test-results/jest/results.xml 44 | 45 | - store_test_results: 46 | path: ./test-results 47 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /dist -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | package-lock = false 2 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | v10.15.3 2 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnSave": true 3 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # `httype` 2 | 3 | > ⛓️ Type-safe HTTP client/server communications with awesome autocompletion 4 | 5 | Let your compiler tell you when you break a contract with your client. 6 | 7 | ## Features 8 | 9 | ✅ Awesome autocompletion and type safety for request parts 10 | 11 | ![autocompletion and type safety on request parts](./docs/autocomplete_params.png) 12 | 13 | ✅ Nice and simple route documentation 14 | 15 | ![documentation using types](./docs/route_listing.png) 16 | 17 | ✅ Compile time and runtime type validation 18 | 19 | ![Compile-time and runtime type validations](./docs/runtime_type_validations.png) 20 | 21 | # Usage 22 | 23 | ## Route 24 | 25 | A route is defined by `RouteDefiner`. This part is shared for both server and client. 26 | 27 | :white_check_mark: Supports dynamic parameters 28 | 29 | :white_check_mark: Supports a typed request body with [io-ts](https://github.com/gcanti/io-ts) 30 | 31 | :white_check_mark: Supports a typed response type with [io-ts](https://github.com/gcanti/io-ts) 32 | 33 | #### Examples: 34 | 35 | - A `GET` route that returns a `string` output, with a fixed path of `/hello`: 36 | 37 | ```ts 38 | RouteDefiner.get.returns(t.string).fixed("hello"); 39 | ``` 40 | 41 | - A route that returns a `string` output, with a dynamic path of `/hello/:name`: 42 | 43 | ```ts 44 | RouteDefiner.get 45 | .returns(t.string) 46 | .fixed("hello") 47 | .param("name"); 48 | ``` 49 | 50 | - A `POST` route that reads a custom type `User` from the request body and returns a custom type `Greeting`: 51 | 52 | ```ts 53 | const Greeting = t.type({ msg: t.string }); 54 | const User = t.type({ name: t.string }); 55 | 56 | RouteDefiner.post 57 | .reads(User) 58 | .returns(Greeting) 59 | .fixed("user-to-greeting"); 60 | ``` 61 | 62 | ## Server 63 | 64 | Implementation for servers is by wrapping Express.js 65 | 66 | :white_check_mark: Type safe body parsing, thanks to [io-ts](https://github.com/gcanti/io-ts) 67 | 68 | :white_check_mark: Awesome autocompletion for routing params and request body 69 | 70 | :white_check_mark: Pretty routing table, documentation ready 71 | 72 | ```ts 73 | import { TypedExpress, success } from "httyped/express"; 74 | import express from "express"; 75 | 76 | const app = express(); 77 | const typed = TypedExpress.of(app); 78 | 79 | typed.route( 80 | RouteDefiner.get // a get request 81 | .returns(t.string) // that returns a string 82 | .fixed("hello") // and its path is 83 | .param("name"), // `/hello/:name` 84 | async req => { 85 | return success(`Hello, ${req.params.name}`); 86 | } 87 | ); 88 | 89 | app.listen(3000); 90 | typed.listRoutes(); // Will print a table of routes 91 | ``` 92 | 93 | ## Client 94 | 95 | A http client based on `node-fetch`: 96 | 97 | :white_check_mark: Type-safety between client and server 98 | 99 | :white_check_mark: Autocompletion for request parameters and request body 100 | 101 | ```ts 102 | import { fetcher } from "httyped/fetch"; 103 | 104 | const User = t.type({ name: string }, "User"); 105 | 106 | // given a route 107 | const route = RouteDefiner.post 108 | .reads(User) 109 | .returns(t.string) 110 | .fixed("hello") 111 | .param("greeting"); 112 | 113 | const fetch = fetcher(route, "http://localhost:3000"); 114 | // ^ the base URI 115 | 116 | const { status, data } = await fetch({ 117 | params: { 118 | greeting: "Hello" 119 | }, 120 | body: { 121 | name: "Gal" 122 | } 123 | }); 124 | ``` 125 | -------------------------------------------------------------------------------- /docs/autocomplete_params.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Schniz/httyped/7dd932b816a0e08641661238415e128f68d05860/docs/autocomplete_params.png -------------------------------------------------------------------------------- /docs/route_listing.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Schniz/httyped/7dd932b816a0e08641661238415e128f68d05860/docs/route_listing.png -------------------------------------------------------------------------------- /docs/runtime_type_validations.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Schniz/httyped/7dd932b816a0e08641661238415e128f68d05860/docs/runtime_type_validations.png -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: "ts-jest", 3 | testEnvironment: "node", 4 | testMatch: ["**/*.spec.ts"] 5 | }; 6 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "httyped", 3 | "version": "1.0.1", 4 | "description": "", 5 | "main": "dist/index.js", 6 | "scripts": { 7 | "build": "tsc", 8 | "prepublishOnly": "npm run build && npm run test", 9 | "test": "jest" 10 | }, 11 | "files": [ 12 | "dist", 13 | "!**/__fixtures__", 14 | "!**/*.spec.*" 15 | ], 16 | "keywords": [], 17 | "author": "", 18 | "license": "ISC", 19 | "devDependencies": { 20 | "@types/express": "^4.16.1", 21 | "@types/jest": "^24.0.11", 22 | "@types/node-fetch": "^2.1.7", 23 | "express": "^4.16.4", 24 | "infer-types": "0.0.1", 25 | "jest": "^24.5.0", 26 | "jest-junit": "^6.3.0", 27 | "node-fetch": "^2.3.0", 28 | "prettier": "^1.16.4", 29 | "ts-jest": "^24.0.1", 30 | "ts-node": "^8.1.0", 31 | "typescript": "^3.4.1" 32 | }, 33 | "peerDependencies": { 34 | "express": "^4.16.4", 35 | "node-fetch": "^2.3.0" 36 | }, 37 | "dependencies": { 38 | "io-ts": "^1.8.5" 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/RouteDefiner.ts: -------------------------------------------------------------------------------- 1 | import * as t from "io-ts"; 2 | 3 | export type RouteParams = { 4 | [param in ParamNames]: string 5 | }; 6 | 7 | type RoutePieces = 8 | | { type: "fixed"; text: string } 9 | | { type: "parameter"; name: ParamNames }; 10 | 11 | export type Method = "get" | "post"; 12 | 13 | export class RouteDefiner< 14 | RequestType extends t.Any, 15 | ResponseType extends t.Any, 16 | ParamNames extends string, 17 | Method extends string 18 | > { 19 | private readonly pieces: RoutePieces[] = []; 20 | readonly responseType: ResponseType; 21 | readonly requestType: RequestType; 22 | readonly method: Method; 23 | 24 | constructor( 25 | requestType: RequestType, 26 | responseType: ResponseType, 27 | pieces: RoutePieces[], 28 | method: Method 29 | ) { 30 | this.requestType = requestType; 31 | this.responseType = responseType; 32 | this.pieces = pieces; 33 | this.method = method; 34 | } 35 | 36 | static get = new RouteDefiner( 37 | t.unknown, 38 | t.string, 39 | [], 40 | "get" 41 | ); 42 | static post = new RouteDefiner( 43 | t.unknown, 44 | t.string, 45 | [], 46 | "post" 47 | ); 48 | 49 | reads(requestType: NewRequestType) { 50 | return new RouteDefiner( 51 | requestType, 52 | this.responseType, 53 | this.pieces, 54 | this.method 55 | ); 56 | } 57 | 58 | returns(responseType: ResponseType) { 59 | return new RouteDefiner( 60 | this.requestType, 61 | responseType, 62 | this.pieces, 63 | this.method 64 | ); 65 | } 66 | 67 | fixed(text: string) { 68 | return new RouteDefiner( 69 | this.requestType, 70 | this.responseType, 71 | [...this.pieces, { type: "fixed", text }], 72 | this.method 73 | ); 74 | } 75 | 76 | param(name: Param) { 77 | return new RouteDefiner< 78 | RequestType, 79 | ResponseType, 80 | ParamNames | Param, 81 | Method 82 | >( 83 | this.requestType, 84 | this.responseType, 85 | [...this.pieces, { type: "parameter", name }], 86 | this.method 87 | ); 88 | } 89 | 90 | toRoutingString(): string { 91 | const parts = this.pieces.map(piece => { 92 | switch (piece.type) { 93 | case "fixed": 94 | return piece.text; 95 | case "parameter": 96 | return `:${piece.name}`; 97 | } 98 | }); 99 | 100 | return "/" + parts.join("/"); 101 | } 102 | 103 | toString(params: RouteParams) { 104 | const parts = this.pieces.map(piece => { 105 | switch (piece.type) { 106 | case "fixed": 107 | return piece.text; 108 | case "parameter": 109 | return params[piece.name]; 110 | } 111 | }); 112 | 113 | return "/" + parts.join("/"); 114 | } 115 | 116 | toJSON() { 117 | return { 118 | method: this.method, 119 | path: this.toRoutingString(), 120 | takes: this.requestType ? this.requestType.name : null, 121 | returns: this.responseType.name 122 | }; 123 | } 124 | } 125 | 126 | export namespace Meta { 127 | export type AnyRoute = RouteDefiner; 128 | export type RequestBodyType = T extends RouteDefiner< 129 | infer U, 130 | any, 131 | any, 132 | any 133 | > 134 | ? t.TypeOf 135 | : never; 136 | export type ResponseType = T extends RouteDefiner< 137 | any, 138 | infer U, 139 | any, 140 | any 141 | > 142 | ? t.TypeOf 143 | : never; 144 | export type Params = T extends RouteDefiner< 145 | any, 146 | any, 147 | infer U, 148 | any 149 | > 150 | ? RouteParams 151 | : never; 152 | export type Method = T extends RouteDefiner< 153 | any, 154 | any, 155 | any, 156 | infer U 157 | > 158 | ? U 159 | : never; 160 | } 161 | -------------------------------------------------------------------------------- /src/e2e.spec.ts: -------------------------------------------------------------------------------- 1 | import express from "express"; 2 | import { Server } from "http"; 3 | import { AddressInfo } from "net"; 4 | import * as t from "io-ts"; 5 | 6 | import { TypedExpress, success } from "./express"; 7 | import { RouteDefiner } from "./RouteDefiner"; 8 | import { fetcher } from "./fetch"; 9 | 10 | type User = { name: string }; 11 | const User = t.type({ name: t.string }); 12 | const routes = { 13 | simple: RouteDefiner.get.param("name"), 14 | user: RouteDefiner.post 15 | .reads(User) 16 | .returns(t.string) 17 | .param("name"), 18 | stringBody: RouteDefiner.post.reads(t.string).returns(t.string) 19 | }; 20 | 21 | describe("post data", () => { 22 | let host: string; 23 | let app: TypedExpress; 24 | 25 | beforeEach(() => { 26 | const _app = express(); 27 | app = new TypedExpress(_app); 28 | host = getHost(_app); 29 | }); 30 | 31 | test("Simple route", async () => { 32 | app.route(routes.simple, async req => { 33 | return success(`body: ${req.body}, param: ${req.params.name}`); 34 | }); 35 | const fetch = fetcher(routes.simple, host); 36 | const result = await fetch({ params: { name: "gal" } }); 37 | expect(result.status).toBe(200); 38 | expect(result.data).toBe(`body: null, param: gal`); 39 | }); 40 | 41 | test("Parse JSON route", async () => { 42 | app.route(routes.user, async req => { 43 | return success(`from body: ${req.body.name}, param: ${req.params.name}`); 44 | }); 45 | const fetch = fetcher(routes.user, host); 46 | const result = await fetch({ 47 | params: { name: "gal" }, 48 | body: { name: "gal" } 49 | }); 50 | expect(result.status).toBe(200); 51 | expect(result.data).toEqual(`from body: gal, param: gal`); 52 | }); 53 | 54 | test("Parse string route", async () => { 55 | app.route(routes.stringBody, async req => { 56 | return success(`body: ${req.body}`); 57 | }); 58 | const fetch = fetcher(routes.stringBody, host); 59 | const result = await fetch({ 60 | params: { name: "gal" }, 61 | body: "Hello world!" 62 | }); 63 | expect(result.status).toBe(200); 64 | expect(result.data).toEqual(`body: Hello world!`); 65 | }); 66 | }); 67 | 68 | let server: Server | null = null; 69 | 70 | afterEach(() => { 71 | if (server) { 72 | server.close(); 73 | server = null; 74 | } 75 | }); 76 | 77 | function getHost(app: express.Express) { 78 | server = app.listen(0); 79 | const port = (server.address() as AddressInfo).port; 80 | return `http://localhost:${port}`; 81 | } 82 | -------------------------------------------------------------------------------- /src/express/Request.ts: -------------------------------------------------------------------------------- 1 | import { RequestParamFetcher } from "./RequestParamFetcher"; 2 | import { RouteDefiner, RouteParams, Method } from "../RouteDefiner"; 3 | import * as t from "io-ts"; 4 | import * as E from "express"; 5 | 6 | export type Request

= { 7 | params: RouteParams

; 8 | body: RequestType; 9 | }; 10 | 11 | export function make< 12 | I extends t.Any, 13 | O extends t.Any, 14 | P extends string, 15 | M extends Method 16 | >(req: E.Request, rd: RouteDefiner): Request { 17 | return { 18 | params: new RequestParamFetcher(rd, req).getAll(), 19 | body: methodsWithoutBodies.has(rd.method) ? null : (req.body as t.TypeOf) 20 | }; 21 | } 22 | 23 | const methodsWithoutBodies: Set = new Set(["get"]); 24 | -------------------------------------------------------------------------------- /src/express/RequestParamFetcher.ts: -------------------------------------------------------------------------------- 1 | import { RouteDefiner } from "../RouteDefiner"; 2 | import { Request } from "express"; 3 | 4 | export class RequestParamFetcher { 5 | routeDefiner: RouteDefiner; 6 | request: Request; 7 | 8 | constructor(rd: RouteDefiner, request: Request) { 9 | this.routeDefiner = rd; 10 | this.request = request; 11 | } 12 | 13 | get(name: ParamNames): string { 14 | return this.request.param(name); 15 | } 16 | 17 | getAll(): { [param in ParamNames]: string } { 18 | return this.request.params; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/express/Response.ts: -------------------------------------------------------------------------------- 1 | export type Response = { 2 | status: number; 3 | headers?: { [key: string]: string }; 4 | body: T; 5 | }; 6 | 7 | export function success(data: T): Response { 8 | return { status: 200, headers: {}, body: data }; 9 | } 10 | 11 | export function response(response: Response): Response { 12 | return response; 13 | } 14 | -------------------------------------------------------------------------------- /src/express/Route.ts: -------------------------------------------------------------------------------- 1 | import * as t from "io-ts"; 2 | import { RouteDefiner } from "../RouteDefiner"; 3 | import { Response } from "./Response"; 4 | import { Request } from "./Request"; 5 | import bodyParser = require("body-parser"); 6 | import * as E from "express"; 7 | import { PathReporter } from "io-ts/lib/PathReporter"; 8 | 9 | export type RouteCallback< 10 | RequestBody, 11 | ResponseBody, 12 | ParamNames extends string 13 | > = ( 14 | params: Request 15 | ) => Promise>; 16 | 17 | export class ServerRoute< 18 | RequestBody extends t.Any, 19 | ResponseBody extends t.Any, 20 | P extends string, 21 | M extends string 22 | > { 23 | routeDefiner: RouteDefiner; 24 | callback: RouteCallback; 25 | 26 | constructor(opts: { 27 | routeDefiner: RouteDefiner; 28 | callback: RouteCallback; 29 | }) { 30 | this.routeDefiner = opts.routeDefiner; 31 | this.callback = opts.callback; 32 | } 33 | 34 | getMiddlewares() { 35 | const requestType = this.routeDefiner.requestType; 36 | if (!requestType) { 37 | return []; 38 | } 39 | const rt = requestType as t.Any; 40 | 41 | const parser = rt.name === "string" ? bodyParser.text() : bodyParser.json(); 42 | 43 | return [parser, validate(rt)]; 44 | } 45 | } 46 | 47 | const validate = (requestType: t.Any) => ( 48 | req: E.Request, 49 | res: E.Response, 50 | next: E.NextFunction 51 | ) => { 52 | const body = req.body; 53 | const result = requestType.decode(body); 54 | const report = PathReporter.report(result); 55 | 56 | if (result.isRight()) { 57 | return next(); 58 | } else { 59 | return res.status(406).json({ 60 | success: false, 61 | errors: report 62 | }); 63 | } 64 | }; 65 | -------------------------------------------------------------------------------- /src/express/TypedExpress.spec.ts: -------------------------------------------------------------------------------- 1 | import * as t from "io-ts"; 2 | import fetch from "node-fetch"; 3 | import path from "path"; 4 | import express, { Express } from "express"; 5 | import { TypedExpress } from "./TypedExpress"; 6 | import { success } from "./Response"; 7 | import { RouteDefiner } from "../RouteDefiner"; 8 | import { Server } from "http"; 9 | import { AddressInfo } from "net"; 10 | import { getTypes } from "infer-types"; 11 | 12 | let server: Server | null = null; 13 | 14 | afterEach(() => { 15 | if (server) { 16 | server.close(); 17 | server = null; 18 | } 19 | }); 20 | 21 | test("Simple application works", async () => { 22 | const application = express(); 23 | const app = new TypedExpress(application); 24 | 25 | app.route(RouteDefiner.get.returns(t.string).fixed("hello"), async () => 26 | success("Hello world!") 27 | ); 28 | 29 | const host = getHost(application); 30 | 31 | const result = await fetch(`${host}/hello`).then(x => x.json()); 32 | 33 | expect(result).toEqual("Hello world!"); 34 | }); 35 | 36 | test("Parameters work", async () => { 37 | const application = express(); 38 | const app = new TypedExpress(application); 39 | 40 | const Game = t.type({ name1: t.string, name2: t.string }); 41 | app.route( 42 | RouteDefiner.get 43 | .returns(Game) 44 | .param("name1") 45 | .fixed("vs") 46 | .param("name2"), 47 | async req => success({ name1: req.params.name1, name2: req.params.name2 }) 48 | ); 49 | 50 | const host = getHost(application); 51 | 52 | const result = await fetch(`${host}/gal/vs/the world`).then(x => x.json()); 53 | 54 | expect(Game.decode(result).isRight()).toBe(true); 55 | expect(result).toEqual({ name1: "gal", name2: "the world" }); 56 | }); 57 | 58 | test("Types are inferred correctly", async () => { 59 | const types = getTypes( 60 | path.resolve(__dirname, "./__fixtures__/express-server.ts") 61 | ); 62 | expect(types).toMatchSnapshot(); 63 | }); 64 | 65 | function getHost(app: Express) { 66 | server = app.listen(0); 67 | const port = (server.address() as AddressInfo).port; 68 | return `http://localhost:${port}`; 69 | } 70 | -------------------------------------------------------------------------------- /src/express/TypedExpress.ts: -------------------------------------------------------------------------------- 1 | import * as E from "express"; 2 | import * as t from "io-ts"; 3 | import { RouteDefiner, Method } from "../RouteDefiner"; 4 | import { make as makeRequest } from "./Request"; 5 | import { RouteCallback, ServerRoute } from "./Route"; 6 | 7 | export class TypedExpress { 8 | private readonly app: E.Router; 9 | private routes: ServerRoute[] = []; 10 | 11 | static of(app: E.Router) { 12 | return new TypedExpress(app); 13 | } 14 | 15 | constructor(app: E.Router) { 16 | this.app = app; 17 | } 18 | 19 | route< 20 | Params extends string, 21 | RouteResult extends t.Any, 22 | RequestType extends t.Any 23 | >( 24 | routeDefiner: RouteDefiner, 25 | callback: RouteCallback< 26 | t.TypeOf, 27 | t.TypeOf, 28 | Params 29 | > 30 | ) { 31 | const route = new ServerRoute({ 32 | callback, 33 | routeDefiner 34 | }); 35 | this.routes.push(route); 36 | 37 | const routeString = route.routeDefiner.toRoutingString(); 38 | this.app[route.routeDefiner.method as "get" | "post"]( 39 | routeString, 40 | route.getMiddlewares(), 41 | async (req: E.Request, res: E.Response, next: E.NextFunction) => { 42 | try { 43 | const typedRequest = makeRequest(req, route.routeDefiner); 44 | const { status, body, headers } = await route.callback(typedRequest); 45 | for (const [key, value] of Object.entries(headers || {})) { 46 | res.header(key, value); 47 | } 48 | res.status(status).json(body); 49 | } catch (e) { 50 | next(e); 51 | } 52 | } 53 | ); 54 | } 55 | 56 | listRoutes() { 57 | const tableData = this.routes.map(route => { 58 | return route.routeDefiner.toJSON(); 59 | }); 60 | 61 | console.log("Routes:"); 62 | console.table(tableData); 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/express/__fixtures__/express-server.ts: -------------------------------------------------------------------------------- 1 | import express from "express"; 2 | import { TypedExpress } from "../TypedExpress"; 3 | import { type } from "os"; 4 | import { RouteDefiner } from "../../RouteDefiner"; 5 | import { success } from "../Response"; 6 | import * as t from "io-ts"; 7 | 8 | const app = express(); 9 | const typed = TypedExpress.of(app); 10 | 11 | typed.route( 12 | RouteDefiner.post.reads(t.string).param("name"), 13 | /** @export RouteDefiner.post.reads(t.string).param("name") */ async req => { 14 | return success("Ok"); 15 | } 16 | ); 17 | 18 | typed.route( 19 | RouteDefiner.post 20 | .reads(t.string) 21 | .returns(t.type({ name: t.string })) 22 | .param("name"), 23 | /** @export RouteDefiner.post.reads(t.string).returns(t.type({ name: t.string })).param("name") */ async req => { 24 | return success({ name: req.params.name }); 25 | } 26 | ); 27 | 28 | typed.route( 29 | RouteDefiner.get.param("name"), 30 | /** @export RouteDefiner.get.param("name") */ 31 | async req => { 32 | return success("Ok"); 33 | } 34 | ); 35 | -------------------------------------------------------------------------------- /src/express/__snapshots__/TypedExpress.spec.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Types are inferred correctly 1`] = ` 4 | Object { 5 | "RouteDefiner.get.param(\\"name\\")": "(req: Request<\\"name\\", unknown>) => Promise>", 6 | "RouteDefiner.post.reads(t.string).param(\\"name\\")": "(req: Request<\\"name\\", string>) => Promise>", 7 | "RouteDefiner.post.reads(t.string).returns(t.type({ name: t.string })).param(\\"name\\")": "(req: Request<\\"name\\", string>) => Promise>", 8 | } 9 | `; 10 | -------------------------------------------------------------------------------- /src/express/index.ts: -------------------------------------------------------------------------------- 1 | export { success, Response } from "./Response"; 2 | export { TypedExpress } from "./TypedExpress"; 3 | -------------------------------------------------------------------------------- /src/express/parseBody.ts: -------------------------------------------------------------------------------- 1 | import * as E from "express"; 2 | import * as t from "io-ts"; 3 | import * as bodyParser from "body-parser"; 4 | import { PathReporter } from "io-ts/lib/PathReporter"; 5 | 6 | export function parseBody( 7 | bodyType: BodyType 8 | ): ExpressMiddleware[] { 9 | const parser = 10 | bodyType.name === "string" ? bodyParser.text() : bodyParser.json(); 11 | 12 | const validate = (req: E.Request, res: E.Response, next: E.NextFunction) => { 13 | const body = req.body; 14 | const result = bodyType.decode(body); 15 | const report = PathReporter.report(result); 16 | 17 | if (result.isRight()) { 18 | return next(); 19 | } else { 20 | return res.status(406).json({ 21 | success: false, 22 | errors: report 23 | }); 24 | } 25 | }; 26 | 27 | return [parser, validate]; 28 | } 29 | 30 | type ExpressMiddleware = ( 31 | req: E.Request, 32 | res: E.Response, 33 | next: E.NextFunction 34 | ) => void; 35 | -------------------------------------------------------------------------------- /src/fetch.spec.ts: -------------------------------------------------------------------------------- 1 | import { fetcher } from "./fetch"; 2 | import { RouteDefiner } from "./RouteDefiner"; 3 | import * as t from "io-ts"; 4 | import { Server, createServer } from "http"; 5 | import { AddressInfo } from "net"; 6 | 7 | let server: Server | null = null; 8 | 9 | afterEach(() => { 10 | if (server) { 11 | server.close(); 12 | server = null; 13 | } 14 | }); 15 | 16 | test("Calls the right place", async () => { 17 | const server = createServer((req, res) => { 18 | res.writeHead(200, { "Content-Type": "application/json" }); 19 | const result: t.TypeOf = { 20 | url: req.url 21 | }; 22 | res.end(JSON.stringify(result)); 23 | }); 24 | 25 | const host = getHost(server); 26 | 27 | const ResponseResult = t.type({ url: t.string }); 28 | const fetch = fetcher( 29 | RouteDefiner.get 30 | .returns(ResponseResult) 31 | .fixed("hello") 32 | .param("name"), 33 | host 34 | ); 35 | const r2 = RouteDefiner.get.returns(ResponseResult).fixed("hello"); 36 | const fetch2 = fetcher(r2, host); 37 | 38 | await fetch2({ params: {} }); 39 | 40 | const response = await fetch({ 41 | params: { name: "Gal" } 42 | }); 43 | 44 | expect(response.status).toBe(200); 45 | expect(response.data.url).toBe("/hello/Gal"); 46 | }); 47 | 48 | function getHost(app: Server) { 49 | server = app.listen(0); 50 | const port = (server.address() as AddressInfo).port; 51 | return `http://localhost:${port}`; 52 | } 53 | -------------------------------------------------------------------------------- /src/fetch.ts: -------------------------------------------------------------------------------- 1 | import * as t from "io-ts"; 2 | import { RouteDefiner, Meta } from "./RouteDefiner"; 3 | import nodeFetch, { Headers } from "node-fetch"; 4 | import { Response } from "./express/Response"; 5 | 6 | type TypedResponse = { 7 | status: number; 8 | headers: Headers; 9 | data: T; 10 | }; 11 | 12 | type Opts = ([keyof Params] extends [never] 13 | ? { params?: Params } 14 | : { 15 | params: Params; 16 | }) & 17 | (M extends "get" 18 | ? {} 19 | : { 20 | body: [Body] extends [never] ? undefined : Body; 21 | }); 22 | 23 | export async function fetch< 24 | I extends t.Any, 25 | O extends t.Any, 26 | P extends string, 27 | M extends string 28 | >( 29 | rd: RouteDefiner, 30 | opts: Opts, Meta.RequestBodyType>, 31 | rootURL: string 32 | ): Promise>> { 33 | const path = rd.toString(opts.params || ({} as Meta.Params)); 34 | const response = await nodeFetch(`${rootURL}${path}`, { 35 | method: rd.method, 36 | ...(rd.method !== "get" && { 37 | headers: { 38 | "Content-Type": 39 | rd.requestType.name === "string" ? "text/plain" : "application/json" 40 | }, 41 | body: 42 | rd.requestType.name === "string" ? opts.body : JSON.stringify(opts.body) 43 | }) 44 | }); 45 | 46 | const data = await response.json(); 47 | return { status: response.status, data, headers: response.headers }; 48 | } 49 | 50 | export function fetcher< 51 | I extends t.Any, 52 | O extends t.Any, 53 | P extends string, 54 | M extends string 55 | >(rd: RouteDefiner, rootURL: string = "/") { 56 | return ( 57 | opts: Opts, Meta.RequestBodyType> 58 | ) => fetch(rd, opts, rootURL); 59 | } 60 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import * as express from "./express/index"; 2 | import * as fetch from "./fetch"; 3 | import { RouteDefiner } from "./RouteDefiner"; 4 | 5 | export { express, fetch, RouteDefiner }; 6 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2018", 4 | "module": "commonjs", 5 | "declaration": true, 6 | "sourceMap": true, 7 | "outDir": "./dist", 8 | "types": [ 9 | "jest", 10 | "node" 11 | ], 12 | "esModuleInterop": true 13 | } 14 | } 15 | --------------------------------------------------------------------------------