import { assertServerErrorResponse, type RelayAdapter, type RelayInput, type RelayResponse, type ServerErrorResponse, } from "../libraries/adapter.ts"; import { ServerError, type ServerErrorType } from "../libraries/errors.ts"; /** * HttpAdapter provides a unified transport layer for Relay. * * It supports sending JSON objects, nested structures, arrays, and file uploads * via FormData. The adapter automatically detects the payload type and formats * the request accordingly. Responses are normalized into `RelayResponse`. * * @example * ```ts * const adapter = new HttpAdapter({ url: "https://api.example.com" }); * * // Sending JSON data * const jsonResponse = await adapter.send({ * method: "POST", * endpoint: "/users", * body: { name: "Alice", age: 30 }, * }); * * // Sending files and nested objects * const formResponse = await adapter.send({ * method: "POST", * endpoint: "/upload", * body: { * user: { name: "Bob", avatar: fileInput.files[0] }, * documents: [fileInput.files[1], fileInput.files[2]], * }, * }); * ``` */ export class HttpAdapter implements RelayAdapter { /** * Instantiate a new HttpAdapter instance. * * @param options - Adapter options. */ constructor(readonly options: HttpAdapterOptions) {} /** * Override the initial url value set by instantiator. */ set url(value: string) { this.options.url = value; } /** * Retrieve the URL value from options object. */ get url() { return this.options.url; } /** * Return the full URL from given endpoint. * * @param endpoint - Endpoint to get url for. */ getUrl(endpoint: string): string { return `${this.url}${endpoint}`; } async send({ method, endpoint, query, body, headers = new Headers() }: RelayInput): Promise { const init: RequestInit = { method, headers }; // ### Before Request // If any before request hooks has been defined, we run them here passing in the // request headers for further modification. await this.#beforeRequest(headers); // ### Body if (body !== undefined) { const type = this.#getRequestFormat(body); if (type === "form-data") { headers.delete("content-type"); init.body = this.#getFormData(body); } if (type === "json") { headers.set("content-type", "application/json"); init.body = JSON.stringify(body); } } // ### Response return this.request(`${endpoint}${query}`, init); } /** * Send a fetch request using the given fetch options and returns * a relay formatted response. * * @param endpoint - Which endpoint to submit request to. * @param init - Request init details to submit with the request. */ async request(endpoint: string, init?: RequestInit): Promise { return this.#toResponse(await fetch(this.getUrl(endpoint), init)); } /** * Run before request operations. * * @param headers - Headers to pass to hooks. */ async #beforeRequest(headers: Headers) { if (this.options.hooks?.beforeRequest !== undefined) { for (const hook of this.options.hooks.beforeRequest) { await hook(headers); } } } /** * Determine the parser method required for the request. * * @param body - Request body. */ #getRequestFormat(body: unknown): "form-data" | "json" { if (body instanceof FormData) { return "form-data"; } if (containsFile(body) === true) { return "form-data"; } return "json"; } /** * Get FormData instance for the given body. * * @param body - Request body. */ #getFormData(data: Record, formData = new FormData(), parentKey?: string): FormData { for (const key in data) { const value = data[key]; if (value === undefined || value === null) continue; const formKey = parentKey ? `${parentKey}[${key}]` : key; if (value instanceof File) { formData.append(formKey, value, value.name); } else if (Array.isArray(value)) { value.forEach((item, index) => { if (item instanceof File) { formData.append(`${formKey}[${index}]`, item, item.name); } else if (typeof item === "object") { this.#getFormData(item as Record, formData, `${formKey}[${index}]`); } else { formData.append(`${formKey}[${index}]`, String(item)); } }); } else if (typeof value === "object") { this.#getFormData(value as Record, formData, formKey); } else { formData.append(formKey, String(value)); } } return formData; } /** * Convert a fetch response to a compliant relay response. * * @param response - Fetch response to convert. */ async #toResponse(response: Response): Promise { const type = response.headers.get("content-type"); // ### Content Type // Ensure that the server responds with a 'content-type' definition. We should // always expect the server to respond with a type. if (type === null) { return { result: "error", headers: response.headers, error: { code: "CONTENT_TYPE_MISSING", status: response.status, message: "Missing 'content-type' in header returned from server.", }, }; } // ### Empty Response // If the response comes back with empty response status 204 we simply return a // empty success. if (response.status === 204) { return { result: "success", headers: response.headers, data: null, }; } // ### JSON // If the 'content-type' contains 'json' we treat it as a 'json' compliant response // and attempt to resolve it as such. if (type.includes("json") === true) { const parsed = await response.json(); if ("data" in parsed) { return { result: "success", headers: response.headers, data: parsed.data, }; } if ("error" in parsed) { return { result: "error", headers: response.headers, error: this.#toError(parsed), }; } return { result: "error", headers: response.headers, error: { code: "INVALID_SERVER_RESPONSE", status: response.status, message: "Unsupported 'json' body returned from server, missing 'data' or 'error' key.", }, }; } // ### Error // If the 'content-type' is not a JSON response from the API then we check if the // response status is an error code. if (response.status >= 400) { return { result: "error", headers: response.headers, error: { code: "SERVER_ERROR_RESPONSE", status: response.status, message: await response.text(), }, }; } // ### Success // If the 'content-type' is not a JSON response from the API and the request is not // an error we simply return the pure response in the data key. return { result: "success", headers: response.headers, data: response, }; } #toError(candidate: unknown, status: number = 500): ServerErrorType | ServerErrorResponse["error"] { if (assertServerErrorResponse(candidate)) { return ServerError.fromJSON(candidate.error); } if (typeof candidate === "string") { return { code: "ERROR", status, message: candidate, }; } return { code: "UNSUPPORTED_SERVER_ERROR", status, message: "Unsupported 'error' returned from server.", }; } } function containsFile(value: unknown): boolean { if (value instanceof File) { return true; } if (Array.isArray(value)) { return value.some(containsFile); } if (typeof value === "object" && value !== null) { return Object.values(value).some(containsFile); } return false; } export type HttpAdapterOptions = { url: string; hooks?: { beforeRequest?: ((headers: Headers) => Promise)[]; }; };