* feat(ui): add `input group` base component * feat(history): add history viewer and rewind * test: update selector snapshot * test: add PW base page * style: apply TS style fixes * chore(history): request history wiki * chore(history): remove unwanted symbol * chore: fix type * style: apply TS style fixes
205 lines
7.3 KiB
TypeScript
205 lines
7.3 KiB
TypeScript
import { httpClientConfig } from '@/config';
|
|
import { ParameterContract, RequestHeader } from '@/interfaces';
|
|
import {
|
|
HttpHeaders,
|
|
PendingRequest,
|
|
RelayProxyResponse,
|
|
Response,
|
|
} from '@/interfaces/http';
|
|
import { useConfigStore } from '@/stores';
|
|
import { convertPayloadToFormData, getStatusGroup } from '@/utils/http';
|
|
import { generateContentTypeHeader } from '@/utils/request/content-type-header-generator';
|
|
import axios, { AxiosError, AxiosResponse } from 'axios';
|
|
import { readonly, ref } from 'vue';
|
|
|
|
export interface RequestResult {
|
|
response: Response;
|
|
duration: number;
|
|
}
|
|
|
|
export function useHttpClient() {
|
|
const configStore = useConfigStore();
|
|
|
|
const abortController = ref<AbortController | null>(null);
|
|
const isExecuting = ref(false);
|
|
|
|
const buildRequestUrl = (request: PendingRequest): string => {
|
|
const baseUrl = configStore.apiUrl;
|
|
|
|
// Remove leading slashes to prevent double slashes in final URL
|
|
const endpoint = request.endpoint.replace(/^\/+/, '');
|
|
|
|
const url = new URL(`${baseUrl}/${endpoint}`);
|
|
|
|
// Only append enabled parameters with non-empty keys to avoid malformed URLs
|
|
request.queryParameters
|
|
.filter(
|
|
(parameter: ParameterContract) =>
|
|
parameter.enabled && parameter.key.trim(),
|
|
)
|
|
.forEach((parameter: ParameterContract) => {
|
|
url.searchParams.append(parameter.key, parameter.value);
|
|
});
|
|
|
|
return url.toString();
|
|
};
|
|
|
|
const createRelayPayload = (request: PendingRequest) => {
|
|
// Generate Content-Type header just before making the request
|
|
// This ensures the correct header is sent without persisting it in the store
|
|
const headersWithContentType = generateContentTypeHeader(
|
|
request.payloadType,
|
|
request.headers
|
|
.filter(
|
|
(parameter: ParameterContract) =>
|
|
parameter.enabled && parameter.key.trim() !== '',
|
|
)
|
|
.map(
|
|
(parameter): RequestHeader => ({
|
|
key: parameter.key,
|
|
value: parameter.value,
|
|
}),
|
|
),
|
|
);
|
|
|
|
return {
|
|
endpoint: buildRequestUrl(request),
|
|
method: request.method,
|
|
headers: headersWithContentType,
|
|
authorization: request.authorization,
|
|
body: getMemoizedBody(request),
|
|
};
|
|
};
|
|
|
|
/**
|
|
* Body is memoized by method > payload type structure for better UX (keep-alive state).
|
|
*/
|
|
const getMemoizedBody = (request: PendingRequest) => {
|
|
// First extraction: get body for the specific HTTP method (GET, POST, etc.)
|
|
const body = request.body[request.method] ?? null;
|
|
|
|
// Second extraction: get body for the specific payload type (JSON, FormData, etc.)
|
|
// This double extraction is necessary due to the nested memoization structure
|
|
return body ? (body[request.payloadType] ?? null) : null;
|
|
};
|
|
|
|
const transformRelayResponse = (relayResponse: RelayProxyResponse): Response => {
|
|
const headers = relayResponse.headers;
|
|
const statusCode = relayResponse.statusCode;
|
|
|
|
const contentLengthHeader = relayResponse.headers.find(
|
|
(header: HttpHeaders) => header.key.toLowerCase() === 'content-length',
|
|
);
|
|
|
|
return {
|
|
status: getStatusGroup(statusCode),
|
|
statusCode,
|
|
statusText: relayResponse.statusText,
|
|
body: relayResponse.body,
|
|
// Prefer content-length header for accurate size, fallback to body length
|
|
sizeInBytes: contentLengthHeader
|
|
? Number(contentLengthHeader.value)
|
|
: relayResponse.body.length,
|
|
headers: headers,
|
|
cookies: relayResponse.cookies.map(cookie => ({
|
|
key: cookie.key,
|
|
value: {
|
|
raw: cookie.value.raw,
|
|
decrypted: cookie.value.decrypted,
|
|
},
|
|
})),
|
|
timestamp: relayResponse.timestamp,
|
|
};
|
|
};
|
|
|
|
const sendRequest = (request: PendingRequest): Promise<RequestResult | null> => {
|
|
return new Promise<RequestResult | null>((resolve, reject) => {
|
|
const url = configStore.appBasePath + '/api/relay';
|
|
const payload = createRelayPayload(request);
|
|
const formData = convertPayloadToFormData(payload);
|
|
|
|
axios
|
|
.post(url, formData, {
|
|
headers: { 'Content-Type': 'multipart/form-data' },
|
|
// Prevent Axios from parsing JSON automatically as we are handling it manually.
|
|
transformResponse: response => response,
|
|
signal: abortController.value?.signal,
|
|
})
|
|
.then((axiosResponse: AxiosResponse) => {
|
|
// Parse the relay response manually to maintain control over the process
|
|
const relayResponse = JSON.parse(
|
|
axiosResponse.data,
|
|
) as RelayProxyResponse;
|
|
|
|
const response = transformRelayResponse(relayResponse);
|
|
|
|
resolve({ response, duration: relayResponse.duration });
|
|
})
|
|
.catch((error: AxiosError) => {
|
|
// Handle request cancellation gracefully -> not an error state.
|
|
if (error.code === 'ERR_CANCELED') {
|
|
resolve(null);
|
|
|
|
return;
|
|
}
|
|
|
|
// HTTP error responses (4xx, 5xx) -> include status and body for debugging.
|
|
if (error.response) {
|
|
reject({
|
|
message: error.message,
|
|
status: error.response.status,
|
|
body: error.response.data,
|
|
});
|
|
|
|
return;
|
|
}
|
|
|
|
// Network errors or other failures -> just the error message
|
|
reject({ message: error.message });
|
|
});
|
|
});
|
|
};
|
|
|
|
const cancelCurrentRequest = () => {
|
|
if (!abortController.value) {
|
|
return;
|
|
}
|
|
|
|
abortController.value.abort();
|
|
};
|
|
|
|
const executeRequest = async (
|
|
request: PendingRequest,
|
|
): Promise<RequestResult | null> => {
|
|
// Prevent concurrent requests to avoid race conditions
|
|
if (isExecuting.value) {
|
|
throw new Error('Request already in progress');
|
|
}
|
|
|
|
isExecuting.value = true;
|
|
abortController.value = new AbortController();
|
|
|
|
try {
|
|
const timeoutPromise = new Promise<never>((_, reject) =>
|
|
setTimeout(
|
|
() => reject(new Error('Request timeout')),
|
|
httpClientConfig.TIMEOUT,
|
|
),
|
|
);
|
|
|
|
return await Promise.race([sendRequest(request), timeoutPromise]);
|
|
} finally {
|
|
// Always clean up state, even if request fails
|
|
isExecuting.value = false;
|
|
abortController.value = null;
|
|
}
|
|
};
|
|
|
|
return {
|
|
executeRequest,
|
|
cancelCurrentRequest,
|
|
buildRequestUrl,
|
|
isExecuting: readonly(isExecuting),
|
|
};
|
|
}
|