diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..aa48257 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,48 @@ +name: Tests + +on: + push: + branches: [main] + pull_request: + +jobs: + test: + name: PHP ${{ matrix.php }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + php: ["8.1", "8.2", "8.3"] + steps: + - uses: actions/checkout@v4 + + - name: Setup PHP ${{ matrix.php }} + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + extensions: curl, json + coverage: none + + - name: Validate composer.json + run: composer validate --strict + + - name: Install dependencies + run: composer update --no-interaction --prefer-dist --no-progress + + - name: Run unit tests + run: vendor/bin/phpunit + + # Optional live smoke test. IMPORTANT: do NOT gate this step with + # `if: ${{ secrets.* }}` - the secrets context is unavailable in + # step-level `if` expressions and invalidates the whole workflow file. + # Guard inside the shell instead so it skips gracefully when the + # secret is not configured. + - name: Live smoke test (skips when no secret) + env: + OILPRICEAPI_TEST_KEY: ${{ secrets.OILPRICEAPI_TEST_KEY }} + run: | + if [ -z "$OILPRICEAPI_TEST_KEY" ]; then + echo "OILPRICEAPI_TEST_KEY not configured - skipping live smoke test." + exit 0 + fi + OILPRICEAPI_KEY="$OILPRICEAPI_TEST_KEY" php examples/smoke.php diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..283d9da --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +/vendor/ +composer.lock +.phpunit.result.cache +.phpunit.cache/ +.DS_Store diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..dc674e3 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,17 @@ +# Changelog + +## 2.0.0 (2026-07-03) + +Ground-up rewrite of the PHP SDK. + +### Added + +- `OilPriceAPI\Client` with `latest()`, `pastDay()`, `pastWeek()`, `pastMonth()`, `pastYear()`, `demoPrices()`, and the `raw()` escape hatch for any endpoint. +- Keyless demo mode via `/v1/demo/prices`; helpful `AuthenticationException` (with signup URL) when keyed endpoints are called without a key. +- `OILPRICEAPI_KEY` environment variable fallback. +- Immutable `Price` DTO (`code`, `price`, `currency`, `updatedAt` as `DateTimeImmutable`, `change24h`, plus `name`/`unit`/`type`/`formatted`) with `toArray()`. +- Automatic retries with exponential backoff + jitter on 429/5xx, honoring `Retry-After`. +- Typed exceptions: `ApiException`, `AuthenticationException`, `RateLimitException`, `TransportException`. +- Zero runtime dependencies (`ext-curl` + `ext-json` only); PHP >= 8.1; strict types throughout. +- `HttpTransport` interface for dependency-free testing and custom HTTP stacks. +- PHPUnit suite (offline, mocked transport) and GitHub Actions CI across PHP 8.1/8.2/8.3. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..5a85e00 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 OilPriceAPI (Metiri Labs LLC) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 07610fb..ddc6fba 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,168 @@ -# oilpriceapi-php +# OilPriceAPI - PHP SDK -Official PHP SDK for [OilPriceAPI](https://oilpriceapi.com). +> **Real-time oil, gas, LNG, carbon and fuel prices for PHP** — one class, zero dependencies, works everywhere PHP does (including shared hosting and WordPress). + +[![Tests](https://github.com/OilpriceAPI/oilpriceapi-php/actions/workflows/test.yml/badge.svg)](https://github.com/OilpriceAPI/oilpriceapi-php/actions/workflows/test.yml) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) + +**[Get a Free API Key](https://oilpriceapi.com/auth/signup?utm_source=php-sdk)** | **[Documentation](https://docs.oilpriceapi.com)** | **[Pricing](https://oilpriceapi.com/pricing?utm_source=php-sdk-limit)** + +The official PHP SDK for [OilPriceAPI](https://oilpriceapi.com) — real-time and historical prices for Brent, WTI, Natural Gas, Diesel, EU Carbon (ETS), TTF Gas and 100+ commodities. + +- **Zero dependencies** — only `ext-curl` and `ext-json` (bundled with virtually every PHP install). No Guzzle, no framework, no conflicts with your host's packages. +- **PHP 8.1+**, strict types, immutable `Price` DTOs. +- **Resilient** — automatic retries with exponential backoff + jitter on 429/5xx, honors `Retry-After`. +- **Typed errors** — `AuthenticationException`, `RateLimitException`, `ApiException`. +- **Demo mode** — try it without an API key. +- **Escape hatch** — `$client->raw()->get(...)` reaches any endpoint, present or future. + +## Install + +```bash +composer require oilpriceapi/oilpriceapi +``` + +## Quick start + +```php +use OilPriceAPI\Client; + +$client = new Client('your_api_key'); // or set OILPRICEAPI_KEY env var +$brent = $client->latest('BRENT_CRUDE_USD'); +echo $brent->price; // e.g. XX.XX (USD per barrel) +``` + +`latest()` without a code returns every commodity on your plan as a list of `Price` objects. + +## No composer? Plain PHP + +No SDK, no packages — this is the whole integration with nothing but PHP's built-in cURL: + +```php + true, + CURLOPT_TIMEOUT => 10, + CURLOPT_HTTPHEADER => [ + 'Authorization: Token ' . $apiKey, + 'Accept: application/json', + ], +]); +$response = curl_exec($ch); + +$json = json_decode($response, true); +echo $json['data']['price'] ?? 'No price returned'; // e.g. XX.XX +``` + +## Try it without an API key (demo mode) + +The client works out of the box — no signup required — via the demo endpoint (rate limited per IP, free-tier commodities only): + +```php +$client = new \OilPriceAPI\Client(); // no key + +foreach ($client->demoPrices() as $price) { + printf("%s: %s %.2f\n", $price->code, $price->currency, $price->price); +} +``` + +Calling a keyed endpoint without a key throws an `AuthenticationException` that tells you exactly where to [get a free key](https://oilpriceapi.com/auth/signup?utm_source=php-sdk). + +## Historical prices + +```php +$day = $client->pastDay('BRENT_CRUDE_USD'); // last 24 hours +$week = $client->pastWeek('BRENT_CRUDE_USD'); +$month = $client->pastMonth('BRENT_CRUDE_USD'); +$year = $client->pastYear('BRENT_CRUDE_USD'); + +foreach ($week as $price) { + echo $price->updatedAt?->format('Y-m-d H:i'), ' -> ', $price->price, PHP_EOL; +} +``` + +Each method returns a `list` — an immutable DTO with `code`, `price` (float), `currency`, `updatedAt` (`DateTimeImmutable|null`), `change24h` (`float|null`), plus `name`, `unit`, `type`, `formatted` where the API provides them, and a `toArray()` helper. + +## Beyond oil — gas, LNG, carbon & fuels + +OilPriceAPI is not just crude. The same client covers the energy complex that maritime compliance, fleet & logistics, LNG analytics and CBAM reporting teams need: + +```php +// EU ETS carbon allowances (EUR/tonne) - CBAM & maritime compliance +$eua = $client->latest('EU_CARBON_EUR'); +echo $eua->price; // e.g. XX.XX EUR/tonne + +// Diesel - fleet & logistics fuel-surcharge calculations +$diesel = $client->latest('DIESEL_USD'); + +// Dutch TTF natural gas futures curve - LNG & gas analytics +$ttf = $client->raw()->get('/v1/futures/ttf-gas/curve'); + +// ICE Brent futures curve via the same escape hatch +$curve = $client->raw()->get('/v1/futures/ice-brent/curve'); +``` + +> Futures endpoints require a plan with futures access — see [pricing](https://oilpriceapi.com/pricing?utm_source=php-sdk-limit). + +## Any endpoint: the `raw()` escape hatch + +New endpoints ship in the API before they ship in the SDK. `raw()` gives you the full decoded JSON envelope for any path: + +```php +$response = $client->raw()->get('/v1/futures/ice-brent/curve', ['unit' => 'usd']); +// ['status' => 'success', 'data' => [...]] +``` + +## Error handling + +```php +use OilPriceAPI\Exception\ApiException; +use OilPriceAPI\Exception\AuthenticationException; +use OilPriceAPI\Exception\RateLimitException; + +try { + $price = $client->latest('BRENT_CRUDE_USD'); +} catch (AuthenticationException $e) { + // 401 or missing key - message includes the signup URL +} catch (RateLimitException $e) { + // 429 after retries - $e->retryAfter (seconds), $e->limit, + // message includes the upgrade URL +} catch (ApiException $e) { + // everything else - $e->statusCode, $e->responseBody +} +``` + +All exceptions extend `ApiException`, so a single `catch` handles everything. + +## Retries & timeouts + +Requests that hit `429` or `5xx` are retried automatically (default: 3 retries) with exponential backoff plus jitter. If the API sends a `Retry-After` header, it is honored exactly. Everything is configurable: + +```php +$client = new \OilPriceAPI\Client( + apiKey: 'your_api_key', + timeout: 10.0, // seconds per request (default 10) + maxRetries: 3, // retries on 429/5xx (default 3) +); +``` + +## WordPress + +The SDK has no dependencies to collide with other plugins, so it drops straight into themes and plugins — `composer require` it, or copy `src/` and load it with any PSR-4 autoloader. Prefer no code at all? Use the official [OilPriceAPI WordPress plugin](https://github.com/OilpriceAPI/oilpriceapi-wordpress-plugin) for ready-made price widgets and shortcodes. + +## Testing + +```bash +composer install +composer test +``` + +The test suite is fully offline — HTTP is mocked through the `OilPriceAPI\Http\HttpTransport` interface, which you can also implement to route the SDK through your own HTTP stack. + +## License + +MIT — see [LICENSE](LICENSE). diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..1da6a93 --- /dev/null +++ b/composer.json @@ -0,0 +1,58 @@ +{ + "name": "oilpriceapi/oilpriceapi", + "description": "Official PHP SDK for OilPriceAPI - real-time and historical oil, gas, LNG, carbon and fuel prices. Zero dependencies, works on shared hosting and WordPress.", + "type": "library", + "license": "MIT", + "keywords": [ + "oil", + "oil-price", + "brent", + "wti", + "commodities", + "natural-gas", + "lng", + "carbon", + "energy", + "api", + "sdk", + "prices" + ], + "homepage": "https://oilpriceapi.com", + "authors": [ + { + "name": "OilPriceAPI", + "email": "support@oilpriceapi.com", + "homepage": "https://oilpriceapi.com" + } + ], + "support": { + "email": "support@oilpriceapi.com", + "docs": "https://docs.oilpriceapi.com", + "issues": "https://github.com/OilpriceAPI/oilpriceapi-php/issues" + }, + "require": { + "php": ">=8.1", + "ext-curl": "*", + "ext-json": "*" + }, + "require-dev": { + "phpunit/phpunit": "^10.5 || ^11.0 || ^12.0" + }, + "autoload": { + "psr-4": { + "OilPriceAPI\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "OilPriceAPI\\Tests\\": "tests/" + } + }, + "scripts": { + "test": "phpunit" + }, + "config": { + "sort-packages": true + }, + "minimum-stability": "stable" +} diff --git a/examples/quickstart.php b/examples/quickstart.php new file mode 100644 index 0000000..6e48670 --- /dev/null +++ b/examples/quickstart.php @@ -0,0 +1,27 @@ +hasApiKey()) { + $brent = $client->latest('BRENT_CRUDE_USD'); + printf( + "Brent: %s %.2f (as of %s)\n", + $brent->currency, + $brent->price, + $brent->updatedAt?->format(DATE_ATOM) ?? 'n/a', + ); +} else { + // No key? Demo mode still works (rate limited per IP). + echo "No API key set - showing demo prices instead.\n"; + foreach ($client->demoPrices() as $price) { + printf("%-20s %s %.2f\n", $price->code, $price->currency, $price->price); + } +} diff --git a/examples/smoke.php b/examples/smoke.php new file mode 100644 index 0000000..e131410 --- /dev/null +++ b/examples/smoke.php @@ -0,0 +1,35 @@ +hasApiKey()) { + fwrite(STDERR, "smoke: no API key available\n"); + exit(1); +} + +$brent = $client->latest('BRENT_CRUDE_USD'); + +if ($brent->code !== 'BRENT_CRUDE_USD' || $brent->price <= 0.0) { + fwrite(STDERR, "smoke: unexpected latest price payload\n"); + exit(1); +} + +$week = $client->pastWeek('BRENT_CRUDE_USD'); + +if ($week === []) { + fwrite(STDERR, "smoke: past_week returned no prices\n"); + exit(1); +} + +echo "smoke: OK (latest + past_week)\n"; diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..2323aad --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,17 @@ + + + + + tests + + + + + src + + + diff --git a/src/Client.php b/src/Client.php new file mode 100644 index 0000000..dc32c02 --- /dev/null +++ b/src/Client.php @@ -0,0 +1,370 @@ +latest('BRENT_CRUDE_USD'); + * echo $brent->price; // e.g. XX.XX + * + * No API key? Demo mode works out of the box: + * + * $client = new \OilPriceAPI\Client(); + * $prices = $client->demoPrices(); + */ +final class Client +{ + public const VERSION = '2.0.0'; + public const DEFAULT_BASE_URL = 'https://api.oilpriceapi.com'; + public const DEFAULT_TIMEOUT = 10.0; + public const DEFAULT_MAX_RETRIES = 3; + + /** Maximum backoff sleep between retries, in seconds. */ + private const MAX_BACKOFF_SECONDS = 30.0; + + private readonly ?string $apiKey; + private readonly string $baseUrl; + private readonly HttpTransport $transport; + /** @var Closure(float): void */ + private readonly Closure $sleeper; + + /** + * @param string|null $apiKey API key; falls back to the OILPRICEAPI_KEY + * environment variable. May be omitted entirely + * for keyless demo mode ({@see demoPrices()}). + * @param string $baseUrl Override the API base URL (rarely needed) + * @param float $timeout Per-request timeout in seconds (default 10) + * @param int $maxRetries Retries on 429/5xx with exponential backoff (default 3) + * @param HttpTransport|null $transport Custom transport (used by tests); defaults to cURL + * @param callable|null $sleeper Injectable sleep function for tests; fn (float $seconds): void + */ + public function __construct( + ?string $apiKey = null, + string $baseUrl = self::DEFAULT_BASE_URL, + private readonly float $timeout = self::DEFAULT_TIMEOUT, + private readonly int $maxRetries = self::DEFAULT_MAX_RETRIES, + ?HttpTransport $transport = null, + ?callable $sleeper = null, + ) { + $envKey = getenv('OILPRICEAPI_KEY'); + $key = $apiKey ?? ($envKey !== false ? $envKey : null); + $this->apiKey = ($key !== null && trim($key) !== '') ? trim($key) : null; + $this->baseUrl = rtrim($baseUrl, '/'); + $this->transport = $transport ?? new CurlTransport(); + $this->sleeper = $sleeper !== null + ? $sleeper(...) + : static function (float $seconds): void { + usleep((int) round($seconds * 1_000_000)); + }; + } + + /** + * Whether this client was constructed with an API key. + */ + public function hasApiKey(): bool + { + return $this->apiKey !== null; + } + + /** + * Get the latest price(s). + * + * With a commodity code, returns a single {@see Price}: + * + * $brent = $client->latest('BRENT_CRUDE_USD'); + * + * Without one, returns the latest price for every commodity on your + * plan as a list of {@see Price}: + * + * foreach ($client->latest() as $price) { ... } + * + * @return Price|list + */ + public function latest(?string $byCode = null): Price|array + { + $params = $byCode !== null ? ['by_code' => $byCode] : []; + $body = $this->request('/v1/prices/latest', $params); + $data = $this->dataOrFail($body, '/v1/prices/latest'); + + if (isset($data['prices']) && is_array($data['prices'])) { + return array_map(Price::fromArray(...), array_values($data['prices'])); + } + + return Price::fromArray($data); + } + + /** + * Prices from the past 24 hours. + * + * @return list + */ + public function pastDay(?string $byCode = null): array + { + return $this->historical('past_day', $byCode); + } + + /** + * Prices from the past week. + * + * @return list + */ + public function pastWeek(?string $byCode = null): array + { + return $this->historical('past_week', $byCode); + } + + /** + * Prices from the past month. + * + * @return list + */ + public function pastMonth(?string $byCode = null): array + { + return $this->historical('past_month', $byCode); + } + + /** + * Prices from the past year. + * + * @return list + */ + public function pastYear(?string $byCode = null): array + { + return $this->historical('past_year', $byCode); + } + + /** + * Demo prices - works WITHOUT an API key (rate limited per IP). + * + * @return list + */ + public function demoPrices(): array + { + $body = $this->request('/v1/demo/prices', []); + $data = $this->dataOrFail($body, '/v1/demo/prices'); + $prices = is_array($data['prices'] ?? null) ? $data['prices'] : []; + + return array_map(Price::fromArray(...), array_values($prices)); + } + + /** + * Escape hatch: reach ANY endpoint and get the decoded JSON envelope. + * + * $curve = $client->raw()->get('/v1/futures/ice-brent/curve'); + */ + public function raw(): RawClient + { + return new RawClient(fn (string $path, array $params): array => $this->request($path, $params)); + } + + /** + * @return list + */ + private function historical(string $period, ?string $byCode): array + { + $params = $byCode !== null ? ['by_code' => $byCode] : []; + $body = $this->request('/v1/prices/' . $period, $params); + $data = $this->dataOrFail($body, '/v1/prices/' . $period); + $prices = is_array($data['prices'] ?? null) ? $data['prices'] : []; + + return array_map(Price::fromArray(...), array_values($prices)); + } + + /** + * @param array $body + * + * @return array + */ + private function dataOrFail(array $body, string $path): array + { + if (($body['status'] ?? null) !== 'success' || !is_array($body['data'] ?? null)) { + throw new ApiException( + sprintf('Unexpected response shape from %s.', $path), + 200, + $body, + ); + } + + return $body['data']; + } + + /** + * Perform a GET request with retries and typed error mapping. + * + * @param array $params + * + * @return array Decoded JSON body + */ + private function request(string $path, array $params): array + { + $isDemo = str_starts_with($path, '/v1/demo'); + + if (!$isDemo && $this->apiKey === null) { + throw new AuthenticationException( + 'No API key configured. Pass one to the Client constructor or set the ' + . 'OILPRICEAPI_KEY environment variable. (Keyless demo mode is available ' + . 'via $client->demoPrices().)', + 0, + ); + } + + $url = $this->baseUrl . $path; + if ($params !== []) { + $url .= '?' . http_build_query($params); + } + + $headers = [ + 'Accept' => 'application/json', + 'User-Agent' => 'oilpriceapi-php/' . self::VERSION, + ]; + if (!$isDemo && $this->apiKey !== null) { + $headers['Authorization'] = 'Token ' . $this->apiKey; + } + + $attempts = max(1, $this->maxRetries + 1); + $response = null; + + for ($attempt = 0; $attempt < $attempts; $attempt++) { + $response = $this->transport->request('GET', $url, $headers, $this->timeout); + + if (!$this->isRetryable($response->statusCode) || $attempt === $attempts - 1) { + break; + } + + ($this->sleeper)($this->backoffDelay($attempt, $response)); + } + + assert($response instanceof HttpResponse); + + return $this->handleResponse($response, $path); + } + + private function isRetryable(int $statusCode): bool + { + return $statusCode === 429 || $statusCode >= 500; + } + + /** + * Exponential backoff with full jitter, honoring Retry-After when present. + */ + private function backoffDelay(int $attempt, HttpResponse $response): float + { + $retryAfter = $this->parseRetryAfter($response); + if ($retryAfter !== null) { + return min((float) $retryAfter, self::MAX_BACKOFF_SECONDS); + } + + $base = min(0.5 * (2 ** $attempt), self::MAX_BACKOFF_SECONDS); + $jitter = mt_rand(0, 1000) / 1000 * ($base / 2); + + return min($base + $jitter, self::MAX_BACKOFF_SECONDS); + } + + private function parseRetryAfter(HttpResponse $response): ?int + { + $value = $response->header('Retry-After'); + if ($value === null) { + return null; + } + + if (is_numeric($value)) { + return max(0, (int) $value); + } + + $timestamp = strtotime($value); + if ($timestamp !== false) { + return max(0, $timestamp - time()); + } + + return null; + } + + /** + * @return array + */ + private function handleResponse(HttpResponse $response, string $path): array + { + $decoded = json_decode($response->body, true); + $body = is_array($decoded) ? $decoded : []; + + if ($response->statusCode === 401) { + throw new AuthenticationException( + $this->errorMessage($body, 'Invalid API key'), + 401, + $body, + ); + } + + if ($response->statusCode === 429) { + throw new RateLimitException( + $this->errorMessage($body, 'Rate limit exceeded'), + $this->parseRetryAfter($response), + $response->header('X-RateLimit-Limit'), + $body, + ); + } + + if ($response->statusCode === 403) { + throw new ApiException( + $this->errorMessage($body, sprintf('Access to %s is not included in your plan', $path)) + . ' See https://oilpriceapi.com/pricing?utm_source=php-sdk-limit for plan options.', + 403, + $body, + ); + } + + if ($response->statusCode >= 400) { + throw new ApiException( + sprintf('API request to %s failed: %s', $path, $this->errorMessage($body, 'HTTP ' . $response->statusCode)), + $response->statusCode, + $body, + ); + } + + if (!is_array($decoded)) { + throw new ApiException( + sprintf('API returned invalid JSON from %s.', $path), + $response->statusCode, + ); + } + + return $body; + } + + /** + * @param array $body + */ + private function errorMessage(array $body, string $fallback): string + { + // Production error envelope: {"error": {"code": ..., "message": ...}} + if (isset($body['error']['message']) && is_string($body['error']['message']) && $body['error']['message'] !== '') { + return $body['error']['message']; + } + + foreach (['message', 'error', 'detail'] as $key) { + if (isset($body[$key]) && is_string($body[$key]) && $body[$key] !== '') { + return $body[$key]; + } + } + + if (isset($body['data']['message']) && is_string($body['data']['message'])) { + return $body['data']['message']; + } + + return $fallback; + } +} diff --git a/src/Exception/ApiException.php b/src/Exception/ApiException.php new file mode 100644 index 0000000..cc8a400 --- /dev/null +++ b/src/Exception/ApiException.php @@ -0,0 +1,34 @@ +latest('BRENT_CRUDE_USD'); + * } catch (\OilPriceAPI\Exception\ApiException $e) { + * error_log($e->getMessage()); + * } + */ +class ApiException extends RuntimeException +{ + /** + * @param string $message Human-readable error message + * @param int $statusCode HTTP status code (0 if not applicable) + * @param array $responseBody Decoded response body, if any + */ + public function __construct( + string $message, + public readonly int $statusCode = 0, + public readonly array $responseBody = [], + ) { + parent::__construct($message, $statusCode); + } +} diff --git a/src/Exception/AuthenticationException.php b/src/Exception/AuthenticationException.php new file mode 100644 index 0000000..3a08554 --- /dev/null +++ b/src/Exception/AuthenticationException.php @@ -0,0 +1,31 @@ + $responseBody + */ + public function __construct( + string $message = 'Invalid API key.', + int $statusCode = 401, + array $responseBody = [], + ) { + parent::__construct( + rtrim($message, '. ') . '. Get a free API key at ' . self::SIGNUP_URL, + $statusCode, + $responseBody, + ); + } +} diff --git a/src/Exception/RateLimitException.php b/src/Exception/RateLimitException.php new file mode 100644 index 0000000..76d6349 --- /dev/null +++ b/src/Exception/RateLimitException.php @@ -0,0 +1,43 @@ + $responseBody + */ + public function __construct( + string $message = 'Rate limit exceeded.', + public readonly ?int $retryAfter = null, + public readonly ?string $limit = null, + array $responseBody = [], + ) { + $suffix = ''; + if ($retryAfter !== null) { + $suffix .= sprintf(' Retry after %d seconds.', $retryAfter); + } + if ($limit !== null) { + $suffix .= sprintf(' Current plan limit: %s.', $limit); + } + + parent::__construct( + rtrim($message, '. ') . '.' . $suffix . ' Need a higher limit? Upgrade at ' . self::UPGRADE_URL, + 429, + $responseBody, + ); + } +} diff --git a/src/Exception/TransportException.php b/src/Exception/TransportException.php new file mode 100644 index 0000000..2ed679c --- /dev/null +++ b/src/Exception/TransportException.php @@ -0,0 +1,13 @@ + $value) { + $headerLines[] = $name . ': ' . $value; + } + + $responseHeaders = []; + + $ch = curl_init(); + curl_setopt_array($ch, [ + CURLOPT_URL => $url, + CURLOPT_CUSTOMREQUEST => $method, + CURLOPT_HTTPHEADER => $headerLines, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_FOLLOWLOCATION => true, + CURLOPT_MAXREDIRS => 3, + CURLOPT_TIMEOUT_MS => (int) round($timeout * 1000), + CURLOPT_CONNECTTIMEOUT_MS => (int) round(min($timeout, 10.0) * 1000), + CURLOPT_HEADERFUNCTION => static function ($ch, string $line) use (&$responseHeaders): int { + $parts = explode(':', $line, 2); + if (count($parts) === 2) { + $responseHeaders[strtolower(trim($parts[0]))] = trim($parts[1]); + } + + return strlen($line); + }, + ]); + + $body = curl_exec($ch); + + if ($body === false) { + $error = curl_error($ch); + $errno = curl_errno($ch); + + throw new TransportException( + sprintf('HTTP request to %s failed: %s (cURL error %d)', $url, $error, $errno) + ); + } + + $statusCode = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE); + + // Note: no curl_close() - it has been a no-op since PHP 8.0 and is + // deprecated as of PHP 8.5; the handle is freed when it goes out of scope. + return new HttpResponse($statusCode, $responseHeaders, (string) $body); + } +} diff --git a/src/Http/HttpResponse.php b/src/Http/HttpResponse.php new file mode 100644 index 0000000..aca6ee4 --- /dev/null +++ b/src/Http/HttpResponse.php @@ -0,0 +1,31 @@ + $headers Response headers, keys lowercased + * @param string $body Raw response body + */ + public function __construct( + public readonly int $statusCode, + public readonly array $headers, + public readonly string $body, + ) { + } + + /** + * Case-insensitive header lookup. + */ + public function header(string $name): ?string + { + return $this->headers[strtolower($name)] ?? null; + } +} diff --git a/src/Http/HttpTransport.php b/src/Http/HttpTransport.php new file mode 100644 index 0000000..6331f54 --- /dev/null +++ b/src/Http/HttpTransport.php @@ -0,0 +1,34 @@ + $headers Request headers + * @param float $timeout Total request timeout in seconds + * + * @throws TransportException on network-level failure + */ + public function request(string $method, string $url, array $headers, float $timeout): HttpResponse; +} diff --git a/src/Price.php b/src/Price.php new file mode 100644 index 0000000..e3e1434 --- /dev/null +++ b/src/Price.php @@ -0,0 +1,93 @@ +latest('BRENT_CRUDE_USD'); + * echo $price->code; // BRENT_CRUDE_USD + * echo $price->price; // e.g. XX.XX + * echo $price->currency; // USD + * echo $price->updatedAt?->format(DATE_ATOM); // 2026-01-01T12:00:00+00:00 + */ +final class Price +{ + public function __construct( + public readonly string $code, + public readonly float $price, + public readonly string $currency, + public readonly ?DateTimeImmutable $updatedAt = null, + public readonly ?float $change24h = null, + public readonly ?string $name = null, + public readonly ?string $unit = null, + public readonly ?string $type = null, + public readonly ?string $formatted = null, + ) { + } + + /** + * Build a Price from a decoded API payload. + * + * Tolerates the field-name variations across endpoints: + * `created_at`/`updated_at` for the timestamp and `change_24h`/ + * `change_percent_24h` for the 24h change. + * + * @param array $data + */ + public static function fromArray(array $data): self + { + $timestamp = $data['created_at'] ?? $data['updated_at'] ?? null; + $updatedAt = null; + if (is_string($timestamp) && $timestamp !== '') { + $parsed = DateTimeImmutable::createFromFormat(DateTimeInterface::ATOM, $timestamp); + if ($parsed === false) { + try { + $parsed = new DateTimeImmutable($timestamp); + } catch (\Exception) { + $parsed = null; + } + } + $updatedAt = $parsed ?: null; + } + + $change = $data['change_24h'] ?? $data['change_percent_24h'] ?? null; + + return new self( + code: (string) ($data['code'] ?? ''), + price: (float) ($data['price'] ?? 0.0), + currency: (string) ($data['currency'] ?? 'USD'), + updatedAt: $updatedAt, + change24h: is_numeric($change) ? (float) $change : null, + name: isset($data['name']) ? (string) $data['name'] : null, + unit: isset($data['unit']) ? (string) $data['unit'] : null, + type: isset($data['type']) ? (string) $data['type'] : null, + formatted: isset($data['formatted']) ? (string) $data['formatted'] : null, + ); + } + + /** + * @return array + */ + public function toArray(): array + { + return [ + 'code' => $this->code, + 'price' => $this->price, + 'currency' => $this->currency, + 'updated_at' => $this->updatedAt?->format(DateTimeInterface::ATOM), + 'change_24h' => $this->change24h, + 'name' => $this->name, + 'unit' => $this->unit, + 'type' => $this->type, + 'formatted' => $this->formatted, + ]; + } +} diff --git a/src/RawClient.php b/src/RawClient.php new file mode 100644 index 0000000..05f842a --- /dev/null +++ b/src/RawClient.php @@ -0,0 +1,43 @@ +raw()->get('/v1/futures/ice-brent/curve'); + * foreach ($curve['data']['contracts'] ?? [] as $contract) { + * // ... + * } + */ +final class RawClient +{ + /** + * @param Closure(string, array): array $requester + * + * @internal constructed by {@see Client::raw()} + */ + public function __construct(private readonly Closure $requester) + { + } + + /** + * Perform a GET request against any API path. + * + * @param string $path API path, e.g. '/v1/futures/ice-brent/curve' + * @param array $params Query string parameters + * + * @return array Full decoded JSON response (envelope included) + */ + public function get(string $path, array $params = []): array + { + return ($this->requester)($path, $params); + } +} diff --git a/tests/ClientTest.php b/tests/ClientTest.php new file mode 100644 index 0000000..57288f7 --- /dev/null +++ b/tests/ClientTest.php @@ -0,0 +1,393 @@ + */ + private array $sleeps = []; + + private string|false $originalEnvKey = false; + + protected function setUp(): void + { + $this->transport = new MockTransport(); + $this->sleeps = []; + $this->originalEnvKey = getenv('OILPRICEAPI_KEY'); + putenv('OILPRICEAPI_KEY'); // ensure a clean slate for every test + } + + protected function tearDown(): void + { + if ($this->originalEnvKey === false) { + putenv('OILPRICEAPI_KEY'); + } else { + putenv('OILPRICEAPI_KEY=' . $this->originalEnvKey); + } + } + + private function client(?string $apiKey = 'test_key', int $maxRetries = 3): Client + { + return new Client( + apiKey: $apiKey, + timeout: 10.0, + maxRetries: $maxRetries, + transport: $this->transport, + sleeper: function (float $seconds): void { + $this->sleeps[] = $seconds; + }, + ); + } + + // --------------------------------------------------------------- + // Happy paths + // --------------------------------------------------------------- + + public function testLatestByCodeReturnsSinglePrice(): void + { + $this->transport->queue(200, [ + 'status' => 'success', + 'data' => [ + 'code' => 'BRENT_CRUDE_USD', + 'price' => 71.23, + 'formatted' => '$71.23', + 'currency' => 'USD', + 'type' => 'spot_price', + 'created_at' => '2026-07-03T09:00:00+00:00', + ], + ]); + + $price = $this->client()->latest('BRENT_CRUDE_USD'); + + $this->assertInstanceOf(Price::class, $price); + $this->assertSame('BRENT_CRUDE_USD', $price->code); + $this->assertSame(71.23, $price->price); + $this->assertSame('USD', $price->currency); + $this->assertInstanceOf(DateTimeImmutable::class, $price->updatedAt); + $this->assertSame('2026-07-03T09:00:00+00:00', $price->updatedAt->format(DATE_ATOM)); + + $request = $this->transport->requests[0]; + $this->assertSame('GET', $request['method']); + $this->assertSame( + 'https://api.oilpriceapi.com/v1/prices/latest?by_code=BRENT_CRUDE_USD', + $request['url'], + ); + $this->assertSame('Token test_key', $request['headers']['Authorization']); + $this->assertSame('oilpriceapi-php/' . Client::VERSION, $request['headers']['User-Agent']); + } + + public function testLatestWithoutCodeReturnsPriceList(): void + { + $this->transport->queue(200, [ + 'status' => 'success', + 'data' => [ + 'prices' => [ + ['code' => 'BRENT_CRUDE_USD', 'price' => 71.23, 'currency' => 'USD'], + ['code' => 'WTI_USD', 'price' => 68.10, 'currency' => 'USD'], + ], + ], + ]); + + $prices = $this->client()->latest(); + + $this->assertIsArray($prices); + $this->assertCount(2, $prices); + $this->assertContainsOnlyInstancesOf(Price::class, $prices); + $this->assertSame('WTI_USD', $prices[1]->code); + $this->assertStringNotContainsString('by_code', $this->transport->requests[0]['url']); + } + + public function testHistoricalPeriodEndpoints(): void + { + $envelope = static fn (): array => [ + 'status' => 'success', + 'data' => [ + 'prices' => [ + ['price' => 70.00, 'created_at' => '2026-07-01T00:00:00Z', 'code' => 'BRENT_CRUDE_USD'], + ['price' => 71.00, 'created_at' => '2026-07-02T00:00:00Z', 'code' => 'BRENT_CRUDE_USD'], + ], + ], + ]; + + $client = $this->client(); + + foreach (['pastDay' => 'past_day', 'pastWeek' => 'past_week', 'pastMonth' => 'past_month', 'pastYear' => 'past_year'] as $method => $path) { + $this->transport->queue(200, $envelope()); + $prices = $client->{$method}('BRENT_CRUDE_USD'); + + $this->assertCount(2, $prices, $method); + $this->assertContainsOnlyInstancesOf(Price::class, $prices, $method); + + $url = $this->transport->requests[$this->transport->requestCount() - 1]['url']; + $this->assertSame( + 'https://api.oilpriceapi.com/v1/prices/' . $path . '?by_code=BRENT_CRUDE_USD', + $url, + $method, + ); + } + } + + // --------------------------------------------------------------- + // Demo mode (keyless) + // --------------------------------------------------------------- + + public function testDemoPricesWorksWithoutApiKeyAndSendsNoAuthHeader(): void + { + $this->transport->queue(200, [ + 'status' => 'success', + 'data' => [ + 'prices' => [ + ['code' => 'BRENT_CRUDE_USD', 'name' => 'Brent Crude', 'price' => 71.23, 'currency' => 'USD', 'unit' => 'barrel'], + ], + 'meta' => ['demo_mode' => true, 'rate_limit' => '20/hour'], + ], + ]); + + $prices = $this->client(apiKey: null)->demoPrices(); + + $this->assertCount(1, $prices); + $this->assertSame('Brent Crude', $prices[0]->name); + $this->assertSame('barrel', $prices[0]->unit); + + $request = $this->transport->requests[0]; + $this->assertSame('https://api.oilpriceapi.com/v1/demo/prices', $request['url']); + $this->assertArrayNotHasKey('Authorization', $request['headers']); + } + + public function testKeylessClientThrowsHelpfulErrorOnKeyedEndpoint(): void + { + try { + $this->client(apiKey: null)->latest('BRENT_CRUDE_USD'); + $this->fail('Expected AuthenticationException'); + } catch (AuthenticationException $e) { + $this->assertStringContainsString('No API key configured', $e->getMessage()); + $this->assertStringContainsString('https://oilpriceapi.com/auth/signup?utm_source=php-sdk', $e->getMessage()); + $this->assertStringContainsString('demoPrices', $e->getMessage()); + } + + $this->assertSame(0, $this->transport->requestCount(), 'no network call should be made without a key'); + } + + public function testApiKeyFallsBackToEnvironmentVariable(): void + { + putenv('OILPRICEAPI_KEY=env_key_123'); + + $this->transport->queue(200, [ + 'status' => 'success', + 'data' => ['code' => 'WTI_USD', 'price' => 68.10, 'currency' => 'USD'], + ]); + + $price = $this->client(apiKey: null)->latest('WTI_USD'); + + $this->assertSame('WTI_USD', $price->code); + $this->assertSame('Token env_key_123', $this->transport->requests[0]['headers']['Authorization']); + } + + // --------------------------------------------------------------- + // Errors + // --------------------------------------------------------------- + + public function testAuthenticationExceptionOn401IncludesSignupHint(): void + { + $this->transport->queue(401, ['status' => 'error', 'message' => 'Invalid Authorization token']); + + try { + $this->client()->latest('BRENT_CRUDE_USD'); + $this->fail('Expected AuthenticationException'); + } catch (AuthenticationException $e) { + $this->assertSame(401, $e->statusCode); + $this->assertStringContainsString('Invalid Authorization token', $e->getMessage()); + $this->assertStringContainsString('https://oilpriceapi.com/auth/signup?utm_source=php-sdk', $e->getMessage()); + } + + $this->assertSame(1, $this->transport->requestCount(), '401 must not be retried'); + } + + public function testNestedProductionErrorEnvelopeIsSurfaced(): void + { + // Exact shape returned by the production API on 401. + $this->transport->queue(401, [ + 'error' => [ + 'code' => 'UNAUTHORIZED', + 'message' => 'Missing or invalid API key. Include header: Authorization: Token YOUR_API_KEY', + 'status' => 401, + 'signup_url' => 'https://www.oilpriceapi.com/auth/signup', + 'demo_endpoint' => '/v1/demo/prices', + ], + ]); + + try { + $this->client()->latest('BRENT_CRUDE_USD'); + $this->fail('Expected AuthenticationException'); + } catch (AuthenticationException $e) { + $this->assertStringContainsString('Missing or invalid API key', $e->getMessage()); + $this->assertSame('UNAUTHORIZED', $e->responseBody['error']['code']); + } + } + + public function testClientErrorIsNotRetried(): void + { + $this->transport->queue(400, ['status' => 'error', 'message' => 'by_code is invalid']); + + try { + $this->client()->latest('NOPE'); + $this->fail('Expected ApiException'); + } catch (ApiException $e) { + $this->assertSame(400, $e->statusCode); + $this->assertStringContainsString('by_code is invalid', $e->getMessage()); + } + + $this->assertSame(1, $this->transport->requestCount()); + $this->assertSame([], $this->sleeps); + } + + public function testUnexpectedEnvelopeShapeThrows(): void + { + $this->transport->queue(200, ['status' => 'weird']); + + $this->expectException(ApiException::class); + $this->expectExceptionMessage('Unexpected response shape'); + + $this->client()->latest('BRENT_CRUDE_USD'); + } + + // --------------------------------------------------------------- + // Retries + // --------------------------------------------------------------- + + public function testRetriesOn429AndHonorsRetryAfterHeader(): void + { + $this->transport + ->queue(429, ['status' => 'error', 'message' => 'Rate limit exceeded'], ['Retry-After' => '7']) + ->queue(200, [ + 'status' => 'success', + 'data' => ['code' => 'BRENT_CRUDE_USD', 'price' => 71.23, 'currency' => 'USD'], + ]); + + $price = $this->client()->latest('BRENT_CRUDE_USD'); + + $this->assertSame(71.23, $price->price); + $this->assertSame(2, $this->transport->requestCount()); + $this->assertSame([7.0], $this->sleeps, 'backoff must honor Retry-After exactly'); + } + + public function testRetriesOn500WithExponentialBackoff(): void + { + $this->transport + ->queue(500, []) + ->queue(502, []) + ->queue(200, [ + 'status' => 'success', + 'data' => ['code' => 'WTI_USD', 'price' => 68.10, 'currency' => 'USD'], + ]); + + $price = $this->client()->latest('WTI_USD'); + + $this->assertSame('WTI_USD', $price->code); + $this->assertSame(3, $this->transport->requestCount()); + $this->assertCount(2, $this->sleeps); + // attempt 0: base 0.5s, attempt 1: base 1.0s - each plus up to 50% jitter + $this->assertGreaterThanOrEqual(0.5, $this->sleeps[0]); + $this->assertLessThanOrEqual(0.75, $this->sleeps[0]); + $this->assertGreaterThanOrEqual(1.0, $this->sleeps[1]); + $this->assertLessThanOrEqual(1.5, $this->sleeps[1]); + } + + public function testRateLimitExceptionAfterRetriesExhausted(): void + { + for ($i = 0; $i < 3; $i++) { + $this->transport->queue( + 429, + ['status' => 'error', 'message' => 'Rate limit exceeded'], + ['Retry-After' => '1', 'X-RateLimit-Limit' => '10000'], + ); + } + + try { + $this->client(maxRetries: 2)->latest('BRENT_CRUDE_USD'); + $this->fail('Expected RateLimitException'); + } catch (RateLimitException $e) { + $this->assertSame(429, $e->statusCode); + $this->assertSame(1, $e->retryAfter); + $this->assertSame('10000', $e->limit); + $this->assertStringContainsString('https://oilpriceapi.com/pricing?utm_source=php-sdk-limit', $e->getMessage()); + } + + $this->assertSame(3, $this->transport->requestCount(), 'initial attempt + 2 retries'); + } + + // --------------------------------------------------------------- + // Raw escape hatch + // --------------------------------------------------------------- + + public function testRawEscapeHatchReachesAnyEndpoint(): void + { + $envelope = [ + 'status' => 'success', + 'data' => [ + 'contract' => 'ice-brent', + 'curve' => [ + ['month' => '2026-08', 'price' => 71.50], + ['month' => '2026-09', 'price' => 71.10], + ], + ], + ]; + $this->transport->queue(200, $envelope); + + $result = $this->client()->raw()->get('/v1/futures/ice-brent/curve', ['unit' => 'usd']); + + $this->assertSame($envelope, $result, 'raw() must return the full decoded envelope'); + $this->assertSame( + 'https://api.oilpriceapi.com/v1/futures/ice-brent/curve?unit=usd', + $this->transport->requests[0]['url'], + ); + $this->assertSame('Token test_key', $this->transport->requests[0]['headers']['Authorization']); + } + + public function testRawInvalidJsonThrowsApiException(): void + { + $this->transport->queueRaw(200, 'not json'); + + $this->expectException(ApiException::class); + $this->expectExceptionMessage('invalid JSON'); + + $this->client()->raw()->get('/v1/prices/latest'); + } + + // --------------------------------------------------------------- + // Price DTO + // --------------------------------------------------------------- + + public function testPriceToArrayRoundTrip(): void + { + $price = Price::fromArray([ + 'code' => 'EU_CARBON_EUR', + 'price' => 88.00, + 'currency' => 'EUR', + 'created_at' => '2026-07-03T08:30:00+00:00', + 'change_24h' => -1.25, + 'unit' => 'tonne', + ]); + + $this->assertSame(-1.25, $price->change24h); + + $array = $price->toArray(); + $this->assertSame('EU_CARBON_EUR', $array['code']); + $this->assertSame(88.00, $array['price']); + $this->assertSame('EUR', $array['currency']); + $this->assertSame('2026-07-03T08:30:00+00:00', $array['updated_at']); + $this->assertSame('tonne', $array['unit']); + } +} diff --git a/tests/MockTransport.php b/tests/MockTransport.php new file mode 100644 index 0000000..f719959 --- /dev/null +++ b/tests/MockTransport.php @@ -0,0 +1,57 @@ + */ + private array $queue = []; + + /** @var list, timeout: float}> */ + public array $requests = []; + + public function queue(int $statusCode, array $body = [], array $headers = []): self + { + $normalized = []; + foreach ($headers as $name => $value) { + $normalized[strtolower($name)] = $value; + } + + $this->queue[] = new HttpResponse($statusCode, $normalized, json_encode($body, JSON_THROW_ON_ERROR)); + + return $this; + } + + public function queueRaw(int $statusCode, string $body, array $headers = []): self + { + $this->queue[] = new HttpResponse($statusCode, $headers, $body); + + return $this; + } + + public function request(string $method, string $url, array $headers, float $timeout): HttpResponse + { + $this->requests[] = compact('method', 'url', 'headers', 'timeout'); + + $response = array_shift($this->queue); + if ($response === null) { + throw new RuntimeException('MockTransport queue exhausted for ' . $url); + } + + return $response; + } + + public function requestCount(): int + { + return count($this->requests); + } +}