feat: add API Gateway, RapidMail and Shopify integrations, update WireGuard configs, add Redis override and architecture docs

This commit is contained in:
2025-11-04 23:08:17 +01:00
parent 5d6edea3bb
commit f9b8cf9f33
23 changed files with 3621 additions and 8 deletions

View File

@@ -0,0 +1,288 @@
<?php
declare(strict_types=1);
namespace App\Framework\ApiGateway;
use App\Framework\ApiGateway\Exceptions\ApiGatewayException;
use App\Framework\ApiGateway\Metrics\ApiMetrics;
use App\Framework\CircuitBreaker\CircuitBreaker;
use App\Framework\CircuitBreaker\CircuitBreakerConfig;
use App\Framework\CircuitBreaker\CircuitBreakerManager;
use App\Framework\CircuitBreaker\Exceptions\CircuitBreakerException;
use App\Framework\Core\ValueObjects\Duration;
use App\Framework\HttpClient\ClientOptions;
use App\Framework\HttpClient\ClientRequest;
use App\Framework\HttpClient\ClientResponse;
use App\Framework\HttpClient\HttpClient;
use App\Framework\Logging\Logger;
use App\Framework\Performance\OperationTracker;
use App\Framework\Performance\PerformanceCategory;
use App\Framework\Retry\RetryStrategy;
use Throwable;
/**
* Central API Gateway for all outbound API calls
*
* Provides cross-cutting concerns:
* - Circuit breaker pattern for resilience
* - Retry strategies for transient failures
* - Centralized logging and metrics
* - Request/response transformation
*
* Example usage:
*
* $request = new SendEmailApiRequest($email, $template);
* $response = $apiGateway->send($request);
*/
final readonly class ApiGateway
{
public function __construct(
private HttpClient $httpClient,
private CircuitBreakerManager $circuitBreakerManager,
private ApiMetrics $metrics,
private OperationTracker $operationTracker,
private ?Logger $logger = null
) {
}
/**
* Send an API request through the gateway
*
* @param ApiRequest $request The API request to send
* @return ClientResponse The response from the API
* @throws ApiGatewayException If the request fails after all retries
* @throws CircuitBreakerException If the circuit breaker is open
*/
public function send(ApiRequest $request): ClientResponse
{
$service = $request->getEndpoint()->getServiceIdentifier();
$requestName = $request->getRequestName();
// Start performance tracking
$operationId = "api_gateway.{$service}.{$requestName}." . uniqid();
$snapshot = $this->operationTracker->startOperation(
operationId: $operationId,
category: PerformanceCategory::HTTP,
contextData: [
'service' => $service,
'request_name' => $requestName,
'method' => $request->getMethod()->value,
'endpoint' => $request->getEndpoint()->toString(),
]
);
$retryAttempts = 0;
$circuitBreakerTriggered = false;
// Log request start
$this->logger?->info("[ApiGateway] Sending request", [
'operation_id' => $operationId,
'service' => $service,
'request_name' => $requestName,
'method' => $request->getMethod()->value,
'endpoint' => $request->getEndpoint()->toString(),
]);
// Get circuit breaker for this service
$circuitBreaker = $this->circuitBreakerManager->getCircuitBreaker($service);
$circuitBreakerConfig = CircuitBreakerConfig::forHttpClient();
// Check circuit breaker status (throws if open)
try {
$circuitBreaker->check($service, $circuitBreakerConfig);
} catch (CircuitBreakerException $e) {
$circuitBreakerTriggered = true;
$this->logger?->warning("[ApiGateway] Circuit breaker open", [
'operation_id' => $operationId,
'service' => $service,
'request_name' => $requestName,
]);
// Record failure and metrics
$completedSnapshot = $this->operationTracker->failOperation($operationId, $e);
if ($completedSnapshot !== null) {
$this->recordMetrics($service, $requestName, $completedSnapshot, false, 0, true);
}
throw $e;
}
// Convert ApiRequest to ClientRequest
$clientRequest = $this->buildClientRequest($request);
// Execute with retry strategy if configured
$retryStrategy = $request->getRetryStrategy();
try {
if ($retryStrategy !== null) {
$result = $this->sendWithRetry($clientRequest, $retryStrategy, $service, $requestName, $operationId);
$response = $result['response'];
$retryAttempts = $result['attempts'];
} else {
$response = $this->httpClient->send($clientRequest);
}
// Record success with circuit breaker
$circuitBreaker->recordSuccess($service, $circuitBreakerConfig);
$this->logger?->info("[ApiGateway] Request successful", [
'operation_id' => $operationId,
'service' => $service,
'request_name' => $requestName,
'status' => $response->status->value,
'retry_attempts' => $retryAttempts,
]);
// Complete operation tracking and record metrics
$completedSnapshot = $this->operationTracker->completeOperation($operationId);
if ($completedSnapshot !== null) {
$this->recordMetrics($service, $requestName, $completedSnapshot, true, $retryAttempts, false);
}
return $response;
} catch (Throwable $exception) {
// Record failure with circuit breaker
$circuitBreaker->recordFailure($service, $exception, $circuitBreakerConfig);
$this->logger?->error("[ApiGateway] Request failed", [
'operation_id' => $operationId,
'service' => $service,
'request_name' => $requestName,
'error' => $exception->getMessage(),
'exception_class' => get_class($exception),
'retry_attempts' => $retryAttempts,
]);
// Record failure and metrics
$completedSnapshot = $this->operationTracker->failOperation($operationId, $exception);
if ($completedSnapshot !== null) {
$this->recordMetrics($service, $requestName, $completedSnapshot, false, $retryAttempts, $circuitBreakerTriggered);
}
throw new ApiGatewayException(
"API request failed for {$requestName}",
0,
$exception
);
}
}
/**
* Build ClientRequest from ApiRequest
*/
private function buildClientRequest(ApiRequest $request): ClientRequest
{
// Convert timeout Duration to seconds for ClientOptions
$timeoutSeconds = $request->getTimeout()->toSeconds();
$options = new ClientOptions(
timeout: $timeoutSeconds,
connectTimeout: min(3, $timeoutSeconds), // Connect timeout max 3s or total timeout
);
// Use factory method for JSON requests if payload is present
if ($request instanceof HasPayload) {
return ClientRequest::json(
method: $request->getMethod(),
url: $request->getEndpoint()->toString(),
data: $request->getPayload(),
options: $options
)->with([
'headers' => $request->getHeaders(),
]);
}
// Simple request without body
return new ClientRequest(
method: $request->getMethod(),
url: $request->getEndpoint()->toString(),
headers: $request->getHeaders(),
body: '',
options: $options
);
}
/**
* Send request with retry strategy
*
* @return array{response: ClientResponse, attempts: int}
*/
private function sendWithRetry(
ClientRequest $request,
RetryStrategy $retryStrategy,
string $service,
string $requestName,
string $operationId
): array {
$attempt = 0;
while (true) {
try {
if ($attempt > 0) {
$this->logger?->info("[ApiGateway] Retry attempt", [
'operation_id' => $operationId,
'service' => $service,
'request_name' => $requestName,
'attempt' => $attempt,
]);
}
$response = $this->httpClient->send($request);
return [
'response' => $response,
'attempts' => $attempt,
];
} catch (Throwable $exception) {
$attempt++;
if (!$retryStrategy->shouldRetry($exception, $attempt)) {
$this->logger?->warning("[ApiGateway] Retry limit reached", [
'operation_id' => $operationId,
'service' => $service,
'request_name' => $requestName,
'attempts' => $attempt,
]);
throw $exception;
}
$delay = $retryStrategy->getDelay($attempt);
$this->logger?->info("[ApiGateway] Retrying after delay", [
'operation_id' => $operationId,
'service' => $service,
'request_name' => $requestName,
'attempt' => $attempt,
'delay_ms' => $delay->toMilliseconds(),
]);
// Sleep before retry
usleep($delay->toMicroseconds());
}
}
}
/**
* Record metrics from performance snapshot
*/
private function recordMetrics(
string $service,
string $requestName,
\App\Framework\Performance\PerformanceSnapshot $snapshot,
bool $success,
int $retryAttempts,
bool $circuitBreakerTriggered
): void {
$this->metrics->recordRequest(
service: $service,
requestName: $requestName,
durationMs: $snapshot->duration->toMilliseconds(),
success: $success,
retryAttempts: $retryAttempts,
circuitBreakerTriggered: $circuitBreakerTriggered
);
}
}

