first commit

This commit is contained in:
User A0264400
2026-04-01 23:20:16 +03:00
commit a766acdc90
23071 changed files with 4933189 additions and 0 deletions

View File

@@ -0,0 +1,37 @@
<?php
namespace Yoast\WP\SEO\AI_HTTP_Request\Application;
use Yoast\WP\SEO\AI_HTTP_Request\Domain\Exceptions\Bad_Request_Exception;
use Yoast\WP\SEO\AI_HTTP_Request\Domain\Exceptions\Forbidden_Exception;
use Yoast\WP\SEO\AI_HTTP_Request\Domain\Exceptions\Internal_Server_Error_Exception;
use Yoast\WP\SEO\AI_HTTP_Request\Domain\Exceptions\Not_Found_Exception;
use Yoast\WP\SEO\AI_HTTP_Request\Domain\Exceptions\Payment_Required_Exception;
use Yoast\WP\SEO\AI_HTTP_Request\Domain\Exceptions\Request_Timeout_Exception;
use Yoast\WP\SEO\AI_HTTP_Request\Domain\Exceptions\Service_Unavailable_Exception;
use Yoast\WP\SEO\AI_HTTP_Request\Domain\Exceptions\Too_Many_Requests_Exception;
use Yoast\WP\SEO\AI_HTTP_Request\Domain\Exceptions\Unauthorized_Exception;
use Yoast\WP\SEO\AI_HTTP_Request\Domain\Request;
use Yoast\WP\SEO\AI_HTTP_Request\Domain\Response;
interface Request_Handler_Interface {
/**
* Executes the request to the API.
*
* @param Request $request The request to execute.
*
* @return Response The response from the API.
*
* @throws Bad_Request_Exception When the request fails for any other reason.
* @throws Forbidden_Exception When the response code is 403.
* @throws Internal_Server_Error_Exception When the response code is 500.
* @throws Not_Found_Exception When the response code is 404.
* @throws Payment_Required_Exception When the response code is 402.
* @throws Request_Timeout_Exception When the response code is 408.
* @throws Service_Unavailable_Exception When the response code is 503.
* @throws Too_Many_Requests_Exception When the response code is 429.
* @throws Unauthorized_Exception When the response code is 401.
*/
public function handle( Request $request ): Response;
}

View File

