Create a gist now

Instantly share code, notes, and snippets.

What would you like to do?
import * as Govern from 'govern'
import axios, { AxiosInstance, AxiosResponse, AxiosRequestConfig } from 'axios'
import { Operation } from '../records';
export type RequestResponse<Data> = AxiosResponse<Data>
export type RequestProps<Data = any> = AxiosRequestConfig & {
maxRetries?: number,
/**
* You may want to start the request in a held state if you know it'll fail,
* to avoid making unnecessary requests.
*/
initialHoldReason?: string,
/**
* Allows you to configure which responses are considered unauthenticated,
* not connected, failed, and successful.
*/
getResponseStatusAndReason?(response: RequestResponse<any>): { status: Operation.Status, reason: string },
/**
* Get the value of the operation's `data` field. This will be called for
* any response, whether it results in a successful or failed status.
*
* By default, this will be undefined.
*/
getData?(response: RequestResponse<any>, status: Operation.Status): Data,
/**
* As authentication headers may change between request attempts, you can
* return a set up auth-specific headers on each attempt, that will take
* priority over the main headers.
*/
mergeAuthenticationHeaders?(existingHeaders: { [name: string]: string }): { [name: string]: string }
/**
* Called upon successful completion of the request. Use this to store
* received data, update authentication headers, etc.
*/
onSuccess?(response: RequestResponse<any>, data: Data): void
/**
* The request failed. Note that this will not be called if a request is
* rescheduled - only when failed for a specific reason.
*/
onFail?(reason: string, response?: RequestResponse<any>, data?: any): void
/**
* Called after success, failure or cancelled. Facilitates cleanup that must
* be run regardless of the result.
*/
onComplete?(response?: RequestResponse<any>): void
/**
* The request was cancelled before it could be completed. This is called
* if a request can't be made due to network or authentication issues, and
* is cancelled before it can be retried.
**/
onCancel?(reason?: any): void
/**
* The request was held due to lack of authentication, lack of connection,
* etc. In the case of lack of authentication, the response will be passed
* in.
*/
onHold?(reason: string, response?: RequestResponse<any>): void
/**
* A request is about to be sent.
*/
onSend?(): void;
/**
* You cannot supply axios' "validateStatus" option, as "request" uses it
* internally.
*/
validateStatus?: never,
}
export class Request<Data, Reason extends Operation.Status = Operation.Status> extends Govern.Component<RequestProps<any>, any, Operation> {
static defaultProps = {
getResponseStatusAndReason: defaultGetResponseStatus,
}
axiosConfig: AxiosRequestConfig
holdCount: number
isDisposed: boolean
constructor(props) {
super(props)
this.holdCount = 0
let {
maxDisconnectedRetries,
maxUnauthenticatedRetries,
initialHoldReason,
getResponseStatus,
getResult,
getError,
mergeAuthenticationHeaders,
requestDidSucceed,
requestDidFail,
requestDidComplete,
requestWasCancelled,
requestWasHeld,
requestWillSend,
...axiosConfig,
} = props
this.axiosConfig = axiosConfig
// We want to allow configuration of response status via
// `getResponseStatus`, so always have axios return success (except when
// there is no connection)
this.axiosConfig.validateStatus = () => true
// Set state *after* calling `this.start`, so that `this.start` doesn't
// find "Busy" as the status and then throw an error.
this.state = {
reason: initialHoldReason,
status: initialHoldReason ? Operation.Status.Held : undefined,
}
}
publish() {
return {
status: this.state.status,
/**
* Details on what the action is requesting.
*/
config: this.props,
/**
* The result of the action. Only available when status is "success".
*/
data: this.state.data,
/**
* Information on why the response failed, was cancelled, or is being held.
*/
reason: this.state.reason,
/**
* Cancel the action, preventing further changes and optionally setting
* the provided reason.
*/
cancel: this.cancel,
/**
* Start or restart an action.
*/
start: this.start,
}
}
componentDidInstantiate() {
if (!this.state.status) {
this.start()
}
else if (this.props.onHold && this.state.status === Operation.Status.Held) {
this.props.onHold(this.state.reason)
}
}
componentWillBeDisposed() {
this.isDisposed = true
}
start = () => {
let props = this.props
let status = this.state.status
if (status && status !== Operation.Status.Held) {
console.error(`You can only start an "held" requests, but the request to "${props.url}" has status "${status}".`)
return
}
// The `requestWillSend` callback and `setState` may both publish new
// values, so run them in a single dispatcher action.
this.dispatch(this.doRequest)
}
doRequest = () => {
let attemptConfig = Object.assign({}, this.axiosConfig)
if (this.props.mergeAuthenticationHeaders) {
attemptConfig.headers = this.props.mergeAuthenticationHeaders(this.axiosConfig.headers)
}
// Run the "requestWillSend" lifecycle method before updating state
// and notifying subscribers that we're sending.
if (this.props.onSend) {
this.props.onSend()
}
this.setState({
reason: null,
status: Operation.Status.Busy
})
axios.request(attemptConfig).then(
this.handleAxiosResponse,
this.handleAxiosError
)
}
cancel = (reason?: any) => {
let props = this.props
let status = this.state.status
if (status !== Operation.Status.Held && status !== Operation.Status.Busy) {
console.error(`You can only cancel "held" or "busy" requests, but the request to "${props.url}" has status "${status}".`)
return
}
if (status === Operation.Status.Busy) {
console.warn(`Cancelling in-progress HTTP requests is only partially supported; the request will continue, but the result will not be processed.`)
}
// The `requestWasCancelled` callback and `setState` may both publish new
// values, so run them in a single dispatcher action.
this.dispatch(() => {
this.setState({
reason: reason,
status: Operation.Status.Cancelled,
})
if (this.props.onCancel) {
this.props.onCancel(reason)
}
})
}
hold = (reason: string, response?: AxiosResponse<any>) => {
let props = this.props
++this.holdCount
let maxRetries =
props.maxRetries === undefined
? 0
: props.maxRetries
if (this.holdCount <= maxRetries) {
this.dispatch(() => {
// We haven't yet reached our retry limit.
this.setState({
reason: reason,
status: Operation.Status.Held,
})
if (this.props.onHold) {
this.props.onHold(reason)
}
})
}
else {
this.fail(reason, response)
}
}
fail = (reason: string, response?: AxiosResponse<any>) => {
let data = (response && this.props.getData) ? this.props.getData(response, 'failed') : undefined
this.dispatch(() => {
this.setState({
data: data,
reason: reason,
status: Operation.Status.Failed,
})
if (this.props.onFail) {
this.props.onFail(reason, response)
}
})
}
handleAxiosResponse = (response: AxiosResponse<any>) => {
let props = this.props
if (this.isDisposed) {
// The request may be disposed before we receive a response if it
// is cancelled.
return
}
if (this.state.status !== Operation.Status.Cancelled) {
let { status, reason } = this.props.getResponseStatusAndReason!(response)
switch (status) {
case Operation.Status.Cancelled:
return this.cancel()
case Operation.Status.Succeeded:
this.dispatch(() => {
let data = this.props.getData ? this.props.getData(response, status) : undefined
this.setState({
data,
reason: null,
status,
})
if (this.props.onSuccess) {
this.props.onSuccess(response, data)
}
})
break
case Operation.Status.Failed:
this.fail(reason, response)
break
case Operation.Status.Held:
this.hold(reason, response)
break
default:
throw new Error(`getResponseStatus returned unknow status "${status}".`)
}
}
}
protected handleAxiosError = (error) => {
if (this.isDisposed) {
// The request may be disposed before we receive a response if it
// is cancelled.
return
}
if (this.state.status !== Operation.Status.Cancelled) {
// I'm assuming that any Axios errors indicate lack of network
// connectivity. If this is incorrect, PRs with fixes are welcome!
this.hold('disconnected')
}
}
}
function defaultGetResponseStatus(response: RequestResponse<any>): { reason: string | null, status: Operation.Status } {
if (!response.status) {
return {
reason: 'disconnected',
status: Operation.Status.Held,
}
}
else if (response.status === 401) {
return {
reason: 'unauthenticated',
status: Operation.Status.Held,
}
}
else if (response.status >= 200 && response.status < 300) {
return {
reason: null,
status: Operation.Status.Succeeded
}
}
else if (response.status >= 400 && response.status < 500) {
return {
reason: 'rejected',
status: Operation.Status.Failed,
}
}
else {
return {
reason: 'error',
// TODO: this should probably be "held", as it makes sense to retry
// unknown errors at least a few times. However, there is no auto-
// retry logic for errors yet.
status: Operation.Status.Failed,
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment