# Relire un contrôleur REST maison : ce qu’une revue de code cherche vraiment

> Avant de valider un endpoint personnalisé, un relecteur expérimenté vérifie toujours les mêmes points précis : validation, échappement, permission_callback.

- Auteur : WordPress Développement
- Publié le : 2023-08-03
- Mis à jour le : 2026-09-30
- Catégorie : Headless &amp; API
- URL : https://www.wpmoderne.fr/headless/relire-controleur-rest-maison-revue-code/

## L’essentiel

- Un permission_callback absent équivaut à une route ouverte à tous
- validate_callback et sanitize_callback ne jouent pas le même rôle
- L'échappement en sortie protège même une donnée déjà validée en entrée

« La documentation officielle sur developer.wordpress.org est formelle : un endpoint enregistré sans `permission_callback` explicite déclenche un avertissement de dépréciation depuis WordPress 5.5, précisément parce que son absence conduit trop souvent à une route accessible sans aucune restriction. » Cette phrase, un relecteur expérimenté la garde en tête à chaque fois qu'il ouvre une pull request contenant un nouvel appel à `register_rest_route()`.

Relire un contrôleur REST maison ne consiste pas à vérifier que le code fonctionne — les tests s'en chargent généralement assez bien. Il s'agit de vérifier ce que les tests fonctionnels ne couvrent presque jamais : le comportement de la route face à une entrée malveillante ou simplement inattendue. Trois points reviennent systématiquement dans ce type de relecture.

## Premier point : le permission_callback n'est pas optionnel

Un argument `permission_callback` renvoyant systématiquement `true`, ou pire, absent de la déclaration, ouvre la route à n'importe quel visiteur, authentifié ou non. La vérification la plus fréquente consiste à s'assurer que cette fonction interroge réellement les capacités de l'utilisateur courant via `current_user_can()`, plutôt que de se contenter d'un test de présence de jeton :

> L'essentiel à retenir : Un permission_callback absent équivaut à une route ouverte à tous ; validate_callback et sanitize_callback ne jouent pas le même rôle ; L'échappement en sortie protège même une donnée déjà validée en entrée

```
// À refuser en relecture : le test ne porte que sur la présence d'un en-tête.
register_rest_route( 'boutique/v1', '/remises', array(
	'methods'             => WP_REST_Server::CREATABLE,
	'callback'            => 'boutique_creer_remise',
	'permission_callback' => function ( WP_REST_Request $requete ) {
		return '' !== (string) $requete->get_header( 'x-api-key' );
	},
) );
```

N'importe quelle valeur non vide dans l'en-tête `X-Api-Key` ouvre la route : le code ressemble à un contrôle, il n'en est pas un. La correction interroge un droit réel de l'utilisateur authentifié. Quand la route porte sur un objet précis, le droit se vérifie sur cet objet, pas sur un rôle global :

```
register_rest_route( 'boutique/v1', '/remises/(?P<id>\d+)', array(
	'methods'             => WP_REST_Server::EDITABLE,
	'callback'            => 'boutique_modifier_remise',
	'permission_callback' => function ( WP_REST_Request $requete ) {
		$id = (int) $requete['id'];

		if ( 'remise' !== get_post_type( $id ) ) {
			return new WP_Error(
				'rest_post_invalid_id',
				__( 'Identifiant invalide.', 'boutique' ),
				array( 'status' => 404 )
			);
		}
		if ( ! current_user_can( 'edit_post', $id ) ) {
			return new WP_Error(
				'rest_forbidden',
				__( 'Modification non autorisée.', 'boutique' ),
				array( 'status' => rest_authorization_required_code() )
			);
		}

		return true;
	},
) );
```

Le relecteur vérifie aussi le cas inverse : une route réellement publique, en lecture seule, doit le dire explicitement avec `'permission_callback' => '__return_true'`, de préférence accompagné d'un commentaire qui explique pourquoi. Une ouverture volontaire se lit ; un oubli se devine.

## Deuxième point : valider n'est pas nettoyer

Le deuxième réflexe consiste à regarder, pour chaque argument de la route, si la déclaration distingue ce qui est refusé de ce qui est transformé. Le `validate_callback` accepte ou refuse la valeur (il retourne `true`, `false` ou un `WP_Error`) ; le `sanitize_callback` la convertit dans la forme que le code utilisera. Une revue repère deux confusions fréquentes : un nettoyage utilisé comme seule protection (`absint` transforme `abc` en `0` au lieu de le refuser, ce qui peut viser un mauvais objet) et une validation qui s'arrête au type sans borner la valeur.

```
'args' => array(
	'id'      => array(
		'type'              => 'integer',
		'required'          => true,
		'validate_callback' => 'rest_validate_request_arg',
		'minimum'           => 1,
	),
	'libelle' => array(
		'type'              => 'string',
		'required'          => true,
		'validate_callback' => function ( $valeur ) {
			return is_string( $valeur ) && '' !== trim( $valeur ) && mb_strlen( $valeur ) <= 120;
		},
		'sanitize_callback' => 'sanitize_text_field',
	),
),
```

