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.
RE: Utilisation des code de status HTTP - Xenos - 13-06-2019
Si, tu as bien saisis 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.
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?!
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
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
|