The HTTP Client Module
This module exports a generic http client environmental effect with syntactic sugar to deal with http requests

Feature set

Multiple implementations are available with different tradeoffs:
Package
Features
@matechs/http-client-fetch
Content: JSON, URLEncoded, Multipart Protocols: HTTP 1 & 2 (not in Node.js)
Target: Browser & Node.js (with any fetch polyfill)
Cancellable: yes (but socket will complete due to fetch)
@matechs/http-client-libcurl
Content: JSON, URLEncoded (no support for multipart yet) Protocols: HTTP 1 & 2
Target: Node.js
Cancellable: yes
1
yarn add @matechs/http-client
Copied!

Module

1
import { effect as T } from "@matechs/effect";
2
import { Predicate } from "fp-ts/lib/function";
3
import { Option } from "fp-ts/lib/Option";
4
5
// various environment entries
6
export const middlewareStackEnv: unique symbol = Symbol();
7
export const httpEnv: unique symbol = Symbol();
8
export const httpHeadersEnv: unique symbol = Symbol();
9
export const httpDeserializerEnv: unique symbol = Symbol();
10
11
// http methods
12
export enum Method {
13
GET,
14
POST,
15
PUT,
16
DELETE,
17
PATCH
18
}
19
20
// request content type use for posting data
21
export type RequestType = "JSON" | "DATA" | "FORM";
22
23
// represents an input compatible with type DATA
24
export interface DataInput {
25
}
26
27
// represents headers
28
export type Headers = Record<string, string>;
29
30
// represents an http response
31
export interface Response<Body> {
32
body: Option<Body>;
33
headers: Headers;
34
status: number;
35
}
36
37
// represent the error when we have a response
38
export interface HttpResponseError<ErrorBody> {
39
_tag: HttpErrorReason.Response;
40
response: Response<ErrorBody>;
41
}
42
43
// represent cases where the request failed,
44
// for example malformed or network down
45
export interface HttpRequestError {
46
_tag: HttpErrorReason.Request;
47
error: Error;
48
}
49
50
// index error cases
51
export enum HttpErrorReason {
52
Request,
53
Response
54
}
55
56
// describe http error
57
export type HttpError<ErrorBody> =
58
| HttpRequestError
59
| HttpResponseError<ErrorBody>;
60
61
// describe an effect used to deserialize http responses
62
export interface HttpDeserializer {
63
response: <A>(a: string) => A | undefined;
64
errorResponse: <E>(error: string) => E | undefined;
65
};
66
}
67
68
// fold on an http error
69
export function foldHttpError<A, B, ErrorBody>(
70
onError: (e: Error) => A,
71
onResponseError: (e: Response<ErrorBody>) => B
72
): (err: HttpError<ErrorBody>) => A | B
73
74
// describe an environment used to provide headers
75
export interface HttpHeaders {
76
[httpHeadersEnv]: Record<string, string>;
77
}
78
79
// main http effect
80
export interface Http {
81
request: <E, O>(
82
method: Method,
83
url: string,
84
headers: Record<string, string>,
85
requestType: RequestType,
86
body?: unknown
87
) => T.Effect<HttpDeserializer, HttpError<E>, Response<O>>;
88
};
89
}
90
91
// request function type exposed to allow middleware creation
92
export type RequestF = <R, E, O>(
93
method: Method,
94
url: string,
95
requestType: RequestType,
96
body?: unknown
97
) => T.Effect<RequestEnv & R, HttpError<E>, Response<O>>;
98
99
// describe a middleware that will be executed on every request
100
export type RequestMiddleware = (request: RequestF) => RequestF;
101
102
// describe an environment entry to hold the middlewares configured
103
export interface MiddlewareStack {
104
[middlewareStackEnv]?: {
105
stack: RequestMiddleware[];
106
};
107
}
108
109
// construct an environment with provided middlewares
110
export const middlewareStack: (
111
stack?: RequestMiddleware[]
112
) => MiddlewareStack
113
114
// represent environment to be used by consumer
115
export type RequestEnv = Http & HttpDeserializer & MiddlewareStack;
116
117
// JSON GET
118
export function get<E, O>(
119
url: string
120
): T.Effect<RequestEnv, HttpError<E>, Response<O>>
121
122
// JSON POST
123
export function post<I, E, O>(
124
url: string,
125
body?: I
126
): T.Effect<RequestEnv, HttpError<E>, Response<O>>
127
128
// DATA GET
129
export function postData<I extends DataInput, E, O>(
130
url: string,
131
body?: I
132
): T.Effect<RequestEnv, HttpError<E>, Response<O>>
133
134
// FORM POST
135
export function postForm<E, O>(
136
url: string,
137
body: FormData
138
): T.Effect<RequestEnv, HttpError<E>, Response<O>>
139
140
// JSON PATCH
141
export function patch<I, E, O>(
142
url: string,
143
body?: I
144
): T.Effect<RequestEnv, HttpError<E>, Response<O>>
145
146
// DATA PATCH
147
export function patchData<I extends DataInput, E, O>(
148
url: string,
149
body?: I
150
): T.Effect<RequestEnv, HttpError<E>, Response<O>>
151
152
// FORM PATCH
153
export function patchForm<E, O>(
154
url: string,
155
body: FormData
156
): T.Effect<RequestEnv, HttpError<E>, Response<O>>
157
158
// JSON PUT
159
export function put<I, E, O>(
160
url: string,
161
body?: I
162
): T.Effect<RequestEnv, HttpError<E>, Response<O>>
163
164
// DATA PUT
165
export function putData<I extends DataInput, E, O>(
166
url: string,
167
body?: I
168
): T.Effect<RequestEnv, HttpError<E>, Response<O>>
169
170
// FORM PUT
171
export function putForm<E, O>(
172
url: string,
173
body: FormData
174
): T.Effect<RequestEnv, HttpError<E>, Response<O>>
175
176
// JSON DELETE
177
export function del<I, E, O>(
178
url: string,
179
body?: I
180
): T.Effect<RequestEnv, HttpError<E>, Response<O>>
181
182
export function delData<I extends DataInput, E, O>(
183
url: string,
184
body?: I
185
): T.Effect<RequestEnv, HttpError<E>, Response<O>>
186
187
// FORM DELETE
188
export function delForm<E, O>(
189
url: string,
190
body: FormData
191
): T.Effect<RequestEnv, HttpError<E>, Response<O>>
192
193
// Provide headers in child environment
194
// replace = true will discard any header already in environment
195
// replace = false (default) will merge the two
196
export function withHeaders(
197
headers: Record<string, string>,
198
replace = false
199
): <R, E, A>(eff: T.Effect<R, E, A>) => T.Effect<R, E, A>
200
201
// Provide headers through a middleware
202
// Request path used to restrict to specific domains/urls
203
// Replace as per withHeaders
204
export function withPathHeaders(
205
headers: Record<string, string>,
206
path: Predicate<string>,
207
replace = false
208
): RequestMiddleware
209
210
// Default json deserializer implementation
211
export const jsonDeserializer: HttpDeserializer
212
213
// Fold over the request type (useful in middleware dev)
214
export function foldRequestType<A, B, C>(
215
requestType: RequestType,
216
onJson: () => A,
217
onData: () => B,
218
onForm: () => C
219
): A | B | C
220
221
// Get method as string (useful in middleware dev)
222
export function getMethodAsString(method: Method): string
Copied!

