JeuWeb - Crée ton jeu par navigateur
Utilisation des code de status HTTP - Version imprimable

+- JeuWeb - Crée ton jeu par navigateur (https://jeuweb.org)
+-- Forum : Discussions, Aide, Ressources... (https://jeuweb.org/forumdisplay.php?fid=38)
+--- Forum : Programmation, infrastructure (https://jeuweb.org/forumdisplay.php?fid=51)
+--- Sujet : Utilisation des code de status HTTP (/showthread.php?tid=7988)



Utilisation des code de status HTTP - Forban - 13-06-2019

Bonjour,

Je poste aujourd'hui afin d'obtenir des réponses à une question que je me pose depuis peu : quand faut-il utiliser les codes de status HTTP ?

En effet, il est tout à fait possible, lorsqu'une page n'existe pas, d'afficher une page 'Erreur 404' sans pour autant retourner un code 404.
Et c'est le cas pour tous ces codes, c'est pourquoi je me pose la question :
-Est-il indispensable d'utiliser les codes de status HTTP ?
-Si oui, pourquoi et comment savoir quand le faire ? (il existe un bon nombre de codes..)
-Si non, est-ce une bonne pratique ? Pourquoi ?

Merci par avance.

Bonne fin de semaine à tous.


RE: Utilisation des code de status HTTP - Xenos - 13-06-2019

Salut,

pour ma part (quand bien même me traiterait-on de psycho-rigide), je pense qu'il faut toujours setter les code de retour HTTP, car ce sont eux qui, selon le standard web, définissent ce que "représente" la page (aka "est ce que ma commande a bien marché? si elle a foiré pourquoi?").

Mettre le bon statut permettra alors de gérer correctement ces erreurs dans le code client, et d'être compris par les autres clients (moteur de recherche, autres développeurs utilisant ton API, etc).

Par exemple, si tu retournes un code 200 au lieu du 404, et que tu écris dans la page "Page non trouvée, 404", alors pour google (ou tout autre moteur de recherche), ce sera une page valide: elle sera indexée, t'auras 0 retour de la part du moteur (ou d'autre système) pour te dire "cette page a fichu le camp!" et tu pourrais même être pénalisé pour "duplicated content" (= cette fausse page 404 revient souvent, donc ton site a beaucoup de pages en doublon, donc tu essaies peut-être de blouzer le moteur de recherche).

Pour les navigateurs, cela pourrait également être important: cela permet au navigateur de savoir s'il peut/devrait recharger la page (si on répond un "temporarily not available", peut-être que le navigateur va recharger la page au bout de 1 minute, ou qu'un plugin le fera).

Pour tes outils de stats, c'est aussi important: dans les stats Urchin d'OVH, tu as le nb de codes 5xx et 4xx, pour savoir si tu as des erreurs ou non. Idem dans les log Apache.

Donc, oui, c'est important de retourner les bons status HTTP, ça (te) sera toujours utile un jour. Et il en existe des brouettes, donc tu as surement déjà le code qui matche ton erreur (rien n'interdit d'en rajouter 1 ou 2 custom si besoin).


PS: en pièce jointe, je t'ai mis les enums dont je me sert pours les codes http, et les headers de requete/réponse (puis quelques enums de plus)


RE: Utilisation des code de status HTTP - Forban - 13-06-2019

Merci pour ton retour d'expérience.

Je me pose tout de même une question : faut-il retourner un code d'erreur 405 (method not allowed) si jamais un utilisateur souhaite directement accéder à une page qui devrait accueillir une requête POST ou n'ai-je pas bien saisi l'application de tels codes ? (par exemple si l'on traite un formulaire sur une page "traitement.php" qui n'aurait pour but que le traitement d'un formulaire, doit-on retourner 405 si l'utilisateur tape directement cette adresse dans son navigateur ?)

Merci pour le fichier enums, je vais voir ça, c'est super sympa. Smile


RE: Utilisation des code de status HTTP - Xenos - 13-06-2019

Si, tu as bien saisis Wink Si une requête GET (ou autre verbe: on peut mettre ce qu'on veut donc une requete NESGENOUX est possible aussi) est faite sur une URL qui ne gère pas ce verbe, alors l'URL devrait retourner un code 405. Après, l'URL peut aussi retourner un contenu à sa réponse (un "body") qui peut être une page HTML (ou autre format, suivant ce que le client a demandé via sont header "Accept") décrivant plus en avant l'erreur ("This only expects POST request").

Pour ECLERD par exemple (release prévue dans la semaine), si la page n'est pas trouvée, le jeu renvoie une 404, et le contenu de la page dépend du header Accept du client (si "Accept:text/html", alors ce sera une apge classique "Cette page n'existe pas"; si "Accept: application/json" alors je crois que le contenu est un objet vide "{}", etc). Même principe pour une erreur 500 (si le serveur a planté, normalement dans ce genre de cas, tu as une exception qui est "throw", et un "catch (Throwable $ex)" global qui log cette exception, et qui va ensuite retourner un code HTTP 500 avec une page disant "le serveur a planté"). Même principe avec une 401 (quand on essaie d'accéder à une page nécessitant d'être connecté alors qu'on ne l'est pas déjà) ou 403 (quand on veut accéder à une page et qu'on n'a pas le droit, même si on se connectait). Ca vaut aussi pour les 301, 302, 307, 308 (redirections une fois le formulaire traité, ou redirection parce que la page a bougée)


RE: Utilisation des code de status HTTP - Forban - 13-06-2019

D'accord, c'est déjà beaucoup plus clair pour moi, merci pour l'explication. Smile
Dans l'exemple de ton jeu, je suppose que :
Dès qu'un utilisateur souhaite accéder à une page nécessitant d'être connecté, tu renvoies un code d'erreur si jamais la session n'existe pas ?

C'est vraiment dommage que ce genre de choses ne soit pas enseigné durant les études. (en tout cas, ça ne l'est pas dans ma licence ^^)


RE: Utilisation des code de status HTTP - Xenos - 13-06-2019

(avis personnel en approche: put*** l'enseignement de l'informatique à l'école est une grosse grosse chiasse sans rigueur ni méthode, qui se contente de pousser les futurs ingénieurs [parce que oui, ce sont des ingénieurs avant tout] à juste pisser du code ou à aller récupéré les tonnes de code pissées par d'autres inutilement. Fin du vent)

C'est cela, si la page a besoin que l'utilisateur soit connecté, son code utilise:

Code PHP :
<?php 
$config
->getSessionManager()->mustBeLoggedIn()

Cette méthode retourne un int si la session existe et qu'elle est valide (j'ai un n° de version dans ma session qui doit matcher celui en dur dans le code du jeu: en changeant ce code en dur, je peux alors invalider les sessions de tous les joueurs si besoin, ce qui est pratique sur un mutu où on ne peut pas supprimer les sessions), et elle throw une exception spécifique "NotLoggedInException" sinon. Cette exception est alors catchée hors du code de la page (dans un code plus générique), et ce code retourne alors une model de données:

