feat: add API Gateway, RapidMail and Shopify integrations, update WireGuard configs, add Redis override and architecture docs
This commit is contained in:
@@ -10,7 +10,6 @@
|
|||||||
wireguard_config_file: "{{ wireguard_config_path }}/{{ wireguard_interface }}.conf"
|
wireguard_config_file: "{{ wireguard_config_path }}/{{ wireguard_interface }}.conf"
|
||||||
wireguard_client_configs_path: "/etc/wireguard/clients"
|
wireguard_client_configs_path: "/etc/wireguard/clients"
|
||||||
wireguard_local_client_configs_dir: "{{ playbook_dir }}/../wireguard-clients"
|
wireguard_local_client_configs_dir: "{{ playbook_dir }}/../wireguard-clients"
|
||||||
wireguard_dns_servers: []
|
|
||||||
|
|
||||||
tasks:
|
tasks:
|
||||||
- name: Validate client name
|
- name: Validate client name
|
||||||
@@ -81,7 +80,7 @@
|
|||||||
|
|
||||||
- name: Extract server IP from config
|
- name: Extract server IP from config
|
||||||
set_fact:
|
set_fact:
|
||||||
server_vpn_ip: "{{ (wireguard_server_config_read.content | b64decode | regex_search('Address\\s*=\\s*([0-9.]+)')) | default(['10.8.0.1']) | first }}"
|
server_vpn_ip: "{{ (wireguard_server_config_read.content | b64decode | regex_findall('Address\\s*=\\s*([0-9.]+)') | first) | default('10.8.0.1') }}"
|
||||||
failed_when: false
|
failed_when: false
|
||||||
|
|
||||||
- name: Extract WireGuard server IP octets
|
- name: Extract WireGuard server IP octets
|
||||||
|
|||||||
@@ -35,7 +35,7 @@
|
|||||||
|
|
||||||
- name: Extract client IP from config
|
- name: Extract client IP from config
|
||||||
set_fact:
|
set_fact:
|
||||||
client_vpn_ip: "{{ (client_config_content.content | b64decode | regex_search('Address = ([0-9.]+)')) | default(['10.8.0.7']) | first }}"
|
client_vpn_ip: "{{ (client_config_content.content | b64decode | regex_findall('Address\\s*=\\s*([0-9.]+)') | first) | default('10.8.0.7') }}"
|
||||||
failed_when: false
|
failed_when: false
|
||||||
|
|
||||||
- name: Display extracted client IP
|
- name: Display extracted client IP
|
||||||
|
|||||||
@@ -3,13 +3,13 @@
|
|||||||
|
|
||||||
[Interface]
|
[Interface]
|
||||||
# Client private key
|
# Client private key
|
||||||
PrivateKey = wFxqFHe4R8IVzkAQSHaAwVfwQ2rfm5vCySZMpvPsVUQ=
|
PrivateKey = iDCbQUsZ2u950CIFIMFw1cYUc7dBXFjUFF8kaK4E0H4=
|
||||||
|
|
||||||
# Client IP address in VPN network
|
# Client IP address in VPN network
|
||||||
Address = 10.8.0.3/24
|
Address = 10.8.0.5/24
|
||||||
|
|
||||||
# DNS server (optional)
|
# DNS servers provided via Ansible (optional)
|
||||||
DNS = 1.1.1.1, 8.8.8.8
|
DNS = 10.8.0.1
|
||||||
|
|
||||||
[Peer]
|
[Peer]
|
||||||
# Server public key
|
# Server public key
|
||||||
@@ -24,4 +24,4 @@ Endpoint = 94.16.110.151:51820
|
|||||||
AllowedIPs = 10.8.0.0/24
|
AllowedIPs = 10.8.0.0/24
|
||||||
|
|
||||||
# Keep connection alive
|
# Keep connection alive
|
||||||
PersistentKeepalive = 25
|
PersistentKeepalive = 25
|
||||||
|
|||||||
34
docker-compose.redis-override.yml
Normal file
34
docker-compose.redis-override.yml
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
# Redis Stack Integration Override
|
||||||
|
# Usage: docker compose -f docker-compose.base.yml -f docker-compose.production.yml -f docker-compose.postgres-override.yml -f docker-compose.redis-override.yml up -d
|
||||||
|
#
|
||||||
|
# This file overrides the application stack configuration to connect to the external Redis stack.
|
||||||
|
# It follows the same pattern as docker-compose.postgres-override.yml for consistency.
|
||||||
|
|
||||||
|
services:
|
||||||
|
php:
|
||||||
|
environment:
|
||||||
|
REDIS_HOST: redis-stack # External Redis container name
|
||||||
|
REDIS_PORT: 6379
|
||||||
|
# REDIS_PASSWORD comes from Docker Secrets (not changed)
|
||||||
|
networks:
|
||||||
|
- app-internal
|
||||||
|
|
||||||
|
queue-worker:
|
||||||
|
environment:
|
||||||
|
REDIS_HOST: redis-stack
|
||||||
|
REDIS_PORT: 6379
|
||||||
|
# REDIS_PASSWORD comes from Docker Secrets (not changed)
|
||||||
|
networks:
|
||||||
|
- app-internal
|
||||||
|
|
||||||
|
scheduler:
|
||||||
|
environment:
|
||||||
|
REDIS_HOST: redis-stack
|
||||||
|
REDIS_PORT: 6379
|
||||||
|
# REDIS_PASSWORD comes from Docker Secrets (not changed)
|
||||||
|
networks:
|
||||||
|
- app-internal
|
||||||
|
|
||||||
|
networks:
|
||||||
|
app-internal:
|
||||||
|
external: true
|
||||||
1981
docs/ARCHITECTURE_IMPROVEMENTS.md
Normal file
1981
docs/ARCHITECTURE_IMPROVEMENTS.md
Normal file
File diff suppressed because it is too large
Load Diff
288
src/Framework/ApiGateway/ApiGateway.php
Normal file
288
src/Framework/ApiGateway/ApiGateway.php
Normal 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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
62
src/Framework/ApiGateway/ApiRequest.php
Normal file
62
src/Framework/ApiGateway/ApiRequest.php
Normal 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;
|
||||||
|
}
|
||||||
14
src/Framework/ApiGateway/Exceptions/ApiGatewayException.php
Normal file
14
src/Framework/ApiGateway/Exceptions/ApiGatewayException.php
Normal 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
|
||||||
|
{
|
||||||
|
}
|
||||||
35
src/Framework/ApiGateway/HasPayload.php
Normal file
35
src/Framework/ApiGateway/HasPayload.php
Normal 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;
|
||||||
|
}
|
||||||
302
src/Framework/ApiGateway/Metrics/ApiMetrics.php
Normal file
302
src/Framework/ApiGateway/Metrics/ApiMetrics.php
Normal 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 = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
92
src/Framework/ApiGateway/ValueObjects/ApiEndpoint.php
Normal file
92
src/Framework/ApiGateway/ValueObjects/ApiEndpoint.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Infrastructure\Api\RapidMail\ApiRequests;
|
||||||
|
|
||||||
|
use App\Framework\ApiGateway\ApiRequest;
|
||||||
|
use App\Framework\ApiGateway\HasPayload;
|
||||||
|
use App\Framework\ApiGateway\ValueObjects\ApiEndpoint;
|
||||||
|
use App\Framework\Core\ValueObjects\Duration;
|
||||||
|
use App\Framework\Http\Headers;
|
||||||
|
use App\Framework\Http\Method as HttpMethod;
|
||||||
|
use App\Framework\Http\Url\Url;
|
||||||
|
use App\Framework\Retry\ExponentialBackoffStrategy;
|
||||||
|
use App\Framework\Retry\RetryStrategy;
|
||||||
|
use App\Infrastructure\Api\RapidMail\RapidMailConfig;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Domain-specific API request for creating recipients in RapidMail
|
||||||
|
*
|
||||||
|
* Example usage:
|
||||||
|
*
|
||||||
|
* $request = new CreateRecipientApiRequest(
|
||||||
|
* config: $rapidMailConfig,
|
||||||
|
* recipientData: [
|
||||||
|
* 'email' => 'user@example.com',
|
||||||
|
* 'firstname' => 'John',
|
||||||
|
* 'lastname' => 'Doe'
|
||||||
|
* ]
|
||||||
|
* );
|
||||||
|
*
|
||||||
|
* $response = $apiGateway->send($request);
|
||||||
|
*/
|
||||||
|
final readonly class CreateRecipientApiRequest implements ApiRequest, HasPayload
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private RapidMailConfig $config,
|
||||||
|
private array $recipientData
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getEndpoint(): ApiEndpoint
|
||||||
|
{
|
||||||
|
$url = rtrim($this->config->baseUrl, '/') . '/recipients';
|
||||||
|
|
||||||
|
return ApiEndpoint::fromUrl(Url::parse($url));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getMethod(): HttpMethod
|
||||||
|
{
|
||||||
|
return HttpMethod::POST;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getPayload(): array
|
||||||
|
{
|
||||||
|
return $this->recipientData;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getTimeout(): Duration
|
||||||
|
{
|
||||||
|
return Duration::fromSeconds((int) $this->config->timeout);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getRetryStrategy(): ?RetryStrategy
|
||||||
|
{
|
||||||
|
// Exponential backoff: 1s, 2s, 4s
|
||||||
|
return new ExponentialBackoffStrategy(
|
||||||
|
maxAttempts: 3,
|
||||||
|
baseDelaySeconds: 1,
|
||||||
|
maxDelaySeconds: 10
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getHeaders(): Headers
|
||||||
|
{
|
||||||
|
// Basic Auth
|
||||||
|
$credentials = base64_encode("{$this->config->username}:{$this->config->password}");
|
||||||
|
|
||||||
|
return new Headers([
|
||||||
|
'Authorization' => "Basic {$credentials}",
|
||||||
|
'Content-Type' => 'application/json',
|
||||||
|
'Accept' => 'application/json',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getRequestName(): string
|
||||||
|
{
|
||||||
|
return 'rapidmail.create_recipient';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Infrastructure\Api\RapidMail\ApiRequests;
|
||||||
|
|
||||||
|
use App\Framework\ApiGateway\ApiRequest;
|
||||||
|
use App\Framework\ApiGateway\ValueObjects\ApiEndpoint;
|
||||||
|
use App\Framework\Core\ValueObjects\Duration;
|
||||||
|
use App\Framework\Http\Headers;
|
||||||
|
use App\Framework\Http\Method as HttpMethod;
|
||||||
|
use App\Framework\Http\Url\Url;
|
||||||
|
use App\Framework\Retry\ExponentialBackoffStrategy;
|
||||||
|
use App\Framework\Retry\RetryStrategy;
|
||||||
|
use App\Infrastructure\Api\RapidMail\RapidMailConfig;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Domain-specific API request for deleting a recipient from RapidMail
|
||||||
|
*
|
||||||
|
* Example usage:
|
||||||
|
*
|
||||||
|
* $request = new DeleteRecipientApiRequest(
|
||||||
|
* config: $rapidMailConfig,
|
||||||
|
* recipientId: '12345'
|
||||||
|
* );
|
||||||
|
*
|
||||||
|
* $response = $apiGateway->send($request);
|
||||||
|
*/
|
||||||
|
final readonly class DeleteRecipientApiRequest implements ApiRequest
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private RapidMailConfig $config,
|
||||||
|
private string $recipientId
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getEndpoint(): ApiEndpoint
|
||||||
|
{
|
||||||
|
$url = rtrim($this->config->baseUrl, '/') . '/recipients/' . $this->recipientId;
|
||||||
|
|
||||||
|
return ApiEndpoint::fromUrl(Url::parse($url));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getMethod(): HttpMethod
|
||||||
|
{
|
||||||
|
return HttpMethod::DELETE;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getTimeout(): Duration
|
||||||
|
{
|
||||||
|
return Duration::fromSeconds((int) $this->config->timeout);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getRetryStrategy(): ?RetryStrategy
|
||||||
|
{
|
||||||
|
// Exponential backoff: 1s, 2s, 4s
|
||||||
|
return new ExponentialBackoffStrategy(
|
||||||
|
maxAttempts: 3,
|
||||||
|
baseDelaySeconds: 1,
|
||||||
|
maxDelaySeconds: 10
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getHeaders(): Headers
|
||||||
|
{
|
||||||
|
// Basic Auth
|
||||||
|
$credentials = base64_encode("{$this->config->username}:{$this->config->password}");
|
||||||
|
|
||||||
|
return new Headers([
|
||||||
|
'Authorization' => "Basic {$credentials}",
|
||||||
|
'Accept' => 'application/json',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getRequestName(): string
|
||||||
|
{
|
||||||
|
return 'rapidmail.delete_recipient';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Infrastructure\Api\RapidMail\ApiRequests;
|
||||||
|
|
||||||
|
use App\Framework\ApiGateway\ApiRequest;
|
||||||
|
use App\Framework\ApiGateway\ValueObjects\ApiEndpoint;
|
||||||
|
use App\Framework\Core\ValueObjects\Duration;
|
||||||
|
use App\Framework\Http\Headers;
|
||||||
|
use App\Framework\Http\Method as HttpMethod;
|
||||||
|
use App\Framework\Http\Url\Url;
|
||||||
|
use App\Framework\Retry\ExponentialBackoffStrategy;
|
||||||
|
use App\Framework\Retry\RetryStrategy;
|
||||||
|
use App\Infrastructure\Api\RapidMail\RapidMailConfig;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Domain-specific API request for retrieving a recipient from RapidMail
|
||||||
|
*
|
||||||
|
* Example usage:
|
||||||
|
*
|
||||||
|
* $request = new GetRecipientApiRequest(
|
||||||
|
* config: $rapidMailConfig,
|
||||||
|
* recipientId: '12345'
|
||||||
|
* );
|
||||||
|
*
|
||||||
|
* $response = $apiGateway->send($request);
|
||||||
|
*/
|
||||||
|
final readonly class GetRecipientApiRequest implements ApiRequest
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private RapidMailConfig $config,
|
||||||
|
private string $recipientId
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getEndpoint(): ApiEndpoint
|
||||||
|
{
|
||||||
|
$url = rtrim($this->config->baseUrl, '/') . '/recipients/' . $this->recipientId;
|
||||||
|
|
||||||
|
return ApiEndpoint::fromUrl(Url::parse($url));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getMethod(): HttpMethod
|
||||||
|
{
|
||||||
|
return HttpMethod::GET;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getTimeout(): Duration
|
||||||
|
{
|
||||||
|
return Duration::fromSeconds((int) $this->config->timeout);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getRetryStrategy(): ?RetryStrategy
|
||||||
|
{
|
||||||
|
// Exponential backoff: 1s, 2s, 4s
|
||||||
|
return new ExponentialBackoffStrategy(
|
||||||
|
maxAttempts: 3,
|
||||||
|
baseDelaySeconds: 1,
|
||||||
|
maxDelaySeconds: 10
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getHeaders(): Headers
|
||||||
|
{
|
||||||
|
// Basic Auth
|
||||||
|
$credentials = base64_encode("{$this->config->username}:{$this->config->password}");
|
||||||
|
|
||||||
|
return new Headers([
|
||||||
|
'Authorization' => "Basic {$credentials}",
|
||||||
|
'Accept' => 'application/json',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getRequestName(): string
|
||||||
|
{
|
||||||
|
return 'rapidmail.get_recipient';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Infrastructure\Api\RapidMail\ApiRequests;
|
||||||
|
|
||||||
|
use App\Framework\ApiGateway\ApiRequest;
|
||||||
|
use App\Framework\ApiGateway\ValueObjects\ApiEndpoint;
|
||||||
|
use App\Framework\Core\ValueObjects\Duration;
|
||||||
|
use App\Framework\Http\Headers;
|
||||||
|
use App\Framework\Http\Method as HttpMethod;
|
||||||
|
use App\Framework\Http\Url\Url;
|
||||||
|
use App\Framework\Retry\ExponentialBackoffStrategy;
|
||||||
|
use App\Framework\Retry\RetryStrategy;
|
||||||
|
use App\Infrastructure\Api\RapidMail\RapidMailConfig;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Domain-specific API request for searching recipients in RapidMail
|
||||||
|
*
|
||||||
|
* Example usage:
|
||||||
|
*
|
||||||
|
* $request = new SearchRecipientsApiRequest(
|
||||||
|
* config: $rapidMailConfig,
|
||||||
|
* filter: ['email' => 'user@example.com'],
|
||||||
|
* page: 1,
|
||||||
|
* perPage: 50
|
||||||
|
* );
|
||||||
|
*
|
||||||
|
* $response = $apiGateway->send($request);
|
||||||
|
*/
|
||||||
|
final readonly class SearchRecipientsApiRequest implements ApiRequest
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private RapidMailConfig $config,
|
||||||
|
private array $filter = [],
|
||||||
|
private int $page = 1,
|
||||||
|
private int $perPage = 50
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getEndpoint(): ApiEndpoint
|
||||||
|
{
|
||||||
|
$baseUrl = rtrim($this->config->baseUrl, '/') . '/recipients';
|
||||||
|
|
||||||
|
// Build query parameters
|
||||||
|
$queryParams = [
|
||||||
|
'page' => (string) $this->page,
|
||||||
|
'per_page' => (string) $this->perPage,
|
||||||
|
];
|
||||||
|
|
||||||
|
// Merge filter parameters
|
||||||
|
if (!empty($this->filter)) {
|
||||||
|
foreach ($this->filter as $key => $value) {
|
||||||
|
$queryParams[$key] = is_array($value) ? json_encode($value) : (string) $value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build query string
|
||||||
|
$queryString = http_build_query($queryParams);
|
||||||
|
|
||||||
|
// Create URL with query parameters
|
||||||
|
$url = Url::parse($baseUrl)->withQuery($queryString);
|
||||||
|
|
||||||
|
return ApiEndpoint::fromUrl($url);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getMethod(): HttpMethod
|
||||||
|
{
|
||||||
|
return HttpMethod::GET;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getTimeout(): Duration
|
||||||
|
{
|
||||||
|
return Duration::fromSeconds((int) $this->config->timeout);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getRetryStrategy(): ?RetryStrategy
|
||||||
|
{
|
||||||
|
// Exponential backoff: 1s, 2s, 4s
|
||||||
|
return new ExponentialBackoffStrategy(
|
||||||
|
maxAttempts: 3,
|
||||||
|
baseDelaySeconds: 1,
|
||||||
|
maxDelaySeconds: 10
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getHeaders(): Headers
|
||||||
|
{
|
||||||
|
// Basic Auth
|
||||||
|
$credentials = base64_encode("{$this->config->username}:{$this->config->password}");
|
||||||
|
|
||||||
|
return new Headers([
|
||||||
|
'Authorization' => "Basic {$credentials}",
|
||||||
|
'Accept' => 'application/json',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getRequestName(): string
|
||||||
|
{
|
||||||
|
return 'rapidmail.search_recipients';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Infrastructure\Api\RapidMail\ApiRequests;
|
||||||
|
|
||||||
|
use App\Framework\ApiGateway\ApiRequest;
|
||||||
|
use App\Framework\ApiGateway\HasPayload;
|
||||||
|
use App\Framework\ApiGateway\ValueObjects\ApiEndpoint;
|
||||||
|
use App\Framework\Core\ValueObjects\Duration;
|
||||||
|
use App\Framework\Http\Headers;
|
||||||
|
use App\Framework\Http\Method as HttpMethod;
|
||||||
|
use App\Framework\Http\Url\Url;
|
||||||
|
use App\Framework\Retry\ExponentialBackoffStrategy;
|
||||||
|
use App\Framework\Retry\RetryStrategy;
|
||||||
|
use App\Infrastructure\Api\RapidMail\RapidMailConfig;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Domain-specific API request for updating a recipient in RapidMail
|
||||||
|
*
|
||||||
|
* Example usage:
|
||||||
|
*
|
||||||
|
* $request = new UpdateRecipientApiRequest(
|
||||||
|
* config: $rapidMailConfig,
|
||||||
|
* recipientId: '12345',
|
||||||
|
* recipientData: [
|
||||||
|
* 'firstname' => 'Jane',
|
||||||
|
* 'lastname' => 'Doe'
|
||||||
|
* ]
|
||||||
|
* );
|
||||||
|
*
|
||||||
|
* $response = $apiGateway->send($request);
|
||||||
|
*/
|
||||||
|
final readonly class UpdateRecipientApiRequest implements ApiRequest, HasPayload
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private RapidMailConfig $config,
|
||||||
|
private string $recipientId,
|
||||||
|
private array $recipientData
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getEndpoint(): ApiEndpoint
|
||||||
|
{
|
||||||
|
$url = rtrim($this->config->baseUrl, '/') . '/recipients/' . $this->recipientId;
|
||||||
|
|
||||||
|
return ApiEndpoint::fromUrl(Url::parse($url));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getMethod(): HttpMethod
|
||||||
|
{
|
||||||
|
return HttpMethod::PATCH;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getPayload(): array
|
||||||
|
{
|
||||||
|
return $this->recipientData;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getTimeout(): Duration
|
||||||
|
{
|
||||||
|
return Duration::fromSeconds((int) $this->config->timeout);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getRetryStrategy(): ?RetryStrategy
|
||||||
|
{
|
||||||
|
// Exponential backoff: 1s, 2s, 4s
|
||||||
|
return new ExponentialBackoffStrategy(
|
||||||
|
maxAttempts: 3,
|
||||||
|
baseDelaySeconds: 1,
|
||||||
|
maxDelaySeconds: 10
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getHeaders(): Headers
|
||||||
|
{
|
||||||
|
// Basic Auth
|
||||||
|
$credentials = base64_encode("{$this->config->username}:{$this->config->password}");
|
||||||
|
|
||||||
|
return new Headers([
|
||||||
|
'Authorization' => "Basic {$credentials}",
|
||||||
|
'Content-Type' => 'application/json',
|
||||||
|
'Accept' => 'application/json',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getRequestName(): string
|
||||||
|
{
|
||||||
|
return 'rapidmail.update_recipient';
|
||||||
|
}
|
||||||
|
}
|
||||||
86
src/Infrastructure/Api/RapidMail/SendEmailApiRequest.php
Normal file
86
src/Infrastructure/Api/RapidMail/SendEmailApiRequest.php
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Infrastructure\Api\RapidMail;
|
||||||
|
|
||||||
|
use App\Framework\ApiGateway\ApiRequest;
|
||||||
|
use App\Framework\ApiGateway\ValueObjects\ApiEndpoint;
|
||||||
|
use App\Framework\Core\ValueObjects\Duration;
|
||||||
|
use App\Framework\Http\Headers;
|
||||||
|
use App\Framework\Http\Method as HttpMethod;
|
||||||
|
use App\Framework\Http\Url\Url;
|
||||||
|
use App\Framework\Retry\RetryStrategy;
|
||||||
|
use App\Infrastructure\Api\RapidMail\ValueObjects\{EmailAddress, EmailTemplate, RapidMailApiKey};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Domain-specific API request for sending emails via RapidMail
|
||||||
|
*
|
||||||
|
* Example usage:
|
||||||
|
*
|
||||||
|
* $request = new SendEmailApiRequest(
|
||||||
|
* apiKey: $apiKey,
|
||||||
|
* to: EmailAddress::fromString('user@example.com'),
|
||||||
|
* template: EmailTemplate::fromString('welcome'),
|
||||||
|
* data: ['name' => 'John Doe']
|
||||||
|
* );
|
||||||
|
*
|
||||||
|
* $response = $apiGateway->send($request);
|
||||||
|
*/
|
||||||
|
final readonly class SendEmailApiRequest implements ApiRequest
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private RapidMailApiKey $apiKey,
|
||||||
|
private EmailAddress $to,
|
||||||
|
private EmailTemplate $template,
|
||||||
|
private array $data = [],
|
||||||
|
private ?RetryStrategy $retryStrategy = null
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getEndpoint(): ApiEndpoint
|
||||||
|
{
|
||||||
|
return ApiEndpoint::fromUrl(
|
||||||
|
Url::parse('https://api.rapidmail.com/v1/send')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getMethod(): HttpMethod
|
||||||
|
{
|
||||||
|
return HttpMethod::POST;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getPayload(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'to' => $this->to->value,
|
||||||
|
'template' => $this->template->value,
|
||||||
|
'data' => $this->data,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getTimeout(): Duration
|
||||||
|
{
|
||||||
|
// Email sending can take a bit longer
|
||||||
|
return Duration::fromSeconds(15);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getRetryStrategy(): ?RetryStrategy
|
||||||
|
{
|
||||||
|
return $this->retryStrategy;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getHeaders(): Headers
|
||||||
|
{
|
||||||
|
return new Headers([
|
||||||
|
'Authorization' => "Bearer {$this->apiKey->value}",
|
||||||
|
'Content-Type' => 'application/json',
|
||||||
|
'Accept' => 'application/json',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getRequestName(): string
|
||||||
|
{
|
||||||
|
return 'rapidmail.send_email';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Infrastructure\Api\RapidMail\ValueObjects;
|
||||||
|
|
||||||
|
use InvalidArgumentException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Value object for email address
|
||||||
|
*/
|
||||||
|
final readonly class EmailAddress
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public string $value
|
||||||
|
) {
|
||||||
|
if (!filter_var($value, FILTER_VALIDATE_EMAIL)) {
|
||||||
|
throw new InvalidArgumentException("Invalid email address: {$value}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function fromString(string $value): self
|
||||||
|
{
|
||||||
|
return new self($value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function toString(): string
|
||||||
|
{
|
||||||
|
return $this->value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function __toString(): string
|
||||||
|
{
|
||||||
|
return $this->toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Infrastructure\Api\RapidMail\ValueObjects;
|
||||||
|
|
||||||
|
use InvalidArgumentException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Value object for email template identifier
|
||||||
|
*/
|
||||||
|
final readonly class EmailTemplate
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public string $value
|
||||||
|
) {
|
||||||
|
if (empty($value)) {
|
||||||
|
throw new InvalidArgumentException('Email template identifier cannot be empty');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!preg_match('/^[a-z0-9_-]+$/', $value)) {
|
||||||
|
throw new InvalidArgumentException(
|
||||||
|
'Email template identifier must contain only lowercase letters, numbers, hyphens and underscores'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function fromString(string $value): self
|
||||||
|
{
|
||||||
|
return new self($value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function toString(): string
|
||||||
|
{
|
||||||
|
return $this->value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function __toString(): string
|
||||||
|
{
|
||||||
|
return $this->toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Infrastructure\Api\RapidMail\ValueObjects;
|
||||||
|
|
||||||
|
use InvalidArgumentException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Value object for RapidMail API key
|
||||||
|
*/
|
||||||
|
final readonly class RapidMailApiKey
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public string $value
|
||||||
|
) {
|
||||||
|
if (empty($value)) {
|
||||||
|
throw new InvalidArgumentException('RapidMail API key cannot be empty');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (strlen($value) < 32) {
|
||||||
|
throw new InvalidArgumentException('RapidMail API key appears invalid (too short)');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function fromString(string $value): self
|
||||||
|
{
|
||||||
|
return new self($value);
|
||||||
|
}
|
||||||
|
}
|
||||||
89
src/Infrastructure/Api/Shopify/CreateOrderApiRequest.php
Normal file
89
src/Infrastructure/Api/Shopify/CreateOrderApiRequest.php
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Infrastructure\Api\Shopify;
|
||||||
|
|
||||||
|
use App\Framework\ApiGateway\ApiRequest;
|
||||||
|
use App\Framework\ApiGateway\ValueObjects\ApiEndpoint;
|
||||||
|
use App\Framework\Core\ValueObjects\Duration;
|
||||||
|
use App\Framework\Http\Headers;
|
||||||
|
use App\Framework\Http\Method as HttpMethod;
|
||||||
|
use App\Framework\Http\Url\Url;
|
||||||
|
use App\Framework\Retry\RetryStrategy;
|
||||||
|
use App\Infrastructure\Api\Shopify\ValueObjects\{ShopifyApiKey, ShopifyStore};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Domain-specific API request for creating orders in Shopify
|
||||||
|
*
|
||||||
|
* Example usage:
|
||||||
|
*
|
||||||
|
* $request = new CreateOrderApiRequest(
|
||||||
|
* apiKey: $apiKey,
|
||||||
|
* store: ShopifyStore::fromString('mystore'),
|
||||||
|
* orderData: [
|
||||||
|
* 'line_items' => [
|
||||||
|
* ['variant_id' => 123, 'quantity' => 2]
|
||||||
|
* ],
|
||||||
|
* 'customer' => ['email' => 'customer@example.com']
|
||||||
|
* ]
|
||||||
|
* );
|
||||||
|
*
|
||||||
|
* $response = $apiGateway->send($request);
|
||||||
|
*/
|
||||||
|
final readonly class CreateOrderApiRequest implements ApiRequest
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private ShopifyApiKey $apiKey,
|
||||||
|
private ShopifyStore $store,
|
||||||
|
private array $orderData,
|
||||||
|
private ?RetryStrategy $retryStrategy = null
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getEndpoint(): ApiEndpoint
|
||||||
|
{
|
||||||
|
$shopUrl = "https://{$this->store->value}.myshopify.com/admin/api/2024-01/orders.json";
|
||||||
|
|
||||||
|
return ApiEndpoint::fromUrl(
|
||||||
|
Url::parse($shopUrl)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getMethod(): HttpMethod
|
||||||
|
{
|
||||||
|
return HttpMethod::POST;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getPayload(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'order' => $this->orderData,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getTimeout(): Duration
|
||||||
|
{
|
||||||
|
// Order creation should be quick
|
||||||
|
return Duration::fromSeconds(10);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getRetryStrategy(): ?RetryStrategy
|
||||||
|
{
|
||||||
|
return $this->retryStrategy;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getHeaders(): Headers
|
||||||
|
{
|
||||||
|
return new Headers([
|
||||||
|
'X-Shopify-Access-Token' => $this->apiKey->value,
|
||||||
|
'Content-Type' => 'application/json',
|
||||||
|
'Accept' => 'application/json',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getRequestName(): string
|
||||||
|
{
|
||||||
|
return 'shopify.create_order';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Infrastructure\Api\Shopify\ValueObjects;
|
||||||
|
|
||||||
|
use InvalidArgumentException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Value object for Shopify API access token
|
||||||
|
*/
|
||||||
|
final readonly class ShopifyApiKey
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public string $value
|
||||||
|
) {
|
||||||
|
if (empty($value)) {
|
||||||
|
throw new InvalidArgumentException('Shopify API key cannot be empty');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shopify access tokens are typically 32+ characters
|
||||||
|
if (strlen($value) < 32) {
|
||||||
|
throw new InvalidArgumentException('Shopify API key appears invalid (too short)');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function fromString(string $value): self
|
||||||
|
{
|
||||||
|
return new self($value);
|
||||||
|
}
|
||||||
|
}
|
||||||
51
src/Infrastructure/Api/Shopify/ValueObjects/ShopifyStore.php
Normal file
51
src/Infrastructure/Api/Shopify/ValueObjects/ShopifyStore.php
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Infrastructure\Api\Shopify\ValueObjects;
|
||||||
|
|
||||||
|
use InvalidArgumentException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Value object for Shopify store identifier
|
||||||
|
*/
|
||||||
|
final readonly class ShopifyStore
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public string $value
|
||||||
|
) {
|
||||||
|
if (empty($value)) {
|
||||||
|
throw new InvalidArgumentException('Shopify store identifier cannot be empty');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shopify store names are lowercase alphanumeric with hyphens
|
||||||
|
if (!preg_match('/^[a-z0-9][a-z0-9-]*[a-z0-9]$/', $value)) {
|
||||||
|
throw new InvalidArgumentException(
|
||||||
|
'Shopify store identifier must be lowercase alphanumeric with hyphens'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function fromString(string $value): self
|
||||||
|
{
|
||||||
|
return new self($value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function toString(): string
|
||||||
|
{
|
||||||
|
return $this->value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function __toString(): string
|
||||||
|
{
|
||||||
|
return $this->toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the full Shopify URL for this store
|
||||||
|
*/
|
||||||
|
public function getShopifyUrl(): string
|
||||||
|
{
|
||||||
|
return "https://{$this->value}.myshopify.com";
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user