-
Notifications
You must be signed in to change notification settings - Fork 184
[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
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,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'; | ||
} | ||
} |
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; | ||
} | ||
} |
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); | ||
} | ||
} |
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'; | ||
} | ||
} |
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'; | ||
} | ||
} |
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); | ||
} | ||
} |
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,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: ( | ||
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. 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 |
||
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); | ||
} | ||
} | ||
} |
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 ''; | ||
} | ||
} | ||
} |
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"; |
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'); | ||
} | ||
} |
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"; |
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 | ||
}; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
export * as Acknowledgement from './acknowledgement'; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,2 @@ | ||
export * as Core from './core'; | ||
export * as Entities from './entities'; |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -17,6 +17,8 @@ import { | |
DateElement, | ||
ButtonElement, | ||
} from 'jsx/Form'; | ||
import { Acknowledgement } from 'jslib/entities' | ||
import { Query } from 'jslib/core' | ||
|
||
/** | ||
* 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: '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); | ||
} | ||
} | ||
|
||
/** | ||
|
@@ -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( | ||
<Index | ||
dataURL={`${loris.BaseURL}/acknowledgements/?format=json`} | ||
submitURL={`${loris.BaseURL}/acknowledgements/AcknowledgementsProcess`} | ||
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. I can also replace this submission call in this PR to show the 'create' functionality. |
||
hasPermission={loris.userHasPermission} | ||
/> | ||
|
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...