Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion htdocs/index.php
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ function array_find(array $array, callable $callback)
$factory->database(),
$factory->config(),
[
__DIR__ . "/../project/",
__DIR__ . "/../project/modules",
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

needed to get things to load...

__DIR__ . "/../modules/"
]
);
Expand Down
15 changes: 15 additions & 0 deletions jslib/core/errors/ApiNetworkError.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import {HttpError} from './HttpError';

/**
* Error thrown for network-level issues (e.g., no internet connection, DNS failure).
*/
export class ApiNetworkError extends HttpError {
/**
*
* @param message The error message.
*/
constructor(message?: string) {
super(message || 'Network error occurred during API call.');
this.name = 'APINetworkError';
}
}
24 changes: 24 additions & 0 deletions jslib/core/errors/ApiResponseError.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import {HttpError} from './HttpError';

/**
* Error thrown for non-2xx HTTP responses from the API.
* It includes the raw Response object for additional context.
*/
export class ApiResponseError extends HttpError {
public readonly response: Response;

/**
*
* @param response The raw HTTP Response object.
* @param request The Request object that generated the error.
* @param message The error message.
*/
constructor(response: Response, request: Request, message?: string) {
super(
message ||
`Request to ${request.url} failed with status code ${response.status}.`
);
this.name = 'ApiResponseError';
this.response = response;
}
}
14 changes: 14 additions & 0 deletions jslib/core/errors/BaseError.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
/**
* Base class for all custom API-related errors.
*/
export class BaseError extends Error {
/**
*
* @param message The error message.
*/
constructor(message?: string) {
super(message);
this.name = 'BaseError';
Object.setPrototypeOf(this, new.target.prototype);
}
}
15 changes: 15 additions & 0 deletions jslib/core/errors/HttpError.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import {BaseError} from './BaseError';

/**
* Base class for HTTP-related errors.
*/
export class HttpError extends BaseError {
/**
*
* @param message The error message.
*/
constructor(message: string) {
super(message);
this.name = 'HttpError';
}
}
15 changes: 15 additions & 0 deletions jslib/core/errors/JsonParseError.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import {BaseError} from './BaseError';

/**
* Error thrown when a JSON response from the server cannot be parsed.
*/
export class JsonParseError extends BaseError {
/**
*
* @param message The error message.
*/
constructor(message?: string) {
super(message || 'The server returned an invalid JSON response.');
this.name = 'JsonParseError';
}
}
16 changes: 16 additions & 0 deletions jslib/core/errors/ValidationError.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import {BaseError} from './BaseError';

/**
* Error thrown when data validation fails.
*/
export class ValidationError extends BaseError {
/**
*
* @param message The error message.
*/
constructor(message?: string) {
super(message);
this.name = 'ValidationError';
Object.setPrototypeOf(this, new.target.prototype);
}
}
6 changes: 6 additions & 0 deletions jslib/core/errors/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export {BaseError as Base} from './BaseError';
export {HttpError as Http} from './HttpError';
export {ValidationError as Validation} from './ValidationError';
export {ApiNetworkError as ApiNetwork} from './ApiNetworkError';
export {ApiResponseError as ApiResponse} from './ApiResponseError';
export {JsonParseError as JsonParse} from './JsonParseError';
154 changes: 154 additions & 0 deletions jslib/core/http/Client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
declare const loris: any;
import {Query, QueryParam} from './Query';
import {Errors} from '../';

export interface ErrorContext {
key: string | number; // The key that triggered the custom message (e.g., 'ApiNetworkError' or 404)
request: Request,
response?: Response,
}

/**
* A basic client for making HTTP requests to a REST API endpoint.
*/
export class Client<T> {
protected baseUrl: string;
protected subEndpoint?: string;
/**
* Function to retrieve a custom error message for a given error context.
*/
public getMessage: (

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since this method handles error messages, I think its name should make that purpose more immediately clear

key: string | number,
request: Request,
response?: Response
) => string | undefined = () => undefined;

/**
* Creates a new API client instance.
*
* @param baseUrl The base URL for the API requests.
*/
constructor(baseUrl: string) {
this.baseUrl = loris.BaseURL+'/'+baseUrl+'/';

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of concatenating strings to build the URL, I recommend using the URL object for cleaner and more reliable construction

}

/**
* Sets an optional sub-endpoint path.
*
* @param subEndpoint An optional endpoint segment to append to the baseUrl.
*/
setSubEndpoint(subEndpoint: string): this {
this.subEndpoint = subEndpoint;
return this;
}


/**
* Fetches a collection of resources.
*
* @param query A Query object to build the URL query string.
*/
async get<U = T>(query?: Query): Promise<U[]> {
const path = this.subEndpoint ?
`${this.baseUrl}/${this.subEndpoint}` : this.baseUrl;
const queryString = query ? query.build() : '';
const url = queryString ? `${path}?${queryString}` : path;
return this.fetchJSON<U[]>(url, {
method: 'GET',
headers: {'Accept': 'application/json'},
});
}

/**
* Fetches a list of unique labels for the resource type based on query parameters.
*
* @param {...QueryParam} params One or more QueryParam objects to filter the labels.
*/
async getLabels(...params: QueryParam[]): Promise<string[]> {
const query = new Query();
params.forEach((param) => query.addParam(param));
return this.get<string>(query.addField('label'));
}

/**
* Fetches a single resource by its ID.
*
* @param id The unique identifier of the resource to fetch.
*/
async getById(id: string): Promise<T> {
return this.fetchJSON<T>(`${this.baseUrl}/${id}`, {
method: 'GET',
headers: {'Accept': 'application/json'},
});
}

/**
* Creates a new resource on the server.
*
* @param data The resource data to be created.
* @param mapper An optional function to map the input data before sending.
*/
async create<U = T>(data: T, mapper?: (data: T) => U): Promise<T> {
const payload = mapper ? mapper(data) : data;
return this.fetchJSON<T>(this.baseUrl, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(payload),
});
}

