mirror of
https://github.com/rustfs/console.git
synced 2026-08-29 03:52:28 +08:00
242 lines
7.5 KiB
TypeScript
242 lines
7.5 KiB
TypeScript
import { joinURL } from "ufo"
|
|
import type { IApiErrorHandler } from "@/types/api"
|
|
import { redactRequestOptionsForLog } from "./api-request-log"
|
|
import { parseApiError } from "./error-handler"
|
|
import { logger } from "./logger"
|
|
|
|
type ApiRequestInit = RequestInit & { body?: BodyInit | null; aws?: Record<string, unknown> }
|
|
|
|
export interface ApiTransport {
|
|
fetch(input: string | Request, init?: ApiRequestInit): Promise<Response>
|
|
}
|
|
|
|
interface ApiClientOptions {
|
|
baseUrl?: string
|
|
headers?: Record<string, string>
|
|
errorHandler?: IApiErrorHandler
|
|
}
|
|
|
|
interface RequestOptions {
|
|
method?: string
|
|
headers?: Record<string, string>
|
|
body?: unknown
|
|
params?: Record<string, string>
|
|
dedupe?: boolean
|
|
/**
|
|
* If true, 403 errors will throw an error instead of triggering global error handler
|
|
* This allows components to handle permission errors gracefully
|
|
*/
|
|
suppress403Redirect?: boolean
|
|
signal?: AbortSignal
|
|
}
|
|
|
|
async function createHttpError(response: Response) {
|
|
const error = new Error(await parseApiError(response)) as Error & { status: number }
|
|
error.status = response.status
|
|
return error
|
|
}
|
|
|
|
export class ApiClient {
|
|
private $api: ApiTransport
|
|
private config?: ApiClientOptions
|
|
private errorHandler?: IApiErrorHandler
|
|
private inflightGetRequests = new Map<string, Promise<unknown>>()
|
|
|
|
constructor(api: ApiTransport, options?: ApiClientOptions) {
|
|
this.$api = api
|
|
this.config = options
|
|
this.errorHandler = options?.errorHandler
|
|
}
|
|
|
|
setErrorHandler(handler: IApiErrorHandler): void {
|
|
this.errorHandler = handler
|
|
}
|
|
|
|
resolveUrl(url: string): string {
|
|
return new URL(url, this.config?.baseUrl).toString()
|
|
}
|
|
|
|
async request(url: string, options: RequestOptions = {}, parseJson: boolean = true) {
|
|
url = this.config?.baseUrl && !/^https?:\/\//i.test(url) ? joinURL(this.config.baseUrl, url) : url
|
|
const { params, ...providedOptions } = options
|
|
const requestOptions: RequestOptions = {
|
|
...providedOptions,
|
|
headers: { ...this.config?.headers, ...providedOptions.headers },
|
|
}
|
|
const method = (requestOptions.method ?? "GET").toUpperCase()
|
|
requestOptions.method = method
|
|
if (
|
|
requestOptions.body &&
|
|
!(requestOptions.body instanceof FormData) &&
|
|
!(requestOptions.body instanceof Blob) &&
|
|
!(requestOptions.body instanceof ArrayBuffer) &&
|
|
!(requestOptions.body instanceof Uint8Array) &&
|
|
!(requestOptions.body instanceof File)
|
|
) {
|
|
requestOptions.body = JSON.stringify(requestOptions.body)
|
|
}
|
|
|
|
if (params) {
|
|
const queryString = new URLSearchParams(params).toString()
|
|
if (queryString) url += `${url.includes("?") ? "&" : "?"}${queryString}`
|
|
}
|
|
|
|
const shouldDedupe =
|
|
method === "GET" &&
|
|
parseJson &&
|
|
requestOptions.dedupe !== false &&
|
|
!providedOptions.headers &&
|
|
!requestOptions.signal
|
|
const dedupeKey = shouldDedupe ? `${method}:${url}` : null
|
|
|
|
const executeRequest = async () => {
|
|
logger.log("[request] url:", url)
|
|
logger.log("[request] options:", redactRequestOptionsForLog(requestOptions))
|
|
|
|
const response = await this.$api.fetch(url, requestOptions as ApiRequestInit)
|
|
|
|
logger.log("[request] response:", response)
|
|
|
|
if (response.status === 401) {
|
|
if (this.errorHandler) {
|
|
await this.errorHandler.handle401(url)
|
|
}
|
|
throw await createHttpError(response)
|
|
}
|
|
if (response.status === 403) {
|
|
// If suppress403Redirect is true, throw error instead of triggering global handler
|
|
// This allows components to handle permission errors gracefully
|
|
if (requestOptions.suppress403Redirect) {
|
|
throw await createHttpError(response)
|
|
}
|
|
|
|
try {
|
|
const cloned = response.clone()
|
|
let codeText = ""
|
|
try {
|
|
const data = (await cloned.json()) as Record<string, unknown>
|
|
let code: string | undefined = (data?.code as string | undefined) || (data?.Code as string | undefined)
|
|
if (data && typeof data === "object" && "error" in data) {
|
|
const errObj = (data as { error?: unknown }).error
|
|
if (errObj && typeof errObj === "object") {
|
|
const e = errObj as { code?: unknown }
|
|
if (typeof e.code === "string") code = code ?? e.code
|
|
}
|
|
}
|
|
codeText = typeof code === "string" ? code : ""
|
|
} catch {
|
|
codeText = ""
|
|
}
|
|
|
|
const normalizedCode = codeText.toLowerCase()
|
|
const isUnauthorizedAccess = normalizedCode === "unauthorizedaccess"
|
|
const isInvalidAccessKey = normalizedCode === "invalidaccesskeyid"
|
|
|
|
if (this.errorHandler) {
|
|
if (isUnauthorizedAccess || isInvalidAccessKey) {
|
|
await this.errorHandler.handle401(url)
|
|
} else {
|
|
await this.errorHandler.handle403(url)
|
|
}
|
|
}
|
|
} catch {
|
|
if (this.errorHandler) {
|
|
await this.errorHandler.handle403(url)
|
|
}
|
|
}
|
|
throw await createHttpError(response)
|
|
}
|
|
|
|
if (!response.ok) {
|
|
throw await createHttpError(response)
|
|
}
|
|
|
|
if (response.status === 204 || response.headers.get("content-length") === "0" || !response.body) {
|
|
return null
|
|
}
|
|
|
|
if (parseJson) {
|
|
return await response.json()
|
|
} else {
|
|
return response
|
|
}
|
|
}
|
|
|
|
if (!dedupeKey) {
|
|
return executeRequest()
|
|
}
|
|
|
|
const inflight = this.inflightGetRequests.get(dedupeKey)
|
|
if (inflight) {
|
|
return inflight
|
|
}
|
|
|
|
const requestPromise = executeRequest()
|
|
this.inflightGetRequests.set(dedupeKey, requestPromise)
|
|
try {
|
|
return await requestPromise
|
|
} finally {
|
|
this.inflightGetRequests.delete(dedupeKey)
|
|
}
|
|
}
|
|
|
|
async get(url: string, options?: RequestOptions) {
|
|
return this.request(url, { method: "GET", ...options })
|
|
}
|
|
|
|
async *streamRequest(url: string, options: RequestOptions = {}) {
|
|
const response = (await this.request(url, { method: "GET", ...options }, false)) as Response | null
|
|
|
|
if (!response?.body) {
|
|
throw new Error("No response body")
|
|
}
|
|
|
|
const reader = response.body.getReader()
|
|
const decoder = new TextDecoder("utf-8")
|
|
let buffer = ""
|
|
|
|
while (true) {
|
|
const { done, value } = await reader.read()
|
|
if (done) break
|
|
buffer += decoder.decode(value, { stream: true })
|
|
// Support NDJSON: each line is a JSON object
|
|
const lines = buffer.split("\n")
|
|
buffer = lines.pop() ?? ""
|
|
for (const line of lines) {
|
|
const trimmed = line.trim()
|
|
if (!trimmed) continue
|
|
try {
|
|
const data = JSON.parse(trimmed) as Record<string, unknown>
|
|
yield data
|
|
} catch {
|
|
// skip invalid JSON
|
|
}
|
|
}
|
|
}
|
|
if (buffer.trim()) {
|
|
try {
|
|
const data = JSON.parse(buffer.trim()) as Record<string, unknown>
|
|
yield data
|
|
} catch {
|
|
// skip trailing incomplete JSON
|
|
}
|
|
}
|
|
}
|
|
|
|
async post(url: string, body: unknown, options?: RequestOptions) {
|
|
return this.request(url, { method: "POST", body, ...options })
|
|
}
|
|
|
|
async delete(url: string, options?: RequestOptions) {
|
|
return this.request(url, { method: "DELETE", ...options })
|
|
}
|
|
|
|
async put(url: string, body: unknown, options?: RequestOptions) {
|
|
return this.request(url, { method: "PUT", body, ...options })
|
|
}
|
|
|
|
async patch(url: string, body: unknown, options?: RequestOptions) {
|
|
return this.request(url, { method: "PATCH", body, ...options })
|
|
}
|
|
}
|