Skip to content

Commit 280f613

Browse files
authored
Merge pull request #5 from fluture-js/avaq/http
Add HTTP-related utilties to the library
2 parents 12da26e + 9163b89 commit 280f613

3 files changed

Lines changed: 531 additions & 23 deletions

File tree

index.js

Lines changed: 328 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,30 @@
11
//. # Fluture Node
22
//.
3-
//. Common Node API's wrapped to return [Fluture][] Futures.
3+
//. FP-style HTTP and streaming utils for Node based on [Fluture][].
4+
//.
5+
//. ```console
6+
//. $ npm install fluture fluture-node
7+
//. ```
48
//.
59
//. ## API
610

7-
import Future from 'fluture/index.js';
11+
import http from 'http';
12+
import https from 'https';
13+
import qs from 'querystring';
14+
import {Readable, pipeline} from 'stream';
15+
16+
import {
17+
Future,
18+
attempt,
19+
chain,
20+
encase,
21+
map,
22+
mapRej,
23+
reject,
24+
resolve,
25+
} from 'fluture/index.js';
26+
27+
//. ### EventEmitter
828

929
//# once :: String -> EventEmitter -> Future Error a
1030
//.
@@ -38,9 +58,50 @@ export const once = event => emitter => Future ((rej, res) => {
3858
return removeListeners;
3959
});
4060

41-
//# buffer :: ReadableStream a -> Future Error (Array a)
61+
//. ### Buffer
62+
63+
//# encode :: Charset -> Buffer -> Future Error String
64+
//.
65+
//. Given an encoding and a [Buffer][], returns a Future of the result of
66+
//. encoding said buffer using the given encoding. The Future will reject
67+
//. with an Error if the encoding is unknown.
68+
//.
69+
//. ```js
70+
//. > encode ('utf8') (Buffer.from ('Hello world!'));
71+
//. 'Hello world!'
72+
//. ```
73+
export const encode = encoding => buffer => (
74+
mapRej (e => new Error (e.message))
75+
(attempt (() => buffer.toString (encoding)))
76+
);
77+
78+
//. ### Stream
79+
80+
//# streamOf :: Buffer -> Future a (Readable Buffer)
4281
//.
43-
//. Buffer all data on a Stream into a Future of an Array.
82+
//. Given a [Buffer][], returns a Future of a [Readable][] stream which will
83+
//. emit the given Buffer before ending.
84+
//.
85+
//. The stream is wrapped in a Future because creation of a stream causes
86+
//. side-effects if it's not consumed in time, making it safer to pass it
87+
//. around wrapped in a Future.
88+
export const streamOf = encase (buf => new Readable ({
89+
highWaterMark: buf.byteLength,
90+
read: function() {
91+
if (this._pushed || this.push (buf)) { this.push (null); }
92+
this._pushed = true;
93+
},
94+
}));
95+
96+
//# emptyStream :: Future a (Readable Buffer)
97+
//.
98+
//. A [Readable][] stream which ends after emiting zero bytes. Can be useful
99+
//. as an empty [`request`](#request) body, for example.
100+
export const emptyStream = streamOf (Buffer.alloc (0));
101+
102+
//# buffer :: Readable a -> Future Error (Array a)
103+
//.
104+
//. Buffer all data on a [Readable][] stream into a Future of an Array.
44105
//.
45106
//. When the Future is cancelled, it removes any trace of
46107
//. itself from the Stream.
@@ -77,6 +138,18 @@ export const buffer = stream => Future ((rej, res) => {
77138
return removeListeners;
78139
});
79140

141+
//# bufferString :: Charset -> Readable Buffer -> Future Error String
142+
//.
143+
//. A version of [`buffer`](#buffer) specialized in Strings.
144+
//.
145+
//. Takes a charset and a [Readable][] stream of [Buffer][]s, and returns
146+
//. a Future containing a String with the fully buffered and encoded result.
147+
export const bufferString = charset => stream => (
148+
chain (encode (charset)) (map (Buffer.concat) (buffer (stream)))
149+
);
150+
151+
//. ### Event Loop
152+
80153
//# instant :: b -> Future a b
81154
//.
82155
//. Resolves a Future with the given value in the next tick,
@@ -109,6 +182,256 @@ export const immediate = x => Future ((rej, res) => {
109182
return () => { clearImmediate (job); };
110183
});
111184