View File

@@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
namespace App\Framework\ApiGateway;
use App\Framework\ApiGateway\ValueObjects\ApiEndpoint;
use App\Framework\Core\ValueObjects\Duration;
use App\Framework\Http\{Headers, Method as HttpMethod};
use App\Framework\Retry\RetryStrategy;
/**
* Interface for API requests that can be sent through the ApiGateway
*
* Domain-specific modules implement this interface to define their API calls
* with proper configuration (timeout, retry strategy, headers, etc.)
*
* For requests with body payload (POST/PUT/PATCH), also implement HasPayload.
*
* Example implementations:
* - SendEmailApiRequest (RapidMail) implements ApiRequest, HasPayload
* - CreateOrderApiRequest (Shopify) implements ApiRequest, HasPayload
* - GetOrderApiRequest (Shopify) implements ApiRequest (no payload)
*
* @see HasPayload For requests with request body
*/
interface ApiRequest
{
/**
* Get the API endpoint URL
*/
public function getEndpoint(): ApiEndpoint;
/**
* Get the HTTP method (GET, POST, PUT, DELETE, etc.)
*/
public function getMethod(): HttpMethod;
/**
* Get the request timeout
*/
public function getTimeout(): Duration;
/**
* Get the retry strategy for this request
*
* @return RetryStrategy|null Null means no retries
*/
public function getRetryStrategy(): ?RetryStrategy;
/**
* Get request headers
*/
public function getHeaders(): Headers;
/**
* Get a human-readable name for this request (for logging/metrics)
*
* Examples: "rapidmail.send_email", "shopify.create_order", "stripe.charge"
*/
public function getRequestName(): string;
}

