@canopytax/fetcher
v0.0.2
Published
Canopy's wrapper around the [`window.fetch` api](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API/Using_Fetch) . It automatically provides:
Downloads
5
Keywords
Readme
fetcher
Canopy's wrapper around
the window.fetch
api
. It automatically provides:
- JSON.stringifying the request body by default.
- Prefixing urls with the api gateway url (canopyUrls.getAPIUrl) if a full url is not provided.
- Defaulting the
Content-Type
header to beapplication/json
for all requests that have a body. - Auth token refreshes. This will happen automatically whenever there is a 401
unless you provide a truthy
passThrough401
property on the fetch configuration object. (For example,fetchAsObservable('/url', {passThrough401: true})
). When thepassThrough401
property is set, the auth token will not be refreshed and the request will be passed through like normal. - The X-CSRF-TOKEN http header
- credentials
- Removing angular properties that are prefixed with $$ (similar to what $http does).
Fancy Caching
Fetcher has an opt-in caching feature (fetchWithSharedCache
) that allows
redundant calls to the same URL without the performance/load penalty of making
many ajax requests. It also gets even fancier by pushing changes to cached
objects out to everyone who's interested in them. This is done by detecting if a
PUT/PATCH is changing an object that other parts of the app have cached. If so,
the response to the PUT/PATCH will be pushed out to all subscribers. See
the fetchWithSharedCache
and forceBustCache
APIs
Methods
fetcher(url: string, options: {})
Returns Promise<any>
The default exported value, fetcher
, is a function that simply wraps
the fetch
api. You should call fetcher just as if you were calling fetch. The
only known limitation that we know of right now is that if you call fetcher with
the first parameter being
a Request object,
fetcher will not be able to do its auth token refreshes for you if a 401 http
status is returned for the server.
import fetcher from "fetcher!sofe";
fetcher("/my-url").then((res) => res);
fetchAsObservable(url: string, options: {})
Returns Observable<any>
Takes almost the exact same params as fetcher
but returns an RxJS 6 observable
rather than a promise. If response.ok
, it will by default automatically try to
return JSON with a fallback of text. To modify the default return behavior, you
can including an optional responseType
(ex. responseType: 'blob'
) in the
second argument's object to explicitly specify the fetch response body
operation (i.e. .json()
, .text()
, .blob()
, etc.).
import { fetchAsObservable } from "fetcher!sofe";
fetchAsObservable("/my-url").subscribe((res) => res);
fetchAbort()
Returns (url: string, options: {}) => Promise<any>
This function will return a new fetch function, while also instantiating a new abort controller. That abort signal will be tied to all uses of this fetch.
The fetch function will also have a new property method on it called abort
,
with each use subsequently instantiating a new abort controller (basically
this means you can reuse the fetch as much as you'd like).
Abort all fetches:
import { fetchAbort } from "fetcher!sofe";
// only one fetch is needed if you're going to abort them together
const runFetch = fetchAbort();
runFetch("/my-url-1").then((res) => res);
runFetch("/my-url-2").then((res) => res);
// abort all
runFetch.abort();
Abort a single fetch:
import { fetchAbort } from "fetcher!sofe";
// controlling each abort differently requires multiple fetches
const runFetch1 = fetchAbort();
const runFetch2 = fetchAbort();
runFetch1("/my-url-1").then((res) => res);
runFetch2("/my-url-2").then((res) => res);
// abort only "runFetch1"
runFetch1.abort();
useFetchAbort(opts: { abortOnUnmount: boolean })
React hook version of fetchAbort
. Hook returns an array of two variables:
(url: string, options: {}) => Promise<any>
- fetcher method() => void
- method to abort fetch
When fetching on component mount, fetching/aborting is as simple as throwing them in a useEffect:
import React, { useEffect } from 'react';
import { useFetchAbort } from 'fetcher!sofe';
function MyComponent() {
const [runFetch, runAbort] = useFetchAbort();
useEffect(() => {
runFetch('/my-url').then(res => res);
() => runAbort();
});
return (...);
}
(Keep in mind React 18's quirk of running useEffect
twice on initial
render...)
If you need more finite control of aborting, this works the same way as
fetchAbort
:
import React, { useEffect } from 'react';
import { useFetchAbort } from 'fetcher!sofe';
function MyComponent() {
const [runFetch1, runAbort1] = useFetchAbort();
const [runFetch2, runAbort2] = useFetchAbort();
async function getOne() {
const res = await runFetch1('/my-url-1');
}
async function getTwo() {
const res = await runFetch2('/my-url-2');
}
useEffect(() => {
// only abort "runFetch1"
() => runAbort1();
});
return (...);
}
By default, the opts.abortOnUnmount
argument is set to true
, so you don't
need to manually abort - the hook will do that for you 😉.
import React, { useEffect } from 'react';
import { useFetchAbort } from 'fetcher!sofe';
function MyComponent() {
const [runFetch] = useFetchAbort();
async function getTheThing() {
const res = await runFetch('/my-url');
}
return (...);
}
You can set opts.abortOnUnmount
to false if you need to manually control how the
fetch aborts.
Older docs that need to get updated:
React Hook options
- useFetcher
- useObservable
useFetcher
A helper that allows you to run fetcher on component mount. useFetcher
takes
two arguments the url and optionalProps:
- api url: (
eg
api/clients/${contactId}?include=users,contacts,tags,contact_for,contact_sources
) , - optionalProps: {
pluck: 'clients', // string that we use to pluck the value out of the
response (via rxjs pluck operator)
options: fetcherOptions,
initialResults: [] // initial results value
manualFire: true // defaults to false. If true you'll also get a
fire
method that allows you to "fire when ready" and not immediately. }
Example
const { loading, results, error, refetch } = useFetcher(
`api/clients/${contactId}?include=users,contacts,tags,contact_for,contact_sources`,
{
pluck: "clients",
}
);
useObservable
A more powerful version of useFetcher
but requires more configuration.
useObservable
takes two arguments an rxjs observable and the initialResults
- rxjs observable can do anything/everything
- options: {initialResults, manualFire}
- initialResults: represents the initial state returned by the hook
- manualFire: defaults to false. If true you'll also get a
fire
method that allows you to "fire when ready" and not immediately.
NOTE you must memoize your observable if dynamically created
Example
const observable = useMemo(
() => {
return fromEvent(...).pipe(
tap(...),
debounceTime(...),
switchMap(...),
pluck(...)
)
},
[]
)
const { loading, results, error, resubscribe } = useObservable(observable, [])
Usage
import fetcher, {
onPusher,
fetchWithSharedCache,
forceBustCache,
fetchAsObservable,
fetchWithProgress,
} from "fetcher";
import canopyUrls from "canopy-urls!sofe";
import { skipLast, last } from "rxjs/operators";
fetcher(`${canopyUrls.getWorkflowUrl()}/api/users/0`)
.then((resp) => {
if (resp.ok) {
resp
.json() // or .text()
.then((json) => {
console.log(json.users);
})
.catch((ex) => {
throw ex;
});
} else {
throw new Error(
`Couldn't get user -- server responded with ${resp.status}`
);
}
})
.catch((ex) => {
throw ex;
});
fetchAsObservable(`${canopyUrls.getWorkflowUrl()}/api/users/0`).subscribe(
(user) => {
/* user json object */
},
(error) => {
throw error;
}
);
const formData = new FormData();
formData.append("file", document.getElementById("file-input").files[0]);
const withProgress = fetchWithProgress(
`${canopyUrls.getAPIUrl()}/contacts/1/files`,
{
method: "post",
body: formData,
}
);
withProgress.pipe(skipLast(1)).subscribe((progressEvent) => {
console.log(
`File upload progress is now at ${progressEvent.overallProgress}`
);
});
withProgress.pipe(last()).subscribe((data) => {
console.log(`Response from server`, data);
});
withProgress.connect();
const subscription = fetchWithSharedCache(
`${canopyUrls.getWorkflowUrl()}/api/clients/1/engagements/1`
).subscribe(
(engagement) => {
// this is the json from the response, not the response itself
console.log(engagement.id);
},
(err) => {
throw err;
}
);
setTimeout(subscription.dispose, 10000);
forceBustCache(`${canopyUrls.getWorkflowUrl()}/api/clients/1`)
.then((resp) => {
console.log("cache was busted!");
resp
.json()
.then((json) => console.log(json))
.catch((ex) => {
throw ex;
});
})
.catch((ex) => {
throw ex;
});
const disposable = onPusher("message-type").subscribe((data) =>
console.log(data)
);
disposable.dispose();
API
fetcher(params)
: The default exported value,fetcher
, is a function that simply wraps thefetch
api. You should call fetcher just as if you were calling fetch. The only known limitation that we know of right now is that if you call fetcher with the first parameter being a Request object, fetcher will not be able to do its auth token refreshes for you if a 401 http status is returned for the server.fetchAsObservable(params)
: Returns an RxJS 6 Observable. Takes almost the exact same params asfetcher
but returns an RxJS 6 observable rather than a promise. Ifresponse.ok
, it will by default automatically try to return JSON with a fallback of text. To modify the default return behavior, you can including an optionalresponseType
(ex.responseType: 'blob'
) in the second argument's object to explicitly specify the fetch response body operation (i.e..json()
,.text()
,.blob()
, etc.).onPusher(message-type)
: Subscribe to server push events. Unsubscribe by disposing the subscriptions. The returned observable is deferred.fetchWithSharedCache(url, subscriptionDuration, forceBust)
: This function takes in the stringurl
, a string or functionsubscriptionDuration
, and an optional booleanforceBust
. It will either make a network request or return the cached object, depending on if the cache already has the object for that URL. Here are some things to note:- This API returns an Observable. Please make sure
to
dispose()
your subscription to the observable when you are done with it - The value given is not a
Response
object, but a json object. This is different than when you dofetcher(url).then(response => ...)
, which indeed gives you a Response object. - The
subscriptionDuration
parameter is either a string (recommended) or a function. Whenever a hashchange event fires, fetcher will check to see if the subscriptionDuration has finished. It will check this for all subscriptions to the sameurl
and if any of them says that the duration has ended, then it will bust the cache and callonCompleted
on the observable which will end all of the subscriptions for that url. The reasoning for this is that the supported use case is subscribing to a data source as long as a certain SPA route is active, and then not caring about updates to the data once that SPA route is no longer active.- Usage as a string: as long as window.location.hash includes this string, your subscription will stay alive.
- Usage as a function: your function will be called with no arguments. Your subscription will stay alive until the function returns something truthy.
- Usually the best time to bust the cache is when the user navigates away from the part of the single-page-app that cares about the specific url/restful-object. This is just the best practice for SPAs, since the page is never refreshed and we want the object to be re-fetched if users go away from part of the app and then come back later.
- If anyone performs a PUT or PATCH to a url that has been cached, it is assumed that the response to that PUT or PATCH is the full object (the same as if a GET had been performed). That assumption allows us to not make another GET request immediately after a PUT or PATCH. Because of that assumption, however, you have to be aware of if the RESTful url you are hitting actually meets that assumption or not.
- This API tries to be smart about query params in urls and should work just fine no matter what you do with query params.
- If anyone performs a plain old
fetcher
GET on a url that is cached, when the response comes back it will automatically update the cache and its subscribers.
- This API returns an Observable. Please make sure
to
forceBustCache(url)
: This function will forcibly bust the cache for a URL, and also trigger a re-fetch of the URL so that all subscribers to the cache are notified of the new change. It returns a promise just the same as if you had donefetch(url)
.bustCacheForSubscriptionDuration(subscriptionDuration, [refetchCachedUrls = false])
: Will bust the cache for allsubscriptionDuration
key/url hash that===
what you pass in. By default, it will notify all observers that the observable returned from fetchWithSharedCache is completed., but optionally you can makerefetchCachedUrls = true
to have all subjects refetch their data and update the cache instead of being removed.Note: will only work for
subscriptionDurations
that aretypeof string
. Also seefetchWithSharedCache
'ssubscriptionDuration
for more infofetchWithProgress(url, options)
: This function uses XMLHttpRequest to call an API so that the progress of the API request can be tracked. It returns an ConnectableObservable that will produce a stream of ProgressEvent objects, except the last value is the response data the server responds with. The ProgressEvent objects are also modified to include anoverallProgress
property, which is a number between 0 and 1 which approximates what percentage of the overall user wait time has occurred. Note that since it is a ConnectableObservable, you have to callconnect()
on it after you subscribe. ThefetchWithProgress
function takes two arguments: a stringurl
and an objectoptions
. The following properties are supported in options:method
(required): a string for the HTTP method of the requestheaders
(optional): an object where the keys are header names and the values are header values.body
(optional): Something that you can call xhr.send(body). This is usually a string or FormData object. The FormData object is useful when you want to upload files.baseServerLatency
(optional): A number of milliseconds to use when approximating theoverallProgress
of the api request. This number represents how long it takes on average for the server to handle a small request. For example, file uploads incur a set amount of latency on the server even when the file size is very small.baseServerLatency
defaults to 1500 ms, which seems about right for file uploads.
Examples
fetchWithSharedCache(
`https://workflow.canopytax.com/api/clients/1`,
`clients/1`
).subscribe((json) => {
console.log(`Client name is ${json.clients.name}`);
});
fetchWithSharedCache(`https://workflow.canopytax.com/api/clients/1`, () => {
const pleaseEndMySubscription = true;
return pleaseEndMySubscription;
}).subscribe((json) => {
console.log(`Client name is ${json.clients.name}`);
});
redirectOrCatch()
redirectOrCatch() is a shortcut for asyncStacktrace -> check for 403/404 and maybe redirect -> else catchSyncStacktrace. It checks the request error for 404/403, logs to console the failure, and redirect the user to relevant 404/403 page
import {redirectOrCatch} from 'fetcher!sofe';
useEffect(() => {
const sub = getContact().subscribe(
successFunction(),
asyncStacktrace(err => {
if (err.status === 404 || err.status === 403) {
console.info(`api/contact returned ${err.status}, redirecting`);
window.history.replaceState(null, "", `/#/${err.status}`);
} else catchSyncStacktrace(err);
}
)
return () => sub.unsubscribe()
}, [])
// becomes
useEffect(() => {
const sub = getContact().subscribe(
successFunction(),
redirectOrCatch()
)
return () => sub.unsubscribe()
}, [])