diff --git a/jslib/core/errors/ApiNetworkError.ts b/jslib/core/errors/ApiNetworkError.ts new file mode 100644 index 00000000000..9c591c4bc30 --- /dev/null +++ b/jslib/core/errors/ApiNetworkError.ts @@ -0,0 +1,19 @@ +import {HttpError} from './HttpError'; + +/** + * Error thrown for network-level issues (e.g., no internet connection, DNS failure). + */ +export class ApiNetworkError extends HttpError { + /** + * @param request The Request object that generated the error. + * @param message The error message. + */ + constructor(request: Request, message?: string) { + super( + request, + undefined, + message || 'Network error occurred during API call.' + ); + this.name = 'APINetworkError'; + } +} diff --git a/jslib/core/errors/ApiResponseError.ts b/jslib/core/errors/ApiResponseError.ts new file mode 100644 index 00000000000..2e23147bc8e --- /dev/null +++ b/jslib/core/errors/ApiResponseError.ts @@ -0,0 +1,23 @@ +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 { + /** + * + * @param request The Request object that generated the error. + * @param response The raw HTTP Response object. + * @param message The error message. + */ + constructor(request: Request, response: Response, message?: string) { + super( + request, + response, + message || + `Request to ${request.url} failed with status code ${response.status}.`, + ); + this.name = 'ApiResponseError'; + } +} diff --git a/jslib/core/errors/BaseError.ts b/jslib/core/errors/BaseError.ts new file mode 100644 index 00000000000..1efc0c27779 --- /dev/null +++ b/jslib/core/errors/BaseError.ts @@ -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); + } +} diff --git a/jslib/core/errors/HttpError.ts b/jslib/core/errors/HttpError.ts new file mode 100644 index 00000000000..e12bd31e9e6 --- /dev/null +++ b/jslib/core/errors/HttpError.ts @@ -0,0 +1,20 @@ +import {BaseError} from './BaseError'; + +/** + * Base class for HTTP-related errors. + */ +export class HttpError extends BaseError { + /** + * @param request The Request object that generated the error. + * @param response The raw HTTP Response object (guaranteed to be 2xx, e.g., 204). + * @param message The error message. + */ + constructor( + public readonly request: Request, + public readonly response?: Response, + message?: string + ) { + super(message); + this.name = 'HttpError'; + } +} diff --git a/jslib/core/errors/JsonParseError.ts b/jslib/core/errors/JsonParseError.ts new file mode 100644 index 00000000000..ba47db93f22 --- /dev/null +++ b/jslib/core/errors/JsonParseError.ts @@ -0,0 +1,19 @@ +import {HttpError} from './HttpError'; + +/** + * Error thrown when a JSON response from the server cannot be parsed. + */ +export class JsonParseError extends HttpError { + /** + * @param request The Request object that generated the error. + * @param message The error message. + */ + constructor(request: Request, message?: string) { + super( + request, + undefined, + message || 'The server returned an invalid JSON response.' + ); + this.name = 'JsonParseError'; + } +} diff --git a/jslib/core/errors/NoContentError.ts b/jslib/core/errors/NoContentError.ts new file mode 100644 index 00000000000..de5601538b5 --- /dev/null +++ b/jslib/core/errors/NoContentError.ts @@ -0,0 +1,22 @@ +import {HttpError} from './HttpError'; + +/** + * Error thrown when a request succeeds (i.e., status 2xx) + * but the response body contains no content or is not the expected JSON format. + * This typically corresponds to a 204 No Content status when content was expected. + */ +export class NoContentError extends HttpError { + /** + * @param request The Request object that was sent. + * @param response The Response object from the fetch call. + * @param message The error message. + */ + constructor(request: Request, response: Response, message?: string) { + super( + request, + response, + message || 'Operation succeeded but server returned no content.' + ); + this.name = 'NoContentError'; + } +} diff --git a/jslib/core/errors/ValidationError.ts b/jslib/core/errors/ValidationError.ts new file mode 100644 index 00000000000..59c6668ea7b --- /dev/null +++ b/jslib/core/errors/ValidationError.ts @@ -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); + } +} diff --git a/jslib/core/errors/index.ts b/jslib/core/errors/index.ts new file mode 100644 index 00000000000..cedff95f430 --- /dev/null +++ b/jslib/core/errors/index.ts @@ -0,0 +1,7 @@ +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'; +export {NoContentError as NoContent} from './NoContentError'; diff --git a/jslib/core/http/Client.ts b/jslib/core/http/Client.ts new file mode 100644 index 00000000000..0811eb72b51 --- /dev/null +++ b/jslib/core/http/Client.ts @@ -0,0 +1,183 @@ +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 { + protected baseURL: URL; + protected subEndpoint?: string; + /** + * Function to retrieve a custom error message for a given error context. + */ + public getErrorMessage: ( + 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 = new URL(baseURL, loris.BaseURL); + } + + /** + * 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(query?: Query): Promise { + // 1. Determine the path to resolve + const relativePath = this.subEndpoint ? this.subEndpoint : ''; + + // 2. Create the full URL object by resolving the path against this.baseURL. + const url = new URL(relativePath, this.baseURL); + + // 3. Add Query Parameters using the URL object's searchParams + if (query) { + const params = new URLSearchParams(query.build()); + params.forEach((value, key) => { + url.searchParams.append(key, value); + }); + } + + // 4. Use the final URL object for the fetch request. + return this.fetchJSON(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 { + const query = new Query(); + params.forEach((param) => query.addParam(param)); + return this.get(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 { + // 1. Resolve the ID as a path segment against the this.baseURL object. + const url = new URL(id, this.baseURL); + + // 2. Pass the final URL string to fetchJSON + return this.fetchJSON(url, { + 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(data: T, mapper?: (data: T) => U): Promise { + const payload = mapper ? mapper(data) : data; + return this.fetchJSON(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 { + // 1. Resolve the ID as a path segment against the this.baseURL object. + const url = new URL(id, this.baseURL); + + // 2. Pass the final URL string to fetchJSON + return this.fetchJSON(url, { + 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( + url: URL, + options: RequestInit + ): Promise { + const request = new Request(url, options); + try { + const response = await fetch(request); + + // 1. Handle HTTP status errors (e.g., 404, 500) + if (!response.ok) { + throw new Errors.ApiResponse( + request, + response, + this.getErrorMessage('ApiResponseError', request, response) + ); + } + + // Handle responses with no content or non-JSON content + const contentType = response.headers.get('content-type'); + if (!contentType || !contentType.includes('application/json')) { + throw new Errors.NoContent( + request, + response, + this.getErrorMessage('NoContentError', request, response) + ); + } + + // 2. Handle JSON parsing errors + try { + const data = await response.json(); + return data as U; + } catch (e) { + const message = this.getErrorMessage('JsonParseError', request); + throw new Errors.JsonParse(request, 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.getErrorMessage('ApiNetworkError', request); + throw new Errors.ApiNetwork(request, message); + } + } +} diff --git a/jslib/core/http/Query.ts b/jslib/core/http/Query.ts new file mode 100644 index 00000000000..bed466a86f0 --- /dev/null +++ b/jslib/core/http/Query.ts @@ -0,0 +1,96 @@ +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 = {}; + + /** + * 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); + this.params[`${encodedField}${operator}`] = 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(); + } +} diff --git a/jslib/core/http/index.ts b/jslib/core/http/index.ts new file mode 100644 index 00000000000..35d5d1fda07 --- /dev/null +++ b/jslib/core/http/index.ts @@ -0,0 +1,2 @@ +export {Client} from './Client'; +export {Query, QueryParam} from './Query'; diff --git a/jslib/core/index.ts b/jslib/core/index.ts new file mode 100644 index 00000000000..c3c83f23d0e --- /dev/null +++ b/jslib/core/index.ts @@ -0,0 +1,2 @@ +export * as Errors from './errors'; +export * as Http from './http'; diff --git a/jslib/entities/acknowledgement/clients/AcknowledgementClient.ts b/jslib/entities/acknowledgement/clients/AcknowledgementClient.ts new file mode 100644 index 00000000000..e20da0ea0b7 --- /dev/null +++ b/jslib/entities/acknowledgement/clients/AcknowledgementClient.ts @@ -0,0 +1,14 @@ +import {Acknowledgement} from '../../'; +import {Http} from 'jslib/core'; + +/** + * + */ +export class AcknowledgementClient extends Http.Client { + /** + * + */ + constructor() { + super('/acknowledgements'); + } +} diff --git a/jslib/entities/acknowledgement/index.ts b/jslib/entities/acknowledgement/index.ts new file mode 100644 index 00000000000..90f2ff59da5 --- /dev/null +++ b/jslib/entities/acknowledgement/index.ts @@ -0,0 +1,5 @@ +// Types +export {Acknowledgement as Type} from './types/Acknowledgement'; + +// Clients +export {AcknowledgementClient as Client} from './clients/AcknowledgementClient'; diff --git a/jslib/entities/acknowledgement/types/Acknowledgement.ts b/jslib/entities/acknowledgement/types/Acknowledgement.ts new file mode 100644 index 00000000000..632f8fd61a0 --- /dev/null +++ b/jslib/entities/acknowledgement/types/Acknowledgement.ts @@ -0,0 +1,10 @@ +export interface Acknowledgement { + ordering: number, + fullName: string, + citationName: string, + affiliations: string, + degrees: string, + roles: string, + startDate: string, // to be converted to Date object when possible + endDate: string, // to be converted to Date object when possible +} diff --git a/jslib/entities/index.ts b/jslib/entities/index.ts new file mode 100644 index 00000000000..d6e73be5eba --- /dev/null +++ b/jslib/entities/index.ts @@ -0,0 +1 @@ +export * as Acknowledgement from './acknowledgement'; diff --git a/jslib/index.ts b/jslib/index.ts new file mode 100644 index 00000000000..8476a12672d --- /dev/null +++ b/jslib/index.ts @@ -0,0 +1,2 @@ +export * as Core from './core'; +export * as Entities from './entities'; diff --git a/modules/acknowledgements/jsx/acknowledgementsIndex.js b/modules/acknowledgements/jsx/acknowledgementsIndex.js index 3ef2883b9ec..3ddfd68c01d 100644 --- a/modules/acknowledgements/jsx/acknowledgementsIndex.js +++ b/modules/acknowledgements/jsx/acknowledgementsIndex.js @@ -17,6 +17,8 @@ import { DateElement, ButtonElement, } from 'jsx/Form'; +import {Acknowledgement} from 'jslib/entities'; +import {Query} from 'jslib/core/http'; /** * Acknowledgements Module page. @@ -103,14 +105,16 @@ class AcknowledgementsIndex extends Component { * * @return {object} */ - fetchData() { - return fetch(this.props.dataURL, {credentials: 'same-origin'}) - .then((resp) => resp.json()) - .then((data) => this.setState({data})) - .catch((error) => { - this.setState({error: true}); - console.error(error); - }); + async fetchData() { + const query = new Query().addParam({field: 'format', value: 'json'}); + const client = new Acknowledgement.Client(); + try { + const acknowledgements = await client.get(query); + this.setState({data: {...acknowledgements}}); + } catch (error) { + this.setState({error: true}); + console.error(error); + } } /** @@ -477,7 +481,6 @@ class AcknowledgementsIndex extends Component { } AcknowledgementsIndex.propTypes = { - dataURL: PropTypes.string.isRequired, submitURL: PropTypes.string.isRequired, hasPermission: PropTypes.func.isRequired, }; @@ -491,7 +494,6 @@ window.addEventListener('load', () => { document.getElementById('lorisworkspace') ).render(