Un détail d'ordre d'exécution mérite d'être connu du relecteur : le cœur valide et nettoie les arguments *avant* d'appeler le `permission_callback`. Un visiteur non autorisé qui envoie un paramètre invalide reçoit donc une erreur 400 plutôt qu'une erreur 401 ou 403. Ce n'est pas une faille en soi, mais cela signifie que les messages de validation ne doivent jamais révéler d'information interne.

## Troisième point : l'échappement en sortie et la requête préparée

Le troisième point est le plus contre-intuitif : une donnée validée à l'entrée doit quand même être échappée à la sortie. La validation vérifie une forme à un instant donné ; elle ne garantit rien sur ce qui se trouve déjà en base (import ancien, autre extension, contenu saisi par un compte éditeur) ni sur le contexte d'affichage. L'échappement dépend de l'endroit où la donnée atterrit : `esc_html()` dans un contenu HTML, `esc_attr()` dans un attribut, `esc_url()` dans une adresse. Même logique pour le SQL, où `$wpdb->prepare()` est la seule protection fiable.

```
// À refuser : la valeur est injectée dans le SQL et dans le HTML sans traitement.
function boutique_rechercher_a_refuser( WP_REST_Request $requete ) {
	global $wpdb;
	$terme  = $requete['terme'];
	$lignes = $wpdb->get_results(
		"SELECT ID, post_title FROM {$wpdb->posts} WHERE post_title LIKE '%{$terme}%'"
	);

	$html = '';
	foreach ( $lignes as $ligne ) {
		$html .= '<li>' . $ligne->post_title . '</li>';
	}

	return array( 'html' => $html );
}

// Version corrigée.
function boutique_rechercher( WP_REST_Request $requete ) {
	global $wpdb;
	$motif  = '%' . $wpdb->esc_like( $requete['terme'] ) . '%';
	$lignes = $wpdb->get_results( $wpdb->prepare(
		"SELECT ID, post_title FROM {$wpdb->posts} WHERE post_status = 'publish' AND post_title LIKE %s",
		$motif
	) );

	$html = '';
	foreach ( $lignes as $ligne ) {
		$html .= '<li>' . esc_html( $ligne->post_title ) . '</li>';
	}

	return array( 'html' => $html );
}
```

Si la route peut renvoyer uniquement des données brutes, sans HTML, le plus sûr reste de le faire : le front échappe alors au moment d'insérer dans la page, avec les outils de son framework. Une route qui fabrique du balisage prend la responsabilité de l'échapper.

## Les autres questions d'une relecture attentive

- **Que retourne la route ?** Un objet interne sérialisé tel quel expose des champs jamais prévus (adresses électroniques, métadonnées privées). Seuls les champs déclarés sont renvoyés.
- **Quel code HTTP en cas d'échec ?** Un `WP_Error` avec un `status` explicite : 400 pour une entrée invalide, 401 ou 403 pour un défaut d'autorisation, 404 pour un objet absent.
- **La route modifie-t-elle des données par un `GET` ?** Une lecture ne doit jamais avoir d'effet de bord : les navigateurs, les caches et les robots peuvent la rejouer.
- **Existe-t-il un test qui vérifie le refus ?** Un test qui appelle la route sans droit et attend un 401 ou un 403 vaut mieux que dix tests du cas nominal.
- **Le nonce est-il pris pour une authentification ?** Il protège contre la requête forgée depuis un autre site, il ne prouve pas l'identité de l'appelant.

```
public function test_modification_refusee_sans_droit() {
	$remise = self::factory()->post->create( array( 'post_type' => 'remise' ) );
	wp_set_current_user( self::factory()->user->create( array( 'role' => 'subscriber' ) ) );

	$requete = new WP_REST_Request( 'POST', '/boutique/v1/remises/' . $remise );
	$requete->set_param( 'libelle', 'Soldes' );
	$reponse = rest_do_request( $requete );

	$this->assertSame( 403, $reponse->get_status() );
}
```

## Cas concret : le déroulé d'une relecture

Devant une pull request qui ajoute une route de modification, l'ordre utile est toujours le même. On cherche d'abord le `permission_callback` et on se demande si un abonné peut modifier l'objet d'un autre. On lit ensuite le tableau `args` pour voir ce qui est refusé et ce qui est seulement transformé. On suit enfin chaque donnée jusqu'à sa sortie, dans le SQL ou dans le balisage. Ces trois lectures prennent quelques minutes et attrapent la grande majorité des défauts ; elles ne demandent ni outil particulier ni connaissance de la logique métier.

> Un test qui passe prouve que la route fait ce qu'on attend d'elle ; la relecture sert à vérifier qu'elle ne fait rien d'autre.

## Conclusion

Relire un contrôleur REST revient à poser les mêmes trois questions à chaque route : qui a le droit, que refuse-t-on et que nettoie-t-on à l'entrée, comment protège-t-on les données à la sortie. Un `permission_callback` réel, une séparation nette entre validation et nettoyage, et un échappement systématique couvrent l'essentiel. Affichez cette liste dans votre modèle de pull request : une revue qui sait ce qu'elle cherche va plus vite et oublie moins.