Usage

Let's add an implementation, we are gonna use libcurl for this purpose:
1
yarn add @matechs/http-client-libcurl
Copied!
We are going to start with a simple get request:
1
import { effect as T, exit as E } from "@matechs/effect";
2
import * as HTTP from "@matechs/http-client";
3
import * as L from "@matechs/http-client-libcurl";
4
import { pipe } from "fp-ts/lib/pipeable";
5
6
// live environment with libcurl and json deserializer
7
const envLive = pipe(
8
T.noEnv,
9
T.mergeEnv(L.libcurl()),
10
T.mergeEnv(HTTP.jsonDeserializer)
11
);
12
13
// simple http get
14
const program: T.Effect<
15
HTTP.RequestEnv,
16
HTTP.HttpError<unknown>,
17
HTTP.Response<unknown>
18
> = HTTP.get("https://jsonplaceholder.typicode.com/todos/1");
19
20
// run the program
21
T.run(
22
T.provideAll(envLive)(program),
23
E.fold(
24
res => {
25
console.log(res);
26
},
27
err => {
28
console.log(err);
29
},
30
e => console.error(e),
31
() => console.error("interrupted")
32
)
33
);
Copied!
Will output something like:
1
{
2
status: 200,
3
body: {
4
_tag: 'Some',
5
value: { userId: 1, id: 1, title: 'delectus aut autem', completed: false }
6
},
7
headers: {
8
result: { version: 'HTTP/2', code: 200, reason: '' },
9
date: 'Sat, 14 Dec 2019 14:06:53 GMT',
10
'content-type': 'application/json; charset=utf-8',
11
'content-length': '83',
12
'Set-Cookie': [
13
'__cfduid=dd36c55568f2aa040768612d145c13f1b1576332413; expires=Mon, 13-Jan-20 14:06:53 GMT; path=/; domain=.typicode.com; HttpOnly'
14
],
15
'x-powered-by': 'Express',
16
vary: 'Origin, Accept-Encoding',
17
'access-control-allow-credentials': 'true',
18
'cache-control': 'max-age=14400',
19
pragma: 'no-cache',
20
expires: '-1',
21
'x-content-type-options': 'nosniff',
22
etag: 'W/"53-hfEnumeNh6YirfjyjaujcOPPT+s"',
23
via: '1.1 vegur',
24
'cf-cache-status': 'HIT',
25
age: '2833',
26
'accept-ranges': 'bytes',
27
'expect-ct': 'max-age=604800, report-uri="https://report-uri.cloudflare.com/cdn-cgi/beacon/expect-ct"',
28
server: 'cloudflare',
29
'cf-ray': '5450bdaff97fe638-LHR'
30
}
31
}
Copied!
Note the request was performed using HTTP/2 and you have control over the full response, let's now do something more interesting:
1
import { effect as T, exit as E } from "@matechs/effect";
2
import * as HTTP from "@matechs/http-client";
3
import * as L from "@matechs/http-client-libcurl";
4
import { pipe } from "fp-ts/lib/pipeable";
5
import * as A from "fp-ts/lib/Array";
6
7
// live environment with libcurl and json deserializer
8
const envLive = pipe(
9
T.noEnv,
10
T.mergeEnv(L.libcurl()),
11
T.mergeEnv(HTTP.jsonDeserializer)
12
);
13
14
// fetch 10 todos in parallel and map the response
15
const program: T.Effect<
16
HTTP.RequestEnv,
17
HTTP.HttpError<unknown> | Error,
18
unknown[]
19
> = pipe(
20
// fetch a list of 10 todos in parallel
21
A.array.sequence(T.parEffect)(
22
pipe(
23
A.range(1, 10),
24
A.map(n => HTTP.get(`https://jsonplaceholder.typicode.com/todos/${n}`))
25
)
26
),
27
// extract body from each request, fail if empty
28
T.chain(arr =>
29
A.array.traverse(T.effect)(arr, r =>
30
T.fromOption(() => new Error("empty response"))(r.body)
31
)
32
)
33
);
34
35
T.run(
36
T.provideAll(envLive)(program),
37
E.fold(
38
res => {
39
console.log(res);
40
},
41
err => {
42
console.log(err);
43
},
44
e => console.error(e),
45
() => console.error("interrupted")
46
)
47
);
Copied!
This will print:
1
[
2
{ userId: 1, id: 1, title: 'delectus aut autem', completed: false },
3
{
4
userId: 1,
5
id: 2,
6
title: 'quis ut nam facilis et officia qui',
7
completed: false
8
},
9
{ userId: 1, id: 3, title: 'fugiat veniam minus', completed: false },
10
{ userId: 1, id: 4, title: 'et porro tempora', completed: true },
11
{
12
userId: 1,
13
id: 5,
14
title: 'laboriosam mollitia et enim quasi adipisci quia provident illum',
15
completed: false
16
},
17
{
18
userId: 1,
19
id: 6,
20
title: 'qui ullam ratione quibusdam voluptatem quia omnis',
21
completed: false
22
},
23
{
24
userId: 1,
25
id: 7,
26
title: 'illo expedita consequatur quia in',
27
completed: false
28
},
29
{
30
userId: 1,
31
id: 8,
32
title: 'quo adipisci enim quam ut ab',
33
completed: true
34
},
35
{
36
userId: 1,
37
id: 9,
38
title: 'molestiae perspiciatis ipsa',
39
completed: false
40
},
41
{
42
userId: 1,
43
id: 10,
44
title: 'illo est ratione doloremque quia maiores aut',
45
completed: true
46
}
47
]
Copied!
Let's suppose https://jsonplaceholder.typicode.com is a domain where we want to send specific headers, for example an auth token. We can easily wire it in the middleware environment with only a small addition:
1
// live environment with libcurl and json deserializer and header middleware
2
const envLive = pipe(
3
T.noEnv,
4
T.mergeEnv(L.libcurl()),
5
T.mergeEnv(HTTP.jsonDeserializer),
6
T.mergeEnv(
7
HTTP.middlewareStack([
8
HTTP.withPathHeaders({ token: "demo" }, path =>
9
path.startsWith("https://jsonplaceholder.typicode.com")
10
)
11
])
12
)
13
);
Copied!
The rest of the code doesn't change and you are not sending the header!

Notes

We strongly recommend using io-ts to refine the responses to proper runtime safe types. If you don't need full deserialization you can specify the types on the request function in order to have the response typed.
1
interface Todo {
2
userId: number,
3
id: number,
4
title: string,
5
completed: boolean
6
}
7
8
interface TodoError {
9
message: string
10
}
11
12
const program: T.Effect<
13
HTTP.RequestEnv,
14
HTTP.HttpError<TodoError> | Error,
15
Todo[]
16
> = pipe(
17
// fetch a list of 10 todos in parallel
18
A.array.sequence(T.parEffect)(
19
pipe(
20
A.range(1, 10),
21
A.map(n => HTTP.get<TodoError, Todo>(`https://jsonplaceholder.typicode.com/todos/${n}`))
22
)
23
),
24
// extract body from each request, fail if empty
25
T.chain(arr =>
26
A.array.traverse(T.effect)(arr, r =>
27
T.fromOption(() => new Error("empty response"))(r.body)
28
)
29
)
30
);
Copied!
Last modified 1yr ago