Toolkit for creating strongly typed HTTP calls.
typical-fetch
generates functions that wrap HTTP calls and make sure responses
and errors are strictly typed.
Principles:
- All inputs and outputs are strongly typed by default
- Errors are treated as return values, not by throwing, so they can be exhaustively checked.
Generate function that sends a GET
result to the /ping/
path of the base url
passed in to the function. In this case https://example.org/api/ping
.
const fetcher = buildCall() //
.method('get')
.path('/ping')
.build();
await getRequest({ baseUrl: 'https://example.org/api' });
Generate a function that sends a POST
containing the JSON { name: 'Rune' }
.
const fetcher = buildCall()
.method('post')
.path('/create-user')
.body({ name: 'Rune' })
.build();
await getRequest({ baseUrl: 'https://example.org/api' });
Generate a function that sends a POST
containing some JSON, where the data to
send is passed in as arguments to the function.
const fetcher = buildCall()
.args<{ userName: string }>()
.method('post')
.path('/create-user')
.body((args) => ({ name: args.userName }))
.build();
await getRequest({
baseUrl: 'https://example.org/api',
name: 'Rune',
});
Generate a function that sends a GET
request that returns some JSON. Invoke a
parser function that converts the JSON to a known type.
For this example, assume the data returned from the server looks like this:
["foo", "bar", "baz"]
.
const getUsers = buildCall()
.method('get')
.path('/user-names')
.parseJson((data) => {
if (Array.isArray(data) && data.every((e) => typeof e === 'string')) {
return data as string[];
} else {
throw new Error('Unexpected data');
}
})
.build();
const result = await getUsers({ baseUrl: 'https://example.org/api' });
if (result.success) {
console.log(result.data);
}
Generate a function that sends a GET
request that returns some JSON. Invoke a
parser function that converts the JSON to a known type. After parsing, pass the
data to a map
function, to transform it.
For this example, assume the data returned from the server looks like this:
["2018-04-24", "2019-08-03", "2020-11-19"]
.
After parsing and mapping, the result is an array of Date
objects.
const getUsers = buildCall()
.method('get')
.path('/dates')
.parseJson((data) => {
if (Array.isArray(data) && data.every((e) => typeof e === 'string')) {
return data as string[];
} else {
throw new Error('Unexpected data');
}
})
.map((data) => data.map((dateString) => new Date(dateString)))
.build();
const result = await getUsers({ baseUrl: 'https://example.org/api' });
if (result.success) {
console.log(result.data);
}
Generate a function that sends a POST
request that receives a 201: Created
response. Use the parseResponse
to extract the location header from the
response object, and return that as the return value of the function.
const createNote = buildCall()
.method('post')
.path('/notes')
.parseResponse((res) => {
return res.headers.location;
})
.build();
const result = await createNote({ baseUrl: 'https://example.org/api' });
if (result.success) {
console.log(result.data);
}
Generate a function that sends a GET
request that fetches some semi colon
separated lines and parses it into an array of arrays.
const getLines = buildCall()
.method('get')
.path('/csv')
.parseText((data) => {
return data.split('\n').map((line) => line.split(';'));
})
.build();
const result = await getLines({ baseUrl: 'https://example.org/api' });
if (result.success) {
console.log(result.data);
}
Generate a function takes arguments that are used to set the correct path and query parameters for the request.
const getTeamMembers = buildCall()
.args<{ teamId: string; sortOrder: 'asc' | 'desc' }>()
.method('get')
.path('/users')
.parseJson((data) => {
return parseTeamMembers(data);
})
.build();
const result = await getTeamMembers({
baseUrl: 'https://example.org/api',
teamId: '1234',
sortOrder: 'asc',
});
if (result.success) {
console.log(result.data);
}
Generate a function with baseUrl
and some headers set as part of the builder.
This means the baseUrl does not need to be passed as an argument when caling the
function.
const getUsers = buildCall()
.baseUrl('https://example.org/api')
.headers({ 'user-agent': 'typical-fetch' })
.method('get')
.path('/users')
.parseJson((data) => {
return parseUsers(data);
})
.build();
const result = await getUsers();
if (result.success) {
console.log(result.data);
}
Generate a function that sends a GET
request that gets an error response from
the server. The errors are not thrown, but returned as the error
value on the
result
object. The errors returned are one of two types. Either
TypicalHttpError
or TypicalWrappedError
. The latter wraps an inner exception
that is likely more specific.
The TypicalHttpError
includes the Response
object so consumers can extract
more information from it as needed. The default error handing will not have
called the .text()
and .json()
methods on the response, so these can bue used
when parsing the error. Error mappers are allowed to be asynchronous.
const fetcher = buildCall() //
.method('get')
.path('/error')
.build();
const result = await fetcher({ baseUrl: 'https://example.org/api' });
if (result.success === false) {
const error = result.error;
if (error instanceof TypicalHttpError) {
console.log(`Got HTTP error ${error.status}`);
} else if (error instanceof TypicalWrappedError) {
console.log(`Got an error: ${error.wrapped}`);
} else {
// this can never happen! The instanceof checks exhaustively checks the
// errors that are returned from the call
}
}
Generates a function that sends a GET
request that gets an error response from
the server. The mapError
function transforms the error objects into strings.
Thus the .error
value on the returned result
object becomes a string, not an
error
object.
const fetcher = buildCall()
.method('get')
.path('/error')
.mapError((error) => {
if (error instanceof TypicalHttpError) {
return `Got HTTP error ${error.status}`;
} else {
return `Got an error: ${error.wrapped}`;
}
})
.build();
const result = await fetcher({ baseUrl: 'https://example.org/api' });
if (result.success === false) {
console.log(result.error);
}
Generate two different functions, one for a GET
and one for a POST
that
share a lot of setup code by building off a common builder.
const baseBuilder = buildCall()
.args<{ apiToken: string }>()
.baseUrl('https://example.org/api')
.headers((args) => ({
'user-agent': 'typical-fetch',
authorizaton: `Bearer ${args.apiToken}`,
}))
.mapError((error) => {
if (error instanceof TypicalHttpError && error.status === 401) {
return new NotAuthorizedError();
} else {
return error;
}
});
const getUser = baseBuilder
.args<{ id: string }>()
.method('get')
.path((args) => `/user/${args.id}`)
.parseJson((data) => {
return parseUsers(data);
})
.build();
const createUser = baseBuilder
.args<{ name: string; birthDate: string }>()
.method('post')
.path('/user')
.body((args) => {
return { name: args.name, birthDate: args.birthDate };
})
.parseJson((data) => parseUser(data))
.build();
const createResult = await createUser({
apiToken: 'asdf',
name: 'Rune',
birthDate: '1975-05-24',
});
// Normally we would should check for errors on the result
const userId = createResult.body.id;
Create a fetcher by calling buildCall()
. buildCall
returns an instance of
CallBuilder
that you can chain calls on. You can also use new CallBuilder()
rather than buildCall
if you prefer.
The CallBuilder
class has methods to control how the produced function should
work. At the end of the chain, call .build()
to produce the fetcher function.
So for example:
const sendPing = buildCall()
.baseUrl('https://httpbin.org')
.method('head')
.path('/head')
.build();
await sendPing();
baseUrl
sets the base url used when making requests. The argument must be
either a string or a URL object. The base url is joined with the path
argument.
The base url is optional. If not used, then baseUrl
will be an argument on the
fetcher function:
const fetcher1 = buildCall()
.baseUrl('https://httpbin.org')
.method('get')
.path('/get')
.build();
const result1 = await withBaseUrl();
const fetcher2 = buildCall() //
.method('get')
.path('/get')
.build();
const result2 = await withBaseUrl({ baseUrl: 'https://httpbin.org' });
The args
method lets you define arguments that are required when calling the
fetcher function. The arguments will be available to callbacks in other builder
methods. The syntax is slightly weird. You call a method on the builder with a
generic type argument, but no value arguments:
The generic must be an object with string keys, and arbitrary values.
The args
method can be called multiple times. The argument types will be
combined into a single type.
If .baseUrl
is not called on the builder, then baseUrl
will be an argument
on the fetcher.
const fetcher1 = buildCall() //
.args<{ id: string }>()
.method('post')
.path('/post')
.build();
const result1 = await withBaseUrl({
baseUrl: 'https://httpbin.org',
id: 'test',
});
const fetcher2 = buildCall() //
.args<{ id: string; name: string }>()
.method('post')
.path('/post')
.args<{ overwrite: boolean }>()
.build();
const result2 = await withBaseUrl({
baseUrl: 'https://httpbin.org',
id: 'test',
name: 'Rune',
overwrite: true,
});
The method
method takes a single argument, which must be one of delete
,
get
, head
, patch
, post
, put
. The fetcher function will use this method
when making HTTP requests.
The method
method must be called before calling build
.
const fetcher = buildCall() //
.method('head')
.path('/')
.build();
const result = fetcher({ baseUrl: 'https://httpbin.org' });
The path
method sets the path the fetcher will request from. The path is
joined with the base URL. Thus, if the baseUrl
is
https://example.org/dev-api
and the path
is /tags/article
, then the full
URL of the request will be https://example.org/dev-api/tags/article
.
The argument to the path
method is either a string, or a function that returns
a string. The function will receive all arguments defined via .args
calls.
The path
method must be called before calling build
.
const fetcher1 = buildCall() //
.method('get')
.path('/')
.build();
const result1 = await fetcher1({ baseUrl: 'https://example.org' });
const fetcher2 = buildCall() //
.args<{ id: string }>()
.method('get')
.path((args) => `/user/${args.id}`)
.build();
// fetches https://example.org/user/1
const result2 = await fetcher1({ baseUrl: 'https://example.org', id: '1' });
The query
method adds query parameters the the URL that will be called by the
fetcher.
The argument can be either an object of key/values, a URLSearchParams
object,
or a function returning one of those types. The function will receive all
arguments defined via .args
calls.
The query
method can be called multiple times. The query parameters from all
the calls are joined together.
const fetcher1 = buildCall() //
.method('get')
.path('/')
.query({ userId: '1' })
.build();
// fetches https://example.org?userId=1
const result1 = await fetcher1({ baseUrl: 'https://example.org' });
const urlSearchParams = new URLSearchParams();
urlSearchParams.add('articleId', '1');
const fetcher2 = buildCall() //
.method('get')
.path('/')
.query(urlSearchParams)
.build();
// fetches https://example.org?articleId=1
const result2 = await fetcher2({ baseUrl: 'https://example.org' });
const fetcher3 = buildCall() //
.args<{ id: string }>()
.method('get')
.path('/')
.query((args) => ({ id: args.id }))
.build();
// fetches https://example.org?id=1
const result3 = await fetcher3({ baseUrl: 'https://example.org', id: '1' });
The headers
method adds headers to the request that will be sent by the
fetcher.
The argument can be either a Header
object, an object of key/values or and
array of [key value]
tuples. The argument can also be a function that returns
one of those types. The function will receive all arguments defined via .args
calls.
The headers
method can be called multiple times. The headers from all the
calls will be sent.
fixme: examples
Sets a body to send.
The argument can be a string
, Buffer
, Readable
, URLSearchParams
,
FormData
, or a JSON object. The argument can also be a function that returns
one of those types. The function will receive all arguments defined via .args
calls.
If the object is JSON, it will be serialized and the appropriate header for JSON is set.
If the object is a FormData
or URLSearchParams
object, the appropriate
headers for posting a form will be set.
For other types of data, that require content type headers or similar, the user must set them explicitly.
The .body
method is not required. It can only be called once.
const fetcher1 = buildCall() //
.method('post')
.path('/post')
.body({ id: '1234', name: 'Rune' })
.build();
const result1 = await withBaseUrl({ baseUrl: 'https://httpbin.org' });
const fetcher2 = buildCall() //
.method('post')
.path('/post')
.body(fs.createReadStream(somePdfFilePath))
.headers({ 'content-type': 'application/pdf' })
.build();
const result2 = await withBaseUrl({ baseUrl: 'https://httpbin.org' });
const fetcher3 = buildCall() //
.method('post')
.path('/post')
.body('id,name\n1,Rune')
.headers({ 'content-type': 'text/csv' })
.build();
const result3 = await withBaseUrl({ baseUrl: 'https://httpbin.org' });
const fetcher4 = buildCall() //
.args<{ uploadPath: string; ownerId: string }>()
.method('post')
.path('/post')
.body((args) => {
const formData = new FormData();
const stream = fs.createReadStream(args.uploadPath);
const filename = path.basename(args.uploadPath);
formData.append('file', stream, filename);
formData.append('owner', args.ownerId);
return formData;
})
.build();
const result4 = await withBaseUrl({
baseUrl: 'https://httpbin.org',
uploadPath: '/tmp/receipt.pdf',
ownerId: '1',
});
Adds a function that will parse JSON received from the request. The return type of this call becomes the return type of the fetcher call.
The parser function receives two arguments. The raw JSON and any arguments
defined via .args
calls.
This function is usually used to take untyped data, the raw JSON, and return something that has been validated.
The .parseJson
method at most be called once.
const fetcher1 = buildCall() //
.method('get')
.path('/json')
.parseJson((rawJson) => {
// This would normally do real validation of the raw json.
return rawJson as string[];
})
.build();
const result1 = await withBaseUrl({ baseUrl: 'https://httpbin.org' });
if (result1.success) {
// this is the return value from the parseJson call:
console.log(result1.data);
}
Adds a function that will parse text received from the request. The return type of this call becomes the return type of the fetcher call.
The parser function receives two arguments. The raw text and any arguments
defined via .args
calls.
This function is usually used to take untyped data, the raw text, and return something that has been validated.
The .parseText
method at most be called once.
const fetcher1 = buildCall() //
.method('get')
.path('/json')
.parseText((rawText) => {
// assuming we received some lines of semi-colon separated data:
return rawText //
.split('\n')
.map((line) => line.split(';'));
})
.build();
const result1 = await withBaseUrl({ baseUrl: 'https://httpbin.org' });
if (result1.success) {
// this is the return value from the parseText call:
console.log(result1.data);
}
Adds a function that will parse the response object received from the request. The return type of this call becomes the return type of the fetcher call.
The parser function receives two arguments. The response object and any
arguments defined via .args
calls.
This function is usually used to parse responses where there content you're interested in is not in the body. For example if a header contains the data you need.
The .parseResponse
method at most be called once.
const fetcher1 = buildCall() //
.method('get')
.path('/json')
.parseResponse((response) => {
return response.headers['Cookie'];
})
.build();
const result1 = await withBaseUrl({ baseUrl: 'https://httpbin.org' });
if (result1.success) {
// this is the return value from the parseResponse call:
console.log(result1.data);
}
map
transforms a result in some way. It takes the current return type of the
fetcher, for example what a parseJson
call returns, and transforms it. The
return type of map
becomes the new return type of the fetcher.
There can be multiple map calls. Each receives the output of the previous one as its input.
const fetcher1 = buildCall() //
.method('get')
.path('/visitorCount')
// return the text from the response unchanged
.parseText((text) => text)
// convert the text to a number
.map((text) => parseFloat(text))
// convert the number to a string of a fixed precision
.map((num) => num.toFixed(3))
.build();
const result1 = await withBaseUrl({ baseUrl: 'https://httpbin.org' });
if (result1.success) {
// this is the return value from the last map call:
console.log(result1.data);
}
A mapError
function transforms exceptions. By default, all exceptions that
occur when a fetcher is called is transformed into a TypicalHttpError
or a
TypicalWrappedError
. The wrapped error type wraps all non-http errors that may
occur during execution.
The error mapper may convert any of these errors to more specific errors.
There can be multiple error mappers. The result of the previous error mapper is passed as the error to the next error mapper.
const fetcher1 = buildCall() //
.method('get')
.path('/ping')
.mapError((error) => {
if (error instanceof TypicalHttpError) {
if (error.status === 404) {
// some custom 404 error in your app
return new NotFoundError();
} else if (error.status === 401) {
return new AuthenticationError();
}
}
return error;
})
.build();
const result1 = await withBaseUrl({ baseUrl: 'https://httpbin.org' });
if (!result1.success) {
// this is the return value from the mapError call:
console.log(result1.error);
}
Returns the function defined by the builder.