import { GLib } from 'astal'; import Soup from 'gi://Soup?version=3.0'; import { HttpError } from './HttpError'; import { RequestOptions, RestResponse } from './types'; import { errorHandler } from 'src/core/errors/handler'; /** * HTTP client wrapper for Soup.Session providing a Promise-based API * Handles authentication, timeouts, and JSON parsing automatically */ class HttpClient { private _session: Soup.Session; constructor(defaultTimeout = 30) { this._session = new Soup.Session(); this._session.timeout = defaultTimeout; this._session.user_agent = 'HyprPanel/1.0'; } /******************************************* * HTTP Methods * *******************************************/ /** * Performs an HTTP GET request * @param url - Target URL for the request * @param options - Optional configuration for the request */ public async get(url: string, options?: RequestOptions): Promise { return this._request('GET', url, options); } /** * Performs an HTTP POST request * @param url - Target URL for the request * @param data - Request payload to send * @param options - Optional configuration for the request */ public async post( url: string, data?: Record, options?: RequestOptions, ): Promise { return this._request('POST', url, { ...options, body: data }); } /** * Performs an HTTP PUT request * @param url - Target URL for the request * @param data - Request payload to send * @param options - Optional configuration for the request */ public async put( url: string, data?: Record, options?: RequestOptions, ): Promise { return this._request('PUT', url, { ...options, body: data }); } /** * Performs an HTTP PATCH request * @param url - Target URL for the request * @param data - Request payload with partial updates * @param options - Optional configuration for the request */ public async patch( url: string, data?: Record, options?: RequestOptions, ): Promise { return this._request('PATCH', url, { ...options, body: data }); } /** * Performs an HTTP DELETE request * @param url - Target URL for the request * @param options - Optional configuration for the request */ public async delete(url: string, options?: RequestOptions): Promise { return this._request('DELETE', url, options); } /******************************************* * SOUP Infrastructure * *******************************************/ /** * Internal request handler for all HTTP methods * @param method - HTTP method to use * @param url - Target URL for the request * @param options - Configuration options for the request * @private */ private async _request(method: string, url: string, options: RequestOptions = {}): Promise { const requestPromise = new Promise((resolve, reject) => { const message = Soup.Message.new(method, url); if (!message) { return reject(new Error(`Failed to create request for ${url}`)); } this._assignHeaders(message, options); this._constructBodyIfExists(method, options, message); if (options.timeout) { this._session.timeout = options.timeout / 1000; } this._sendRequest(resolve, reject, message, options); }); return requestPromise; } /** * Constructs and sets the request body for HTTP methods that support it * @param method - HTTP method being used * @param options - Request options containing the body * @param message - Soup message to attach the body to */ private _constructBodyIfExists(method: string, options: RequestOptions, message: Soup.Message): void { const canContainBody = ['POST', 'PUT', 'PATCH'].includes(method); if (options.body && canContainBody) { let body: string; let contentType = options.headers?.['Content-Type'] || 'application/json'; if (typeof options.body === 'object') { body = JSON.stringify(options.body); } else { body = options.body; contentType = contentType || 'text/plain'; } const textEncoder = new TextEncoder(); const bytes = new GLib.Bytes(textEncoder.encode(body)); message.set_request_body_from_bytes(contentType, bytes); } } /** * Assigns headers to the request message * @param message - Soup message to add headers to * @param options - Request options containing headers */ private _assignHeaders(message: Soup.Message, options: RequestOptions): Soup.MessageHeaders { const headers = message.get_request_headers(); if (options.headers) { Object.entries(options.headers).forEach(([key, value]) => { headers.append(key, value); }); } return headers; } /** * Sends the HTTP request and handles the response * @param resolve - Promise resolve callback * @param reject - Promise reject callback * @param message - Prepared Soup message to send * @param options - Request configuration options */ private _sendRequest( resolve: (value: RestResponse | PromiseLike) => void, reject: (reason?: unknown) => void, message: Soup.Message, options: RequestOptions, ): void { const cancellable = options.signal ?? null; try { const bytes = this._session.send_and_read(message, cancellable); const { response: responseText, headers: responseHeaders, status, } = this._decodeResponseSync(message, bytes); const responseData = this._parseReponseData(options, responseText); const response: RestResponse = { data: responseData, status, headers: responseHeaders, }; if (status >= 400) { const httpError = new HttpError({ status, data: responseData, url: message.get_uri().to_string(), method: message.get_method(), }); return reject(httpError); } return resolve(response); } catch (error) { reject(error); } } /** * Decodes the response bytes into text and extracts response metadata * @param message - Soup message containing the response * @param bytes - Response bytes from the sync request */ private _decodeResponseSync( message: Soup.Message, bytes: GLib.Bytes | null, ): { response: string; status: Soup.Status; headers: Record; } { if (!bytes) { throw new Error('No response received'); } const decoder = new TextDecoder(); const byteData = bytes.get_data(); const responseText = byteData ? decoder.decode(byteData) : ''; const status = message.get_status(); const responseHeaders: Record = {}; message.get_response_headers().foreach((name, value) => { responseHeaders[name] = value; }); return { response: responseText, status, headers: responseHeaders, }; } /** * Parses response text based on the expected response type * @param options - Request options containing responseType preference * @param responseText - Raw response text to parse */ private _parseReponseData( options: RequestOptions, responseText: string, ): string | Record { if (!responseText) { return ''; } if (options.responseType === 'text') { return responseText; } try { const parsedResponseText = JSON.parse(responseText); return parsedResponseText; } catch (e) { errorHandler(`Failed to parse JSON response: ${e}`); } } } export const httpClient = new HttpClient();