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 be application/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
the passThrough401
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";
const runFetch = fetchAbort();
runFetch("/my-url-1").then((res) => res);
runFetch("/my-url-2").then((res) => res);
runFetch.abort();
Abort a single fetch:
import { fetchAbort } from "fetcher!sofe";
const runFetch1 = fetchAbort();
const runFetch2 = fetchAbort();
runFetch1("/my-url-1").then((res) => res);
runFetch2("/my-url-2").then((res) => res);
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(() => {
() => 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
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()
.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) => {
},
(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) => {
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 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.
-
fetchAsObservable(params)
: Returns an RxJS 6 Observable. 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.).
-
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 string url
, a string or function subscriptionDuration
, and an
optional boolean forceBust
. 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 do fetcher(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 same url
and if any of them says that the duration has ended,
then it will bust the cache and call onCompleted
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.
-
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 done fetch(url)
.
-
bustCacheForSubscriptionDuration(subscriptionDuration, [refetchCachedUrls = false])
:
Will bust the cache for all subscriptionDuration
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
make refetchCachedUrls = true
to have all subjects refetch their data and
update the cache instead of being removed.
Note: will only work for subscriptionDurations
that are typeof string
.
Also see fetchWithSharedCache
's subscriptionDuration
for more info
-
fetchWithProgress(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 an overallProgress
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 call connect()
on it after you subscribe.
The fetchWithProgress
function takes two arguments: a string url
and an
object options
. 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 the overallProgress
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()
}, [])
useEffect(() => {
const sub = getContact().subscribe(
successFunction(),
redirectOrCatch()
)
return () => sub.unsubscribe()
}, [])