Introduzione
Ogni applicazione moderna comunica attraverso API. WordPress, che alimenta oltre il 43% del web, non fa eccezione: dalla versione 4.7 il suo sistema REST API integrato ha trasformato radicalmente il modo in cui sviluppatori e agenzie costruiscono prodotti digitali.
Ma gli endpoint nativi coprono solo i dati del core — post, pagine, utenti, tassonomie. Quando costruisci un’app mobile che consuma dati custom, un frontend headless in Next.js o React, o un’integrazione con un CRM esterno, hai bisogno di WordPress REST API endpoints personalizzati: endpoint che espongono esattamente i dati che vuoi, con la logica di business che decidi tu, protetti con il livello di sicurezza che ogni contesto richiede.
Questo articolo è la guida che avrei voluto trovare quando ho iniziato a costruire AgencyPilot — una piattaforma SaaS che usa WordPress come hub dati e Next.js come frontend. Copre tutto: dalla registrazione del primo endpoint alla checklist di deployment in produzione, passando per autenticazione, sicurezza, caching e testing.
\”WordPress non è più solo un CMS. È una piattaforma applicativa. Le Custom REST API sono lo strato che rende possibile questa trasformazione.\” — WordPress REST API Handbook
Prerequisiti e Setup
Prima di scrivere una riga di codice, è importante avere l’ambiente giusto.
Versione WordPress minima: 4.7 (REST API integrata), ma consiglio almeno WordPress 6.4+ per avere la piena stabilità del sistema di blocchi e le ultime correzioni di sicurezza sulle API.
PHP: 8.1 o superiore. PHP 8.2+ offre miglioramenti di performance rilevanti e typing più rigoroso.
Strumenti indispensabili:
- Postman — interfaccia grafica per testare endpoint, salvare collection e scrivere test automatici. Scaricabile gratuitamente da postman.com.
- cURL — strumento da terminale, già disponibile su Linux/macOS. Su Windows usa WSL2.
- VS Code REST Client — estensione
humao.rest-clientche permette di inviare richieste HTTP direttamente da file.httpnel progetto. - Query Monitor — plugin WordPress essenziale per monitorare query SQL, hook e performance degli endpoint.
- WP-CLI — interfaccia a riga di comando per WordPress. Utile per svuotare cache, debuggare e testare senza browser.
Struttura del plugin di esempio:
Per questa guida useremo un plugin dedicato chiamato mio-plugin. La struttura è:
mio-plugin/
├── mio-plugin.php # File principale plugin
├── includes/
│ ├── class-api-handler.php # Classe principale per gli endpoint
│ ├── class-validator.php # Validazione input
│ └── class-cache.php # Gestione caching
└── tests/
└── test-api-handler.php # PHPUnit tests
Verifica che la REST API sia attiva:
# Sostituisci con il tuo URL locale
curl -s https://tuosito.local/wp-json/ | python3 -m json.tool | head -20
Se ricevi una risposta JSON con namespaces, tutto funziona correttamente.
Registrare il Primo Endpoint
L’hook rest_api_init è il punto di ingresso per tutti gli endpoint personalizzati. Viene eseguito dopo che il core WordPress ha inizializzato il sistema REST, garantendo che tutti i componenti necessari siano disponibili.
La funzione centrale è register_rest_route(), con questa firma:
register_rest_route(
string $namespace, // 'nomeplugin/v1'
string $route, // '/percorso/(?P<id>\d+)'
array $args, // Configurazione endpoint
bool $override // Raramente true
);
Esempio pratico: endpoint per recuperare progetti custom
<?php
/**
* Registrazione endpoint REST API per i progetti
*
* @package MioPlugin
*/
// Previeni accesso diretto al file
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Registra tutti gli endpoint del plugin.
* Si aggancia all'hook rest_api_init per garantire
* che il sistema REST sia completamente inizializzato.
*/
add_action( 'rest_api_init', 'mio_plugin_registra_endpoint' );
function mio_plugin_registra_endpoint(): void {
// Namespace del plugin — include sempre il numero di versione
$namespace = 'mioplugin/v1';
// GET /wp-json/mioplugin/v1/projects — lista progetti
register_rest_route( $namespace, '/projects', [
[
'methods' => WP_REST_Server::READABLE, // GET
'callback' => 'mio_plugin_get_projects',
'permission_callback' => '__return_true', // Pubblico, da restringere in produzione
'args' => mio_plugin_get_projects_args(),
],
[
'methods' => WP_REST_Server::CREATABLE, // POST
'callback' => 'mio_plugin_create_project',
'permission_callback' => 'mio_plugin_check_auth',
'args' => mio_plugin_create_project_args(),
],
] );
// GET /wp-json/mioplugin/v1/projects/{id} — singolo progetto
register_rest_route( $namespace, '/projects/(?P<id>\d+)', [
[
'methods' => WP_REST_Server::READABLE,
'callback' => 'mio_plugin_get_project',
'permission_callback' => '__return_true',
'args' => [
'id' => [
'description' => 'ID univoco del progetto',
'type' => 'integer',
'required' => true,
'minimum' => 1,
'sanitize_callback' => 'absint',
],
],
],
[
'methods' => WP_REST_Server::EDITABLE, // PUT/PATCH
'callback' => 'mio_plugin_update_project',
'permission_callback' => 'mio_plugin_check_auth',
],
[
'methods' => WP_REST_Server::DELETABLE, // DELETE
'callback' => 'mio_plugin_delete_project',
'permission_callback' => 'mio_plugin_check_admin',
],
] );
}
/**
* Handler per la lista dei progetti.
* Restituisce sempre WP_REST_Response — mai array diretti.
*
* @param WP_REST_Request $request Oggetto richiesta con parametri validati.
* @return WP_REST_Response|WP_Error
*/
function mio_plugin_get_projects( WP_REST_Request $request ): WP_REST_Response|WP_Error {
global $wpdb;
// I parametri arrivano già sanitizzati grazie a sanitize_callback
$stato = $request->get_param( 'stato' );
$per_page = $request->get_param( 'per_page' ) ?? 10;
$page = $request->get_param( 'page' ) ?? 1;
$offset = ( $page - 1 ) * $per_page;
// Costruzione query sicura con $wpdb->prepare
$where = '';
$params = [];
if ( ! empty( $stato ) ) {
$where = 'WHERE stato = %s';
$params[] = $stato;
}
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$sql = $wpdb->prepare(
"SELECT id, titolo, descrizione, stato, data_creazione
FROM {$wpdb->prefix}mio_plugin_projects
{$where}
ORDER BY data_creazione DESC
LIMIT %d OFFSET %d",
...( ! empty( $params ) ? $params : [] ),
$per_page,
$offset
);
$progetti = $wpdb->get_results( $sql );
if ( null === $progetti ) {
return new WP_Error(
'db_error',
'Errore durante il recupero dei progetti.',
[ 'status' => 500 ]
);
}
// Totale per la paginazione
$totale = (int) $wpdb->get_var(
"SELECT COUNT(*) FROM {$wpdb->prefix}mio_plugin_projects"
);
$response = new WP_REST_Response( $progetti, 200 );
// Header di paginazione standard (come fa il core WP)
$response->header( 'X-WP-Total', $totale );
$response->header( 'X-WP-TotalPages', (int) ceil( $totale / $per_page ) );
return $response;
}
Costanti WP_REST_Server — usale sempre al posto delle stringhe:
WP_REST_Server::READABLE='GET'WP_REST_Server::CREATABLE='POST'WP_REST_Server::EDITABLE='POST, PUT, PATCH'WP_REST_Server::DELETABLE='DELETE'WP_REST_Server::ALLMETHODS='GET, POST, PUT, PATCH, DELETE'
Errore comune #1: usare return json_encode($data) o wp_send_json() invece di WP_REST_Response. Il sistema REST di WordPress gestisce automaticamente la serializzazione JSON, i content-type header e i codici di stato HTTP. Restituire dati grezzi bypassa questo sistema e introduce bug difficili da debuggare.
Autenticazione e Autorizzazione
La permission_callback è il cancello di sicurezza di ogni endpoint. Se restituisce false o un WP_Error, WordPress risponde automaticamente con 401 Unauthorized o 403 Forbidden prima ancora di chiamare la tua callback principale.
Mai usare 'permission_callback' => '__return_true' in produzione per endpoint che modificano dati. Questo è il pattern corretto solo per endpoint davvero pubblici come feed o cataloghi prodotti.
Bearer Token (API Key)
Pattern usato in AgencyPilot: ogni utente ha una API key, hashata con SHA-256 prima di essere salvata nel database.
<?php
/**
* Verifica il Bearer token nell'header Authorization.
* Pattern: sha256(api_key) salvata su DB, non la chiave in chiaro.
*
* @param WP_REST_Request $request
* @return bool|WP_Error
*/
function mio_plugin_check_auth( WP_REST_Request $request ): bool|WP_Error {
$authorization = $request->get_header( 'authorization' );
if ( empty( $authorization ) ) {
return new WP_Error(
'rest_unauthorized',
'Token di autorizzazione mancante.',
[ 'status' => 401 ]
);
}
// Estrai il token dal formato "Bearer <token>"
if ( ! preg_match( '/^Bearer\s+(.+)$/i', $authorization, $matches ) ) {
return new WP_Error(
'rest_invalid_auth_format',
'Formato Authorization non valido. Usa: Bearer <token>',
[ 'status' => 401 ]
);
}
$api_key_raw = sanitize_text_field( $matches[1] );
$api_key_hash = hash( 'sha256', $api_key_raw );
global $wpdb;
// Cerca l'utente con questo hash — confronto sicuro, non timing-safe ma sufficiente
$utente_id = $wpdb->get_var(
$wpdb->prepare(
"SELECT user_id FROM {$wpdb->prefix}mio_plugin_api_keys
WHERE api_key_hash = %s AND revocata = 0 AND (scadenza IS NULL OR scadenza > NOW())",
$api_key_hash
)
);
if ( empty( $utente_id ) ) {
return new WP_Error(
'rest_invalid_token',
'Token non valido o scaduto.',
[ 'status' => 401 ]
);
}
// Imposta l'utente corrente — importante per current_user_can()
wp_set_current_user( (int) $utente_id );
return true;
}
/**
* Verifica che l'utente sia amministratore.
* Da usare su endpoint di gestione sensibili.
*
* @param WP_REST_Request $request
* @return bool|WP_Error
*/
function mio_plugin_check_admin( WP_REST_Request $request ): bool|WP_Error {
$auth_result = mio_plugin_check_auth( $request );
if ( is_wp_error( $auth_result ) ) {
return $auth_result;
}
if ( ! current_user_can( 'manage_options' ) ) {
return new WP_Error(
'rest_forbidden',
'Permessi insufficienti per questa operazione.',
[ 'status' => 403 ]
);
}
return true;
}
Nonce Auth (Cookie-based, per admin WordPress)
Quando il tuo JavaScript gira nell’admin di WordPress (Gutenberg, metabox, pagine di impostazioni), usa i nonce — sono automatici e non richiedono un sistema di API key separato.
<?php
// Passa il nonce al JavaScript tramite wp_localize_script
add_action( 'admin_enqueue_scripts', function() {
wp_enqueue_script( 'mio-plugin-admin', plugin_dir_url(__FILE__) . 'js/admin.js', ['wp-api'], '1.0', true );
wp_localize_script( 'mio-plugin-admin', 'MioPluginConfig', [
'apiUrl' => rest_url( 'mioplugin/v1' ),
'nonce' => wp_create_nonce( 'wp_rest' ), // Nonce standard per la REST API
] );
} );
// Nel tuo JavaScript admin — includi sempre il nonce nell'header
async function fetchProjects() {
const response = await fetch(`${MioPluginConfig.apiUrl}/projects`, {
headers: {
'X-WP-Nonce': MioPluginConfig.nonce,
'Content-Type': 'application/json',
},
});
if (!response.ok) {
throw new Error(`Errore API: ${response.status}`);
}
return response.json();
}
Tabella Comparativa Metodi di Autenticazione
| Metodo | Quando Usarlo | Sicurezza | Complessità | CORS Friendly |
|---|---|---|---|---|
| Nonce (Cookie) | JS nell’admin WP, Gutenberg | Alta (legata alla sessione) | Bassa | No (stesso sito) |
| API Key / Bearer Token | App esterne, mobile, headless | Alta (se HTTPS) | Media | Si |
| JWT | Stateless, microservizi, SPA | Alta | Alta | Si |
| OAuth 2.0 | Integrazioni terze parti, plugin marketplace | Molto alta | Molto alta | Si |
| Application Passwords | Integrazioni rapide (WP 5.6+) | Media | Bassa | Si |
Application Passwords meritano menzione: sono gestite nativamente da WordPress 5.6+, visibili nel profilo utente, e funzionano con Basic Auth codificato in Base64. Utili per integrazioni rapide, non per produzione ad alto traffico.
Validazione e Sanitizzazione
La validazione risponde alla domanda \”questo dato è accettabile?\”. La sanitizzazione risponde a \”come lo rendo sicuro prima di usarlo?\”. Sono due operazioni distinte e vanno entrambe usate.
WordPress REST API offre un sistema dichiarativo tramite l’array args: ogni parametro può avere validate_callback e sanitize_callback, eseguiti automaticamente prima della tua callback principale.
<?php
/**
* Definisce gli argomenti per la lista progetti.
* Ogni argomento viene validato e sanitizzato dal core WP
* prima che la callback principale venga chiamata.
*
* @return array
*/
function mio_plugin_get_projects_args(): array {
return [
'stato' => [
'description' => 'Filtra per stato del progetto.',
'type' => 'string',
'required' => false,
'default' => '',
'enum' => [ '', 'attivo', 'sospeso', 'completato' ],
'sanitize_callback' => 'sanitize_text_field',
'validate_callback' => function( $value ): bool|WP_Error {
$stati_validi = [ '', 'attivo', 'sospeso', 'completato' ];
if ( ! in_array( $value, $stati_validi, true ) ) {
return new WP_Error(
'rest_invalid_stato',
sprintf( 'Stato non valido. Valori accettati: %s', implode( ', ', array_filter( $stati_validi ) ) ),
[ 'status' => 400 ]
);
}
return true;
},
],
'per_page' => [
'description' => 'Numero di risultati per pagina.',
'type' => 'integer',
'required' => false,
'default' => 10,
'minimum' => 1,
'maximum' => 100, // Limita per prevenire abusi
'sanitize_callback' => 'absint',
],
'page' => [
'description' => 'Numero di pagina corrente.',
'type' => 'integer',
'required' => false,
'default' => 1,
'minimum' => 1,
'sanitize_callback' => 'absint',
],
];
}
/**
* Argomenti per la creazione di un progetto.
* Validazione più stringente su dati obbligatori.
*
* @return array
*/
function mio_plugin_create_project_args(): array {
return [
'titolo' => [
'description' => 'Titolo del progetto.',
'type' => 'string',
'required' => true,
'minLength' => 3,
'maxLength' => 200,
'sanitize_callback' => 'sanitize_text_field',
'validate_callback' => function( $value ): bool|WP_Error {
if ( mb_strlen( trim( $value ) ) < 3 ) {
return new WP_Error(
'rest_titolo_troppo_corto',
'Il titolo deve contenere almeno 3 caratteri.',
[ 'status' => 422 ]
);
}
return true;
},
],
'descrizione' => [
'description' => 'Descrizione HTML del progetto.',
'type' => 'string',
'required' => false,
'default' => '',
// wp_kses_post mantiene solo HTML sicuro (es. <p>, <strong>, <a>)
'sanitize_callback' => 'wp_kses_post',
],
'budget' => [
'description' => 'Budget in euro (centesimi).',
'type' => 'integer',
'required' => false,
'minimum' => 0,
'maximum' => 99999999, // Max ~1 milione di euro
'sanitize_callback' => 'absint',
],
'data_scadenza' => [
'description' => 'Data scadenza in formato ISO 8601.',
'type' => 'string',
'required' => false,
'format' => 'date',
'validate_callback' => function( $value ): bool|WP_Error {
$data = \DateTime::createFromFormat( 'Y-m-d', $value );
if ( ! $data || $data->format( 'Y-m-d' ) !== $value ) {
return new WP_Error(
'rest_data_non_valida',
'Formato data non valido. Usa YYYY-MM-DD.',
[ 'status' => 422 ]
);
}
// La data non può essere nel passato
if ( $data < new \DateTime( 'today' ) ) {
return new WP_Error(
'rest_data_passata',
'La data di scadenza non può essere nel passato.',
[ 'status' => 422 ]
);
}
return true;
},
],
];
}
HTTP Status Code corretti — una delle lacune più comuni nelle REST API WordPress:
200 OK— GET riuscito, UPDATE riuscito201 Created— POST riuscito, risorsa creata204 No Content— DELETE riuscito, nessun body400 Bad Request— parametro mancante o formato errato401 Unauthorized— autenticazione mancante o token non valido403 Forbidden— autenticato ma senza permessi404 Not Found— risorsa non esiste409 Conflict— conflitto (es. email già registrata)422 Unprocessable Entity— dati ben formati ma semanticamente errati429 Too Many Requests— rate limit raggiunto500 Internal Server Error— errore del server non gestito
Security Hardening
La sicurezza di un endpoint REST non si ferma all’autenticazione. È un sistema a strati.
CORS Headers
<?php
/**
* Configura gli header CORS per la REST API.
* Da aggiungere a functions.php del tema o al plugin principale.
*/
add_action( 'rest_api_init', function() {
// Rimuovi il CORS wildcard di default del core WordPress
remove_filter( 'rest_pre_serve_request', 'rest_send_cors_headers' );
add_filter( 'rest_pre_serve_request', function( $value ) {
// Lista di origini autorizzate — mai usare * in produzione con credenziali
$origini_autorizzate = [
'https://tuofrontend.com',
'https://staging.tuofrontend.com',
];
// Aggiunge localhost solo in ambiente di sviluppo
if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
$origini_autorizzate[] = 'http://localhost:3000';
$origini_autorizzate[] = 'http://localhost:3010';
}
$origin = $_SERVER['HTTP_ORIGIN'] ?? '';
if ( in_array( $origin, $origini_autorizzate, true ) ) {
header( "Access-Control-Allow-Origin: {$origin}" );
header( 'Access-Control-Allow-Credentials: true' );
header( 'Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS' );
header( 'Access-Control-Allow-Headers: Authorization, Content-Type, X-WP-Nonce' );
header( 'Access-Control-Max-Age: 3600' );
}
// Gestisci preflight OPTIONS senza chiamare WordPress
if ( 'OPTIONS' === $_SERVER['REQUEST_METHOD'] ) {
status_header( 204 );
exit;
}
return $value;
} );
}, 15 );
Rate Limiting con Redis/Transient
<?php
/**
* Rate limiting per endpoint sensibili.
* Usa Redis se disponibile (via WP Object Cache), altrimenti Transient.
*
* @param string $identifier IP o user ID
* @param int $limite Numero massimo di richieste
* @param int $finestra Finestra temporale in secondi
* @return bool|WP_Error true se permesso, WP_Error se limite superato
*/
function mio_plugin_check_rate_limit( string $identifier, int $limite = 30, int $finestra = 60 ): bool|WP_Error {
$chiave = 'mio_plugin_rl_' . md5( $identifier );
$contatore = (int) get_transient( $chiave );
if ( $contatore >= $limite ) {
// Header Retry-After standard RFC 6585
header( "Retry-After: {$finestra}" );
return new WP_Error(
'rest_rate_limit',
"Troppe richieste. Riprova tra {$finestra} secondi.",
[ 'status' => 429 ]
);
}
if ( 0 === $contatore ) {
set_transient( $chiave, 1, $finestra );
} else {
// increment() non esiste per i transient — aggiorna il valore
set_transient( $chiave, $contatore + 1, $finestra );
}
return true;
}
// Uso nella permission_callback
function mio_plugin_check_auth_con_rate_limit( WP_REST_Request $request ): bool|WP_Error {
// Rate limit per IP prima di verificare il token
$ip = sanitize_text_field( $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0' );
$rate_check = mio_plugin_check_rate_limit( $ip, 60, 60 );
if ( is_wp_error( $rate_check ) ) {
return $rate_check;
}
return mio_plugin_check_auth( $request );
}
Checklist Sicurezza
Un endpoint REST non sicuro è peggio di nessun endpoint. La sicurezza non è opzionale.
- HTTPS obbligatorio — blocca tutte le richieste HTTP in
nginx.confopermission_callback - Validazione input — ogni parametro con
validate_callbacke tipo dichiarato - Sanitizzazione output —
esc_html(),esc_url(),wp_kses()sui dati prima della risposta - $wpdb->prepare() — sempre, senza eccezioni, per qualsiasi query con parametri variabili
- Nonce verificati — su tutti gli endpoint che usano cookie auth
- Rate limiting — minimo 30 req/min per IP su endpoint pubblici, meno su endpoint sensibili
- Logging errori — scrivi su WP_DEBUG_LOG tentativi falliti e errori 5xx
- Secrets mai in risposta — non esporre hash, chiavi, password anche parziali
- Permission_callback sempre esplicita — mai omettere, mai
__return_truesu endpoint di scrittura - Disabilita REST API per utenti non autenticati se non hai endpoint pubblici (filtro
rest_authentication_errors)
Performance e Caching
Un endpoint non cachato che interroga il database ad ogni richiesta è un collo di bottiglia immediato sotto carico. Il caching è un requisito, non un’ottimizzazione prematura.
<?php
/**
* Recupera la lista progetti con caching via Transient.
* La cache viene invalidata ad ogni modifica.
*
* @param WP_REST_Request $request
* @return WP_REST_Response|WP_Error
*/
function mio_plugin_get_projects_cached( WP_REST_Request $request ): WP_REST_Response|WP_Error {
$stato = $request->get_param( 'stato' ) ?? '';
$per_page = $request->get_param( 'per_page' ) ?? 10;
$page = $request->get_param( 'page' ) ?? 1;
// Chiave cache specifica per combinazione di parametri
$cache_key = sprintf(
'mio_plugin_projects_%s_%d_%d',
md5( $stato ),
$per_page,
$page
);
// Prova a recuperare dalla cache
$cached = get_transient( $cache_key );
if ( false !== $cached ) {
$response = new WP_REST_Response( $cached['data'], 200 );
$response->header( 'X-WP-Total', $cached['totale'] );
$response->header( 'X-Cache', 'HIT' ); // Header diagnostico
return $response;
}
// Cache miss — esegui la query (richiama la funzione base)
$response = mio_plugin_get_projects( $request );
if ( is_wp_error( $response ) ) {
return $response;
}
// Salva in cache per 5 minuti
$duratura = 5 * MINUTE_IN_SECONDS;
set_transient( $cache_key, [
'data' => $response->get_data(),
'totale' => $response->get_headers()['X-WP-Total'] ?? 0,
], $duratura );
$response->header( 'X-Cache', 'MISS' );
// Header HTTP Cache-Control per proxy e browser
$response->header( 'Cache-Control', 'public, max-age=300, stale-while-revalidate=60' );
return $response;
}
/**
* Invalida la cache dei progetti quando uno viene modificato.
* Aggancia ai CRUD hook del plugin.
*/
function mio_plugin_invalida_cache_projects(): void {
global $wpdb;
// Rimuovi tutti i transient con il prefisso del plugin
$wpdb->query(
"DELETE FROM {$wpdb->options}
WHERE option_name LIKE '_transient_mio_plugin_projects_%'
OR option_name LIKE '_transient_timeout_mio_plugin_projects_%'"
);
}
add_action( 'mio_plugin_project_created', 'mio_plugin_invalida_cache_projects' );
add_action( 'mio_plugin_project_updated', 'mio_plugin_invalida_cache_projects' );
add_action( 'mio_plugin_project_deleted', 'mio_plugin_invalida_cache_projects' );
ETag per caching HTTP granulare:
<?php
/**
* Aggiunge ETag alla risposta per abilitare il caching condizionale.
* Il client invia If-None-Match nell'header e riceve 304 se invariato.
*
* @param WP_REST_Response $response
* @param array $dati
* @return WP_REST_Response
*/
function mio_plugin_aggiungi_etag( WP_REST_Response $response, array $dati ): WP_REST_Response {
$etag = '"' . md5( serialize( $dati ) ) . '"';
$response->header( 'ETag', $etag );
$if_none_match = $_SERVER['HTTP_IF_NONE_MATCH'] ?? '';
if ( $if_none_match === $etag ) {
return new WP_REST_Response( null, 304 );
}
return $response;
}
Caso Studio: API per Frontend Next.js
Questo è il pattern che uso in AgencyPilot. Un endpoint GET /projects completo con filtri, autenticazione Bearer, e consumo da Next.js con ISR (Incremental Static Regeneration).
Backend WordPress — Endpoint completo:
<?php
/**
* Endpoint pubblico progetti per frontend Next.js.
* Supporta filtri, paginazione e caching aggressivo.
*/
add_action( 'rest_api_init', function() {
register_rest_route( 'mioplugin/v1', '/projects', [
'methods' => WP_REST_Server::READABLE,
'callback' => 'mio_plugin_api_get_projects',
'permission_callback' => 'mio_plugin_check_bearer_o_pubblico',
'args' => [
'categoria' => [
'type' => 'string',
'sanitize_callback' => 'sanitize_text_field',
'default' => '',
],
'featured' => [
'type' => 'boolean',
'default' => false,
],
'per_page' => [
'type' => 'integer',
'default' => 12,
'minimum' => 1,
'maximum' => 50,
],
],
] );
} );
function mio_plugin_api_get_projects( WP_REST_Request $request ): WP_REST_Response|WP_Error {
global $wpdb;
$categoria = $request->get_param( 'categoria' );
$featured = (bool) $request->get_param( 'featured' );
$per_page = (int) $request->get_param( 'per_page' );
$page = max( 1, (int) $request->get_param( 'page' ) );
$offset = ( $page - 1 ) * $per_page;
// Costruzione condizioni WHERE dinamiche
$conditions = [ '1=1' ]; // Condizione sempre vera come base
$params = [];
if ( ! empty( $categoria ) ) {
$conditions[] = 'categoria = %s';
$params[] = $categoria;
}
if ( $featured ) {
$conditions[] = 'featured = 1';
}
$where = 'WHERE ' . implode( ' AND ', $conditions );
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$query = $wpdb->prepare(
"SELECT p.id, p.titolo, p.slug, p.descrizione_breve,
p.immagine_url, p.categoria, p.featured, p.data_pubblicazione,
u.display_name AS autore
FROM {$wpdb->prefix}mio_plugin_projects p
LEFT JOIN {$wpdb->users} u ON p.user_id = u.ID
{$where}
ORDER BY p.featured DESC, p.data_pubblicazione DESC
LIMIT %d OFFSET %d",
...( ! empty( $params ) ? $params : [] ),
$per_page,
$offset
);
$progetti = $wpdb->get_results( $query );
if ( null === $progetti ) {
return new WP_Error( 'db_error', 'Errore database.', [ 'status' => 500 ] );
}
// Aggiungi URL immagine processata tramite WP media
$progetti_processati = array_map( function( $p ) {
// Converti URL in attachment ID per avere srcset
$attachment_id = attachment_url_to_postid( $p->immagine_url );
if ( $attachment_id ) {
$p->immagine_srcset = wp_get_attachment_image_srcset( $attachment_id, 'large' );
}
$p->immagine_url = esc_url( $p->immagine_url );
$p->titolo = esc_html( $p->titolo );
return $p;
}, $progetti );
$totale = (int) $wpdb->get_var(
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$wpdb->prepare(
"SELECT COUNT(*) FROM {$wpdb->prefix}mio_plugin_projects {$where}",
...( ! empty( $params ) ? $params : [] )
)
);
$response = new WP_REST_Response( $progetti_processati, 200 );
$response->header( 'X-WP-Total', $totale );
$response->header( 'X-WP-TotalPages', (int) ceil( $totale / $per_page ) );
$response->header( 'Cache-Control', 'public, s-maxage=300, stale-while-revalidate=600' );
return $response;
}
Frontend Next.js — Fetch con ISR:
// app/projects/page.tsx — Next.js 15 App Router
// Server Component con ISR: rigenera ogni 5 minuti
interface Progetto {
id: number;
titolo: string;
slug: string;
descrizione_breve: string;
immagine_url: string;
categoria: string;
featured: boolean;
data_pubblicazione: string;
autore: string;
}
interface ApiResponse {
data: Progetto[];
totale: number;
totalePagine: number;
}
async function fetchProjects(params: {
categoria?: string;
featured?: boolean;
perPage?: number;
page?: number;
}): Promise<ApiResponse> {
const searchParams = new URLSearchParams();
if (params.categoria) searchParams.set('categoria', params.categoria);
if (params.featured) searchParams.set('featured', 'true');
if (params.perPage) searchParams.set('per_page', String(params.perPage));
if (params.page) searchParams.set('page', String(params.page));
const url = `${process.env.WP_API_URL}/wp-json/mioplugin/v1/projects?${searchParams}`;
const response = await fetch(url, {
headers: {
Authorization: `Bearer ${process.env.WP_API_KEY}`,
'Content-Type': 'application/json',
},
// ISR: rigenera ogni 5 minuti, servi stale durante la rigenerazione
next: {
revalidate: 300,
tags: ['projects'],
},
});
if (!response.ok) {
throw new Error(`Errore API WordPress: ${response.status} ${response.statusText}`);
}
const data = await response.json();
return {
data,
totale: parseInt(response.headers.get('X-WP-Total') ?? '0', 10),
totalePagine: parseInt(response.headers.get('X-WP-TotalPages') ?? '1', 10),
};
}
export default async function ProjectsPage({
searchParams,
}: {
searchParams: { categoria?: string; page?: string };
}) {
const { data: progetti, totale } = await fetchProjects({
categoria: searchParams.categoria,
perPage: 12,
page: searchParams.page ? parseInt(searchParams.page, 10) : 1,
});
return (
<main>
<h1>Progetti ({totale})</h1>
<ul>
{progetti.map((progetto) => (
<li key={progetto.id}>
<a href={`/projects/${progetto.slug}`}>{progetto.titolo}</a>
</li>
))}
</ul>
</main>
);
}
Variabili d’ambiente Next.js (.env.local):
WP_API_URL=https://tuosito.com
WP_API_KEY=la_tua_api_key_segreta_mai_committata
Testing e Debugging
WP_DEBUG_LOG
<?php
// In wp-config.php — abilitare solo in sviluppo o staging
define( 'WP_DEBUG', true );
define( 'WP_DEBUG_LOG', true ); // Scrive in wp-content/debug.log
define( 'WP_DEBUG_DISPLAY', false ); // Non mostrare errori a schermo
// Funzione helper per log strutturato dalle API
function mio_plugin_log( string $messaggio, array $contesto = [], string $livello = 'INFO' ): void {
if ( ! defined( 'WP_DEBUG_LOG' ) || ! WP_DEBUG_LOG ) {
return;
}
$timestamp = wp_date( 'Y-m-d H:i:s' );
$log_entry = sprintf(
'[%s] [%s] [mio-plugin] %s %s',
$timestamp,
$livello,
$messaggio,
! empty( $contesto ) ? json_encode( $contesto, JSON_UNESCAPED_UNICODE ) : ''
);
error_log( $log_entry );
}
// Uso nella callback
function mio_plugin_create_project( WP_REST_Request $request ): WP_REST_Response|WP_Error {
mio_plugin_log( 'Richiesta creazione progetto', [
'user_id' => get_current_user_id(),
'ip' => $_SERVER['REMOTE_ADDR'] ?? '',
'dati' => $request->get_params(),
] );
// ... logica creazione ...
}
Test con cURL
# GET pubblico
curl -s "https://tuosito.local/wp-json/mioplugin/v1/projects?per_page=5&stato=attivo" | python3 -m json.tool
# POST con Bearer token
curl -s -X POST "https://tuosito.local/wp-json/mioplugin/v1/projects" -H "Authorization: Bearer LA_TUA_API_KEY" -H "Content-Type: application/json" -d '{"titolo":"Progetto Test","descrizione":"Descrizione di prova","budget":500000}' | python3 -m json.tool
# DELETE con verifica risposta 204
curl -s -o /dev/null -w "%{http_code}" -X DELETE "https://tuosito.local/wp-json/mioplugin/v1/projects/42" -H "Authorization: Bearer LA_TUA_API_KEY"
WP-CLI per Debug
# Controlla se il tuo namespace è registrato
wp eval 'print_r(rest_get_server()->get_namespaces());'
# Lista tutti gli endpoint registrati sotto il tuo namespace
wp eval 'foreach(rest_get_server()->get_routes() as $route => $data) {
if (strpos($route, "mioplugin") !== false) echo $route . "";
}'
# Svuota cache Transient del plugin
wp eval 'global $wpdb; $wpdb->query("DELETE FROM {$wpdb->options} WHERE option_name LIKE '"'"'_transient_mio_plugin_%'"'"'");'
echo "Cache svuotata"
# Testa un endpoint direttamente da WP-CLI
wp eval '
$request = new WP_REST_Request("GET", "/mioplugin/v1/projects");
$request->set_param("per_page", 5);
$response = rest_do_request($request);
echo json_encode($response->get_data(), JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
'
PHPUnit — Test di Base
<?php
/**
* Test endpoint GET /projects.
*
* @package MioPlugin\Tests
*/
class Test_API_Projects extends WP_Test_REST_TestCase {
protected static $editor_id;
public static function wpSetUpBeforeClass( WP_UnitTest_Factory $factory ): void {
self::$editor_id = $factory->user->create( [ 'role' => 'editor' ] );
}
/** Test: risposta 200 con array di progetti. */
public function test_get_projects_restituisce_200(): void {
$request = new WP_REST_Request( 'GET', '/mioplugin/v1/projects' );
$response = $this->server->dispatch( $request );
$this->assertSame( 200, $response->get_status() );
$this->assertIsArray( $response->get_data() );
}
/** Test: parametro stato invalido restituisce 400. */
public function test_get_projects_stato_invalido_restituisce_400(): void {
$request = new WP_REST_Request( 'GET', '/mioplugin/v1/projects' );
$request->set_param( 'stato', 'valore_inesistente' );
$response = $this->server->dispatch( $request );
$this->assertSame( 400, $response->get_status() );
}
/** Test: POST senza autenticazione restituisce 401. */
public function test_create_project_senza_auth_restituisce_401(): void {
$request = new WP_REST_Request( 'POST', '/mioplugin/v1/projects' );
$request->set_body_params( [ 'titolo' => 'Progetto test' ] );
$response = $this->server->dispatch( $request );
$this->assertSame( 401, $response->get_status() );
}
}
Deployment Checklist
Questa è la lista che eseguo prima di ogni rilascio in produzione di un endpoint REST.
Sicurezza
- Tutti gli endpoint di scrittura (POST/PUT/DELETE) hanno
permission_callbacknon__return_true - Ogni parametro ha
sanitize_callbackevalidate_callbackdefiniti - Nessuna query SQL senza
$wpdb->prepare() - Tutti gli output HTML sono processati con
esc_html(),esc_url()owp_kses() - CORS configurato con lista di origini esplicita, non wildcard
* - HTTPS forzato a livello Nginx — richieste HTTP reindirizzate automaticamente
- Rate limiting attivo su endpoint pubblici (max 30-60 req/min per IP)
- Secrets in variabili d’ambiente, mai hardcoded nel codice
- WP_DEBUG disabilitato in produzione (
define('WP_DEBUG', false))
Performance
- Caching Transient attivo su endpoint GET ad alta frequenza
- Header
Cache-Controlcorretti su risposta (public/private, max-age) - Paginazione implementata — nessun endpoint restituisce più di 100 record senza limite
- Query SQL analizzate con EXPLAIN ANALYZE — indici presenti su colonne filtrate
- Nessun problema N+1 (query singole per array, non loop di query)
- Timeout HTTP configurato su client (es. 30s su fetch Next.js)
Affidabilità
- Tutti i casi di errore restituiscono
WP_Errorcon codice HTTP corretto - Logging abilitato per errori 5xx e tentativi di accesso non autorizzati
- Test PHPUnit passanti per i percorsi critici (happy path + errori)
- Endpoint testato con Postman collection (documenta per il team)
- Documentazione aggiornata (namespace, route, parametri, esempi di risposta)
- Nonce e sessioni invalidati correttamente al logout utente
Strumenti Consigliati
- Postman — test visuale degli endpoint, salvataggio collection condivisibili con il team, test automatici in JavaScript. Versione gratuita più che sufficiente per uso individuale.
- Query Monitor — plugin WordPress che mostra query SQL, hook eseguiti, errori PHP e tempo di esecuzione. Indispensabile durante lo sviluppo di endpoint con logica database.
- WP-CLI — interfaccia a riga di comando per WordPress. Testa endpoint, svuota cache, esegui migrazioni e gestisci utenti senza toccare il browser.
- Insomnia — alternativa a Postman, più leggera, con ottimo supporto per REST e GraphQL. Ottima per chi preferisce un’interfaccia più pulita.
- VS Code REST Client — estensione che permette di inviare richieste HTTP direttamente da file
.httpnel repository. Ottimo per tenere le richieste di test vicino al codice.
- JWT.io — decoder online per JWT token. Utile per debuggare payload e scadenze senza dover scrivere codice.
- PHPUnit + wp-phpunit/wp-phpunit — suite di testing per PHP con classi base WordPress (
WP_Test_REST_TestCase). Essenziale per qualsiasi plugin in produzione.
- AgencyPilot — se gestisci più siti WordPress per i tuoi clienti, AgencyPilot monitora le API, gli uptime e le performance di tutti i siti da un unico dashboard. Include alert automatici quando un endpoint inizia a rispondere lentamente o con errori.
Conclusione
Le WordPress REST API endpoints personalizzati non sono una funzionalità avanzata per pochi: sono lo strumento con cui oggi si costruisce qualsiasi prodotto digitale serio sopra WordPress, da app mobile a CMS headless, da integrazioni CRM a piattaforme SaaS.
I punti chiave da portare via da questa guida:
register_rest_route()con namespace versionato (mioplugin/v1) è il punto di partenza. Non shortcode, non AJAX handler legacy.- La
permission_callbacknon è opzionale. È il primo e più importante strato di sicurezza. - Validazione e sanitizzazione sono operazioni distinte: valida il formato, sanitizza il contenuto.
$wpdb->prepare()su ogni query con parametri variabili, senza eccezioni.- Il caching con Transient trasforma endpoint lenti in risposte istantanee sotto carico.
- La checklist di deployment non è burocrazia: è la differenza tra un endpoint che regge il carico di produzione e uno che espone dati o cade sotto stress.
Per approfondire, il punto di riferimento ufficiale rimane il WordPress REST API Handbook su developer.wordpress.org — aggiornato ad ogni release di WordPress.
Se gestisci siti WordPress per clienti o agenzie, considera di integrare un sistema di monitoring delle API nel tuo stack: risposta lenta, errori 5xx non gestiti e downtime improvvisi sono problemi che colpiscono tutti i layer, incluse le tue REST API. Su AgencyPilot trovi il monitoring di siti, uptime e performance in un unico dashboard pensato per chi fa WordPress di professione.
FAQ
Come si crea un endpoint REST API personalizzato in WordPress?
Si usa la funzione register_rest_route() all’interno dell’hook rest_api_init. Bisogna definire il namespace (solitamente nome-plugin/v1), la route, i metodi HTTP accettati (GET, POST, ecc.) e la callback che gestisce la risposta. La funzione di permission_callback gestisce l’autenticazione: returning true rende l’endpoint pubblico, altrimenti si verifica il permesso dell’utente corrente.
Come autenticare le richieste alle REST API di WordPress?
WordPress supporta diversi metodi: cookie authentication per richieste dal frontend dello stesso sito (richiede il nonce wp_rest), Application Passwords per accesso esterno via HTTP Basic Auth, e JWT tramite plugin di terze parti per contesti OAuth. Per le API interne usate da plugin o tema, il cookie con nonce è il metodo standard e più sicuro.
Come limitare l’accesso a un endpoint REST API in WordPress?
Nella permission_callback dell’endpoint, usa current_user_can() per verificare le capacità dell’utente. Per esempio, current_user_can(‘manage_options’) limita l’accesso agli admin. Per endpoint pubblici ma con rate limiting, devi implementare la logica manualmente o usare un plugin di API management. Puoi anche disabilitare l’intera REST API ai non loggati modificando il filtro rest_authentication_errors.
Qual è la differenza tra WP_REST_Request e WP_REST_Response?
WP_REST_Request rappresenta la richiesta in arrivo: contiene metodo HTTP, parametri, headers e body. WP_REST_Response è l’oggetto che costruisci e restituisci dalla callback: contiene i dati, il codice di stato HTTP e gli header della risposta. La funzione rest_ensure_response() converte automaticamente array o WP_Error in un oggetto WP_REST_Response valido.