Add object-oriented license client library (v0.0.2)

- Add LicenseClient with PSR-3 logging and PSR-6 caching support
- Add DTO classes: LicenseInfo, LicenseStatus, ActivationResult
- Add LicenseState enum for license status values
- Add comprehensive exception hierarchy for error handling
- Add PSR dependencies (psr/log, psr/cache, psr/http-client)
- Update README with usage examples
- Update CHANGELOG for v0.0.2

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-22 15:51:05 +01:00
parent a702c262ca
commit 9e0cf0825f
20 changed files with 730 additions and 6 deletions

View File

@@ -7,6 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [0.0.2] - 2026-01-22
### Added
- Object-oriented client library (`LicenseClient`, `LicenseClientInterface`)
- DTO classes for API responses (`LicenseInfo`, `LicenseStatus`, `ActivationResult`)
- `LicenseState` enum for license status values
- Comprehensive exception hierarchy for error handling
- PSR-3 logging support (optional)
- PSR-6 caching support (optional)
- PSR dependencies (`psr/log`, `psr/cache`, `psr/http-client`)
### Changed
- Updated README with usage examples
## [0.0.1] - 2026-01-22
### Added

View File

@@ -29,9 +29,16 @@ This project is proudly **"vibe-coded"** using Claude.AI - the entire codebase w
No known bugs at the moment
### Version 0.0.2
- Create an object oriented client-library for the API endpoints
- Add Logging and caching
- Keep in mind, that the security related parts of the client MUST be obfuscatable while keeping the developer experience nice. Do not obfuscate it yet.
## Technical Stack
- **Language:** PHP 8.3.x
- **PHP-Standards:** PSR-3, PSR-4, PSR-6, PSR-18
- **Coding-Style:** Symfony
- **HTTP-Client-Library:** symfony/http-client
- **Dependency Management:** Composer

View File

@@ -15,12 +15,96 @@ composer require magdev/wc-licensed-product-client
## Features
- Easy integration in licensed software packages
- Object-oriented client library
- PSR-3 logging support
- PSR-6 caching support
- PSR-18 HTTP client compatible
- License validation against domains
- License activation on domains
- License status checking
- Comprehensive exception handling
- Built on Symfony HttpClient
## Usage
### Basic Usage
```php
use Magdev\WcLicensedProductClient\LicenseClient;
use Symfony\Component\HttpClient\HttpClient;
$httpClient = HttpClient::create();
$client = new LicenseClient(
httpClient: $httpClient,
baseUrl: 'https://your-wordpress-site.com',
);
// Validate a license
$licenseInfo = $client->validate('ABCD-1234-EFGH-5678', 'example.com');
echo "Product ID: " . $licenseInfo->productId;
// Check license status
$status = $client->status('ABCD-1234-EFGH-5678');
echo "Status: " . $status->status->value;
// Activate a license
$result = $client->activate('ABCD-1234-EFGH-5678', 'example.com');
echo "Activated: " . ($result->success ? 'Yes' : 'No');
```
### With Logging
```php
use Magdev\WcLicensedProductClient\LicenseClient;
use Psr\Log\LoggerInterface;
use Symfony\Component\HttpClient\HttpClient;
$client = new LicenseClient(
httpClient: HttpClient::create(),
baseUrl: 'https://your-wordpress-site.com',
logger: $yourPsrLogger, // Any PSR-3 compatible logger
);
```
### With Caching
```php
use Magdev\WcLicensedProductClient\LicenseClient;
use Psr\Cache\CacheItemPoolInterface;
use Symfony\Component\HttpClient\HttpClient;
$client = new LicenseClient(
httpClient: HttpClient::create(),
baseUrl: 'https://your-wordpress-site.com',
cache: $yourPsrCache, // Any PSR-6 compatible cache
cacheTtl: 600, // Cache TTL in seconds (default: 300)
);
```
### Exception Handling
```php
use Magdev\WcLicensedProductClient\Exception\LicenseNotFoundException;
use Magdev\WcLicensedProductClient\Exception\LicenseExpiredException;
use Magdev\WcLicensedProductClient\Exception\DomainMismatchException;
use Magdev\WcLicensedProductClient\Exception\RateLimitExceededException;
use Magdev\WcLicensedProductClient\Exception\LicenseException;
try {
$licenseInfo = $client->validate($licenseKey, $domain);
} catch (LicenseNotFoundException $e) {
// License key does not exist
} catch (LicenseExpiredException $e) {
// License has expired
} catch (DomainMismatchException $e) {
// License is not valid for this domain
} catch (RateLimitExceededException $e) {
// Too many requests, retry after $e->retryAfter seconds
} catch (LicenseException $e) {
// Other license-related errors
}
```
## API Endpoints
This client interacts with the following WooCommerce Licensed Product API endpoints:
@@ -29,10 +113,6 @@ This client interacts with the following WooCommerce Licensed Product API endpoi
- **POST /status** - Get detailed license status information
- **POST /activate** - Activate a license on a domain
## Usage
Coming soon in future versions.
## License
GPL-2.0-or-later

