-
Notifications
You must be signed in to change notification settings - Fork 9
/
fetcher.ts
195 lines (167 loc) · 6.58 KB
/
fetcher.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
// tslint:disable:no-any
import { fetch as crossFetch } from 'cross-fetch';
import { fold } from 'fp-ts/lib/Either';
import { flow, unsafeCoerce } from 'fp-ts/lib/function';
import { none, Option, some } from 'fp-ts/lib/Option';
import { pipe } from 'fp-ts/lib/pipeable';
import { TaskEither, tryCatch } from 'fp-ts/lib/TaskEither';
import * as io from 'io-ts';
import { HandlerNotSetError, JsonDeserializationError } from './errors';
/**
* Result of a fetch request – basically, a pair of code and payload
*/
export type Result<Code extends number, A> = { code: Code, payload: A };
export type Extractor<TResult, Code extends number> = (response: Response) => Promise<Data<TResult, Code>>;
type Handled<T, Code extends number> =
T extends Result<infer C, infer D> ? C extends Code ? never : Result<C, D> : never;
type Data<T, Code extends number> = T extends Result<infer C, infer D> ? C extends Code ? D : never : never;
type HandlersMap<TResult extends Result<any, any>, To> = Map<
TResult['code'],
[
(data: Data<TResult, TResult['code']>) => To,
io.Type<Data<TResult, TResult['code']>> | undefined,
Extractor<TResult, TResult['code']>
]
>;
/**
* Fetch type – just for convenience
*/
export type Fetch = typeof fetch;
export const defaultExtractor = (response: Response) => {
const contentType = response.headers.get('content-type');
return contentType?.includes('application/json') ? response.json() : response.text();
};
export const jsonExtractor = (response: Response) => response.json();
export const textExtractor = (response: Response) => response.text();
/**
* Fetcher – a thin type-safe wrapper around @global fetch API
*
* @export
* @class Fetcher
* @template TResult Sum type of a @see Result records
* @template To Target type the fetched result will be transformed into
*
* @example
*
*/
export class Fetcher<TResult extends Result<any, any>, To> {
private readonly handlers: HandlersMap<TResult, To> = new Map();
private restHandler?: () => To = void 0;
/**
* Create a new instance of a Fetcher class
* @param {RequestInfo} input Fetch input – either a string or a @see Request instance
* @param {RequestInit} [init] Fetch initialization parameters
* @param {Fetch} [fetch=crossFetch] (optional) Fetch function override – useful for testing
* @memberof Fetcher
*/
constructor(
private readonly input: RequestInfo,
private readonly init?: RequestInit,
private readonly fetch: Fetch = crossFetch,
) { }
/**
* Transform `Fetcher<T, A>` into `Fetcher<T, B>`.
* A functor method.
*
* @template B Type of the transformation result
* @param {(a: To) => B} f Transformation function. Will be applied to all registered handlers.
* @returns {Fetcher<TResult, B>} Transformed result
* @memberof Fetcher
*/
map<B>(f: (a: To) => B): Fetcher<TResult, B> {
for (const [code, [handler, codec, extractor]] of this.handlers) {
this.handlers.set(code, unsafeCoerce([flow(handler, f), codec, extractor]));
}
return unsafeCoerce(this);
}
/**
* Register a handler for given code
*
* @template Code Type-level HTTP code literal – optional, inferrable
* @param {Code} code HTTP code. Must be present in `TResult` sum type parameter of @see Fetcher
* @param {(data: Data<TResult, Code>) => To} handler Handler for the given code
* @param {io.Type<Data<TResult, Code>>} [codec] Optional codec for `To` type, used for validation
* @returns {Fetcher<Handled<TResult, Code>, To>} A fetcher will `code` being handled
* (so it's not possible to register another handler for it)
* @memberof Fetcher
*/
handle<Code extends TResult['code']>(
code: Code,
handler: (data: Data<TResult, Code>) => To,
codec?: io.Type<Data<TResult, Code>>,
extractor: Extractor<TResult, Code> = defaultExtractor,
): Fetcher<Handled<TResult, Code>, To> {
this.handlers.set(code, [handler, codec, extractor]);
return unsafeCoerce(this);
}
/**
* Handle all not handled explicitly response statuses using a provided fallback thunk
*
* @param {() => To} restHandler Thunk of a `To` type. Will be called if no suitable handles are found
* for the response status code
* @returns {Fetcher<Handled<TResult, never>, To>} Fetcher with ALL status codes being handled.
* Note that you won't be able to add any additional handlers to the chain after a call to this method!
* @memberof Fetcher
*/
discardRest(restHandler: () => To): Fetcher<Handled<TResult, never>, To> {
this.restHandler = restHandler;
return unsafeCoerce(this);
}
/**
* Convert a `Fetcher<T, A>` into a `TaskEither<Error, [A, Option<Errors>]>`.
*
* @returns {TaskEither<Error, [To, Option<io.Errors>]>} A `TaskEither` representing this `Fetcher`
* @memberof Fetcher
*/
toTaskEither(): TaskEither<Error, [To, Option<io.Errors>]> {
return tryCatch(
() => this.run(),
(reason) => reason instanceof Error ? reason : new Error(`Something went wrong, details: ${reason}`),
);
}
/**
* Actually performs @external fetch request and executes and suitable handlers.
*
* @returns {Promise<[To, Option<io.Errors>]>} A promise of a pair of result and possible validation errors
* @memberof Fetcher
*/
async run(): Promise<[To, Option<io.Errors>]> {
try {
const response = await this.fetch(this.input, this.init);
const status = response.status as TResult['code'];
const triplet = this.handlers.get(status);
if (triplet != null) {
const [handler, codec, extractor] = triplet;
try {
const body = await extractor(response);
try {
if (codec) {
return pipe(
codec.decode(body),
fold(
(errors) => [handler(body), some(errors)],
(res) => [handler(res), none],
),
);
}
return [handler(body), none];
} catch (error) {
return Promise.reject(new Error(`Handler side error, details: ${error}`));
}
} catch (jsonError) {
return Promise.reject(
new JsonDeserializationError(`Could not deserialize response JSON, details: ${jsonError}`),
);
}
}
if (this.restHandler != null) {
return [this.restHandler(), none];
}
return Promise.reject(
new HandlerNotSetError(`Neither handler for ${status} nor rest handler are set - consider adding \`.handle(${status}, ...)\` or \`.discardRest(() => ...)\` calls to the chain`),
);
} catch (error) {
return Promise.reject(error);
}
}
}