View File

@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace App\Framework\ApiGateway\Exceptions;
use RuntimeException;
/**
* Exception thrown when API Gateway operations fail
*/
final class ApiGatewayException extends RuntimeException
{
}

View File

@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace App\Framework\ApiGateway;
/**
* Marker interface for API requests that have a request body payload
*
* Requests implementing this interface:
* - POST, PUT, PATCH requests with JSON/form data
* - Requests that need body serialization
*
* Requests NOT implementing this:
* - GET, DELETE, HEAD requests (query params only)
* - Requests without body data
*
* Usage in ApiGateway:
* ```
* if ($request instanceof HasPayload) {
* $clientRequest = ClientRequest::json(..., $request->getPayload());
* } else {
* $clientRequest = new ClientRequest(...); // No body
* }
* ```
*/
interface HasPayload
{
/**
* Get the request payload/body data
*
* @return array Request body data (will be JSON-encoded)
*/
public function getPayload(): array;
}

View File

@@ -0,0 +1,302 @@
<?php
declare(strict_types=1);
namespace App\Framework\ApiGateway\Metrics;
use App\Framework\Metrics\MetricsCollection;
/**
* Collects and tracks metrics for API Gateway operations
*/
final class ApiMetrics
{
/** @var array<string, array{requests: int, successes: int, failures: int, total_duration_ms: float, avg_duration_ms: float}> */
private array $statsByService = [];
/** @var array<string, array{requests: int, successes: int, failures: int, total_duration_ms: float, avg_duration_ms: float}> */
private array $statsByRequestName = [];
private int $totalRequests = 0;
private int $totalSuccesses = 0;
private int $totalFailures = 0;
private float $totalDurationMs = 0.0;
/** @var array<string, int> */
private array $retryCountByService = [];
/** @var array<string, int> */
private array $circuitBreakerOpenCountByService = [];
public function recordRequest(
string $service,
string $requestName,
float $durationMs,
bool $success,
int $retryAttempts = 0,
bool $circuitBreakerTriggered = false
): void {
// Total stats
$this->totalRequests++;
if ($success) {
$this->totalSuccesses++;
} else {
$this->totalFailures++;
}
$this->totalDurationMs += $durationMs;
// Per-service stats
if (!isset($this->statsByService[$service])) {
$this->statsByService[$service] = [
'requests' => 0,
'successes' => 0,
'failures' => 0,
'total_duration_ms' => 0.0,
'avg_duration_ms' => 0.0,
];
}
$this->statsByService[$service]['requests']++;
if ($success) {
$this->statsByService[$service]['successes']++;
} else {
$this->statsByService[$service]['failures']++;
}
$this->statsByService[$service]['total_duration_ms'] += $durationMs;
$this->statsByService[$service]['avg_duration_ms'] =
$this->statsByService[$service]['total_duration_ms'] / $this->statsByService[$service]['requests'];
// Per-request-name stats
if (!isset($this->statsByRequestName[$requestName])) {
$this->statsByRequestName[$requestName] = [
'requests' => 0,
'successes' => 0,
'failures' => 0,
'total_duration_ms' => 0.0,
'avg_duration_ms' => 0.0,
];
}
$this->statsByRequestName[$requestName]['requests']++;
if ($success) {
$this->statsByRequestName[$requestName]['successes']++;
} else {
$this->statsByRequestName[$requestName]['failures']++;
}
$this->statsByRequestName[$requestName]['total_duration_ms'] += $durationMs;
$this->statsByRequestName[$requestName]['avg_duration_ms'] =
$this->statsByRequestName[$requestName]['total_duration_ms'] / $this->statsByRequestName[$requestName]['requests'];
// Retry tracking
if ($retryAttempts > 0) {
if (!isset($this->retryCountByService[$service])) {
$this->retryCountByService[$service] = 0;
}
$this->retryCountByService[$service] += $retryAttempts;
}
// Circuit breaker tracking
if ($circuitBreakerTriggered) {
if (!isset($this->circuitBreakerOpenCountByService[$service])) {
$this->circuitBreakerOpenCountByService[$service] = 0;
}
$this->circuitBreakerOpenCountByService[$service]++;
}
}
public function getStats(): array
{
return [
'total' => [
'requests' => $this->totalRequests,
'successes' => $this->totalSuccesses,
'failures' => $this->totalFailures,
'success_rate' => $this->getSuccessRate(),
'avg_duration_ms' => $this->getAverageDuration(),
],
'by_service' => $this->statsByService,
'by_request_name' => $this->statsByRequestName,
'retry_counts' => $this->retryCountByService,
'circuit_breaker_opens' => $this->circuitBreakerOpenCountByService,
];
}
public function getSuccessRate(): float
{
if ($this->totalRequests === 0) {
return 0.0;
}
return $this->totalSuccesses / $this->totalRequests;
}
public function getAverageDuration(): float
{
if ($this->totalRequests === 0) {
return 0.0;
}
return $this->totalDurationMs / $this->totalRequests;
}
public function getServiceStats(string $service): ?array
{
return $this->statsByService[$service] ?? null;
}
public function getRequestNameStats(string $requestName): ?array
{
return $this->statsByRequestName[$requestName] ?? null;
}
/**
* Collect metrics for Prometheus export
*/
public function collectMetrics(MetricsCollection $collection): void
{
// Total request counter
$collection->counter(
'api_gateway_requests_total',
(float) $this->totalRequests,
'Total number of API gateway requests'
);
// Success counter
$collection->counter(
'api_gateway_requests_success_total',
(float) $this->totalSuccesses,
'Total number of successful API gateway requests'
);
// Failure counter
$collection->counter(
'api_gateway_requests_failure_total',
(float) $this->totalFailures,
'Total number of failed API gateway requests'
);
// Success rate gauge
$collection->gauge(
'api_gateway_success_rate',
$this->getSuccessRate(),
'Success rate of API gateway requests (0.0 to 1.0)'
);
// Average duration gauge
$collection->gauge(
'api_gateway_request_duration_ms',
$this->getAverageDuration(),
'Average duration of API gateway requests in milliseconds'
);
// Per-service metrics
foreach ($this->statsByService as $service => $stats) {
$labels = ['service' => $service];
$collection->counter(
'api_gateway_requests_total',
(float) $stats['requests'],
'Total requests per service',
$labels
);
$collection->counter(
'api_gateway_requests_success_total',
(float) $stats['successes'],
'Successful requests per service',
$labels
);
$collection->counter(
'api_gateway_requests_failure_total',
(float) $stats['failures'],
'Failed requests per service',
$labels
);
$collection->gauge(
'api_gateway_request_duration_ms',
$stats['avg_duration_ms'],
'Average request duration per service in milliseconds',
$labels
);
// Success rate per service
$successRate = $stats['requests'] > 0
? $stats['successes'] / $stats['requests']
: 0.0;
$collection->gauge(
'api_gateway_success_rate',
$successRate,
'Success rate per service',
$labels
);
}
// Per-request-name metrics
foreach ($this->statsByRequestName as $requestName => $stats) {
$labels = ['request_name' => $requestName];
$collection->counter(
'api_gateway_requests_total',
(float) $stats['requests'],
'Total requests per request name',
$labels
);
$collection->counter(
'api_gateway_requests_success_total',
(float) $stats['successes'],
'Successful requests per request name',
$labels
);
$collection->counter(
'api_gateway_requests_failure_total',
(float) $stats['failures'],
'Failed requests per request name',
$labels
);
$collection->gauge(
'api_gateway_request_duration_ms',
$stats['avg_duration_ms'],
'Average request duration per request name in milliseconds',
$labels
);
}
// Retry metrics per service
foreach ($this->retryCountByService as $service => $retryCount) {
$collection->counter(
'api_gateway_retries_total',
(float) $retryCount,
'Total number of retry attempts per service',
['service' => $service]
);
}
// Circuit breaker metrics per service
foreach ($this->circuitBreakerOpenCountByService as $service => $openCount) {
$collection->counter(
'api_gateway_circuit_breaker_open_total',
(float) $openCount,
'Total number of circuit breaker opens per service',
['service' => $service]
);
}
}
public function reset(): void
{
$this->statsByService = [];
$this->statsByRequestName = [];
$this->totalRequests = 0;
$this->totalSuccesses = 0;
$this->totalFailures = 0;
$this->totalDurationMs = 0.0;
$this->retryCountByService = [];
$this->circuitBreakerOpenCountByService = [];
}
}