View File

@@ -17,6 +17,9 @@
},
"require": {
"php": "^8.3",
"psr/cache": "^3.0",
"psr/http-client": "^1.0",
"psr/log": "^3.0",
"symfony/http-client": "^7.0"
},
"autoload": {

156
composer.lock generated
View File

@@ -4,8 +4,57 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "dabb6ce648c8b638dc4b20b8999dea1d",
"content-hash": "e412380889a4c25cef1aa8d453216223",
"packages": [
{
"name": "psr/cache",
"version": "3.0.0",
"source": {
"type": "git",
"url": "https://github.com/php-fig/cache.git",
"reference": "aa5030cfa5405eccfdcb1083ce040c2cb8d253bf"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/php-fig/cache/zipball/aa5030cfa5405eccfdcb1083ce040c2cb8d253bf",
"reference": "aa5030cfa5405eccfdcb1083ce040c2cb8d253bf",
"shasum": ""
},
"require": {
"php": ">=8.0.0"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "1.0.x-dev"
}
},
"autoload": {
"psr-4": {
"Psr\\Cache\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "PHP-FIG",
"homepage": "https://www.php-fig.org/"
}
],
"description": "Common interface for caching libraries",
"keywords": [
"cache",
"psr",
"psr-6"
],
"support": {
"source": "https://github.com/php-fig/cache/tree/3.0.0"
},
"time": "2021-02-03T23:26:27+00:00"
},
{
"name": "psr/container",
"version": "2.0.2",
@@ -59,6 +108,111 @@
},
"time": "2021-11-05T16:47:00+00:00"
},
{
"name": "psr/http-client",
"version": "1.0.3",
"source": {
"type": "git",
"url": "https://github.com/php-fig/http-client.git",
"reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/php-fig/http-client/zipball/bb5906edc1c324c9a05aa0873d40117941e5fa90",
"reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90",
"shasum": ""
},
"require": {
"php": "^7.0 || ^8.0",
"psr/http-message": "^1.0 || ^2.0"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "1.0.x-dev"
}
},
"autoload": {
"psr-4": {
"Psr\\Http\\Client\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "PHP-FIG",
"homepage": "https://www.php-fig.org/"
}
],
"description": "Common interface for HTTP clients",
"homepage": "https://github.com/php-fig/http-client",
"keywords": [
"http",
"http-client",
"psr",
"psr-18"
],
"support": {
"source": "https://github.com/php-fig/http-client"
},
"time": "2023-09-23T14:17:50+00:00"
},
{
"name": "psr/http-message",
"version": "2.0",
"source": {
"type": "git",
"url": "https://github.com/php-fig/http-message.git",
"reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/php-fig/http-message/zipball/402d35bcb92c70c026d1a6a9883f06b2ead23d71",
"reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71",
"shasum": ""
},
"require": {
"php": "^7.2 || ^8.0"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "2.0.x-dev"
}
},
"autoload": {
"psr-4": {
"Psr\\Http\\Message\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "PHP-FIG",
"homepage": "https://www.php-fig.org/"
}
],
"description": "Common interface for HTTP messages",
"homepage": "https://github.com/php-fig/http-message",
"keywords": [
"http",
"http-message",
"psr",
"psr-7",
"request",
"response"
],
"support": {
"source": "https://github.com/php-fig/http-message/tree/2.0"
},
"time": "2023-04-04T09:54:51+00:00"
},
{
"name": "psr/log",
"version": "3.0.2",

View File

@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace Magdev\WcLicensedProductClient\Dto;
final readonly class ActivationResult
{
public function __construct(
public bool $success,
public string $message,
) {
}
public static function fromArray(array $data): self
{
return new self(
success: $data['success'],
message: $data['message'],
);
}
}

34
src/Dto/LicenseInfo.php Normal file
View File

@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace Magdev\WcLicensedProductClient\Dto;
final readonly class LicenseInfo
{
public function __construct(
public int $productId,
public ?\DateTimeImmutable $expiresAt = null,
public ?int $versionId = null,
) {
}
public static function fromArray(array $data): self
{
$expiresAt = null;
if (isset($data['expires_at']) && $data['expires_at'] !== null) {
$expiresAt = new \DateTimeImmutable($data['expires_at']);
}
return new self(
productId: $data['product_id'],
expiresAt: $expiresAt,
versionId: $data['version_id'] ?? null,
);
}
public function isLifetime(): bool
{
return $this->expiresAt === null;
}
}

53
src/Dto/LicenseStatus.php Normal file
View File

@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace Magdev\WcLicensedProductClient\Dto;
enum LicenseState: string
{
case Active = 'active';
case Inactive = 'inactive';
case Expired = 'expired';
case Revoked = 'revoked';
}
final readonly class LicenseStatus
{
public function __construct(
public bool $valid,
public LicenseState $status,
public string $domain,
public ?\DateTimeImmutable $expiresAt,
public int $activationsCount,
public int $maxActivations,
) {
}
public static function fromArray(array $data): self
{
$expiresAt = null;
if (isset($data['expires_at']) && $data['expires_at'] !== null) {
$expiresAt = new \DateTimeImmutable($data['expires_at']);
}
return new self(
valid: $data['valid'],
status: LicenseState::from($data['status']),
domain: $data['domain'],
expiresAt: $expiresAt,
activationsCount: $data['activations_count'],
maxActivations: $data['max_activations'],
);
}
public function isLifetime(): bool
{
return $this->expiresAt === null;
}
public function hasAvailableActivations(): bool
{
return $this->activationsCount < $this->maxActivations;
}
}

View File

@@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
namespace Magdev\WcLicensedProductClient\Exception;
final class ActivationFailedException extends LicenseException
{
}

View File

@@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
namespace Magdev\WcLicensedProductClient\Exception;
final class DomainMismatchException extends LicenseException
{
}

View File

@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace Magdev\WcLicensedProductClient\Exception;
class LicenseException extends \RuntimeException
{
public function __construct(
string $message,
public readonly ?string $errorCode = null,
int $code = 0,
?\Throwable $previous = null,
) {
parent::__construct($message, $code, $previous);
}
public static function fromApiResponse(array $data, int $httpCode = 0): self
{
$errorCode = $data['error'] ?? null;
$message = $data['message'] ?? 'Unknown license error';
return match ($errorCode) {
'license_not_found' => new LicenseNotFoundException($message, $errorCode, $httpCode),
'license_expired' => new LicenseExpiredException($message, $errorCode, $httpCode),
'license_revoked' => new LicenseRevokedException($message, $errorCode, $httpCode),
'license_inactive' => new LicenseInactiveException($message, $errorCode, $httpCode),
'license_invalid' => new LicenseInvalidException($message, $errorCode, $httpCode),
'domain_mismatch' => new DomainMismatchException($message, $errorCode, $httpCode),
'max_activations_reached' => new MaxActivationsReachedException($message, $errorCode, $httpCode),
'activation_failed' => new ActivationFailedException($message, $errorCode, $httpCode),
'rate_limit_exceeded' => new RateLimitExceededException(
$message,
$errorCode,
$httpCode,
retryAfter: $data['retry_after'] ?? null,
),
default => new self($message, $errorCode, $httpCode),
};
}
}

View File

@@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
namespace Magdev\WcLicensedProductClient\Exception;
final class LicenseExpiredException extends LicenseException
{
}

View File

@@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
namespace Magdev\WcLicensedProductClient\Exception;
final class LicenseInactiveException extends LicenseException
{
}

View File

@@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
namespace Magdev\WcLicensedProductClient\Exception;
final class LicenseInvalidException extends LicenseException
{
}

View File

@@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
namespace Magdev\WcLicensedProductClient\Exception;
final class LicenseNotFoundException extends LicenseException
{
}

View File

@@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
namespace Magdev\WcLicensedProductClient\Exception;
final class LicenseRevokedException extends LicenseException
{
}

View File

@@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
namespace Magdev\WcLicensedProductClient\Exception;
final class MaxActivationsReachedException extends LicenseException
{
}

View File

@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace Magdev\WcLicensedProductClient\Exception;
final class RateLimitExceededException extends LicenseException
{
public function __construct(
string $message,
?string $errorCode = null,
int $code = 0,
?\Throwable $previous = null,
public readonly ?int $retryAfter = null,
) {
parent::__construct($message, $errorCode, $code, $previous);
}
}

190
src/LicenseClient.php Normal file
View File

@@ -0,0 +1,190 @@
<?php
declare(strict_types=1);
namespace Magdev\WcLicensedProductClient;
use Magdev\WcLicensedProductClient\Dto\ActivationResult;
use Magdev\WcLicensedProductClient\Dto\LicenseInfo;
use Magdev\WcLicensedProductClient\Dto\LicenseStatus;
use Magdev\WcLicensedProductClient\Exception\LicenseException;
use Psr\Cache\CacheItemPoolInterface;
use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;
use Symfony\Contracts\HttpClient\HttpClientInterface;
final class LicenseClient implements LicenseClientInterface
{
private const API_PATH = '/wp-json/wc-licensed-product/v1';
private const CACHE_TTL = 300; // 5 minutes
private readonly LoggerInterface $logger;
public function __construct(
private readonly HttpClientInterface $httpClient,
private readonly string $baseUrl,
?LoggerInterface $logger = null,
private readonly ?CacheItemPoolInterface $cache = null,
private readonly int $cacheTtl = self::CACHE_TTL,
) {
$this->logger = $logger ?? new NullLogger();
}
public function validate(string $licenseKey, string $domain): LicenseInfo
{
$cacheKey = $this->buildCacheKey('validate', $licenseKey, $domain);
if ($this->cache !== null) {
$item = $this->cache->getItem($cacheKey);
if ($item->isHit()) {
$this->logger->debug('License validation cache hit', [
'domain' => $domain,
]);
return $item->get();
}
}
$this->logger->info('Validating license', [
'domain' => $domain,
]);
$response = $this->request('validate', [
'license_key' => $licenseKey,
'domain' => $domain,
]);
$result = LicenseInfo::fromArray($response['license']);
if ($this->cache !== null) {
$item = $this->cache->getItem($cacheKey);
$item->set($result);
$item->expiresAfter($this->cacheTtl);
$this->cache->save($item);
}
$this->logger->info('License validated successfully', [
'domain' => $domain,
'product_id' => $result->productId,
]);
return $result;
}
public function status(string $licenseKey): LicenseStatus
{
$cacheKey = $this->buildCacheKey('status', $licenseKey);
if ($this->cache !== null) {
$item = $this->cache->getItem($cacheKey);
if ($item->isHit()) {
$this->logger->debug('License status cache hit');
return $item->get();
}
}
$this->logger->info('Checking license status');
$response = $this->request('status', [
'license_key' => $licenseKey,
]);
$result = LicenseStatus::fromArray($response);
if ($this->cache !== null) {
$item = $this->cache->getItem($cacheKey);
$item->set($result);
$item->expiresAfter($this->cacheTtl);
$this->cache->save($item);
}
$this->logger->info('License status retrieved', [
'status' => $result->status->value,
'valid' => $result->valid,
]);
return $result;
}
public function activate(string $licenseKey, string $domain): ActivationResult
{
$this->logger->info('Activating license', [
'domain' => $domain,
]);
$response = $this->request('activate', [
'license_key' => $licenseKey,
'domain' => $domain,
]);
$result = ActivationResult::fromArray($response);
// Invalidate related cache entries after activation
if ($this->cache !== null) {
$this->cache->deleteItem($this->buildCacheKey('validate', $licenseKey, $domain));
$this->cache->deleteItem($this->buildCacheKey('status', $licenseKey));
}
$this->logger->info('License activation completed', [
'domain' => $domain,
'success' => $result->success,
]);
return $result;
}
/**
* @throws LicenseException
*/
private function request(string $endpoint, array $payload): array
{
$url = rtrim($this->baseUrl, '/') . self::API_PATH . '/' . $endpoint;
try {
$response = $this->httpClient->request('POST', $url, [
'json' => $payload,
'headers' => [
'Accept' => 'application/json',
'Content-Type' => 'application/json',
],
]);
$statusCode = $response->getStatusCode();
$data = $response->toArray(false);
if ($statusCode >= 400) {
$this->logger->warning('License API error response', [
'endpoint' => $endpoint,
'status_code' => $statusCode,
'error' => $data['error'] ?? 'unknown',
]);
throw LicenseException::fromApiResponse($data, $statusCode);
}
return $data;
} catch (LicenseException $e) {
throw $e;
} catch (\Throwable $e) {
$this->logger->error('License API request failed', [
'endpoint' => $endpoint,
'error' => $e->getMessage(),
]);
throw new LicenseException(
'Failed to communicate with license server: ' . $e->getMessage(),
null,
0,
$e
);
}
}
private function buildCacheKey(string $operation, string $licenseKey, ?string $domain = null): string
{
$key = 'wc_license_' . $operation . '_' . hash('sha256', $licenseKey);
if ($domain !== null) {
$key .= '_' . hash('sha256', $domain);
}
return $key;
}
}

View File

@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace Magdev\WcLicensedProductClient;
use Magdev\WcLicensedProductClient\Dto\ActivationResult;
use Magdev\WcLicensedProductClient\Dto\LicenseInfo;
use Magdev\WcLicensedProductClient\Dto\LicenseStatus;
use Magdev\WcLicensedProductClient\Exception\LicenseException;
interface LicenseClientInterface
{
/**
* Validate a license key for a specific domain.
*
* @throws LicenseException When validation fails
*/
public function validate(string $licenseKey, string $domain): LicenseInfo;
/**
* Get detailed status information for a license key.
*
* @throws LicenseException When status check fails
*/
public function status(string $licenseKey): LicenseStatus;
/**
* Activate a license on a specific domain.
*
* @throws LicenseException When activation fails
*/
public function activate(string $licenseKey, string $domain): ActivationResult;
}