From 9e0cf0825f0fec0684225dd65d1391d26fa3f96e Mon Sep 17 00:00:00 2001 From: magdev Date: Thu, 22 Jan 2026 15:51:05 +0100 Subject: [PATCH] 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 --- CHANGELOG.md | 16 ++ CLAUDE.md | 7 + README.md | 90 ++++++++- composer.json | 3 + composer.lock | 156 +++++++++++++- src/Dto/ActivationResult.php | 22 ++ src/Dto/LicenseInfo.php | 34 ++++ src/Dto/LicenseStatus.php | 53 +++++ src/Exception/ActivationFailedException.php | 9 + src/Exception/DomainMismatchException.php | 9 + src/Exception/LicenseException.php | 41 ++++ src/Exception/LicenseExpiredException.php | 9 + src/Exception/LicenseInactiveException.php | 9 + src/Exception/LicenseInvalidException.php | 9 + src/Exception/LicenseNotFoundException.php | 9 + src/Exception/LicenseRevokedException.php | 9 + .../MaxActivationsReachedException.php | 9 + src/Exception/RateLimitExceededException.php | 18 ++ src/LicenseClient.php | 190 ++++++++++++++++++ src/LicenseClientInterface.php | 34 ++++ 20 files changed, 730 insertions(+), 6 deletions(-) create mode 100644 src/Dto/ActivationResult.php create mode 100644 src/Dto/LicenseInfo.php create mode 100644 src/Dto/LicenseStatus.php create mode 100644 src/Exception/ActivationFailedException.php create mode 100644 src/Exception/DomainMismatchException.php create mode 100644 src/Exception/LicenseException.php create mode 100644 src/Exception/LicenseExpiredException.php create mode 100644 src/Exception/LicenseInactiveException.php create mode 100644 src/Exception/LicenseInvalidException.php create mode 100644 src/Exception/LicenseNotFoundException.php create mode 100644 src/Exception/LicenseRevokedException.php create mode 100644 src/Exception/MaxActivationsReachedException.php create mode 100644 src/Exception/RateLimitExceededException.php create mode 100644 src/LicenseClient.php create mode 100644 src/LicenseClientInterface.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 3025632..686cc1f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/CLAUDE.md b/CLAUDE.md index 8fc8e35..753f764 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/README.md b/README.md index 3639a9b..926eb9d 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/composer.json b/composer.json index 6435f88..a607e74 100644 --- a/composer.json +++ b/composer.json @@ -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": { diff --git a/composer.lock b/composer.lock index 6f8ea95..ea5a2df 100644 --- a/composer.lock +++ b/composer.lock @@ -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", diff --git a/src/Dto/ActivationResult.php b/src/Dto/ActivationResult.php new file mode 100644 index 0000000..a47ca2b --- /dev/null +++ b/src/Dto/ActivationResult.php @@ -0,0 +1,22 @@ +expiresAt === null; + } +} diff --git a/src/Dto/LicenseStatus.php b/src/Dto/LicenseStatus.php new file mode 100644 index 0000000..68400b9 --- /dev/null +++ b/src/Dto/LicenseStatus.php @@ -0,0 +1,53 @@ +expiresAt === null; + } + + public function hasAvailableActivations(): bool + { + return $this->activationsCount < $this->maxActivations; + } +} diff --git a/src/Exception/ActivationFailedException.php b/src/Exception/ActivationFailedException.php new file mode 100644 index 0000000..c840272 --- /dev/null +++ b/src/Exception/ActivationFailedException.php @@ -0,0 +1,9 @@ + 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), + }; + } +} diff --git a/src/Exception/LicenseExpiredException.php b/src/Exception/LicenseExpiredException.php new file mode 100644 index 0000000..6cfeaab --- /dev/null +++ b/src/Exception/LicenseExpiredException.php @@ -0,0 +1,9 @@ +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; + } +} diff --git a/src/LicenseClientInterface.php b/src/LicenseClientInterface.php new file mode 100644 index 0000000..0cde6c6 --- /dev/null +++ b/src/LicenseClientInterface.php @@ -0,0 +1,34 @@ +