Replace Axios with the Fetch API (#3899)

This commit is contained in:
Jon Koops 2022-11-30 17:46:17 +01:00 committed by GitHub
parent 4b3eb4a9e4
commit 7eb77cc0a7
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 159 additions and 135 deletions

View file

@ -1,6 +1,5 @@
import { NetworkError } from "@keycloak/keycloak-admin-client";
import { AlertVariant } from "@patternfly/react-core";
import type { AxiosError } from "axios";
import axios from "axios";
import { FunctionComponent, useCallback, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
@ -85,8 +84,8 @@ function getErrorMessage(error: unknown) {
return error;
}
if (axios.isAxiosError(error)) {
return getErrorMessageAxios(error);
if (error instanceof NetworkError) {
return getNetworkErrorMessage(error);
}
if (error instanceof Error) {
@ -96,8 +95,8 @@ function getErrorMessage(error: unknown) {
throw new Error("Unable to determine error message.");
}
function getErrorMessageAxios(error: AxiosError) {
const data = (error.response?.data ?? {}) as Record<string, unknown>;
function getNetworkErrorMessage({ responseData }: NetworkError) {
const data = responseData as Record<string, unknown>;
for (const key of ["error_description", "errorMessage", "error"]) {
const value = data[key];

View file

@ -1,3 +1,4 @@
import { NetworkError } from "@keycloak/keycloak-admin-client";
import type RealmRepresentation from "@keycloak/keycloak-admin-client/lib/defs/realmRepresentation";
import { sortBy } from "lodash-es";
import {
@ -7,7 +8,6 @@ import {
useRef,
useState,
} from "react";
import axios from "axios";
import { RecentUsed } from "../components/realm-selector/recent-used";
import { createNamedContext } from "../utils/createNamedContext";
@ -46,11 +46,7 @@ export const RealmsProvider: FunctionComponent = ({ children }) => {
try {
return await adminClient.realms.find({ briefRepresentation: true });
} catch (error) {
if (
axios.isAxiosError(error) &&
error.response &&
error.response.status < 500
) {
if (error instanceof NetworkError && error.response.status < 500) {
return [];
}

View file

@ -1,5 +1,4 @@
import KeycloakAdminClient from "@keycloak/keycloak-admin-client";
import axios from "axios";
import Keycloak from "keycloak-js";
import { DependencyList, useEffect } from "react";
import { useErrorHandler } from "react-error-boundary";
@ -39,35 +38,24 @@ export function useFetch<T>(
callback: (param: T) => void,
deps?: DependencyList
) {
const { adminClient } = useAdminClient();
const onError = useErrorHandler();
const controller = new AbortController();
const { signal } = controller;
useEffect(() => {
const source = axios.CancelToken.source();
adminClient.setConfig({
requestConfig: { cancelToken: source.token },
});
adminClientCall()
.then((result) => {
if (!source.token.reason) {
if (!signal.aborted) {
callback(result);
}
})
.catch((error) => {
if (!axios.isCancel(error)) {
if (!signal.aborted) {
onError(error);
}
});
adminClient.setConfig({
requestConfig: { cancelToken: undefined },
});
return () => {
source.cancel();
};
return () => controller.abort();
}, deps);
}

View file

@ -22,8 +22,8 @@ import KcAdminClient from '@keycloak/keycloak-admin-client';
// {
// baseUrl: 'http://127.0.0.1:8080',
// realmName: 'master',
// requestConfig: {
// /* Axios request config options https://github.com/axios/axios#request-config */
// requestOptions: {
// /* Fetch request options https://developer.mozilla.org/en-US/docs/Web/API/fetch#options */
// },
// }
const kcAdminClient = new KcAdminClient();

View file

@ -37,7 +37,6 @@
}
},
"dependencies": {
"axios": "^0.27.2",
"camelize-ts": "^2.1.1",
"lodash-es": "^4.17.21",
"url-join": "^5.0.0",

View file

@ -1,4 +1,3 @@
import type { AxiosRequestConfig } from "axios";
import type { RequestArgs } from "./resources/agent.js";
import { AttackDetection } from "./resources/attackDetection.js";
import { AuthenticationManagement } from "./resources/authenticationManagement.js";
@ -26,7 +25,7 @@ export interface TokenProvider {
export interface ConnectionConfig {
baseUrl?: string;
realmName?: string;
requestConfig?: AxiosRequestConfig;
requestOptions?: RequestInit;
requestArgOptions?: Pick<RequestArgs, "catchNotFound">;
}
@ -55,14 +54,14 @@ export class KeycloakAdminClient {
public accessToken?: string;
public refreshToken?: string;
private requestConfig?: AxiosRequestConfig;
private requestOptions?: RequestInit;
private globalRequestArgOptions?: Pick<RequestArgs, "catchNotFound">;
private tokenProvider?: TokenProvider;
constructor(connectionConfig?: ConnectionConfig) {
this.baseUrl = connectionConfig?.baseUrl || defaultBaseUrl;
this.realmName = connectionConfig?.realmName || defaultRealm;
this.requestConfig = connectionConfig?.requestConfig;
this.requestOptions = connectionConfig?.requestOptions;
this.globalRequestArgOptions = connectionConfig?.requestArgOptions;
// Initialize resources
@ -89,7 +88,7 @@ export class KeycloakAdminClient {
baseUrl: this.baseUrl,
realmName: this.realmName,
credentials,
requestConfig: this.requestConfig,
requestOptions: this.requestOptions,
});
this.accessToken = accessToken;
this.refreshToken = refreshToken;
@ -115,8 +114,8 @@ export class KeycloakAdminClient {
return this.accessToken;
}
public getRequestConfig() {
return this.requestConfig;
public getRequestOptions() {
return this.requestOptions;
}
public getGlobalRequestArgOptions():
@ -139,6 +138,6 @@ export class KeycloakAdminClient {
) {
this.realmName = connectionConfig.realmName;
}
this.requestConfig = connectionConfig.requestConfig;
this.requestOptions = connectionConfig.requestOptions;
}
}

View file

@ -1,5 +1,7 @@
import { RequiredActionAlias } from "./defs/requiredActionProviderRepresentation.js";
import { KeycloakAdminClient } from "./client.js";
import { RequiredActionAlias } from "./defs/requiredActionProviderRepresentation.js";
export const requiredAction = RequiredActionAlias;
export default KeycloakAdminClient;
export { NetworkError } from "./utils/fetchWithError.js";
export type { NetworkErrorOptions } from "./utils/fetchWithError.js";

View file

@ -1,13 +1,19 @@
import axios, { AxiosRequestConfig, AxiosRequestHeaders, Method } from "axios";
import { isUndefined, last, omit, pick } from "lodash-es";
import urlJoin from "url-join";
import { parseTemplate } from "url-template";
import type { KeycloakAdminClient } from "../client.js";
import {
fetchWithError,
NetworkError,
parseResponse,
} from "../utils/fetchWithError.js";
import { stringifyQueryParams } from "../utils/stringifyQueryParams.js";
// constants
const SLASH = "/";
type Method = "GET" | "POST" | "PUT" | "DELETE";
// interface
export interface RequestArgs {
method: Method;
@ -31,7 +37,7 @@ export interface RequestArgs {
* Keys to be ignored, meaning that they will not be filtered out of the request payload even if they are a part of `urlParamKeys` or `queryParamKeys`,
*/
ignoredKeys?: string[];
headers?: AxiosRequestHeaders;
headers?: HeadersInit;
}
export class Agent {
@ -76,7 +82,9 @@ export class Agent {
const baseParams = this.getBaseParams?.() ?? {};
// Filter query parameters by queryParamKeys
const queryParams = queryParamKeys ? pick(payload, queryParamKeys) : null;
const queryParams = queryParamKeys
? pick(payload, queryParamKeys)
: undefined;
// Add filtered payload parameters to base parameters
const allUrlParamKeys = [...Object.keys(baseParams), ...urlParamKeys];
@ -128,7 +136,9 @@ export class Agent {
const baseParams = this.getBaseParams?.() ?? {};
// Filter query parameters by queryParamKeys
const queryParams = queryParamKeys ? pick(query, queryParamKeys) : null;
const queryParams = queryParamKeys
? pick(query, queryParamKeys)
: undefined;
// Add filtered query parameters to base parameters
const allUrlParamKeys = [...Object.keys(baseParams), ...urlParamKeys];
@ -171,64 +181,72 @@ export class Agent {
path: string;
payload: any;
urlParams: any;
queryParams?: Record<string, any> | null;
queryParams?: Record<string, string>;
catchNotFound: boolean;
payloadKey?: string;
returnResourceIdInLocationHeader?: { field: string };
headers?: AxiosRequestHeaders;
headers?: HeadersInit;
}) {
const newPath = urlJoin(this.basePath, path);
// Parse template and replace with values from urlParams
const pathTemplate = parseTemplate(newPath);
const parsedPath = pathTemplate.expand(urlParams);
const url = `${this.getBaseUrl?.() ?? ""}${parsedPath}`;
const url = new URL(`${this.getBaseUrl?.() ?? ""}${parsedPath}`);
const requestOptions = { ...this.client.getRequestOptions() };
const requestHeaders = new Headers([
...new Headers(requestOptions.headers).entries(),
["authorization", `Bearer ${await this.client.getAccessToken()}`],
["accept", "application/json, text/plain, */*"],
...new Headers(headers).entries(),
]);
// Prepare request config
const requestConfig: AxiosRequestConfig = {
paramsSerializer: (params) => stringifyQueryParams(params),
...(this.client.getRequestConfig() || {}),
method,
url,
};
const searchParams: Record<string, string> = {};
// Headers
requestConfig.headers = {
...requestConfig.headers,
Authorization: `bearer ${await this.client.getAccessToken()}`,
...headers,
};
// Put payload into querystring if method is GET
// Add payload parameters to search params if method is 'GET'.
if (method === "GET") {
requestConfig.params = payload;
Object.assign(searchParams, payload);
} else if (requestHeaders.get("content-type") === "text/plain") {
// Pass the payload as a plain string if the content type is 'text/plain'.
requestOptions.body = payload as unknown as string;
} else {
// Set the request data to the payload, or the value corresponding to the payloadKey, if it's defined
requestConfig.data = payloadKey ? payload[payloadKey] : payload;
// Otherwise assume it's JSON and stringify it.
requestOptions.body = JSON.stringify(
payloadKey ? payload[payloadKey] : payload
);
}
if (!requestHeaders.has("content-type")) {
requestHeaders.set("content-type", "application/json");
}
// Concat to existing queryParams
if (queryParams) {
requestConfig.params = requestConfig.params
? {
...requestConfig.params,
...queryParams,
}
: queryParams;
Object.assign(searchParams, queryParams);
}
url.search = stringifyQueryParams(searchParams);
if (!requestHeaders.has("content-type")) {
requestHeaders.set("content-type", "application/x-www-form-urlencoded");
}
try {
const res = await axios.default(requestConfig);
const res = await fetchWithError(url, {
...requestOptions,
headers: requestHeaders,
method,
});
// now we get the response of the http request
// if `resourceIdInLocationHeader` is true, we'll get the resourceId from the location header field
// todo: find a better way to find the id in path, maybe some kind of pattern matching
// for now, we simply split the last sub-path of the path returned in location header field
if (returnResourceIdInLocationHeader) {
const locationHeader = res.headers.location;
const locationHeader = res.headers.get("location");
if (typeof locationHeader !== "string") {
throw new Error(
`location header is not found in request: ${res.config.url}`
`location header is not found in request: ${res.url}`
);
}
@ -236,7 +254,7 @@ export class Agent {
if (!resourceId) {
// throw an error to let users know the response is not expected
throw new Error(
`resourceId is not found in Location header from request: ${res.config.url}`
`resourceId is not found in Location header from request: ${res.url}`
);
}
@ -244,11 +262,12 @@ export class Agent {
const { field } = returnResourceIdInLocationHeader;
return { [field]: resourceId };
}
return res.data;
return parseResponse(res);
} catch (err) {
if (
axios.default.isAxiosError(err) &&
err.response?.status === 404 &&
err instanceof NetworkError &&
err.response.status === 404 &&
catchNotFound
) {
return null;

View file

@ -223,7 +223,6 @@ export class Users extends Resource<{ realm?: string }> {
urlParamKeys: ["id"],
payloadKey: "actions",
queryParamKeys: ["lifespan", "redirectUri", "clientId"],
headers: { "content-type": "application/json" },
keyTransform: {
clientId: "client_id",
redirectUri: "redirect_uri",

View file

@ -1,6 +1,6 @@
import axios, { AxiosRequestConfig, AxiosResponse } from "axios";
import camelize from "camelize-ts";
import { defaultBaseUrl, defaultRealm } from "./constants.js";
import { fetchWithError } from "./fetchWithError.js";
import { stringifyQueryParams } from "./stringifyQueryParams.js";
export type GrantTypes = "client_credentials" | "password" | "refresh_token";
@ -20,7 +20,7 @@ export interface Settings {
realmName?: string;
baseUrl?: string;
credentials: Credentials;
requestConfig?: AxiosRequestConfig;
requestOptions?: RequestInit;
}
export interface TokenResponseRaw {
@ -69,20 +69,25 @@ export const getToken = async (settings: Settings): Promise<TokenResponse> => {
: {}),
});
const config: AxiosRequestConfig = {
...settings.requestConfig,
};
const options = settings.requestOptions ?? {};
const headers = new Headers(options.headers);
if (credentials.clientSecret) {
config.auth = {
username: credentials.clientId,
password: credentials.clientSecret,
};
headers.set(
"Authorization",
atob(credentials.clientId + ":" + credentials.clientSecret)
);
}
const { data } = await axios.default.post<
any,
AxiosResponse<TokenResponseRaw>
>(url, payload, config);
headers.set("content-type", "application/x-www-form-urlencoded");
const response = await fetchWithError(url, {
...options,
method: "POST",
headers,
body: payload,
});
const data: TokenResponseRaw = await response.json();
return camelize(data);
};

View file

@ -0,0 +1,44 @@
export type NetworkErrorOptions = { response: Response; responseData: unknown };
export class NetworkError extends Error {
response: Response;
responseData: unknown;
constructor(message: string, options: NetworkErrorOptions) {
super(message);
this.response = options.response;
this.responseData = options.responseData;
}
}
export async function fetchWithError(
input: RequestInfo | URL,
init?: RequestInit
) {
const response = await fetch(input, init);
if (!response.ok) {
const responseData = await parseResponse(response);
throw new NetworkError("Network response was not OK.", {
response,
responseData,
});
}
return response;
}
export async function parseResponse(response: Response): Promise<any> {
if (!response.body) {
return "";
}
const data = await response.text();
try {
return JSON.parse(data);
// eslint-disable-next-line no-empty
} catch (error) {}
return data;
}

56
package-lock.json generated
View file

@ -548,7 +548,6 @@
"version": "999.0.0-dev",
"license": "Apache-2.0",
"dependencies": {
"axios": "^0.27.2",
"camelize-ts": "^2.1.1",
"lodash-es": "^4.17.21",
"url-join": "^5.0.0",
@ -5691,6 +5690,7 @@
},
"node_modules/asynckit": {
"version": "0.4.0",
"dev": true,
"license": "MIT"
},
"node_modules/at-least-node": {
@ -5732,14 +5732,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/axios": {
"version": "0.27.2",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.14.9",
"form-data": "^4.0.0"
}
},
"node_modules/babel-loader": {
"version": "8.2.5",
"dev": true,
@ -6685,6 +6677,7 @@
},
"node_modules/combined-stream": {
"version": "1.0.8",
"dev": true,
"license": "MIT",
"dependencies": {
"delayed-stream": "~1.0.0"
@ -7597,6 +7590,7 @@
},
"node_modules/delayed-stream": {
"version": "1.0.0",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.4.0"
@ -9065,24 +9059,6 @@
"tabbable": "^5.3.2"
}
},
"node_modules/follow-redirects": {
"version": "1.15.1",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/RubenVerborgh"
}
],
"license": "MIT",
"engines": {
"node": ">=4.0"
},
"peerDependenciesMeta": {
"debug": {
"optional": true
}
}
},
"node_modules/for-in": {
"version": "1.0.2",
"dev": true,
@ -9101,6 +9077,7 @@
},
"node_modules/form-data": {
"version": "4.0.0",
"dev": true,
"license": "MIT",
"dependencies": {
"asynckit": "^0.4.0",
@ -11233,6 +11210,7 @@
},
"node_modules/mime-db": {
"version": "1.52.0",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.6"
@ -11240,6 +11218,7 @@
},
"node_modules/mime-types": {
"version": "2.1.35",
"dev": true,
"license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
@ -17819,7 +17798,6 @@
"@types/lodash-es": "^4.17.5",
"@types/mocha": "^10.0.1",
"@types/node": "^18.0.3",
"axios": "^0.27.2",
"camelize-ts": "^2.1.1",
"chai": "^4.3.7",
"lodash-es": "^4.17.21",
@ -19854,7 +19832,8 @@
"optional": true
},
"asynckit": {
"version": "0.4.0"
"version": "0.4.0",
"dev": true
},
"at-least-node": {
"version": "1.0.0",
@ -19875,13 +19854,6 @@
"version": "1.11.0",
"dev": true
},
"axios": {
"version": "0.27.2",
"requires": {
"follow-redirects": "^1.14.9",
"form-data": "^4.0.0"
}
},
"babel-loader": {
"version": "8.2.5",
"dev": true,
@ -20516,6 +20488,7 @@
},
"combined-stream": {
"version": "1.0.8",
"dev": true,
"requires": {
"delayed-stream": "~1.0.0"
}
@ -21123,7 +21096,8 @@
}
},
"delayed-stream": {
"version": "1.0.0"
"version": "1.0.0",
"dev": true
},
"deprecation": {
"version": "2.3.1"
@ -22140,9 +22114,6 @@
"tabbable": "^5.3.2"
}
},
"follow-redirects": {
"version": "1.15.1"
},
"for-in": {
"version": "1.0.2",
"dev": true
@ -22153,6 +22124,7 @@
},
"form-data": {
"version": "4.0.0",
"dev": true,
"requires": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
@ -23570,10 +23542,12 @@
}
},
"mime-db": {
"version": "1.52.0"
"version": "1.52.0",
"dev": true
},
"mime-types": {
"version": "2.1.35",
"dev": true,
"requires": {
"mime-db": "1.52.0"
}