Code PHP :
<?php 
try {
return
$this->treat($config); // Ca, c'est l'appel au code spécifique de la page
} catch (NotLoggedInException $e) {
$loginBean = new NotLoggedInBean(); // Le modèle de données
$loginBean->row = new NotLoggedInRow();
$loginBean->row->loginUrl = PlayerIdentifyEndpoint::uri(true); // L'url où se logger, toujours pratique

return AEclerdBasicResponder::pdoBeanResponders(
array(
new
NotLoggedInFramedInfos($loginBean), // les templates utilisables pour formatter le modèle de données
new NotLoggedInFramedOverpage($loginBean),
new
NotLoggedInFramedPopin($loginBean),
new
NotLoggedInHtml($loginBean)),
HttpResponseStatusCode::CLIENTERROR_UNAUTHORIZED, // Le status code qui est l'objet de la question
$loginBean);
...

Ces formatters seront alors "testés" un par un jusqu'à en trouver un qui corresponde à ce que le client web a demandé (via son header "Accept"). Si c'est une page web classique, ce header contient du "text/html" (en gros), et donc le serveur testera chaque formatter, et c'est NotLoggedInHtml qui formattera la page web:

Code PHP :
<?php 
/** @private */
trait NotLoggedIn {
/** @var NotLoggedInBean */
private $bean;

public function
__construct(NotLoggedInBean $bean) {
$this->bean = $bean;
}

protected function
httpAdditionalHeaders(): array {
return array(
HttpResponseHeaders::LOCATION => $this->bean->row->loginUrl);
}

protected function
getStatus(): int {
return
HttpResponseStatusCode::CLIENTERROR_UNAUTHORIZED;
}

protected function
innerBody(): void { ?>
<section class="section">
<p class="user-error">
<a href="<?php EHtml::out($this->bean->row->loginUrl); ?>">
Veuillez d'abord vous connecter
</a>
</p>
</section>
<?php
}
}

class
NotLoggedInHtml extends AWebPageTemplate {
use
NotLoggedIn;

protected function
title(): string {
return
"Connection requise";
}
}

Ce formatter est juste une page quasi vide, utilisant le template générique du jeu (AWebPageTemplate). Ca ressemble à une page avec une carte du jeu en arrière-plan (image statique) et un lien au milieu "Veuillez vous connecter", qui renvoie vers la page de login (donnée par le contenu du modèle de données)

Si cela avait été issu d'un appel AJAX, alors l'appel AJAX aurait mis le header "Accept" du client à JSON:


/**
* Submission of forms in AJAX
*/
HTMLFormElement.prototype.jsSubmit = function () {
if (!this.hasAttribute('action')) {
throw "Form has no 'action' to submit to";
}
const formAction = this.getAttribute('action');

// I might add a data-enctype="application/json" if needed (because native enctype does not accept
const formData = this.newFormData();

// Collect all form inputs, mark then as js-deactivated, and deactivate them to avoid multiple submission & misclicks
const formFields = getFormFields(this)
.filter(n => !n.hasAttribute('disabled') && !n.classList.contains('no-js-disable'));
const unlockForm = () => {
formFields.forEach(i => i.removeAttribute('disabled'));
this.classList.remove('global-is-loading');
};
formFields.forEach(i => i.setAttribute('disabled', 'disabled'));
this.classList.add('global-is-loading');

// XHR request
const xhr = new XMLHttpRequest();
xhr.addEventListener('readystatechange', () => {
if (xhr.readyState === XMLHttpRequest.DONE && xhr.status === 0) {
// No network
this.formResponseHandle(-1, {}, unlockForm);
}
});

xhr.addEventListener('load', () => {
const contentType = xhr.getResponseHeader('Content-Type');
if (contentType === 'application/json') {
this.formResponseHandle(
xhr.status,
xhr.responseText
? JSON.parse(xhr.responseText)
: {},
unlockForm);
} else {
this.formResponseHandle(-2, {}, unlockForm);
}
});

xhr.open('POST', formAction);
xhr.setRequestHeader('Accept', 'application/json');
xhr.send(formData);
};

Donc, le serveur n'aurait pas matché sur les formatters HTML, mais sur l'un des formatters plus génériques issus de "AEclerdBasicResponder::pdoBeanResponders" (je saute un niveau d'indirection dans le code ci-dessous):

Code PHP :
<?php 
public static function makeWithPdoBeanResponders(
array
$responders,
int $status,
IPdoBean $pdoBean,
string $generator,
string $docTitle,
string $initialCreator,
string $creator,
string $thumbnailPath): IWebUrlResponder {
return new static(
array_merge(
$responders,
array(
new
PdoBeanJsonResponder($status, $pdoBean),
new
PdoBeanXmlResponder($status, $pdoBean),
new
PdoBeanYamlResponder($status, $pdoBean),
new
PdoBeanOdsResponder(
$status,
$pdoBean,
$generator,
$docTitle,
$initialCreator,
$creator,
$thumbnailPath),
new
PdoBeanPropertiesResponder($status, $pdoBean),
new
PdoBeanTextResponder($status, $pdoBean),
new
PdoBeanPhpResponder($status, $pdoBean))));
}

Après avoir essayé chacun des formatters "NotLoggedIn*Html", aucun n'ayant "fonctionné" (formatter qui ne veut pas formatter le modèle de données parce qu'il ne correspond pas au header Accept reçu), le serveur serait passé à PdoBeanJsonResponder, qui aurait matché et retourné le modèle de données en JSON, avec le HTTP Status code qui va bien:

Code PHP :
<?php 
class PdoBeanJsonResponder extends APdoBeanResponder {
protected function
acceptedMimePatterns(): array {
return array(
MimeType::ANY, MimeType::ANY_APPLICATION, MimeType::JSON);
}

protected function
getMimeType(): string {
return
MimeType::JSON;
}

protected function
content(): void {
$jsonData = json_encode($this->pdoBean, JSON_PRETTY_PRINT);
if (
$jsonData === false) {
throw new
DomainException('Cannot convert data to JSON', 0, new DomainException(json_last_error_msg()));
}

echo
$jsonData;
}
}

Le code de test "est-ce que ce formatter peut formatter ce que le client a demandé dans son Accept?" + "renvoyer le code http donné précédemment" se fait dans une classe parente (pareil: je saute un niveau d'indirection ici et j'abrège un peu la classe parent):

Code PHP :
<?php 
/**
* A responder that handles mimetype matching.
*/
abstract class ABasicResponder implements IWebUrlResponder {
protected const
COMPRESS_OUTPUT_PHPDEFAULT = 'ob_gzhandler';
protected const
COMPRESS_OUTPUT_NONE = null;

final public function
format(string $mimePattern): void {
if (
headers_sent($sentFile, $sentLine)) {
// Should not occur so let's use "LogicException"
throw new LogicException("Headers were already sent", 0x00,
new
LogicException("Headers sent at $sentFile:$sentLine"));
} else if (!
in_array($mimePattern, $this->acceptedMimePatterns(), true)) {
throw new
FormatNotAcceptableException();
} else {
try {
ob_start($this->obHandler());

$this->content();

$headers = array_merge(
// Basic default header
array(
HttpResponseHeaders::CACHE_CONTROL => "private",
// Coding range is hard and pretty useless, so let's say "I don't take ranges"
HttpResponseHeaders::ACCEPT_RANGES => "none",
HttpResponseHeaders::CONTENT_SECURITY_POLICY => HttpUtils::buildContentSecurityPolicy(
array(),
array(
HttpHeadersValue::CSP_SOURCE_SELF))),
// Overriding headers
$this->httpAdditionalHeaders(),
// These ones are not overriddable
array(HttpResponseHeaders::CONTENT_TYPE => $this->getMimeType()));

$this->returnHttpStatus();
foreach (
$headers as $name => $value) {
if (
$value !== null) {
// Let's ignore "NULL" headers
// so you can override a default header value with "null" to mean "drop it"
header("$name: $value");
}
}

ob_end_flush();
} catch (
Throwable $ex) {
ob_end_clean();
throw new
FormatterFailedException($ex);
}
}
}

private function
returnHttpStatus(): void {
$status = $this->getStatus();
if ((
$status >= 100 && $status <= 101)
|| (
$status >= 200 && $status <= 206)
|| (
$status >= 300 && $status <= 305)
|| (
$status >= 400 && $status <= 415)
|| (
$status >= 500 && $status <= 505)) {
// http_response_code only recognize these statuses, so use it in these cases
// And use the basic "header" function for all other exotic HTTP response code
// like logged in / not logged in responses or whatever
http_response_code($status);
} else {
header("{$_SERVER[ApacheServerNames::SERVER_PROTOCOL]} $status Status $status");
}
}
...
}

Le serveur répond donc un JSON, avec un HTTP status:


{
"row": {
"loginUrl": "\/player\/identify\/?show-warning=1"
}
}

"show-warning" est issue du paramètre "true" de "PlayerIdentifyEndpoint::uri(true)", indiquant à la page de login qu'il faudra dire au joueur "Pour continuer, il faut te logguer" (là, je pense que je pourrai me passer de ce paramètre, mais bon)

Ce qui fait que l'AJAX ayant fait l'appel récupère un code HTTP sur lequel il peut faire une discrimination et réagir "au mieux":


HTMLFormElement.prototype.formResponseHandle = function (status, content, formUnlocker) {
try {
switch (status) {
case -2: // Server error (content type)
document.messages.serverError(
'Le server a planté (Bad Content-Type received). '
+ 'Veuillez signaler cette erreur sur Discord');
formUnlocker();
return;

case -1: // No network
document.messages.userError(
'Vous n\'êtes pas connecté à internet: veuillez vérifier votre connexion.');
formUnlocker();
return;

case 200:
if (content['row'] && content['row']['message'] && this.dataset['onSuccess']) {
const onSuccess = this.dataset['onSuccess'].toString().split(':');
switch (onSuccess[0]) {
case 'reload':
switch (onSuccess[1]) {
case 'this':
// Only reload the current viewport, not the global top page
document.messages.delayedSuccess(content['row']['message']);
window.location.reload();
return;

case 'global':
// Reload the global scope of the document
document.messages.delayedSuccess(content['row']['message']);
window.top.location.reload();
return;
default:
}
break;

case 'goto':
// Go to the specified page (must be a simple GET URI, like post-redirect-get)
document.messages.delayedSuccess(content['row']['message']);
window.location.href = URL.make(onSuccess[1]).toString();
return;

case 'event-lock':
// Fires a custom event with the content infos (keeps the lock)
this.dispatchEvent(CustomEvent.create(onSuccess[1], content));
return;

case 'message':
case '':
// Default strategy is to just unlock form and show message
document.messages.success(content['row']['message']);
formUnlocker();
return;

default:
}
}
break;

case 400:
if (content['row']
&& content['row']['errorType']
&& content['row']['fieldsName']) {
const evts = ['change', 'input'];
const fields = getFormFields(this);
for (let parameterName of content['row']['fieldsName']) {
fields
.filter(f => f.getAttribute('name') === parameterName)
.forEach(
/** HTMLSelectElement|HTMLInputElement|HTMLTextAreaElement|HTMLButtonElement */
f => {
const fieldCleanup = () => {
evts.forEach(evt => f.removeEventListener(evt, fieldCleanup));
f.classList.remove('erroneous');
f.setCustomValidity('');
};
f.setCustomValidity(content['row']['errorType']);
f.classList.add('erroneous');
evts.forEach(evt => f.addEventListener(evt, fieldCleanup));
});
}
document.messages.userError('Les données entrées sont invalides.');
formUnlocker();
return;
}
break;

case 401:
if (content['row'] && content['row']['loginUrl']) {
// Show the login page, and don't re-trigger the original action
// Maybe you wanted to do this action, because you were not logged in
// And once logged in, you will no longer want to do this action
// So, to make things simple and UX friendly, don't retrigger the action
window.dispatchEvent(CustomEvent.create('request-login', { 'url': content['row']['loginUrl'] }));
formUnlocker();
return;
}
break;

case 403:
case 404:
case 441:
if (content['row'] && content['row']['message']) {
// Not found or not allowed are generic user errors so show them
document.messages.userError(content['row']['message']);
formUnlocker();
return;
}
break;

case 502:
// Server crashed but the error got logged
if (content['row'] && content['row']['message']) {
document.messages.serverError(content['row']['message']);
formUnlocker();
return;
}
return;

case 503:
// Service not available (project in upgrade?) so retry after a short time
setTimeout(() => {
formUnlocker();
this.submit();
}, 4000);
return;

default:
// Other cases are not handled
}
console.error('Unknown server response', status, content);
document.messages.serverError(
`Le serveur a retourné un code HTTP inattendu (${status}). `
+ 'Veuillez le signaler sur le Discord du jeu');
formUnlocker();
} catch (e) {
// You must unlock the form even if a JS error occurs, and then, let the error listener "log" it
formUnlocker();
throw e;
}
};

Le code 401 (Not allowed "faut essayer de vous connecter d'abord") va donc déclancher un event JS "request-login", avec l'URL issue du modèle de données reçu.
Et quand la page reçoit cet event JS, elle affiche le menu de connexion (ou elle fait remonter l'event à son parent: j'utilise les iframes pour imbriquer des morceaux de pages les uns dans les autres plutôt que d'altérer le DOM de la page à coup d'AJAX; je trouve le résultat tout aussi bon, mais moins bordélique et bien mieux cloisonné):


// Auto-login
window.addEventListener('request-login', e => {
if (window.parent !== window) {
window.parent.dispatchEvent(CustomEvent.create('request-login', { 'url': e.detail.url }));
} else {
window.toggleFraming(null, URL.make(e.detail.url), 'overpage');
}
});

toggleFraming, c'est un code générique en charge d'ouvrir lesdites iframes:


Window.prototype.toggleFraming = function (node, pageUrl, framingDescription) {
const framingInfos = framingDescription.split(':');
const framingType = framingInfos[0];
const framingScope = framingInfos.length > 1 ? framingInfos[1] : 'this';

// Gets proper document scope
const addFrameToDocument =
(framingScope === 'global' ? window.top : (framingScope === 'parent' ? window.parent : window)).document;
const container = addFrameToDocument.querySelector('.global-container');

// The node was already active: we only wanted to close its frame, so now it's done
const onlyDoClose = (node instanceof Element && node.classList.contains('global-framing-active'));

// Close all opened frames of this type
Array.from(addFrameToDocument.querySelectorAll(`.global-container > .global-framing-${framingType}`))
.forEach(n => {
if (n.openedByNode) {
n.openedByNode.classList.remove('global-framing-active');
}
n.remove();
});
container.classList.remove(`global-has-${framingType}-frame`);

if (pageUrl === null || onlyDoClose) {
return;
}

if (node instanceof Element) {
node.classList.add('global-framing-active');
}

// Clone the object to avoid changing its content
const url = URL.make(pageUrl.toString());
url.searchParams.set('http-accept', `text/html;charset=utf-8;subtemplate=${framingType}`);

// Open the button's frame
const iframe = document.createElement('iframe');
iframe.openedByNode = node;
iframe.setAttribute('sandbox', 'allow-forms allow-scripts allow-same-origin allow-top-navigation');
iframe.classList.add('global-framing', `global-framing-${framingType}`, 'global-is-loading');
iframe.setAttribute('src', url.toString());
iframe.addEventListener('load', () => {
iframe.classList.remove('global-is-loading');
});

// Adds the iframe to the proper window scope
container.appendChild(iframe);
container.classList.add(`global-has-${framingType}-frame`);
};

Le menu de connexion apparaitra alors (sur le côté gauche de l'écran) sous la forme d'une iframe, dans laquelle la page web indiquée par le modèle de données "PlayerIdentifyEndpoint::uri(true)" se trouvera.

Là encore, comme tu le vois, ce code JS défini un "header Accept" (sous la forme d'un paramètre GET plutôt que d'un vrai header HTTP car il n'est pas possible de spécifier un header HTTP dans une iframe: on ne peut donner que l'URL, mais pour le serveur, ce sera pareil: si ce paramètre GET existe, il considèrera que sa valeur prime sur le header Accept). Le serveur va donc traiter la requête (comme précédemment), construire un modèle de données, le passer à une liste de formatteurs, et s'arrêter sur le 1er capable de faire le formattage. Là, si tout se passe bien, le modèle de données aura comme statut HTTP "200" (OK) et l'iframe contiendra la page HTML de login, en utilisant le template HTML "overpage" (un template plus léger, adapté au contenu des iframes).

J'espère release ECLERD ce week end, ce sera peut-être plus clair, parce que là, je pense que c'est peut-être un poil trop exhaustif et poussé ce que je viens de poster?! Smile


RE: Utilisation des code de status HTTP - Forban - 30-06-2019

Wow, en effet Xenos, j'avoue que tu me perds un peu pour le coup. ^^


RE: Utilisation des code de status HTTP - Xenos - 01-07-2019

Smile J'essayerai peut-être d'expliquer tout ça plus posément et de façon plus structurée dans un article. D'ici là, ECLERD est déjà sorti ( https://eclerd.com ) donc tu peux éventuellement gratouiller dedans (via le panneau réseau de la console du navigateur par exemple) pour voir comment les statuts HTTP sont utilisés.

Le traitement de ces statuts est dans form.js; les codes HTTP permettent alors de discriminer ce qu'il se passe de façon standard, aka "code 404" tout dev sait ce que ça signifie, alors que {row:{message:"Pas trouvé!"}} ne sera pas compréhensible par tous les humains, encore moins par toutes les machines [c'est l'intérêt de tout standard, que ce soit HTTP, CSS, HTML, etc]. Un code plus spécifique au jeu "if (content['row'] && ...)" permet ensuite de savoir si je sais traiter le soucis (t'as demandé à envoyer un message à un joueur qui n'est pas dans le jeu, donc, je te le dis en affichant le message renvoyé par le jeu), ou si je ne sais pas le traiter (j'ai mal codé le jeu et j'ai demandé une page qui n'existe pas)

Mais ça vaut pour tout autre site web au fond: le but d'utiliser (les codes) standards, c'est d'être compris par tout le monde et de parler la même langue Wink