Open
Description
Description
Hey,
I would like to make a request to the community here to officially support a typed axios along side fetch, I actually prefer fetch over axios in most scenarios due to it being native, however for many projects I end up opting for axios over fetch for a few reasons.
- axios automatically transforms responses (no need to add
.then(response => response.json()))
everywhere - Fetch needs to be polyfilled for IE 11
- axios is able to emit upload progress events, where users can monitor progress of uploads, fetch requires further abstraction
- axios has better more comprehensive errors over fetch,
- axios has support for request pre and post interceptors, fetch requires monkey patching in order to achieve a similar setup.
- Timeouts have first class support in axios, where as fetch you have to manage things through abort controllers.
What is the position of this repository when it comes to axios, is this something that the maintainers are open to adding?
Thanks
Proposal
I have added my types and base hooks to show I am able to achieve a typed axios (note, my typescript is not the greatest, but the low code kinda works)
types.d.ts
import { AxiosRequestConfig } from 'axios'
import { paths } from '../../../../pos-hub/runtime/openapi'
/**
* Enumerate a number N.
* For example, Enumerate<3> gives 0 | 1 | 2.
*/
type Enumerate<
N extends number,
A extends number[] = []
> = A['length'] extends N ? A[number] : Enumerate<N, [...A, A['length']]>
/**
* Range of numbers from F to T.
* For example, Range<1, 4> gives 1 | 2 | 3.
*/
type Range<F extends number, T extends number> = Exclude<
Enumerate<T>,
Enumerate<F>
>
/**
* Returns a union of the values in an object whose keys match the given matcher type.
*/
export type FilterKeys<Obj, Matchers> = Obj[keyof Obj & Matchers]
/**
* @name HttpQueryMethod
* @description Http Methods that are supported with a query operation.
*/
export type HttpQueryMethod = 'get'
/**
* @name HttpMutationMethod
* @description Http Methods that are supported with a mutation operation
*/
export type HttpMutationMethod = 'put' | 'post' | 'delete' | 'patch'
/**
* @name HttpOkStatus
* @description Expected HTTP status codes for a successful response
*/
export type HttpOkStatus = Range<200, 300>
/**
* @name HttpErrorStatus
* @description Expected HTTP status codes for an error response
*/
export type HttpErrorStatus = Range<400, 500> | Range<500, 600>
/**
* @name ResponseJSON
* @description Returns the type that represents the JSON-like
*/
export type ResponseJSON<T> = FilterKeys<T, `${string}/json`>
/**
* @name RequestHeader
* @description Return `header` for an Operation Object or `never` if not required
*/
export type RequestHeader<T> = T extends { parameters: any }
? T['parameters']['header']
: never
/**
* @name PathParameters
* @description Return `parameters` for an Operation Object or `never` if not required
*/
export type PathParameters<T> = T extends { parameters: any }
? T['parameters']['path']
: never
/**
* @name QueryParams
* @description Return `query` for an Operation Object
*/
export type QueryParams<T> = T extends { parameters: any }
? T['parameters']['query']
: never
/**
* @name Responses
* @description Return `responses` for an Operation Object
*/
export type QueryResponses<T> = T extends { responses: any }
? T['responses']
: never
/**
* @name RequestBody
* @description Return `requestBody` for an Operation Object
*/
export type RequestBody<T> = T extends { requestBody?: any }
? T['requestBody']
: never
/**
* @name SchemaContent
* @description Return `content` for a Response Object for a given status code
*/
export type SuccessResponse<T> = SchemaContent<FilterKeys<T, HttpOkStatus>>
/**
* @name ErrorResponse
* @description Return `content` for a Response Object for a given status code
*/
export type ErrorResponse<T> = SchemaContent<FilterKeys<T, HttpErrorStatus>>
/**
* @name SuccessResponseJSON
* @description Return first JSON-like 2XX response from a path + HTTP method
*/
export type SuccessResponseJSON<PathMethod> = ResponseJSON<
SuccessResponse<QueryResponses<PathMethod>>
>
/**
* @name ErrorResponseJSON
* @description Return first JSON-like 4XX or 5XX response from a path + HTTP method
*/
export type ErrorResponseJSON<PathMethod> = ResponseJSON<
ErrorResponse<QueryResponses<PathMethod>>
>
/**
* @name RequestBodyJSON
* @description Return JSON-like request body for a path + HTTP method
*/
export type RequestBodyJSON<PathMethod> = ResponseJSON<
SchemaContent<RequestBody<PathMethod>>
>
/**
* @name PathsWithMethod
* @description Type that represents the paths with a specific HTTP method in a given object.
*/
export type PathsWithMethod<
PathnameMethod extends HttpQueryMethod | HttpMutationMethod
> = {
[Pathname in keyof paths]: paths[Pathname] extends {
[K in PathnameMethod]: any
}
? Pathname
: never
}[keyof paths]
/**
* Type that represents the response content of a given object.
* @template T - The object containing the response content.
*/
export type SchemaContent<T> = T extends { content: any } ? T['content'] : never
/**
* With Path
*/
export type WithPath<
M extends HttpQueryMethod | HttpMutationMethod,
P extends PathsWithMethod<M>
> = {
method: M
path: P
}
export type WithPathParameters<
M extends HttpQueryMethod | HttpMutationMethod,
P extends PathsWithMethod<M>,
Scope extends M extends keyof paths[P] ? paths[P][M] : never
> = PathParameters<Scope> extends never
? { parameters?: never }
: {} extends PathParameters<Scope>
? { parameters?: PathParameters<Scope> }
: { parameters: PathParameters<Scope> }
/**
* With Query Params
*/
export type WithQueryParams<
M extends HttpQueryMethod | HttpMutationMethod,
P extends PathsWithMethod<M>,
Scope extends M extends keyof paths[P] ? paths[P][M] : never
> = QueryParams<Scope> extends never
? { params?: never }
: {} extends QueryParams<Scope>
? { params?: never } // all keys optional → params optional
: { params: QueryParams<Scope> } // some required key → params required
/**
* With Request Body
*
* This helper inspects the operation's request body definition.
*
* - If there is no request body defined, then `data` is not allowed.
* - If the request body object is empty (all keys optional), then `data` is optional.
* - Otherwise, `data` is required.
*/
export type WithRequestBody<
M extends HttpMutationMethod,
P extends PathsWithMethod<M>,
Scope extends M extends keyof paths[P] ? paths[P][M] : never
> = RequestBody<Scope> extends never
? { data?: never }
: {} extends RequestBody<Scope>
? { data?: RequestBodyJSON<Scope> }
: { data: RequestBodyJSON<Scope> }
/**
* With typed headers.
*
* - If no header parameters are defined for the operation, then headers cannot be provided.
* - If the header parameters object is “empty” (i.e. all keys are optional), then headers are optional.
* - Otherwise, headers are required.
*/
export type WithHeaders<
M extends HttpQueryMethod | HttpMutationMethod,
P extends PathsWithMethod<M>,
Scope extends M extends keyof paths[P] ? paths[P][M] : never
> = RequestHeader<Scope> extends never
? { headers?: Record<string, string> }
: {} extends RequestHeader<Scope>
? { headers?: RequestHeader<Scope> }
: { headers: RequestHeader<Scope> }
/**
*
*/
export type TypedRequest<
M extends HttpQueryMethod | HttpMutationMethod,
P extends PathsWithMethod<M>,
Scope extends M extends keyof paths[P] ? paths[P][M] : never
> = WithPathParameters<M, P, Scope> &
WithPath<M, P> &
WithHeaders<M, P, Scope> &
WithQueryParams<M, P, Scope>
/**
* TypedMutationRequest
* @template M - The HTTP method.
* @template P - The path.
* @template Scope - The scope of the path.
*/
export type TypedMutationRequest<
M extends HttpMutationMethod,
P extends PathsWithMethod<M>,
Scope extends M extends keyof paths[P] ? paths[P][M] : never
> = TypedRequest<M, P, Scope>
export interface TypedAxiosRequestConfig<
M extends HttpQueryMethod | HttpMutationMethod,
P extends PathsWithMethod<M>,
Scope extends M extends keyof paths[P] ? paths[P][M] : never
> extends AxiosRequestConfig {
method: Uppercase<M>
url: string
params?: QueryParams<Scope>
}
// Helper type to get the scope of a given path and method
export type ScopeOf<
Method extends HttpQueryMethod | HttpMutationMethod,
Path extends PathsWithMethod<Method>
> = Method extends keyof paths[Path] ? paths[Path][Method] : never
Example Usage:
import {
QueryFunction,
QueryFunctionContext,
useInfiniteQuery,
UseInfiniteQueryOptions,
useMutation,
UseMutationOptions,
useQuery,
UseQueryOptions,
} from 'react-query'
import {
ErrorResponseJSON,
HttpMutationMethod,
HttpQueryMethod,
PathsWithMethod,
RequestBodyJSON,
ScopeOf,
SuccessResponseJSON,
TypedMutationRequest,
TypedRequest,
} from './types'
import axios, { CancelTokenSource } from 'axios'
/**
* @name toCancelToken
* @description Converts an AbortSignal to a CancelTokenSource for axios
* @param {AbortSignal} signal - The AbortSignal to convert
* @returns {CancelTokenSource}
*/
export const toCancelToken = (
signal?: AbortSignal
): CancelTokenSource | undefined => {
if (!signal) return undefined
const source = axios.CancelToken.source()
signal.addEventListener('abort', () => source.cancel())
return source
}
/**
* @name toKey
* @description Takes all of the unique parts of a request and converts them to a string key,
* which can be used to cache the request in react-query.
* @param {TypedRequest} request - The request to compile
* @returns {string}
*/
export const toKey = (request: TypedRequest<any, any, any>) => {
return [request.method, request.path, request.parameters, request.params]
}
/**
* @name toPath
* @description Replaces path parameters {key} with the corresponding value in the parameters object
* @param {string} path - The path to replace parameters in
* @param {Record<string, any>} parameters - The parameters to replace
* @returns {string}
*/
export const toPath = (path: string, parameters?: Record<string, any>) => {
return parameters
? path.replace(/\{(\w+)\}/g, (_, key) => parameters[key])
: path
}
/**
* @name retryStrategy
* @description Retry strategy for react queries
* @param {number} failureCount - The number of times the query has failed
* @param {Error} error - The error that caused the failure
* @returns {boolean}
*/
export const retryStrategy = (failureCount: number, error: any) => {
if (!axios.isAxiosError(error)) return failureCount < 3
// If there's no response, assume a network error and allow a retry.
if (!error.response) return failureCount < 3
const status = error.response.status
// If the error is a client error (400–499) but not 429, don't retry.
if (status >= 400 && status < 500 && status !== 429) return false
// Otherwise, allow a maximum of 3 retries.
return failureCount < 3
}
/**
* @name retryDelayStrategy
* @description Retry delay strategy for react queries
* @param {number} retryAttempt - The number of times the query has been retried
* @returns {number}
*/
export const retryDelayStrategy = (retryAttempt: number) => {
const baseDelay = 100 // in ms
const exponentialDelay = baseDelay * Math.pow(2, retryAttempt)
// Add jitter by adding a random value between 0 and baseDelay.
const jitter = Math.random() * baseDelay
return exponentialDelay + jitter
}
/**
* @name queryFn
* @description Executes a request with axios
* @param {TypedRequest} request - The request to execute
* @returns {Promise<any>}
*/
export const queryFn =
<Scope>(
request: TypedRequest<any, any, Scope>
): QueryFunction<SuccessResponseJSON<Scope>> =>
(context: QueryFunctionContext<any>) => {
const { method, path, headers, parameters, params } = request
return axios
.request({
baseURL: API_ENDPOINT!,
method: method.toUpperCase() as any,
url: toPath(path, parameters!),
params: { ...params, nextPageKey: context.pageParam },
headers,
cancelToken: toCancelToken(context.signal)?.token,
})
.then((response) => response.data)
}
/**
* @name mutationFn
* @description Executes a mutation request with axios
* @param {TypedRequest} request - The request to execute
* @returns {Promise<any>}
*/
export const mutationFn = <Scope>(request: RequestBodyJSON<Scope>) =>
axios
.request({
baseURL: API_ENDPOINT!,
method: request.method.toUpperCase() as any,
url: toPath(request.path, request.parameters!),
headers: request.headers,
data: request.data,
})
.then((response) => response.data)
/**
* @name UseApiQueryOptions
* @description Options for the useApiQuery hook
*/
export type UseApiQueryOptions<Scope> = Omit<
UseQueryOptions<any, ErrorResponseJSON<Scope>, SuccessResponseJSON<Scope>>,
'queryKey' | 'queryFn'
>
/**
* @module useBackend
* @description Custom hook to handle backend requests and responses using a detailed typescript schema
*/
export const useApiQuery = <
Method extends HttpQueryMethod,
Path extends PathsWithMethod<Method>,
Scope extends ScopeOf<Method, Path>
>(
request: TypedRequest<Method, Path, Scope>,
options?: UseApiQueryOptions<Scope>
) => {
return useQuery<
SuccessResponseJSON<Scope>,
ErrorResponseJSON<Scope>,
RequestBodyJSON<Scope>
>({
queryKey: toKey(request),
queryFn: queryFn<Scope>(request),
retry: retryStrategy,
retryDelay: retryDelayStrategy,
...options,
})
}
export type UseApiInfiniteQueryOptions<Scope> = Omit<
UseInfiniteQueryOptions<
SuccessResponseJSON<Scope>,
ErrorResponseJSON<Scope>,
SuccessResponseJSON<Scope>
>,
'queryKey' | 'queryFn'
>
/**
* @module useBackendInfiniteQuery
*/
export const useApiInfiniteQuery = <
Method extends HttpQueryMethod,
Path extends PathsWithMethod<Method>,
Scope extends ScopeOf<Method, Path>
>(
request: TypedRequest<Method, Path, Scope>,
options?: UseApiInfiniteQueryOptions<Scope>
) => {
/**
* Return a query object
*/
return useInfiniteQuery<
SuccessResponseJSON<Scope>,
ErrorResponseJSON<Scope>,
SuccessResponseJSON<Scope>
>({
queryKey: toKey(request),
queryFn: queryFn<Scope>(request),
getNextPageParam: (lastPage) => (lastPage as any).nextPageKey,
retry: retryStrategy,
retryDelay: retryDelayStrategy,
...options,
})
}
/**
* Options Schema
*/
export type UseApiMutationOptions<Scope, Path> = Omit<
UseMutationOptions<
SuccessResponseJSON<Scope>,
ErrorResponseJSON<Scope>,
RequestBodyJSON<Path>
>,
'queryKey' | 'queryFn'
>
/**
* @module useApiMutation
*/
export const useApiMutation = <
Method extends HttpMutationMethod,
Path extends PathsWithMethod<Method>,
Scope extends ScopeOf<Method, Path>
>(
request: TypedMutationRequest<Method, Path, Scope>,
options?: UseApiMutationOptions<Scope, Path>
) => {
return useMutation<
SuccessResponseJSON<Scope>,
ErrorResponseJSON<Scope>,
RequestBodyJSON<Scope>
>({
mutationKey: toKey(request),
mutationFn: mutationFn<Scope>,
...options,
})
}
Hopefully if this project is interested in adding axios support, this could act as a starting point.
Example Usage:
Extra
- I’m willing to open a PR (see CONTRIBUTING.md)