View File

@@ -0,0 +1,92 @@
<?php
declare(strict_types=1);
namespace App\Framework\ApiGateway\ValueObjects;
use App\Framework\Http\Url\Url;
use InvalidArgumentException;
/**
* Value Object representing an API endpoint URL
*
* Wraps the framework's Url interface with additional API-specific functionality
* for service identification and circuit breaker grouping
*/
final readonly class ApiEndpoint
{
public function __construct(
public Url $url
) {
$this->validate();
}
public static function fromString(string $url): self
{
return new self(Url::parse($url));
}
public static function fromUrl(Url $url): self
{
return new self($url);
}
private function validate(): void
{
// Must be HTTP or HTTPS
$scheme = $this->url->getScheme();
if (!in_array($scheme, ['http', 'https'], true)) {
throw new InvalidArgumentException(
"API endpoint must use HTTP or HTTPS, got: {$scheme}"
);
}
// Must have a host
if (empty($this->url->getHost())) {
throw new InvalidArgumentException('API endpoint must have a valid host');
}
}
/**
* Get the base domain for this endpoint (for circuit breaker identification)
*
* Example: https://api.example.com/v1/users -> api.example.com
*/
public function getDomain(): string
{
return $this->url->getHost();
}
/**
* Get a service identifier for this endpoint (for metrics/circuit breaker)
*
* Example: https://api.rapidmail.com/v1/send -> rapidmail
*/
public function getServiceIdentifier(): string
{
$domain = $this->getDomain();
// Extract main domain name (e.g., "rapidmail" from "api.rapidmail.com")
$parts = explode('.', $domain);
if (count($parts) >= 2) {
return $parts[count($parts) - 2]; // Second-to-last part
}
return $domain;
}
public function toString(): string
{
return $this->url->toString();
}
public function __toString(): string
{
return $this->toString();
}
public function equals(self $other): bool
{
return $this->url->equals($other->url);
}
}