-
Notifications
You must be signed in to change notification settings - Fork 186
[jslib] Refactored Acknowledgements with Enhanced HTTP Utilities #9999
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
7852302
74f7e8f
cfef8f5
3b59957
7b8645d
8e7bc39
6242665
9c4a271
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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'; | ||
| } | ||
| } |
| 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; | ||
| } | ||
| } |
| 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); | ||
| } | ||
| } |
| 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'; | ||
| } | ||
| } |
| 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'; | ||
| } | ||
| } |
| 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); | ||
| } | ||
| } |
| 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'; |
| 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: ( | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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+'/'; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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); | ||
| } | ||
| } | ||
| } | ||
| 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'; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The Operator.Includes case is missing |
||
| default: return ''; | ||
| } | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,2 @@ | ||
| export {Client} from './Client'; | ||
| export {Query, QueryParam} from './Query'; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,2 @@ | ||
| export * as Errors from './errors'; | ||
| export * as Http from './http'; |
There was a problem hiding this comment.
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...