@@ -0,0 +1,111 @@
<?php
namespace Yoast\WP\SEO\AI_HTTP_Request\Application;
use Yoast\WP\SEO\AI_HTTP_Request\Domain\Exceptions\Bad_Request_Exception;
use Yoast\WP\SEO\AI_HTTP_Request\Domain\Exceptions\Forbidden_Exception;
use Yoast\WP\SEO\AI_HTTP_Request\Domain\Exceptions\Internal_Server_Error_Exception;
use Yoast\WP\SEO\AI_HTTP_Request\Domain\Exceptions\Not_Found_Exception;
use Yoast\WP\SEO\AI_HTTP_Request\Domain\Exceptions\Payment_Required_Exception;
use Yoast\WP\SEO\AI_HTTP_Request\Domain\Exceptions\Request_Timeout_Exception;
use Yoast\WP\SEO\AI_HTTP_Request\Domain\Exceptions\Service_Unavailable_Exception;
use Yoast\WP\SEO\AI_HTTP_Request\Domain\Exceptions\Too_Many_Requests_Exception;
use Yoast\WP\SEO\AI_HTTP_Request\Domain\Exceptions\Unauthorized_Exception;
use Yoast\WP\SEO\AI_HTTP_Request\Domain\Exceptions\WP_Request_Exception;
use Yoast\WP\SEO\AI_HTTP_Request\Domain\Request;
use Yoast\WP\SEO\AI_HTTP_Request\Domain\Response;
use Yoast\WP\SEO\AI_HTTP_Request\Infrastructure\API_Client;
/**
* Class Request_Handler
* Handles the request to Yoast AI API.
*
* @makePublic
*/
class Request_Handler implements Request_Handler_Interface {
private const TIMEOUT = 60;
/**
* The API client.
*
* @var API_Client
*/
private $api_client;
/**
* The response parser.
*
* @var Response_Parser
*/
private $response_parser;
/**
* Request_Handler constructor.
*
* @param API_Client $api_client The API client.
* @param Response_Parser $response_parser The response parser.
*/
public function __construct( API_Client $api_client, Response_Parser $response_parser ) {
$this->api_client = $api_client;
$this->response_parser = $response_parser;
}
// phpcs:disable Squiz.Commenting.FunctionCommentThrowTag.WrongNumber -- PHPCS doesn't take into account exceptions thrown in called methods.
/**
* Executes the request to the API.
*
* @param Request $request The request to execute.
*
* @return Response The response from the API.
*
* @throws Bad_Request_Exception When the request fails for any other reason.
* @throws Forbidden_Exception When the response code is 403.
* @throws Internal_Server_Error_Exception When the response code is 500.
* @throws Not_Found_Exception When the response code is 404.
* @throws Payment_Required_Exception When the response code is 402.
* @throws Request_Timeout_Exception When the response code is 408.
* @throws Service_Unavailable_Exception When the response code is 503.
* @throws Too_Many_Requests_Exception When the response code is 429.
* @throws Unauthorized_Exception When the response code is 401.
* @throws WP_Request_Exception When the request fails for any other reason.
*/
public function handle( Request $request ): Response {
$api_response = $this->api_client->perform_request(
$request->get_action_path(),
$request->get_body(),
$request->get_headers(),
$request->is_post(),
);
$response = $this->response_parser->parse( $api_response );
// phpcs:disable WordPress.Security.EscapeOutput.ExceptionNotEscaped -- false positive.
switch ( $response->get_response_code() ) {
case 200:
return $response;
case 401:
throw new Unauthorized_Exception( $response->get_message(), $response->get_response_code(), $response->get_error_code() );
case 402:
throw new Payment_Required_Exception( $response->get_message(), $response->get_response_code(), $response->get_error_code(), null, $response->get_missing_licenses() );
case 403:
throw new Forbidden_Exception( $response->get_message(), $response->get_response_code(), $response->get_error_code() );
case 404:
throw new Not_Found_Exception( $response->get_message(), $response->get_response_code(), $response->get_error_code() );
case 408:
throw new Request_Timeout_Exception( $response->get_message(), $response->get_response_code(), $response->get_error_code() );
case 429:
throw new Too_Many_Requests_Exception( $response->get_message(), $response->get_response_code(), $response->get_error_code(), null, $response->get_missing_licenses() );
case 500:
throw new Internal_Server_Error_Exception( $response->get_message(), $response->get_response_code(), $response->get_error_code() );
case 503:
throw new Service_Unavailable_Exception( $response->get_message(), $response->get_response_code(), $response->get_error_code() );
default:
throw new Bad_Request_Exception( $response->get_message(), $response->get_response_code(), $response->get_error_code() );
}
// phpcs:enable WordPress.Security.EscapeOutput.ExceptionNotEscaped
}
// phpcs:enable Squiz.Commenting.FunctionCommentThrowTag.WrongNumber
}

View File

@@ -0,0 +1,17 @@
<?php
namespace Yoast\WP\SEO\AI_HTTP_Request\Application;
use Yoast\WP\SEO\AI_HTTP_Request\Domain\Response;
interface Response_Parser_Interface {
/**
* Parses the response from the API.
*
* @param array<int|string|array<string>> $response The response from the API.
*
* @return Response The parsed response.
*/
public function parse( $response ): Response;
}

View File

@@ -0,0 +1,60 @@
<?php
namespace Yoast\WP\SEO\AI_HTTP_Request\Application;
use Yoast\WP\SEO\AI_HTTP_Request\Domain\Response;
/**
* Class Response_Parser
* Parses the response from the AI API and creates a Response object.
*/
class Response_Parser implements Response_Parser_Interface {
/**
* Parses the response from the API.
*
* @param array<int|string|array<string>> $response The response from the API.
*
* @return Response The parsed response.
*/
public function parse( $response ): Response {
$response_code = ( \wp_remote_retrieve_response_code( $response ) !== '' ) ? \wp_remote_retrieve_response_code( $response ) : 0;
$response_message = \esc_html( \wp_remote_retrieve_response_message( $response ) );
$error_code = '';
$missing_licenses = [];
if ( $response_code !== 200 && $response_code !== 0 ) {
$json_body = \json_decode( \wp_remote_retrieve_body( $response ) );
if ( $json_body !== null ) {
$response_message = ( $json_body->message ?? $response_message );
$error_code = ( $json_body->error_code ?? $this->map_message_to_code( $response_message ) );
if ( $response_code === 402 || $response_code === 429 ) {
$missing_licenses = isset( $json_body->missing_licenses ) ? (array) $json_body->missing_licenses : [];
}
}
}
return new Response( $response['body'], $response_code, $response_message, $error_code, $missing_licenses );
}
/**
* Maps the error message to a code.
*
* @param string $message The error message.
*
* @return string The mapped code.
*/
private function map_message_to_code( string $message ): string {
if ( \strpos( $message, 'must NOT have fewer than 1 characters' ) !== false ) {
return 'NOT_ENOUGH_CONTENT';
}
if ( \strpos( $message, 'Client timeout' ) !== false ) {
return 'CLIENT_TIMEOUT';
}
if ( \strpos( $message, 'Server timeout' ) !== false ) {
return 'SERVER_TIMEOUT';
}
return 'UNKNOWN';
}
}

