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
11 changes: 11 additions & 0 deletions jslib/core/errors/ApiNetworkError.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { HttpError } from './HttpError';

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

constructor(response: Response, request: Request, message?: string) {
// The message can now be constructed dynamically
super(message || `Request to ${request.url} failed with status code ${response.status}.`);
this.name = 'ApiResponseError';
this.response = response;
}
}
12 changes: 12 additions & 0 deletions jslib/core/errors/BaseError.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
/**
* Base class for all custom API-related errors.
*/
export class BaseError extends Error {
public name: string;

constructor(message?: string) {
super(message);
this.name = 'BaseError';
Object.setPrototypeOf(this, new.target.prototype);
}
}
8 changes: 8 additions & 0 deletions jslib/core/errors/HttpError.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { BaseError } from './BaseError';

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

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

export class ValidationError extends BaseError {
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";
105 changes: 105 additions & 0 deletions jslib/core/http/Client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
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,
}

export class Client<T> {
protected baseUrl: string;
protected subEndpoint?: string;
public getCustomMessage: (
Copy link
Collaborator Author

@HenriRabalais HenriRabalais Sep 9, 2025

Choose a reason for hiding this comment

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

this is to allow each Client concrete class to define it's own errors across all the error types, not sure if it's the best implementation, but it seems to work — maybe it just needs a better name. return type be something like Record<string, string>

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

constructor(baseUrl: string) {
this.baseUrl = loris.BaseURL+'/'+baseUrl;
}

setSubEndpoint(subEndpoint: string): this {
this.subEndpoint = subEndpoint;
return this;
}


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"}
});
}

async getLabels(...params: QueryParam[]): Promise<string[]> {
const query = new Query();
params.forEach(param => query.addParam(param));
return this.get<string>(query.addField('label'));
}

async getById(id: string): Promise<T> {
return this.fetchJSON<T>(`${this.baseUrl}/${id}`, {
method: "GET",
headers: {"Accept": "application/json"}
});
}

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),
});
}

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),
});
}

protected async fetchJSON<U>(url: string, options: RequestInit): Promise<U> {
const request = new Request(url, options);
try {
const response = await fetch(url, options);

// 1. Handle HTTP status errors (e.g., 404, 500)
if (!response.ok) {
const customMessage = this.getCustomMessage(response.status, request, response);
throw new Errors.ApiResponse(response, request, customMessage);
}

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

// 2. Handle JSON parsing errors
try {
const data = await response.json();
return data as U;
} catch (e) {
const customMessage = this.getCustomMessage('JsonParseError', request);
throw new Errors.JsonParse(customMessage);
}

} catch (error) {
// 3. Handle network errors (e.g., no internet)
if (error instanceof Errors.Http) {
throw error; // Re-throw our custom errors
}
const customMessage = this.getCustomMessage('ApiNetworkError', request);
throw new Errors.ApiNetwork(customMessage);
}
}
}
70 changes: 70 additions & 0 deletions jslib/core/http/Query.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
export enum Operator {
Equals = '=',
NotEquals = '!=',
LessThan = '<',
GreaterThan = '>',
LessThanOrEqual = '<=',
GreaterThanOrEqual = '>=',
Like = 'like',
Includes = 'in'
}

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

export class Query {
private params: Record<string, string> = {};

addParam({
field,
value,
operator = Operator.Equals
}: QueryParam): this {
const encodedField = encodeURIComponent(field);
const encodedValue = encodeURIComponent(value);
this.params[`${encodedField}${this.getOperatorSuffix(operator)}`] = encodedValue;
return this;
}

addField(field: string): this {
const encodedField = encodeURIComponent(field);
this.params['fields'] = this.params['fields'] ? `${this.params['fields']},${encodedField}` : encodedField;
return this;
}

addLimit(limit: number): this {
this.params['limit'] = limit.toString();
return this;
}

addOffset(offset: number): this {
this.params['offset'] = offset.toString();
return this;
}

addSort(field: string, direction: 'asc' | 'desc'): this {
const encodedField = encodeURIComponent(field);
this.params['sort'] = `${encodedField}:${direction}`;
return this;
}

build(): string {
return new URLSearchParams(this.params).toString();
}

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';
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";
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { Acknowledgement } from '../../';
import { Http } from 'jsx/../jslib/core';

export class AcknowledgementClient extends Http.Client<Acknowledgement.Type> {
constructor() {
super('/acknowledgements');
}
}
5 changes: 5 additions & 0 deletions jslib/entities/acknowledgement/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
// Types
export { Acknowledgement as Type } from "./types/Acknowledgement";

// Clients
export { AcknowledgementClient as Client } from "./clients/AcknowledgementClient";
10 changes: 10 additions & 0 deletions jslib/entities/acknowledgement/types/Acknowledgement.ts
Original file line number Diff line number Diff line change
@@ -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
};
1 change: 1 addition & 0 deletions jslib/entities/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * as Acknowledgement from './acknowledgement';
2 changes: 2 additions & 0 deletions jslib/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * as Core from './core';
export * as Entities from './entities';
22 changes: 12 additions & 10 deletions modules/acknowledgements/jsx/acknowledgementsIndex.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ import {
DateElement,
ButtonElement,
} from 'jsx/Form';
import { Acknowledgement } from 'jslib/entities'
import { Query } from 'jslib/core'

/**
* Acknowledgements Module page.
Expand Down Expand Up @@ -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: 'form', value: 'json'});
const client = new Acknowledgement.Client();
try {
const acknowledgements = await client.get(query);
this.setState({acknowledgements});
} catch (error) {
this.setState({error: true});
console.error(error);
}
}

/**
Expand Down Expand Up @@ -477,7 +481,6 @@ class AcknowledgementsIndex extends Component {
}

AcknowledgementsIndex.propTypes = {
dataURL: PropTypes.string.isRequired,
submitURL: PropTypes.string.isRequired,
hasPermission: PropTypes.func.isRequired,
};
Expand All @@ -491,7 +494,6 @@ window.addEventListener('load', () => {
document.getElementById('lorisworkspace')
).render(
<Index
dataURL={`${loris.BaseURL}/acknowledgements/?format=json`}
submitURL={`${loris.BaseURL}/acknowledgements/AcknowledgementsProcess`}
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I can also replace this submission call in this PR to show the 'create' functionality.

hasPermission={loris.userHasPermission}
/>
Expand Down
Loading
Loading