Skip to content

Commit 9cffbe0

Browse files
authored
Merge pull request #288 from fractal-analytics-platform/refactor-endpoints
Refactoring of error propagation and more
2 parents 89d68f4 + 4c97dcc commit 9cffbe0

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

52 files changed

+2014
-2411
lines changed

.eslintignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ node_modules
66
.env
77
.env.*
88
!.env.example
9+
*.d.ts
910

1011
# Ignore files for PNPM, NPM and YARN
1112
pnpm-lock.yaml

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
# Unreleased
44

5+
* Added proxy endpoints and refactored error propagation (\#288).
56
* Remove use of deployment-type `fractal-server` variable (\#298).
67
* Add a BSD3 license (\#300).
78

docs/structure.md

Lines changed: 46 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -62,34 +62,36 @@ But how is this interaction implemented in this client?
6262

6363
### Client server interoperability
6464

65-
As said, the svelte client communicate with the fractal server through a set of REST APIs.
65+
As said, the svelte client communicates with the fractal server through a set of REST APIs.
6666

6767
In this client, every request to the fractal server is sent by the *nodejs server that is serving the svelte
6868
application*.
69-
In fact, within the project structure, in `src/lib/server/api` are defined all the REST endpoint requests that
70-
the svelte client could make to the fractal server from within the nodejs server context (client backend).
7169

7270
> It is important to understand that these requests are made in the server context of the svelte client.
7371
> No request to the fractal server is sent directly by the browser of the user.
7472
7573
The fact that every request on behalf of a user is sent through a common backend nodejs server, implies a proxy
7674
architecture.
77-
The way this works is the following, the svelte client in the browser, sends a HTTP request to the nodejs server that
78-
is serving the application. This request, with the attached cookies, is then used to compose a new request to be sent to
79-
the fractal server.
75+
The way this works is the following: the svelte client in the browser sends a HTTP request to the nodejs server that
76+
is serving the application. This request, with the attached cookies, is then used to compose a new request to be sent to the fractal server.
8077

8178
> Note that the authentication context is kept thanks to cookies that establish user sessions.
8279
8380
The following image provides an overview for the reader of the described architecture.
8481

8582
![proxy-architecture.png](media/proxy-architecture.png)
8683

87-
Every fractal server REST endpoint that the client application support is listed within a file in `src/server/api/v1`.
88-
Here requests are grouped by contexts as `auth_api`, `monitoring_api`, [...].
84+
To avoid duplicating the logic of each fractal-server endpoint and simplify the error handling, a special Svelte route has been setup to act like a transparent proxy: `src/routes/api/[...path]/+server.js`. This is one of the suggested way to handle a different backend [according to Svelte Kit FAQ](https://kit.svelte.dev/docs/faq#how-do-i-use-x-with-sveltekit-how-do-i-use-a-different-backend-api-server).
8985

90-
### An example
86+
So, by default, the AJAX calls performed by the front-end have exactly the same path and payload of the fractal-server API, but are sent to the Node.js Svelte back-end.
9187

92-
For instance, considering the code at `src/lib/server/api/v1/auth_api.js:5`:
88+
Other than the AJAX calls, there are also some calls to fractal-server API done by Svelte SSR, while generating the HTML page. These requests are defined in files under `src/lib/server/api/v1`. Here requests are grouped by contexts as `auth_api`, `monitoring_api`, [...].
89+
90+
### An example using actions
91+
92+
The login is still using the Svelte action approach, in which we have to extract the data from a formData object and then use it to build a JSON payload to be forwarded to fractal-server.
93+
94+
Consider the code at `src/lib/server/api/v1/auth_api.js:5`:
9395

9496
```javascript
9597
/**
@@ -241,3 +243,37 @@ _Stores_ are modules that export svelte store objects that are used by component
241243
application.
242244
> Note that stores are currently not well-organized or used due to the youth of the client.
243245
246+
## Error handling
247+
248+
The errors received from fractal-server are displayed in error modals without changing the content of their messages. The `displayStandardErrorAlert()` function can be used to easily display an error message alert in a div having a specific id. This function returns a `StandardErrorAlert` object that can be stored in a variable and then used to hide previous error messages calling its `hide()` method.
249+
250+
Here an example:
251+
252+
```javascript
253+
async function myFunction() {
254+
// remove previous error
255+
if (errorAlert) {
256+
errorAlert.hide();
257+
}
258+
259+
const response = await fetch(`/api/v1/something`);
260+
if (response.ok) {
261+
// do something with the result
262+
} else {
263+
const error = await response.json();
264+
// add error alert inside the element having 'errorElement' as id
265+
errorAlert = displayStandardErrorAlert(error, 'errorElement');
266+
}
267+
}
268+
```
269+
270+
When the displaying of the error alert should be handled by the caller function it is possible to throw an `AlertError` that has to be caught by the caller in order to display the message.
271+
272+
Errors happening during SSR should be considered fatal and propagated using the `responseError()` utility function:
273+
274+
```javascript
275+
if (response.ok) {
276+
return await response.json();
277+
}
278+
await responseError(response);
279+
```

lib/fractal-server/.gitignore

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ myenv
55
artifacts
66
LOG*
77
logs
8-
*.db
8+
*.db*
99
FRACTAL_TASKS_DIR
1010
.fractal_server.env
1111
tmp
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
{
2+
"name": "Example Workflow",
3+
"task_list": [
4+
{
5+
"meta": {
6+
"cpus_per_task": 1,
7+
"mem": 4000
8+
},
9+
"args": {
10+
"num_levels": 5,
11+
"coarsening_xy": 2,
12+
"image_extension": "png",
13+
"allowed_channels": [
14+
{
15+
"color": "00FFFF",
16+
"wavelength_id": "A01_C01",
17+
"label": "DAPI",
18+
"window": {
19+
"start": 110,
20+
"end": 800
21+
}
22+
}
23+
]
24+
},
25+
"task": {
26+
"source": "pip_remote:fractal_tasks_core:0.11.0:fractal-tasks::create_ome-zarr_structure"
27+
}
28+
},
29+
{
30+
"meta": {
31+
"cpus_per_task": 1,
32+
"mem": 4000,
33+
"parallelization_level": "image"
34+
},
35+
"args": null,
36+
"task": {
37+
"source": "pip_remote:fractal_tasks_core:0.11.0:fractal-tasks::convert_yokogawa_to_ome-zarr"
38+
}
39+
},
40+
{
41+
"meta": {
42+
"cpus_per_task": 1,
43+
"mem": 1000
44+
},
45+
"args": {
46+
"project_to_2D": true,
47+
"suffix": "mip",
48+
"ROI_table_names": ["FOV_ROI_table", "well_ROI_table"]
49+
},
50+
"task": {
51+
"source": "pip_remote:fractal_tasks_core:0.11.0:fractal-tasks::copy_ome-zarr_structure"
52+
}
53+
},
54+
{
55+
"meta": {
56+
"cpus_per_task": 1,
57+
"mem": 4000,
58+
"parallelization_level": "image"
59+
},
60+
"args": null,
61+
"task": {
62+
"source": "pip_remote:fractal_tasks_core:0.11.0:fractal-tasks::maximum_intensity_projection"
63+
}
64+
},
65+
{
66+
"meta": {
67+
"cpus_per_task": 4,
68+
"mem": 16000,
69+
"needs_gpu": true,
70+
"parallelization_level": "image"
71+
},
72+
"args": {
73+
"input_ROI_table": "well_ROI_table",
74+
"use_masks": true,
75+
"relabeling": true,
76+
"diameter_level0": 60,
77+
"model_type": "nuclei",
78+
"cellprob_threshold": 0,
79+
"flow_threshold": 0.4,
80+
"min_size": 15,
81+
"augment": false,
82+
"net_avg": false,
83+
"use_gpu": true,
84+
"level": 2,
85+
"channel": {
86+
"wavelength_id": "A01_C01"
87+
},
88+
"output_label_name": "nuclei"
89+
},
90+
"task": {
91+
"source": "pip_remote:fractal_tasks_core:0.11.0:fractal-tasks::cellpose_segmentation"
92+
}
93+
}
94+
]
95+
}

src/lib/common/component_utilities.js

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -93,10 +93,15 @@ export function getOnlyModifiedProperties(oldProperties, newProperties) {
9393
return modifiedProperties;
9494
}
9595

96-
export function unsetEmptyStrings(inputValues) {
96+
/**
97+
* Transform an object setting to null all the keys having empty string as value
98+
* @param {object} inputValues
99+
* @returns {object}
100+
*/
101+
export function nullifyEmptyStrings(inputValues) {
97102
const clearedValues = {};
98103
for (let key in inputValues) {
99-
if (typeof(inputValues[key]) === 'string' && inputValues[key].trim() === '') {
104+
if (typeof inputValues[key] === 'string' && inputValues[key].trim() === '') {
100105
clearedValues[key] = null;
101106
} else {
102107
clearedValues[key] = inputValues[key];
@@ -105,6 +110,21 @@ export function unsetEmptyStrings(inputValues) {
105110
return clearedValues;
106111
}
107112

113+
/**
114+
* Replacer function to ignore empty strings when using JSON.stringify().
115+
* @see {@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON/stringify#description}
116+
* @param {string} _key
117+
* @param {any} value
118+
* @returns {any}
119+
*/
120+
export function replaceEmptyStrings(_key, value) {
121+
if (typeof value === 'string' && value.trim() === '') {
122+
return undefined;
123+
} else {
124+
return value;
125+
}
126+
}
127+
108128
export function formatMarkdown(markdownValue) {
109129
if (!markdownValue) {
110130
return '';

src/lib/common/errors.js

Lines changed: 42 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,44 @@
1-
import { error } from "@sveltejs/kit";
1+
import { error } from '@sveltejs/kit';
2+
import StandardErrorAlert from '$lib/components/common/StandardErrorAlert.svelte';
23

3-
export function PostResourceException(response) {
4-
this.reason = response;
5-
}
4+
/**
5+
* Propagates an error response.
6+
* @param {Response} response
7+
*/
68
export async function responseError(response) {
7-
throw error(response.status, await response.json())
8-
}
9+
throw error(response.status, await response.json());
10+
}
11+
12+
/**
13+
* Class that can be used by front-end to propagate an error from a component to another.
14+
* Used for example to handle the displaying of the error alert when using the ConfirmActionButton.
15+
*/
16+
export class AlertError extends Error {
17+
/**
18+
* @param {any} reason
19+
*/
20+
constructor(reason) {
21+
super();
22+
this.reason = reason;
23+
}
24+
}
25+
26+
/**
27+
* Display a standard error alert on the desired HTML element.
28+
* @param {any} error
29+
* @param {string} targetElementId
30+
* @returns {StandardErrorAlert|undefined}
31+
*/
32+
export function displayStandardErrorAlert(error, targetElementId) {
33+
const errorAlert = document.getElementById(targetElementId);
34+
if (errorAlert) {
35+
return new StandardErrorAlert({
36+
target: errorAlert,
37+
props: {
38+
error
39+
}
40+
});
41+
} else {
42+
console.warn(`Unable to display the error: element ${targetElementId} not found`);
43+
}
44+
}

src/lib/components/common/ConfirmActionButton.svelte

Lines changed: 37 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,41 @@
11
<script>
2-
import { onMount } from 'svelte';
3-
export let callbackAction = () => {}; // A default empty function
2+
import { displayStandardErrorAlert } from '$lib/common/errors';
3+
4+
export let callbackAction = async () => {}; // A default empty function
45
export let style = 'primary';
56
export let btnStyle = 'primary';
67
export let label = '';
78
export let buttonIcon = undefined;
89
export let modalId = undefined;
910
export let message = '';
1011
11-
onMount(() => {});
12+
const openModal = () => {
13+
// Remove old errors
14+
const errorAlert = document.getElementById(`errorAlert-${modalId}`);
15+
if (errorAlert) {
16+
errorAlert.innerHTML = '';
17+
}
18+
};
1219
13-
const handleCallbackAction = () => callbackAction();
20+
/**
21+
* Executes the callback handling possible errors
22+
*/
23+
const handleCallbackAction = async () => {
24+
try {
25+
// important: retrieve the modal before executing callbackAction(), because it could remove
26+
// the container element and then cause issues with the hide function
27+
// @ts-ignore
28+
// eslint-disable-next-line no-undef
29+
const modal = bootstrap.Modal.getInstance(document.getElementById(modalId));
30+
await callbackAction();
31+
modal.hide();
32+
} catch (/** @type {any} */ error) {
33+
displayStandardErrorAlert(error, `errorAlert-${modalId}`);
34+
}
35+
};
1436
</script>
1537

16-
<div class="modal" id={modalId}>
38+
<div class="modal modal-lg" id={modalId}>
1739
<div class="modal-dialog">
1840
<div class="modal-content">
1941
<div class="modal-header">
@@ -26,16 +48,22 @@
2648
<p>Do you confirm?</p>
2749
</div>
2850
<div class="modal-footer">
51+
<div class="container">
52+
<div id="errorAlert-{modalId}" />
53+
</div>
2954
<button class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
30-
<button class="btn btn-primary" on:click={handleCallbackAction} data-bs-dismiss="modal"
31-
>Confirm</button
32-
>
55+
<button class="btn btn-primary" on:click={handleCallbackAction}>Confirm</button>
3356
</div>
3457
</div>
3558
</div>
3659
</div>
3760

38-
<button class="btn btn-{btnStyle}" data-bs-toggle="modal" data-bs-target="#{modalId}">
61+
<button
62+
class="btn btn-{btnStyle}"
63+
data-bs-toggle="modal"
64+
data-bs-target="#{modalId}"
65+
on:click={openModal}
66+
>
3967
{#if buttonIcon}
4068
<i class="bi bi-{buttonIcon}" />
4169
{/if}

0 commit comments

Comments
 (0)