View File

@@ -0,0 +1,11 @@
<?php
// phpcs:disable Yoast.NamingConventions.NamespaceName.TooLong -- Needed in the folder structure.
namespace Yoast\WP\SEO\AI_HTTP_Request\Domain\Exceptions;
/**
* Class to manage a 400 - Bad request response.
*/
class Bad_Request_Exception extends Remote_Request_Exception {
}

View File

@@ -0,0 +1,11 @@
<?php
// phpcs:disable Yoast.NamingConventions.NamespaceName.TooLong -- Needed in the folder structure.
namespace Yoast\WP\SEO\AI_HTTP_Request\Domain\Exceptions;
/**
* Class to manage a 403 - Forbidden response.
*/
class Forbidden_Exception extends Remote_Request_Exception {
}

View File

@@ -0,0 +1,13 @@
<?php
// phpcs:disable Yoast.NamingConventions.NamespaceName.TooLong -- Needed in the folder structure.
namespace Yoast\WP\SEO\AI_HTTP_Request\Domain\Exceptions;
/**
* Class to manage a 500 - Internal server error response.
*
* @phpcs:disable Yoast.NamingConventions.ObjectNameDepth.MaxExceeded
*/
class Internal_Server_Error_Exception extends Remote_Request_Exception {
}

View File

@@ -0,0 +1,11 @@
<?php
// phpcs:disable Yoast.NamingConventions.NamespaceName.TooLong -- Needed in the folder structure.
namespace Yoast\WP\SEO\AI_HTTP_Request\Domain\Exceptions;
/**
* Class to manage a 404 - not found response.
*/
class Not_Found_Exception extends Remote_Request_Exception {
}

View File

@@ -0,0 +1,42 @@
<?php
// phpcs:disable Yoast.NamingConventions.NamespaceName.TooLong -- Needed in the folder structure.
namespace Yoast\WP\SEO\AI_HTTP_Request\Domain\Exceptions;
use Throwable;
/**
* Class to manage a 402 - payment required response.
*/
class Payment_Required_Exception extends Remote_Request_Exception {
/**
* The missing plugin licenses.
*
* @var string[]
*/
private $missing_licenses;
/**
* Payment_Required_Exception constructor.
*
* @param string $message The error message.
* @param int $code The error status code.
* @param string $error_identifier The error code identifier, used to identify a type of error.
* @param Throwable| null $previous The previously thrown exception.
* @param string[] $missing_licenses The missing plugin licenses.
*/
public function __construct( $message = '', $code = 0, $error_identifier = '', $previous = null, $missing_licenses = [] ) {
$this->missing_licenses = $missing_licenses;
parent::__construct( $message, $code, $error_identifier, $previous );
}
/**
* Gets the missing plugin licences.
*
* @return string[] The missing plugin licenses.
*/
public function get_missing_licenses() {
return $this->missing_licenses;
}
}

View File

@@ -0,0 +1,42 @@
<?php
// phpcs:disable Yoast.NamingConventions.NamespaceName.TooLong -- Needed in the folder structure.
namespace Yoast\WP\SEO\AI_HTTP_Request\Domain\Exceptions;
use Exception;
use Throwable;
/**
* Class Remote_Request_Exception
*/
abstract class Remote_Request_Exception extends Exception {
/**
* A string error code that can be used to identify a particular type of error.
*
* @var string
*/
private $error_identifier;
/**
* Constructor.
*
* @param string $message The error message.
* @param int $code The error status code.
* @param string $error_identifier The error code identifier, used to identify a type of error.
* @param Throwable|null $previous The previously thrown exception.
*/
public function __construct( $message = '', $code = 0, $error_identifier = '', ?Throwable $previous = null ) {
parent::__construct( $message, $code, $previous );
$this->error_identifier = (string) $error_identifier;
}
/**
* Returns the error identifier.
*
* @return string The error identifier.
*/
public function get_error_identifier(): string {
return $this->error_identifier;
}
}

View File

