Skip to content

Commit

Permalink
Merge pull request #211 from klee-contrib/problem-details
Browse files Browse the repository at this point in the history
[Core] Gérer les erreurs d'API selon la norme RFC 7807
  • Loading branch information
JabX authored Jan 14, 2025
2 parents b6a8ef3 + c9df4d1 commit 12a68c6
Show file tree
Hide file tree
Showing 6 changed files with 167 additions and 35 deletions.
4 changes: 2 additions & 2 deletions packages/core/src/focus4.core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,10 @@ declare module "i18next" {

export {coreFetch, downloadFile, getFileObjectUrl, requestStore} from "./network";
export {makeRouter, param, startHistory} from "./router";
export {MessageStore, UserStore, messageStore} from "./stores";
export {MessageStore, messageStore, UserStore} from "./stores";
export {config, themeable} from "./utils";

export type {HttpMethod, Request} from "./network";
export type {HandledProblemDetails, HttpMethod, ProblemDetails, Request} from "./network";
export type {
Router,
RouterConfirmation,
Expand Down
146 changes: 124 additions & 22 deletions packages/core/src/network/error-parsing.ts
Original file line number Diff line number Diff line change
@@ -1,34 +1,136 @@
import {messageStore} from "../stores/message";

/** Format attendu des erreurs JSON issues du serveur. */
export interface ErrorResponse {
[key: string]: any;
/**
* Retour d'un appel serveur en erreur ([RFC9457]).
*
* Si le serveur n'est pas configuré pour renvoyer un `ProblemDetails` en cas d'erreur, la réponse sera wrappée dans `ProblemDetails` lorsqu'elle sera rejetée.
*/
export interface ProblemDetails {
/**
* A URI reference [RFC3986] that identifies the problem type.
*
* This specification encourages that, when dereferenced, it provide human-readable documentation for the problem type (e.g., using HTML [W3C.REC-html5-20141028]).
*
* When this member is not present, its value is assumed to be "type" (string) - "about:blank".
*/
type?: string | "about:blank";

/**
* The HTTP status code ([RFC7231], Section 6) generated by the origin server for this occurrence of the problem.
*/
status: number;
}

/** Erreur JSON issue du serveur, à laquelle on a ajouté des infos issues du parsing. */
export interface ManagedErrorResponse {
/**
* A short, human-readable summary of the problem type.
*
* It SHOULD NOT change from occurrence to occurrence of the problem, except for purposes of localization (e.g., using proactive content negotiation; see [RFC7231], Section 3.4).
*
* Si aucun message n'a été enregistré dans le `messageStore` à partir de cette instance, alors ce `title` sera enregistré comme `error` dans le `messageStore`.
*/
title?: string;

/**
* A human-readable explanation specific to this occurrence of the problem.
*
* Si renseigné, sera enregistré en premier comme `error` dans le `messageStore`.
*/
detail?: string;

/**
* A URI reference that identifies the specific occurrence of the problem.
*
* It may or may not yield further information if dereferenced.
*/
instance?: string;

/**
* Détails supplémentaires sur le problème (erreurs de validation par exemple).
*
* Chaque message d'erreur supplémentaire sera enregistré comme `error` dans le `messageStore`, préfixé par sa clé (sauf si c'est `global` ou `globals`).
*/
errors?: string | string[] | Record<string, string> | Record<string, string[]>;

/**
* Problem type definitions MAY extend the problem details object with additional members.
*
* Si la clé correspond à un type de message enregistré dans le `messageStore` et que la valeur est un `string`, `string[]`, `Record<string, string>` ou `Record<string, string[]>`,
* alors les messages seront enregistrés comme du type de leur clé dans le `messageStore` (selon les mêmes règles que `errors`).
*/
[key: string]: any;
/** Erreurs détectées dans l'erreur serveur. */
$parsedErrors: {
/** Erreurs globales. */
globals: string[];
};
/** Statut HTTP de la réponse. */
$status: number;
}

/**
* Parse une réponse du serveur pour enregistrer les erreurs.
* @param $status Statut HTTP de la réponse.
* @param response Corps de la réponse.
* ProblemDetails traité par Focus, avec les messages qui ont été ajoutés dans le MessageStore.
*/
export function manageResponseErrors($status: number, response: ErrorResponse): ManagedErrorResponse {
export interface HandledProblemDetails extends ProblemDetails {
/** Messages enregistrés dans le MessageStore, dans l'ordre. */
$messages: {type: string; message: string}[];
}

export function createProblemDetails(status: number, jsonResponse: object): ProblemDetails {
return {
...response,
$status,
$parsedErrors: {
globals: messageStore.addMessages(response)
}
...jsonResponse,
type: "about:blank",
status
};
}

export function handleProblemDetails(problemDetails: ProblemDetails): HandledProblemDetails {
const messages: Record<string, string[]> = {};

function add(type: string, ...newMessages: string[]) {
messages[type] ??= [];
messages[type].push(...newMessages);
}

if (problemDetails.detail) {
add("errors", problemDetails.detail);
}

for (const key in problemDetails) {
if (["type", "status", "title", "detail", "instance"].includes(key)) {
continue;
}

if (typeof problemDetails[key] === "string") {
add(key, problemDetails[key]);
} else if (
Array.isArray(problemDetails[key]) &&
problemDetails[key].length > 0 &&
typeof problemDetails[key][0] === "string"
) {
add(key, ...problemDetails[key]);
} else if (typeof problemDetails[key] === "object") {
for (const subkey in problemDetails[key]) {
if (typeof problemDetails[key][subkey] === "string") {
add(
key,
subkey === "global" || subkey === "globals"
? problemDetails[key][subkey]
: `${subkey}: ${problemDetails[key][subkey]}`
);
} else if (
Array.isArray(problemDetails[key][subkey]) &&
problemDetails[key][subkey].length > 0 &&
typeof problemDetails[key][subkey][0] === "string"
) {
add(
key,
...problemDetails[key][subkey].map(m =>
subkey === "global" || subkey === "globals" ? m : `${subkey}: ${m}`
)
);
}
}
}
}

const $messages = messageStore.addMessages(messages);

if ($messages.length === 0 && problemDetails.title) {
messageStore.addErrorMessage(problemDetails.title);
$messages.push({type: "error", message: problemDetails.title});
}

return {...problemDetails, $messages};
}
13 changes: 8 additions & 5 deletions packages/core/src/network/fetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import {isObject, merge, toPairs} from "lodash";

import {config} from "../utils";

import {ManagedErrorResponse, manageResponseErrors} from "./error-parsing";
import {createProblemDetails, handleProblemDetails} from "./error-parsing";
import {HttpMethod, requestStore} from "./store";

/**
Expand Down Expand Up @@ -72,10 +72,13 @@ export async function coreFetch(

// On détermine le type de retour en fonction du Content-Type dans le header.
const contentType = response.headers.get("Content-Type");
if (contentType?.includes("application/json")) {
// Pour une erreur JSON, on la parse pour trouver et enregistrer les erreurs "attendues".
return await Promise.reject<ManagedErrorResponse>(
manageResponseErrors(response.status, await response.json())
if (contentType?.includes("application/problem+json")) {
// Pour un ProblemDetails, on le parse pour récupérer les erreurs à ajouter dans le MessageStore, puis on le renvoie.
return await Promise.reject(handleProblemDetails(await response.json()));
} else if (contentType?.includes("application/json")) {
// Pour une erreur JSON classique, on la transforme en ProblemDetails, puis on la traite comme précédemment.
return await Promise.reject(
handleProblemDetails(createProblemDetails(response.status, await response.json()))
);
} else {
// Sinon, on renvoie le body de la réponse sous format texte (faute de mieux).
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/network/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
export {manageResponseErrors} from "./error-parsing";
export {coreFetch, downloadFile, getFileObjectUrl} from "./fetch";
export {RequestStore, requestStore} from "./store";

export type {HandledProblemDetails, ProblemDetails} from "./error-parsing";
export type {HttpMethod, Request} from "./store";
4 changes: 2 additions & 2 deletions packages/core/src/stores/message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ export class MessageStore {
*/
@action.bound
addMessages(messages: Record<string, string[] | string>) {
const allMessages: string[] = [];
const allMessages: {type: string; message: string}[] = [];

Object.keys(messages).forEach(type => {
const possibleTypes = [
Expand All @@ -109,7 +109,7 @@ export class MessageStore {
if (this.messageTypes.includes(possibleType)) {
(Array.isArray(messages[type]) ? messages[type] : [messages[type]]).forEach(message => {
this.addMessage(possibleType, message);
allMessages.push(message);
allMessages.push({type: possibleType, message});
});
}
});
Expand Down
33 changes: 30 additions & 3 deletions packages/docs/basics/02-fetch.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,36 @@ Son API est la suivante :
- **`options`** est le paramètre d'options de `window.fetch`. Cet objet d'options est prérempli par `coreFetch` pour y inclure ce qu'on a déjà défini (la
méthode et le body en particulier), mais il est surchargeable via ce paramètre.

Si `coreFetch` reçoit une erreur et que le corps de la réponse est un JSON, alors cette réponse sera envoyée au [`messageStore`](/docs/les-bases-gestion-des-messages--docs) en appelant sa méthode
`addMessages`. Pour assurer une intégration native avec la gestion de messages Focus, les APIs appelées devront renvoyer des réponses de la forme
`{error: "Message d'erreur"}` ou `{errors: ["Message 1", "Message 2"]}`.
### Gestion des erreurs

Si `coreFetch` reçoit une **réponse avec un code de statut en erreur** (4xx-5xx), et si le serveur renvoie une réponse structurée en JSON, alors elle sera
interprétée afin de pouvoir **enregistrer automatiquement des messages d'erreur** (à priori) dans le [`messageStore`](/docs/les-bases-gestion-des-messages--docs).

Focus supporte nativement les erreurs qui utilisent la spécification [ProblemDetails](https://www.rfc-editor.org/rfc/rfc7807), mais acceptera aussi des réponses dans un format JSON arbitaire.

Les messages suivants seront générés :

- Si `detail` est renseigné, alors un message de catégorie `error` avec sa valeur sera ajouté.
- La méthode `addMessages` du `messageStore` est appelée avec tous les champs non standard présent dans la réponse (`type`, `status`, `title`,
`detail` et `instance`). Ces champs peuvent être typés :
- `string` : enregistre un message de la catégorie du nom du champ.
- `string[]` : enregistre plusieurs messages de la catégorie du nom du champ.
- `Record<string, string>` : enregistre un message par valeur du record de la catégorie du nom du champ. Si la clé ne vaut pas `global` ou
`globals`, il sera préfixé par sa clé (ex : `"{champ}: {message}"`).
- `Record<string, string[]>` : combinaison des deux cas précédents.
- Si aucun message n'a été enregistré et que `title` est renseigné, alors un message de catégorie `error` avec sa valeur sera ajouté.

Pour rappel, les catégories de messages supportées par le `messageStore` doivent avoir été définies au préalable, si vous voulez gérer autre chose que
`error`, `info` ou `warning`. Ce mécanisme permet en revanche de pouvoir gérer des types de messages personnalisés à partir de erreurs serveur
personnalisées. Cela peut par la suite être utilisé pour différencier la façon dont les erreurs sont restituées à l'utilisateur (au delà du `MessageCenter` par
défaut) par exemple.

Après traitement des erreurs, `coreFetch` renverra une Promise rejetée avec la réponse du serveur, complétée :

- De `type` et `status` si ce n'était pas un `ProblemDetails`.
- D'une propriété `$messages` qui contient la liste des messages générés (dans l'ordre) par la lecture de la réponse.

Vous pouvez utiliser le type exporté `HandledProblemDetails` si besoin pour la représenter.

## `RequestStore`

Expand Down

0 comments on commit 12a68c6

Please sign in to comment.