Files
custum-hyprpanel/src/lib/httpClient/index.ts
Jas Singh 8cf5806766 Minor: Refactor the code-base for better organization and compartmentalization. (#934)
* Clean up unused code

* Fix media player formatting issue for labels with new line characteres.

* Refactor the media player handlers into a class.

* More code cleanup and organize shared weather utils into distinct classes.

* Flatten some nesting.

* Move weather manager in dedicated class and build HTTP Utility class for Rest API calling.

* Remove logs

* Rebase master merge

* Reorg code (WIP)

* More reorg

* Delete utility scripts

* Reorg options

* Finish moving all options over

* Fix typescript issues

* Update options imports to default

* missed update

* Screw barrel files honestly, work of the devil.

* Only initialize power profiles if power-profiles-daemon is running.

* Fix window positioning and weather service naming

* style dir

* More organization

* Restructure types to be closer to their source

* Remove lib types and constants

* Update basic weather object to be saner with extensibility.

* Service updates

* Fix initialization strategy for services.

* Fix Config Manager to only emit changed objects and added missing temp converters.

* Update storage service to handle unit changes.

* Added cpu temp sensor auto-discovery

* Added missing JSDocs to services

* remove unused

* Migrate to network service.

* Fix network password issue.

* Move out password input into helper

* Rename password mask constant to be less double-negativey.

* Dropdown menu rename

* Added a component to edit JSON in the settings dialog (rough/WIP)

* Align settings

* Add and style JSON Editor.

* Adjust padding

* perf(shortcuts):  avoid unnecessary polling when shortcuts are disabled

Stops the recording poller when shortcuts are disabled, preventing redundant polling and reducing resource usage.

* Fix types and return value if shortcut not enabled.

* Move the swww daemon checking process outside of the wallpaper service into a dedicated deamon lifecyle processor.

* Add more string formatters and use title case for weather status (as it was).

* Fix startup errors.

* Rgba fix

* Remove zod from dependencies

---------

Co-authored-by: KernelDiego <gonzalezdiego.contact@gmail.com>
2025-05-26 19:45:11 -07:00

269 lines
8.5 KiB
TypeScript

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<RestResponse> {
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<string, unknown>,
options?: RequestOptions,
): Promise<RestResponse> {
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<string, unknown>,
options?: RequestOptions,
): Promise<RestResponse> {
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<string, unknown>,
options?: RequestOptions,
): Promise<RestResponse> {
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<RestResponse> {
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<RestResponse> {
const requestPromise = new Promise<RestResponse>((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<RestResponse>) => 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<string, string>;
} {
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<string, string> = {};
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<string, unknown> {
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();