/**
* Updates an existing resource on the server.
*
* @param id The unique identifier of the resource to update.
* @param data The new resource data.
*/
async update(id: string, data: T): Promise<T> {
return this.fetchJSON<T>(`${this.baseUrl}/${id}`, {
method: 'PUT',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(data),
});
}

/**
* Handles the actual fetching and JSON parsing, including error handling.
*
* @param url The URL to which the request will be made.
* @param options The Fetch API request initialization options.
*/
protected async fetchJSON<U>(url: string, options: RequestInit): Promise<U> {
const request = new Request(url, options);
try {
const response = await fetch(url, options);

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Doesn't really change much, but it looks cleaner to use the request const from line 121 here instead of passing url and options again


// 1. Handle HTTP status errors (e.g., 404, 500)
if (!response.ok) {
const message = this.getMessage(response.status, request, response);

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't the first value being passed here be 'ApiResponseError' instead of response.status? The response object is already being passed as the third parameter, therefore it would be more useful to have the type of error like the examples bellow, I think

throw new Errors.ApiResponse(response, request, message);
}

// Handle responses with no content
const contentType = response.headers.get('content-type');
if (!contentType || !contentType.includes('application/json')) {
return null as U;

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of typing null as U, return null directly and include it in the return type. This way, it is required to handle the null case instead of always expecting an object of type U

}

// 2. Handle JSON parsing errors
try {
const data = await response.json();
return data as U;
} catch (e) {
const message = this.getMessage('JsonParseError', request);
throw new Errors.JsonParse(message);
}
} catch (error) {
// 3. Handle network errors (e.g., no internet)
if (error instanceof Errors.Http) {
throw error; // Re-throw our custom errors
}
const message = this.getMessage('ApiNetworkError', request);
throw new Errors.ApiNetwork(message);
}
}
}
115 changes: 115 additions & 0 deletions jslib/core/http/Query.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
export enum Operator {
Equals = '=',
NotEquals = '!=',
LessThan = '<',
GreaterThan = '>',
LessThanOrEqual = '<=',
GreaterThanOrEqual = '>=',
Like = 'like',
Includes = 'in'
}

export interface QueryParam {
field: string,
value: string,
operator: Operator
}

/**
* Utility class to build URL query strings for API requests.
*/
export class Query {
private params: Record<string, string> = {};

/**
* Adds a filter parameter to the query string.
*
* @param root0 The destructured QueryParam object.
* @param root0.field The field to filter on.
* @param root0.value The value to filter against.
* @param root0.operator The comparison operator to use.
*/
addParam({
field,
value,
operator = Operator.Equals,
}: QueryParam): this {
const encodedField = encodeURIComponent(field);
const encodedValue = encodeURIComponent(value);
const operatorSuffix = this.getOperatorSuffix(operator);
this.params[`${encodedField}${operatorSuffix}`] = encodedValue;
return this;
}

/**
* Adds a field to the 'fields' selection parameter.
*
* @param field The field to include in the response payload.
*/
addField(field: string): this {
const encodedField = encodeURIComponent(field);
if (this.params['fields']) {
this.params['fields'] = `${this.params['fields']},${encodedField}`;
} else {
this.params['fields'] = encodedField;
}
return this;
}

/**
* Sets the maximum number of results to return.
*
* @param limit The maximum number of results to return.
*/
addLimit(limit: number): this {
this.params['limit'] = limit.toString();
return this;
}

/**
* Sets the offset for pagination.
*
* @param offset The number of results to skip for pagination.
*/
addOffset(offset: number): this {
this.params['offset'] = offset.toString();
return this;
}

/**
* Sets the sorting field and direction.
*
* @param field The field to sort the results by.
* @param direction The sort direction.
*/
addSort(field: string, direction: 'asc' | 'desc'): this {
const encodedField = encodeURIComponent(field);
this.params['sort'] = `${encodedField}:${direction}`;
return this;
}

/**
* Builds and returns the final URL search string.
*/
build(): string {
return new URLSearchParams(this.params).toString();
}

/**
* Gets string suffix for a given operator to be used in a query parameter key.
*
* @param operator The comparison operator enum value.
*/
private getOperatorSuffix(operator: Operator): string {
switch (operator) {
case Operator.Equals: return '';
case Operator.NotEquals: return '!=';
case Operator.LessThan: return '<';
case Operator.GreaterThan: return '>';
case Operator.LessThanOrEqual: return '<=';
case Operator.GreaterThanOrEqual: return '>=';
case Operator.Like: return '_like';

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Operator.Includes case is missing

default: return '';
}
}
}
2 changes: 2 additions & 0 deletions jslib/core/http/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export {Client} from './Client';
export {Query, QueryParam} from './Query';
2 changes: 2 additions & 0 deletions jslib/core/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * as Errors from './errors';
export * as Http from './http';
Loading
Loading