112-
//. [Fluture]: https://github.com/fluture-js/Fluture
185+
//. ### Http
186+
//.
187+
//. The functions below are to be used in compositions such as the one shown
188+
//. below, in order to cover a wide variety of HTTP-related use cases.
189+
//.
190+
//. ```js
191+
//. import {map, chain, chainRej, encase, fork} from 'fluture/index.js';
192+
//. import {retrieve,
193+
//. acceptStatus,
194+
//. autoBufferResponse,
195+
//. responseToError} from './index.js';
196+
//.
197+
//. const rejectUnacceptable = res => (
198+
//. acceptStatus (200) (res)
199+
//. .pipe (chainRej (responseToError))
200+
//. );
201+
//.
202+
//. retrieve ('https://api.github.com/users/Avaq') ({'User-Agent': 'Avaq'})
203+
//. .pipe (chain (rejectUnacceptable))
204+
//. .pipe (chain (autoBufferResponse))
205+
//. .pipe (chain (encase (JSON.parse)))
206+
//. .pipe (map (avaq => avaq.name))
207+
//. .pipe (fork (console.error) (console.log));
208+
//. ```
209+
//.
210+
//. The example above will either:
211+
//.
212+
//. 1. log `"Aldwin Vlasblom"` to the terminal if nothing weird happens; or
213+
//. 2. log an error to the console if:
214+
//. * a network error occurs;
215+
//. * the response code is not 200; or
216+
//. * the JSON is malformed.
217+
//.
218+
//. Note that we were in control of how an unexpected status was treated,
219+
//. how an erroneous response would be formatted as an error message,
220+
//. whether the response would be parsed as JSON, and how a failure of parsing
221+
//. the JSON would have been treated.
222+
//.
223+
//. The goal of the functions below us to give you as much control over HTTP
224+
//. requests as possible, while still keeping boilerplate low by leveraging
225+
//. function composition.
226+
//.
227+
//. This contrasts with many of the popular HTTP client libraries out there,
228+
//. which often make decisions for you, taking away control in an attempt to
229+
//. provide a smoother usage experience.
230+
231+
// defaultCharset :: String
232+
const defaultCharset = 'utf8';
233+
234+
// defaultContentType :: String
235+
const defaultContentType = 'text/plain; charset=' + defaultCharset;
236+
237+
// charsetRegex :: RegExp
238+
const charsetRegex = /\bcharset=([^;\s]+)/;
239+
240+
// mimeTypes :: StrMap Mimetype
241+
const mimeTypes = {
242+
form: 'application/x-www-form-urlencoded; charset=utf8',
243+
json: 'application/json; charset=utf8',
244+
};
245+
246+
// getRequestModule :: String -> Future Error Module
247+
const getRequestModule = protocol => {
248+
switch (protocol) {
249+
case 'https:': return resolve (https);
250+
case 'http:': return resolve (http);
251+
default: return reject (new Error (`Unsupported protocol '${protocol}'`));
252+
}
253+
};
254+
255+
//# request :: Object -> Url -> Readable Buffer -> Future Error IncomingMessage
256+
//.
257+
//. This is the "lowest level" function for making HTTP requests. It does not
258+
//. handle buffering, encoding, content negotiation, or anything really.
259+
//. For most use cases, you can use one of the more specialized functions:
260+
//.
261+
//. * [`send`](#send): Make a generic HTTP request.
262+
//. * [`retrieve`](#retrieve): Make a GET request.
263+
//.
264+
//. Given an Object of [http options][], a String containing the request URL,
265+
//. and a [Readable][] stream of [Buffer][]s to be sent as the request body,
266+
//. returns a Future which makes an HTTP request and resolves with its
267+
//. [IncomingMessage][]. If the Future is cancelled, the request is aborted.
268+
//.
269+
//. ```js
270+
//. import {attempt, chain} from 'fluture/index.js';
271+
//. import {createReadStream} from 'fs';
272+
//.
273+
//. const sendBinary = request ({
274+
//. method: 'POST',
275+
//. headers: {'Transfer-Encoding': 'chunked'},
276+
//. });
277+
//.
278+
//. const eventualBody = attempt (() => createReadStream ('./data.bin'));
279+
//.
280+
//. eventualBody.pipe (chain (sendBinary ('https://example.com')));
281+
//. ```
282+
//.
283+
//. If you want to use this function to transfer a stream of data, don't forget
284+
//. to set the Transfer-Encoding header to "chunked".
285+
export const request = options => url => body => {
286+
const location = new URL (url);
287+
const makeRequest = lib => Future ((rej, res) => {
288+
const req = lib.request (location, options);
289+
req.once ('response', res);
290+
pipeline (body, req, e => e && rej (e));
291+
return () => {
292+
req.removeListener ('response', res);
293+
req.abort ();
294+
};
295+
});
296+
return chain (makeRequest) (getRequestModule (location.protocol));
297+
};
298+
299+
//# retrieve :: Url -> StrMap String -> Future Error IncomingMessage
300+
//.
301+
//. A version of [`request`](#request) specialized in the `GET` method.
302+
//.
303+
//. Given a URL and a StrMap of request headers, returns a Future which
304+
//. makes a GET requests to the given resource.
305+
//.
306+
//. ```js
307+
//. retrieve ('https://api.github.com/users/Avaq') ({'User-Agent': 'Avaq'})
308+
//. ```
309+
export const retrieve = url => headers => (
310+
chain (request ({headers}) (url)) (emptyStream)
311+
);
312+
313+
//# send :: Mimetype -> Method -> Url -> StrMap String -> Buffer -> Future Error IncomingMessage
314+
//.
315+
//. A version of [`request`](#request) for sending arbitrary data to a server.
316+
//. There's also more specific versions for sending common types of data:
317+
//.
318+
//. * [`sendJson`](#sendJson) sends JSON stringified data.
319+
//. * [`sendForm`](#sendForm) sends form encoded data.
320+
//.
321+
//. Given a MIME type, a request method, a URL, a StrMap of headers, and
322+
//. finally a Buffer, returns a Future which will send the Buffer to the
323+
//. server at the given URL using the given request method, telling it the
324+
//. buffer contains data of the given MIME type.
325+
//.
326+
//. This function will always send the Content-Type and Content-Length headers,
327+
//. alongside the provided headers. Manually provoding either of these headers
328+
//. override those generated by this function.
329+
export const send = mime => method => url => extraHeaders => buf => {
330+
const headers = Object.assign ({
331+
'Content-Type': mime,
332+
'Content-Length': buf.byteLength,
333+
}, extraHeaders);
334+
return chain (request ({method, headers}) (url)) (streamOf (buf));
335+
};
336+
337+
//# sendJson :: Method -> String -> StrMap String -> JsonValue -> Future Error IncomingMessage
338+
//.
339+
//. A version of [`send`](#send) specialized in sending JSON.
340+
//.
341+
//. Given a request method, a URL, a StrMap of headers and a JavaScript plain
342+
//. object, returns a Future which sends the object to the server at the
343+
//. given URL after JSON-encoding it.
344+
//.
345+
//. ```js
346+
//. sendJson ('PUT')
347+
//. ('https://example.com/users/bob')
348+
//. ({Authorization: 'Bearer asd123'})
349+
//. ({name: 'Bob', email: 'bob@example.com'});
350+
//. ```
351+
export const sendJson = method => url => headers => json => {
352+
const buf = Buffer.from (JSON.stringify (json));
353+
return send (mimeTypes.json) (method) (url) (headers) (buf);
354+
};
355+
356+
//# sendForm :: Method -> String -> StrMap String -> JsonValue -> Future Error IncomingMessage
357+
//.
358+
//. A version of [`send`](#send) specialized in sending form data.
359+
//.
360+
//. Given a request method, a URL, a StrMap of headers and a JavaScript plain
361+
//. object, returns a Future which sends the object to the server at the
362+
//. given URL after www-form-urlencoding it.
363+
//.
364+
//. ```js
365+
//. sendForm ('POST')
366+
//. ('https://example.com/users/create')
367+
//. ({})
368+
//. ({name: 'Bob', email: 'bob@example.com'});
369+
//. ```
370+
export const sendForm = method => url => headers => form => {
371+
const buf = Buffer.from (qs.stringify (form));
372+
return send (mimeTypes.form) (method) (url) (headers) (buf);
373+
};
374+
375+
//# acceptStatus :: Number -> IncomingMessage -> Future IncomingMessage IncomingMessage
376+
//.
377+
//. This function "tags" an [IncomingMessage][] based on a given status code.
378+
//. If the response status matches the given status code, the returned Future
379+
//. will resolve. If it doesn't, the returned Future will reject.
380+
//.
381+
//. The idea is that you can compose this function with one that returns an
382+
//. IncomingMessage, and reject any responses that don't meet the expected
383+
//. status code. In combination with [`responseToError`](#responseToError),
384+
//. you can then flatten it back into the outer Future.
385+
//.
386+
//. The usage example under the [Http](#http) section shows this.
387+
export const acceptStatus = code => res => (
388+
code === res.statusCode ? resolve (res) : reject (res)
389+
);
390+
391+
//# bufferResponse :: Charset -> IncomingMessage -> Future Error String
392+
//.
393+
//. A version of [`buffer`](#buffer) specialized in [IncomingMessage][]s.
394+
//.
395+
//. See also [`autoBufferResponse`](#autoBufferResponse).
396+
//.
397+
//. Given a charset and an IncomingMessage, returns a Future with the buffered,
398+
//. encoded, message body.
399+
export const bufferResponse = charset => message => (
400+
mapRej (e => new Error ('Failed to buffer response: ' + e.message))
401+
(bufferString (charset) (message))
402+
);
403+
404+
//# autoBufferResponse :: IncomingMessage -> Future Error String
405+
//.
406+
//. Given an IncomingMessage, buffers and decodes the message body using the
407+
//. charset provided in the message headers. Falls back to UTF-8 if the
408+
//. charset was not provided.
409+
//.
410+
//. Returns a Future with the buffered, encoded, message body.
411+
export const autoBufferResponse = message => {
412+
const contentType = message.headers['content-type'] || defaultContentType;
413+
const parsed = charsetRegex.exec (contentType);
414+
const charset = parsed == null ? defaultCharset : parsed[1];
415+
return bufferResponse (charset) (message);
416+
};
417+
418+
//# responseToError :: IncomingMessage -> Future Error a
419+
//.
420+
//. Given a response, returns a *rejected* Future of an instance of Error
421+
//. with a message based on the content of the response.
422+
export const responseToError = message => (
423+
autoBufferResponse (message)
424+
.pipe (chain (body => reject (new Error (
425+
`Unexpected ${message.statusMessage} (${message.statusCode}) response. ` +
426+
`Response body:\n\n${body.split ('\n').map (x => ` ${x}`).join ('\n')}`
427+
))))
428+
);
429+
113430
//. [`process.nextTick`]: https://nodejs.org/api/process.html#process_process_nexttick_callback_args
114431
//. [`setImmediate`]: https://nodejs.org/api/timers.html#timers_setimmediate_callback_args
432+
433+
//. [Buffer]: https://nodejs.org/api/buffer.html#buffer_class_buffer
434+
//. [Fluture]: https://github.com/fluture-js/Fluture
435+
//. [http options]: https://nodejs.org/api/http.html#http_http_request_url_options_callback
436+
//. [IncomingMessage]: https://nodejs.org/api/http.html#http_class_http_incomingmessage
437+
//. [Readable]: https://nodejs.org/api/stream.html#stream_class_stream_readable

package.json

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,18 @@
11
{
22
"name": "fluture-node",
33
"version": "2.0.0",
4-
"description": "Common Node API's wrapped to return Fluture Futures",
4+
"description": "FP-style HTTP and streaming utils for Node based on Fluture",
5+
"tags": [
6+
"buffer",
7+
"events",
8+
"fluture",
9+
"http",
10+
"https",
11+
"node",
12+
"request",
13+
"streams",
14+
"timers"
15+
],
516
"type": "module",
617
"main": "index.cjs",
718
"module": "index.js",
@@ -33,7 +44,7 @@
3344
"devDependencies": {
3445
"c8": "^6.0.1",
3546
"codecov": "^3.2.0",
36-
"fluture": "^12.0.0",
47+
"fluture": "^12.2.0",
3748
"oletus": "^2.0.0",
3849
"rollup": "^1.27.0",
3950
"sanctuary-scripts": "^3.1.1"

0 commit comments

Comments
 (0)