@@ -0,0 +1,11 @@
<?php
// phpcs:disable Yoast.NamingConventions.NamespaceName.TooLong -- Needed in the folder structure.
namespace Yoast\WP\SEO\AI_HTTP_Request\Domain\Exceptions;
/**
* Class to manage a 408 - request timeout exception
*/
class Request_Timeout_Exception extends Remote_Request_Exception {
}

View File

@@ -0,0 +1,11 @@
<?php
// phpcs:disable Yoast.NamingConventions.NamespaceName.TooLong -- Needed in the folder structure.
namespace Yoast\WP\SEO\AI_HTTP_Request\Domain\Exceptions;
/**
* Class to manage a 503 - service unavailable response.
*/
class Service_Unavailable_Exception extends Remote_Request_Exception {
}

View File

@@ -0,0 +1,13 @@
<?php
// phpcs:disable Yoast.NamingConventions.NamespaceName.TooLong -- Needed in the folder structure.
namespace Yoast\WP\SEO\AI_HTTP_Request\Domain\Exceptions;
/**
* Class to manage a 429 - Too many requests response.
*
* @phpcs:disable Yoast.NamingConventions.ObjectNameDepth.MaxExceeded
*/
class Too_Many_Requests_Exception extends Payment_Required_Exception {
}

View File

@@ -0,0 +1,11 @@
<?php
// phpcs:disable Yoast.NamingConventions.NamespaceName.TooLong -- Needed in the folder structure.
namespace Yoast\WP\SEO\AI_HTTP_Request\Domain\Exceptions;
/**
* Class to manage a 401 - unauthorized response.
*/
class Unauthorized_Exception extends Remote_Request_Exception {
}

View File

@@ -0,0 +1,24 @@
<?php
// phpcs:disable Yoast.NamingConventions.NamespaceName.TooLong -- Needed in the folder structure.
namespace Yoast\WP\SEO\AI_HTTP_Request\Domain\Exceptions;
use Throwable;
/**
* Class to manage an error response in wp_remote_*() requests.
*
* @phpcs:disable Yoast.NamingConventions.ObjectNameDepth.MaxExceeded
*/
class WP_Request_Exception extends Remote_Request_Exception {
/**
* WP_Request_Exception constructor.
*
* @param string $message The error message.
* @param Throwable| null $previous The previously thrown exception.
*/
public function __construct( $message = '', $previous = null ) {
parent::__construct( $message, 400, 'WP_HTTP_REQUEST_ERROR', $previous );
}
}

View File

@@ -0,0 +1,89 @@
<?php
namespace Yoast\WP\SEO\AI_HTTP_Request\Domain;
/**
* Class Request
* Represents a request to the AI Generator API.
*/
class Request {
/**
* The action path for the request.
*
* @var string
*/
private $action_path;
/**
* The body of the request.
*
* @var array<string>
*/
private $body;
/**
* The headers for the request.
*
* @var array<string>
*/
private $headers;
/**
* Whether the request is a POST request.
*
* @var bool
*/
private $is_post;
/**
* Constructor for the Request class.
*
* @param string $action_path The action path for the request.
* @param array<string> $body The body of the request.
* @param array<string> $headers The headers for the request.
* @param bool $is_post Whether the request is a POST request. Default is true.
*/
public function __construct( string $action_path, array $body = [], array $headers = [], bool $is_post = true ) {
$this->action_path = $action_path;
$this->body = $body;
$this->headers = $headers;
$this->is_post = $is_post;
}
/**
* Get the action path for the request.
*
* @return string The action path for the request.
*/
public function get_action_path(): string {
return $this->action_path;
}
/**
* Get the body of the request.
*
* @return array<string> The body of the request.
*/
public function get_body(): array {
return $this->body;
}
/**
* Get the headers for the request.
*
* @return array<string> The headers for the request.
*/
public function get_headers(): array {
return $this->headers;
}
/**
* Whether the request is a POST request.
*
* @return bool True if the request is a POST request, false otherwise.
*/
public function is_post(): bool {
return $this->is_post;
}
}

View File

@@ -0,0 +1,107 @@
<?php
namespace Yoast\WP\SEO\AI_HTTP_Request\Domain;
/**
* Class Response
* Represents a response from the AI Generator API.
*/
class Response {
/**
* The response body.
*
* @var string
*/
private $body;
/**
* The response code.
*
* @var int
*/
private $response_code;
/**
* The response message.
*
* @var string
*/
private $message;
/**
* The error code.
*
* @var string
*/
private $error_code;
/**
* The missing licenses.
*
* @var array<string>
*/
private $missing_licenses;
/**
* Response constructor.
*
* @param string $body The response body.
* @param int $response_code The response code.
* @param string $message The response message.
* @param string $error_code The error code.
* @param array<string> $missing_licenses The missing licenses.
*/
public function __construct( string $body, int $response_code, string $message, string $error_code = '', $missing_licenses = [] ) {
$this->body = $body;
$this->response_code = $response_code;
$this->message = $message;
$this->error_code = $error_code;
$this->missing_licenses = $missing_licenses;
}
/**
* Gets the response body.
*
* @return string The response body.
*/
public function get_body() {
return $this->body;
}
/**
* Gets the response code.
*
* @return int The response code.
*/
public function get_response_code(): int {
return $this->response_code;
}
/**
* Gets the response message.
*
* @return string The response message.
*/
public function get_message(): string {
return $this->message;
}
/**
* Gets the error code.
*
* @return string The error code.
*/
public function get_error_code(): string {
return $this->error_code;
}
/**
* Gets the missing licenses.
*
* @return array<string> The missing licenses.
*/
public function get_missing_licenses(): array {
return $this->missing_licenses;
}
}

View File

@@ -0,0 +1,34 @@
<?php
namespace Yoast\WP\SEO\AI_HTTP_Request\Infrastructure;
use Exception;
use Yoast\WP\SEO\AI_HTTP_Request\Domain\Exceptions\WP_Request_Exception;
/**
* Interface for the API client.
*/
interface API_Client_Interface {
/**
* Performs a request to the API.
*
* @param string $action_path The action path for the request.
* @param array<string> $body The body of the request.
* @param array<string> $headers The headers for the request.
* @param bool $is_post Whether the request is a POST request.
*
* @return array<int|string|array<string>> The response from the API.
*
* @throws WP_Request_Exception When the wp_remote_post() returns an error.
*/
public function perform_request( string $action_path, $body, $headers, bool $is_post ): array;
/**
* Gets the timeout of the requests in seconds.
*
* @return int The timeout of the suggestion requests in seconds.
*/
public function get_request_timeout(): int;
}

View File

@@ -0,0 +1,83 @@
<?php
namespace Yoast\WP\SEO\AI_HTTP_Request\Infrastructure;
use WPSEO_Utils;
use Yoast\WP\SEO\AI_HTTP_Request\Domain\Exceptions\WP_Request_Exception;
/**
* Class API_Client
* Handles the API requests to the AI Generator API.
*
* @makePublic
*/
class API_Client implements API_Client_Interface {
/**
* The base URL for the API.
*
* @var string
*/
private $base_url = 'https://ai.yoa.st/api/v1';
/**
* Performs a request to the API.
*
* @param string $action_path The action path for the request.
* @param array<string> $body The body of the request.
* @param array<string> $headers The headers for the request.
* @param bool $is_post Whether the request is a POST request.
*
* @return array<int|string|array<string>> The response from the API.
*
* @throws WP_Request_Exception When the wp_remote_post() returns an error.
*/
public function perform_request( string $action_path, $body, $headers, bool $is_post ): array {
// Our API expects JSON.
// The request times out after 30 seconds.
$headers = \array_merge( $headers, [ 'Content-Type' => 'application/json' ] );
$arguments = [
'timeout' => $this->get_request_timeout(),
'headers' => $headers,
];
if ( $is_post ) {
// phpcs:ignore Yoast.Yoast.JsonEncodeAlternative.Found -- Reason: We don't want the debug/pretty possibility.
$arguments['body'] = WPSEO_Utils::format_json_encode( $body );
}
/**
* Filter: 'Yoast\WP\SEO\ai_api_url' - Replaces the default URL for the AI API with a custom one.
*
* @internal
*
* @param string $url The default URL for the AI API.
*/
$url = \apply_filters( 'Yoast\WP\SEO\ai_api_url', $this->base_url );
$response = ( $is_post ) ? \wp_remote_post( $url . $action_path, $arguments ) : \wp_remote_get( $url . $action_path, $arguments );
if ( \is_wp_error( $response ) ) {
// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- false positive.
throw new WP_Request_Exception( $response->get_error_message() );
}
return $response;
}
/**
* Gets the timeout of the requests in seconds.
*
* @return int The timeout of the suggestion requests in seconds.
*/
public function get_request_timeout(): int {
/**
* Filter: 'Yoast\WP\SEO\ai_suggestions_timeout' - Replaces the default timeout with a custom one, for testing purposes.
*
* @since 22.7
* @internal
*
* @param int $timeout The default timeout in seconds.
*/
return (int) \apply_filters( 'Yoast\WP\SEO\ai_suggestions_timeout', 60 );
}
}