diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index aa48257..9e7233f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -3,6 +3,7 @@ name: Tests on: push: branches: [main] + tags: ["v*"] pull_request: jobs: @@ -32,6 +33,40 @@ jobs: - name: Run unit tests run: vendor/bin/phpunit + clean-install: + name: Packaged Composer example + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: "8.3" + extensions: curl, json + coverage: none + + - name: Install exact Composer archive and run recovery fixtures + env: + SDK_VERSION: ${{ startsWith(github.ref, 'refs/tags/v') && github.ref_name || '2.1.0' }} + run: ./scripts/clean-install-smoke.sh + + live: + name: Live canonical first request + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: "8.3" + extensions: curl, json + coverage: none + + - name: Install dependencies + run: composer update --no-interaction --prefer-dist --no-progress + # 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. diff --git a/CHANGELOG.md b/CHANGELOG.md index dc674e3..0f89946 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,12 +1,30 @@ # Changelog +## 2.1.0 (2026-07-19) + +### Fixed + +- Reject successful latest-price responses that contain no usable price. +- Preserve the production `source` field on the immutable `Price` DTO. +- Correct the no-code `latest()` documentation to reflect the production + singleton response while retaining legacy list-envelope compatibility. + +### Changed + +- Publish an executable `OILPRICEAPI_KEY` first request with actionable missing + configuration, 401, 403, and 429 recovery. +- Replace mutable product claims with links to the reviewed product-facts, + pricing, catalog, and data-rights contracts. +- Add a Composer archive clean-install smoke, strict production first-request + guard, and public-claim drift test. + ## 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. +- `OilPriceAPI\Client` with `latest()`, `pastDay()`, `pastWeek()`, `pastMonth()`, `pastYear()`, `demoPrices()`, and a `raw()` escape hatch for versioned GET endpoints. - 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()`. diff --git a/README.md b/README.md index 20c915e..85f69fd 100644 --- a/README.md +++ b/README.md @@ -1,40 +1,27 @@ -# OilPriceAPI — PHP SDK +# OilPriceAPI PHP SDK -> **Real-time oil, gas, LNG, carbon and fuel prices in your PHP app in under 60 seconds** — one class, zero dependencies, works everywhere PHP does (including shared hosting and WordPress). +The official PHP client for source-timestamped oil, gas, refined-product, +futures, and related energy data from [OilPriceAPI](https://www.oilpriceapi.com). [![Packagist Version](https://img.shields.io/packagist/v/oilpriceapi/oilpriceapi)](https://packagist.org/packages/oilpriceapi/oilpriceapi) -[![Downloads](https://img.shields.io/packagist/dt/oilpriceapi/oilpriceapi)](https://packagist.org/packages/oilpriceapi/oilpriceapi) [![PHP Version](https://img.shields.io/packagist/dependency-v/oilpriceapi/oilpriceapi/php)](https://packagist.org/packages/oilpriceapi/oilpriceapi) [![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) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) -**[Get a Free API Key](https://oilpriceapi.com/auth/signup?utm_source=php-sdk)** · **[Documentation](https://docs.oilpriceapi.com)** · **[API Explorer](https://api.oilpriceapi.com/swagger)** · **[Pricing](https://oilpriceapi.com/pricing?utm_source=php-sdk-limit)** · **[Live demo, no key needed ↓](#try-it-without-an-api-key-demo-mode)** +[Create an API key](https://www.oilpriceapi.com/auth/signup?utm_source=php-sdk) | +[Documentation](https://docs.oilpriceapi.com) | +[API explorer](https://api.oilpriceapi.com/swagger) | +[Pricing](https://www.oilpriceapi.com/pricing?utm_source=php-sdk-limit) -The official PHP SDK for [OilPriceAPI](https://oilpriceapi.com), the commodity price API behind fintech dashboards, fleet & logistics tools, maritime compliance platforms and energy analytics products — serving **2M+ API requests every month**. +## Requirements -- **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. +- PHP 8.1 or newer +- `ext-curl` and `ext-json` +- API base URL: `https://api.oilpriceapi.com` +- Auth header: `Authorization: Token YOUR_API_KEY` +- Environment variable used by the executable example: `OILPRICEAPI_KEY` -## What can you get? - -110+ commodities across the energy complex. The ones our customers poll the most: - -| Code | What it is | Typical use | -| ----------------- | -------------------------- | ------------------------------------------- | -| `BRENT_CRUDE_USD` | Brent crude (global) | dashboards, market context, deal models | -| `WTI_USD` | WTI crude (US) | trading tools, macro models | -| `NATURAL_GAS_USD` | Henry Hub natural gas | energy analytics, procurement | -| `DUTCH_TTF_EUR` | TTF gas (Europe) | European energy, LNG analytics | -| `JKM_LNG_USD` | JKM LNG (Asia) | LNG trading & shipping | -| `EU_CARBON_EUR` | EU ETS carbon allowances | CBAM reporting, maritime compliance, ESG | -| `DIESEL_USD` | Diesel (Gulf Coast) | fleet fuel-surcharge calculators, logistics | -| `JET_FUEL_USD` | Jet fuel | aviation ops & charter pricing | -| `VLSFO_USD` | Marine bunker fuel (0.5%S) | voyage costing, bunker procurement | -| `GOLD_USD` | Gold | macro & portfolio context | +The package has no third-party runtime dependency. ## Install @@ -42,102 +29,92 @@ The official PHP SDK for [OilPriceAPI](https://oilpriceapi.com), the commodity p composer require oilpriceapi/oilpriceapi ``` -## Quick start +## First Request -```php -use OilPriceAPI\Client; +The canonical authenticated first request is: -$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) +```text +GET /v1/prices/latest?by_code=BRENT_CRUDE_USD ``` -`latest()` without a code returns every commodity on your plan as a list of `Price` objects. +Run the packaged, tested example: -## No composer? Plain PHP +```bash +export OILPRICEAPI_KEY="your-api-key" +php vendor/oilpriceapi/oilpriceapi/examples/quickstart.php +``` -No SDK, no packages — this is the whole integration with nothing but PHP's built-in cURL: +The same request in application code: ```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): +require __DIR__ . '/vendor/autoload.php'; -```php -$client = new \OilPriceAPI\Client(); // no key +use OilPriceAPI\Client; -foreach ($client->demoPrices() as $price) { - printf("%s: %s %.2f\n", $price->code, $price->currency, $price->price); -} +$client = new Client(); // reads OILPRICEAPI_KEY +$brent = $client->latest('BRENT_CRUDE_USD'); + +printf( + "%s %.2f %s/%s as of %s (source: %s)\n", + $brent->code, + $brent->price, + $brent->currency, + $brent->unit ?? 'unknown', + $brent->updatedAt?->format(DATE_ATOM) ?? 'unknown', + $brent->source ?? 'unknown', +); ``` -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 +For missing configuration and actionable 401, 403, and 429 recovery, use the +exact source in [`examples/quickstart.php`](examples/quickstart.php). CI builds +a Composer ZIP, installs it into a clean project, and runs every recovery path +against fixtures. -```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'); +## Latest Response Compatibility -foreach ($week as $price) { - echo $price->updatedAt?->format('Y-m-d H:i'), ' -> ', $price->price, PHP_EOL; -} -``` +Production returns a singleton `data` object for the canonical latest-price +request, so `latest('BRENT_CRUDE_USD')` returns one `Price`. The SDK retains +support for a legacy `data.prices[]` envelope, returned as a list, but rejects a +successful response that contains no usable price. Pass a commodity code when +the caller requires a predictable single `Price` result. -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. +`Price` is immutable and exposes `code`, `price`, `currency`, `updatedAt`, +`source`, `change24h`, `name`, `unit`, `type`, and `formatted` when supplied by +the API. -## Beyond oil — gas, LNG, carbon & fuels +## Demo Request -OilPriceAPI is not just crude. The same client covers the energy complex that maritime compliance, fleet & logistics, LNG analytics and CBAM reporting teams need: +The demo endpoint does not require an API key: ```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'); +$client = new \OilPriceAPI\Client(); +foreach ($client->demoPrices() as $price) { + printf("%s %.2f %s/%s\n", + $price->code, + $price->price, + $price->currency, + $price->unit ?? 'unknown', + ); +} ``` -> Futures endpoints require a plan with futures access — see [pricing](https://oilpriceapi.com/pricing?utm_source=php-sdk-limit). +Demo availability and limits are returned by the endpoint. Authenticated +dataset access and limits vary by plan, source, and account entitlement. -## 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: +## Historical Prices ```php -$response = $client->raw()->get('/v1/futures/ice-brent/curve', ['unit' => 'usd']); -// ['status' => 'success', 'data' => [...]] +$day = $client->pastDay('BRENT_CRUDE_USD'); +$week = $client->pastWeek('BRENT_CRUDE_USD'); +$month = $client->pastMonth('BRENT_CRUDE_USD'); +$year = $client->pastYear('BRENT_CRUDE_USD'); ``` -## Error handling +Each method returns a list of immutable `Price` objects. + +## Typed Errors ```php use OilPriceAPI\Exception\ApiException; @@ -145,81 +122,95 @@ 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 + $brent = $client->latest('BRENT_CRUDE_USD'); +} catch (AuthenticationException $error) { + error_log('Replace OILPRICEAPI_KEY with an active key.'); +} catch (RateLimitException $error) { + error_log(sprintf('Retry after %d seconds.', $error->retryAfter ?? 0)); +} catch (ApiException $error) { + if (in_array($error->statusCode, [402, 403], true)) { + error_log('Review dataset access at https://www.oilpriceapi.com/pricing'); + } else { + error_log(sprintf('Request failed with HTTP %d.', $error->statusCode)); + } } ``` -All exceptions extend `ApiException`, so a single `catch` handles everything. +All SDK exceptions extend `ApiException`. `RateLimitException` exposes +`retryAfter` and the server-reported `limit` when present. -## Retries & timeouts +## Retries and 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: +The client retries `429` and `5xx` responses with bounded exponential backoff +and honors `Retry-After`. ```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) + apiKey: getenv('OILPRICEAPI_KEY') ?: null, + timeout: 10.0, + maxRetries: 2, ); ``` -## WordPress +For tests, `baseUrl`, `HttpTransport`, and the retry sleeper are injectable. +The executable example also reads `OILPRICEAPI_BASE_URL` when a fixture or +private compatible endpoint is required. + +## Raw GET Escape Hatch + +Use `raw()` for a versioned GET route that does not yet have a typed method: + +```php +$curve = $client->raw()->get('/v1/futures/ice-brent/curve'); +``` -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. +Availability varies by dataset, plan, source, and account entitlement. Review +[current access](https://www.oilpriceapi.com/pricing) rather than relying on a +plan claim copied into package metadata. -## The whole OilPriceAPI toolbox +## Reviewed Product Facts -Same data, every stack: +The versioned, reviewed contract is +[`product-facts.json`](https://api.oilpriceapi.com/product-facts.json). Mutable +offer, catalog, freshness, entitlement, and data-rights claims should link to +that contract instead of being duplicated in SDK documentation. -| Tool | Install | -| ----------------------------------------------------------------------------------- | ---------------------------------------------- | -| [Python SDK](https://github.com/OilpriceAPI/python-sdk) | `pip install oilpriceapi` | -| [Node/TypeScript SDK](https://github.com/OilpriceAPI/oilpriceapi-node) | `npm install oilpriceapi` | -| [Go SDK](https://github.com/OilpriceAPI/oilpriceapi-go) | `go get github.com/OilpriceAPI/oilpriceapi-go` | -| [MCP server](https://github.com/OilpriceAPI/mcp-server) (Claude, Cursor, AI agents) | `npx -y oilpriceapi-mcp` | -| [WordPress plugin](https://github.com/OilpriceAPI/oilpriceapi-wordpress-plugin) | wordpress.org, no code | +Current reviewed catalog wording: a broad catalog spanning crude oil, natural +gas, refined products, futures, marine fuels, carbon markets, metals, forex, +and selected energy-intelligence datasets. See the +[commodity catalog](https://www.oilpriceapi.com/commodities) for current +availability. -## Explore the API +Source timestamps describe the values in each response. They do not imply one +sitewide update interval: refresh cadence varies by source, market hours, +dataset, and plan. -- 🧭 **Interactive explorer**: [api.oilpriceapi.com/swagger](https://api.oilpriceapi.com/swagger) — try every endpoint in the browser (works in demo mode, no key needed) -- 📜 **OpenAPI spec**: [swagger.json](https://api.oilpriceapi.com/swagger.json) — import into Postman/Insomnia or generate clients +Standard plans provide API access, normalization, monitoring, and delivery; +they do not grant ownership of source data or unrestricted raw-data +redistribution rights. See the +[data usage policy](https://www.oilpriceapi.com/legal/data-usage). -## Testing +## Verify This Repository ```bash +composer validate --strict composer install composer test +./scripts/clean-install-smoke.sh ``` -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). - ---- +The guarded production smoke requires `OILPRICEAPI_TEST_KEY`: -## Why OilPriceAPI? - -[OilPriceAPI](https://oilpriceapi.com) provides professional-grade commodity price data at **98% less cost than Bloomberg Terminal** ($24,000/year vs $45/month). Trusted by energy traders, financial analysts, and developers worldwide. - -### Key Benefits - -- **Real-time data** updated every 5 minutes -- **Historical data** for trend analysis and backtesting -- **99.9% uptime** with enterprise-grade reliability -- **5-minute integration** with this PHP SDK -- **Free tier** with 100 requests to get started +```bash +OILPRICEAPI_KEY="your-test-key" php examples/smoke.php +``` -**[Start Free](https://oilpriceapi.com/auth/signup?utm_source=php-sdk)** | **[View Pricing](https://oilpriceapi.com/pricing?utm_source=php-sdk-limit)** | **[Read Docs](https://docs.oilpriceapi.com)** +## Support ---- +- [Documentation](https://docs.oilpriceapi.com) +- [API explorer](https://api.oilpriceapi.com/swagger) +- [Status](https://status.oilpriceapi.com) +- [GitHub issues](https://github.com/OilpriceAPI/oilpriceapi-php/issues) +- support@oilpriceapi.com -Made with care by the OilPriceAPI Team +MIT licensed. See [LICENSE](LICENSE). diff --git a/composer.json b/composer.json index 1da6a93..cb20911 100644 --- a/composer.json +++ b/composer.json @@ -1,6 +1,6 @@ { "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.", + "description": "Official PHP SDK for source-timestamped OilPriceAPI energy data, with typed errors and no third-party runtime dependencies.", "type": "library", "license": "MIT", "keywords": [ @@ -17,12 +17,12 @@ "sdk", "prices" ], - "homepage": "https://oilpriceapi.com", + "homepage": "https://www.oilpriceapi.com", "authors": [ { "name": "OilPriceAPI", "email": "support@oilpriceapi.com", - "homepage": "https://oilpriceapi.com" + "homepage": "https://www.oilpriceapi.com" } ], "support": { diff --git a/examples/quickstart.php b/examples/quickstart.php index 6e48670..1d35f72 100644 --- a/examples/quickstart.php +++ b/examples/quickstart.php @@ -2,26 +2,65 @@ declare(strict_types=1); -require __DIR__ . '/../vendor/autoload.php'; - use OilPriceAPI\Client; +use OilPriceAPI\Exception\ApiException; +use OilPriceAPI\Exception\AuthenticationException; +use OilPriceAPI\Exception\RateLimitException; + +$autoloadCandidates = [ + __DIR__ . '/../vendor/autoload.php', + dirname(__DIR__, 3) . '/autoload.php', +]; +$autoload = null; +foreach ($autoloadCandidates as $candidate) { + if (is_file($candidate)) { + $autoload = $candidate; + break; + } +} +if ($autoload === null) { + fwrite(STDERR, "Composer autoloader not found; install oilpriceapi/oilpriceapi first.\n"); + exit(2); +} +require $autoload; + +$apiKey = getenv('OILPRICEAPI_KEY'); +if ($apiKey === false || trim($apiKey) === '') { + fwrite(STDERR, "OILPRICEAPI_KEY is required; create a key at https://www.oilpriceapi.com/auth/signup\n"); + exit(2); +} -// Reads the OILPRICEAPI_KEY environment variable automatically. -// Get a free key: https://oilpriceapi.com/auth/signup?utm_source=php-sdk -$client = new Client(); +$baseUrl = getenv('OILPRICEAPI_BASE_URL'); +$client = new Client( + apiKey: $apiKey, + baseUrl: $baseUrl !== false && $baseUrl !== '' ? $baseUrl : Client::DEFAULT_BASE_URL, + maxRetries: 0, +); -if ($client->hasApiKey()) { +try { $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); +} catch (AuthenticationException) { + fwrite(STDERR, "Authentication failed; replace OILPRICEAPI_KEY with an active key.\n"); + exit(1); +} catch (RateLimitException $error) { + $retry = $error->retryAfter !== null ? sprintf(' Retry after %d seconds.', $error->retryAfter) : ''; + fwrite(STDERR, 'Request limit reached.' . $retry . " Review https://www.oilpriceapi.com/pricing\n"); + exit(1); +} catch (ApiException $error) { + if (in_array($error->statusCode, [402, 403], true)) { + fwrite(STDERR, "This account cannot access the requested dataset; review https://www.oilpriceapi.com/pricing\n"); + } else { + fwrite(STDERR, sprintf("Latest-price request failed (HTTP %d).\n", $error->statusCode)); } + exit(1); } + +printf( + "%s %.2f %s/%s as of %s (source: %s)\n", + $brent->code, + $brent->price, + $brent->currency, + $brent->unit ?? 'unknown', + $brent->updatedAt?->format(DATE_ATOM) ?? 'unknown', + $brent->source ?? 'unknown', +); diff --git a/examples/smoke.php b/examples/smoke.php index e131410..f323ef7 100644 --- a/examples/smoke.php +++ b/examples/smoke.php @@ -20,16 +20,14 @@ $brent = $client->latest('BRENT_CRUDE_USD'); -if ($brent->code !== 'BRENT_CRUDE_USD' || $brent->price <= 0.0) { +if ( + $brent->code !== 'BRENT_CRUDE_USD' + || $brent->price <= 0.0 + || $brent->source === null + || $brent->updatedAt === null +) { 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"; +echo "smoke: OK (canonical latest price)\n"; diff --git a/scripts/clean-install-smoke.sh b/scripts/clean-install-smoke.sh new file mode 100755 index 0000000..1304dad --- /dev/null +++ b/scripts/clean-install-smoke.sh @@ -0,0 +1,83 @@ +#!/usr/bin/env bash +set -euo pipefail + +root_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +tmp_dir="$(mktemp -d)" +server_pid="" +sdk_version="${SDK_VERSION:-2.1.0}" + +cleanup() { + if [[ -n "$server_pid" ]]; then + kill "$server_pid" 2>/dev/null || true + wait "$server_pid" 2>/dev/null || true + fi + rm -rf "$tmp_dir" +} +trap cleanup EXIT + +mkdir -p "$tmp_dir/artifacts" "$tmp_dir/consumer" +COMPOSER_ROOT_VERSION="$sdk_version" composer archive \ + --working-dir="$root_dir" \ + --format=zip \ + --dir="$tmp_dir/artifacts" \ + --file=oilpriceapi \ + --no-interaction \ + --quiet +archive_path="$tmp_dir/artifacts/oilpriceapi.zip" +[[ -s "$archive_path" ]] || { echo "Composer archive was not created" >&2; exit 1; } + +package_json="$(php -r ' + $package = json_decode(file_get_contents($argv[1]), true, flags: JSON_THROW_ON_ERROR); + $package["version"] = $argv[2]; + $package["dist"] = ["url" => "file://" . $argv[3], "type" => "zip"]; + unset($package["require-dev"], $package["autoload-dev"], $package["scripts"]); + echo json_encode(["type" => "package", "package" => $package], JSON_THROW_ON_ERROR); +' "$root_dir/composer.json" "$sdk_version" "$archive_path")" + +pushd "$tmp_dir/consumer" >/dev/null +export COMPOSER_ROOT_VERSION=1.0.0 +composer init --name=oilpriceapi/example-smoke --no-interaction --quiet +composer config --quiet repositories.oilpriceapi "$package_json" +composer require "oilpriceapi/oilpriceapi:$sdk_version" --no-interaction --prefer-dist --no-progress --quiet +quickstart="$tmp_dir/consumer/vendor/oilpriceapi/oilpriceapi/examples/quickstart.php" +[[ -f "$quickstart" ]] || { echo "packaged quickstart is missing" >&2; exit 1; } + +port="$(php -r 'echo random_int(20000, 45000);')" +base_url="http://127.0.0.1:$port" +php -S "127.0.0.1:$port" "$root_dir/tests/fixtures/router.php" >"$tmp_dir/server.log" 2>&1 & +server_pid=$! +for _ in {1..50}; do + if curl -sS "$base_url/v1/prices/latest?by_code=BRENT_CRUDE_USD" >/dev/null 2>&1; then + break + fi + sleep 0.1 +done +kill -0 "$server_pid" 2>/dev/null || { echo "fixture server did not start" >&2; exit 1; } + +OILPRICEAPI_BASE_URL="$base_url" OILPRICEAPI_KEY="valid-smoke-key" php "$quickstart" >"$tmp_dir/success.out" 2>&1 +grep -q '^BRENT_CRUDE_USD 71.80 USD/barrel as of 2026-07-19T12:00:00+00:00 (source: market_reporting)$' "$tmp_dir/success.out" + +set +e +env -u OILPRICEAPI_KEY OILPRICEAPI_BASE_URL="$base_url" php "$quickstart" >"$tmp_dir/missing.out" 2>&1 +missing_status=$? +OILPRICEAPI_BASE_URL="$base_url" OILPRICEAPI_KEY="invalid-smoke-key" php "$quickstart" >"$tmp_dir/auth.out" 2>&1 +auth_status=$? +OILPRICEAPI_BASE_URL="$base_url" OILPRICEAPI_KEY="locked-smoke-key" php "$quickstart" >"$tmp_dir/locked.out" 2>&1 +locked_status=$? +OILPRICEAPI_BASE_URL="$base_url" OILPRICEAPI_KEY="limited-smoke-key" php "$quickstart" >"$tmp_dir/limited.out" 2>&1 +limited_status=$? +set -e + +[[ "$missing_status" -ne 0 && "$auth_status" -ne 0 && "$locked_status" -ne 0 && "$limited_status" -ne 0 ]] +grep -q 'OILPRICEAPI_KEY is required' "$tmp_dir/missing.out" +grep -q 'Authentication failed; replace OILPRICEAPI_KEY' "$tmp_dir/auth.out" +grep -q 'cannot access the requested dataset' "$tmp_dir/locked.out" +grep -q 'Retry after 3 seconds' "$tmp_dir/limited.out" + +if grep -E 'valid-smoke-key|invalid-smoke-key|locked-smoke-key|limited-smoke-key' "$tmp_dir"/*.out; then + echo "quickstart output exposed a credential" >&2 + exit 1 +fi +popd >/dev/null + +echo "packaged Composer smoke passed (success, missing config, 401, 403, 429)" diff --git a/src/Client.php b/src/Client.php index dc32c02..5196a75 100644 --- a/src/Client.php +++ b/src/Client.php @@ -28,7 +28,7 @@ */ final class Client { - public const VERSION = '2.0.0'; + public const VERSION = '2.1.0'; public const DEFAULT_BASE_URL = 'https://api.oilpriceapi.com'; public const DEFAULT_TIMEOUT = 10.0; public const DEFAULT_MAX_RETRIES = 3; @@ -87,10 +87,9 @@ public function hasApiKey(): bool * * $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) { ... } + * Without one, production returns the default latest price. Legacy API + * responses containing a `prices` array are retained as a list for + * backward compatibility. Pass a code for a predictable single result. * * @return Price|list */ @@ -101,10 +100,17 @@ public function latest(?string $byCode = null): Price|array $data = $this->dataOrFail($body, '/v1/prices/latest'); if (isset($data['prices']) && is_array($data['prices'])) { - return array_map(Price::fromArray(...), array_values($data['prices'])); + if ($data['prices'] === []) { + throw new ApiException('Unexpected latest price shape from /v1/prices/latest.', 200, $body); + } + + return array_map( + fn (mixed $price): Price => $this->latestPriceOrFail($price, $body), + array_values($data['prices']), + ); } - return Price::fromArray($data); + return $this->latestPriceOrFail($data, $body); } /** @@ -162,7 +168,7 @@ public function demoPrices(): array } /** - * Escape hatch: reach ANY endpoint and get the decoded JSON envelope. + * Escape hatch: call a versioned GET endpoint and get its decoded envelope. * * $curve = $client->raw()->get('/v1/futures/ice-brent/curve'); */ @@ -202,6 +208,26 @@ private function dataOrFail(array $body, string $path): array return $body['data']; } + /** + * @param mixed $data + * @param array $body + */ + private function latestPriceOrFail(mixed $data, array $body): Price + { + if ( + !is_array($data) + || !isset($data['code']) + || !is_string($data['code']) + || trim($data['code']) === '' + || !array_key_exists('price', $data) + || !is_numeric($data['price']) + ) { + throw new ApiException('Unexpected latest price shape from /v1/prices/latest.', 200, $body); + } + + return Price::fromArray($data); + } + /** * Perform a GET request with retries and typed error mapping. * @@ -321,7 +347,7 @@ private function handleResponse(HttpResponse $response, string $path): array 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.', + . ' Review https://www.oilpriceapi.com/pricing?utm_source=php-sdk-limit for current access options.', 403, $body, ); diff --git a/src/Exception/AuthenticationException.php b/src/Exception/AuthenticationException.php index 3a08554..902d925 100644 --- a/src/Exception/AuthenticationException.php +++ b/src/Exception/AuthenticationException.php @@ -8,11 +8,12 @@ * Thrown when a request is not authenticated (HTTP 401), or when a keyed * endpoint is called on a client constructed without an API key. * - * Get a free API key at https://oilpriceapi.com/auth/signup?utm_source=php-sdk + * Create or manage an API key at + * https://www.oilpriceapi.com/auth/signup?utm_source=php-sdk */ final class AuthenticationException extends ApiException { - public const SIGNUP_URL = 'https://oilpriceapi.com/auth/signup?utm_source=php-sdk'; + public const SIGNUP_URL = 'https://www.oilpriceapi.com/auth/signup?utm_source=php-sdk'; /** * @param array $responseBody @@ -23,7 +24,7 @@ public function __construct( array $responseBody = [], ) { parent::__construct( - rtrim($message, '. ') . '. Get a free API key at ' . self::SIGNUP_URL, + rtrim($message, '. ') . '. Create or manage an API key at ' . self::SIGNUP_URL, $statusCode, $responseBody, ); diff --git a/src/Exception/RateLimitException.php b/src/Exception/RateLimitException.php index 76d6349..22d6c8c 100644 --- a/src/Exception/RateLimitException.php +++ b/src/Exception/RateLimitException.php @@ -8,12 +8,12 @@ * Thrown when the API rate limit is exceeded (HTTP 429) and automatic * retries have been exhausted. * - * Need more requests? Upgrade at - * https://oilpriceapi.com/pricing?utm_source=php-sdk-limit + * Review current limits at + * https://www.oilpriceapi.com/pricing?utm_source=php-sdk-limit */ final class RateLimitException extends ApiException { - public const UPGRADE_URL = 'https://oilpriceapi.com/pricing?utm_source=php-sdk-limit'; + public const UPGRADE_URL = 'https://www.oilpriceapi.com/pricing?utm_source=php-sdk-limit'; /** * @param int|null $retryAfter Seconds until the limit resets, if the API said @@ -35,7 +35,7 @@ public function __construct( } parent::__construct( - rtrim($message, '. ') . '.' . $suffix . ' Need a higher limit? Upgrade at ' . self::UPGRADE_URL, + rtrim($message, '. ') . '.' . $suffix . ' Review current limits at ' . self::UPGRADE_URL, 429, $responseBody, ); diff --git a/src/Price.php b/src/Price.php index e3e1434..8134e6c 100644 --- a/src/Price.php +++ b/src/Price.php @@ -28,6 +28,7 @@ public function __construct( public readonly ?float $change24h = null, public readonly ?string $name = null, public readonly ?string $unit = null, + public readonly ?string $source = null, public readonly ?string $type = null, public readonly ?string $formatted = null, ) { @@ -68,6 +69,7 @@ public static function fromArray(array $data): self change24h: is_numeric($change) ? (float) $change : null, name: isset($data['name']) ? (string) $data['name'] : null, unit: isset($data['unit']) ? (string) $data['unit'] : null, + source: isset($data['source']) ? (string) $data['source'] : null, type: isset($data['type']) ? (string) $data['type'] : null, formatted: isset($data['formatted']) ? (string) $data['formatted'] : null, ); @@ -86,6 +88,7 @@ public function toArray(): array 'change_24h' => $this->change24h, 'name' => $this->name, 'unit' => $this->unit, + 'source' => $this->source, 'type' => $this->type, 'formatted' => $this->formatted, ]; diff --git a/tests/ClientTest.php b/tests/ClientTest.php index 57288f7..299277e 100644 --- a/tests/ClientTest.php +++ b/tests/ClientTest.php @@ -64,6 +64,7 @@ public function testLatestByCodeReturnsSinglePrice(): void 'price' => 71.23, 'formatted' => '$71.23', 'currency' => 'USD', + 'source' => 'market_reporting', 'type' => 'spot_price', 'created_at' => '2026-07-03T09:00:00+00:00', ], @@ -75,6 +76,7 @@ public function testLatestByCodeReturnsSinglePrice(): void $this->assertSame('BRENT_CRUDE_USD', $price->code); $this->assertSame(71.23, $price->price); $this->assertSame('USD', $price->currency); + $this->assertSame('market_reporting', $price->source); $this->assertInstanceOf(DateTimeImmutable::class, $price->updatedAt); $this->assertSame('2026-07-03T09:00:00+00:00', $price->updatedAt->format(DATE_ATOM)); @@ -173,7 +175,7 @@ public function testKeylessClientThrowsHelpfulErrorOnKeyedEndpoint(): void $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('https://www.oilpriceapi.com/auth/signup?utm_source=php-sdk', $e->getMessage()); $this->assertStringContainsString('demoPrices', $e->getMessage()); } @@ -209,7 +211,7 @@ public function testAuthenticationExceptionOn401IncludesSignupHint(): void } 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->assertStringContainsString('https://www.oilpriceapi.com/auth/signup?utm_source=php-sdk', $e->getMessage()); } $this->assertSame(1, $this->transport->requestCount(), '401 must not be retried'); @@ -263,6 +265,32 @@ public function testUnexpectedEnvelopeShapeThrows(): void $this->client()->latest('BRENT_CRUDE_USD'); } + public function testLatestRejectsSuccessWithoutUsablePrice(): void + { + $this->transport->queue(200, [ + 'status' => 'success', + 'data' => [], + ]); + + $this->expectException(ApiException::class); + $this->expectExceptionMessage('Unexpected latest price shape'); + + $this->client()->latest('BRENT_CRUDE_USD'); + } + + public function testLatestRejectsEmptyLegacyPriceList(): void + { + $this->transport->queue(200, [ + 'status' => 'success', + 'data' => ['prices' => []], + ]); + + $this->expectException(ApiException::class); + $this->expectExceptionMessage('Unexpected latest price shape'); + + $this->client()->latest(); + } + // --------------------------------------------------------------- // Retries // --------------------------------------------------------------- @@ -322,7 +350,7 @@ public function testRateLimitExceptionAfterRetriesExhausted(): void $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->assertStringContainsString('https://www.oilpriceapi.com/pricing?utm_source=php-sdk-limit', $e->getMessage()); } $this->assertSame(3, $this->transport->requestCount(), 'initial attempt + 2 retries'); diff --git a/tests/PublicClaimsTest.php b/tests/PublicClaimsTest.php new file mode 100644 index 0000000..7adf10b --- /dev/null +++ b/tests/PublicClaimsTest.php @@ -0,0 +1,80 @@ + '~\b\d+\+\s+(commodit|endpoint|api)~i', + 'fixed update cadence' => '~(updated|refresh(ed)?)\s+every\s+\d+|every\s+\d+\s+minutes~i', + 'unreviewed plan name' => '~professional\+|starter plan|scale tier~i', + 'unreviewed plan price' => '~\$\d+(\.\d+)?\s*(/|per\s+)(mo(nth)?|year)~i', + 'uptime or SLA' => '~\b\d+(\.\d+)?%\s+uptime|\bSLA\b~i', + 'price comparison' => '~bloomberg|\d+(\.\d+)?%\s+less\s+cost~i', + 'quota promise' => '~does\s+not\s+consume.{0,40}quota|\bunlimited\b~i', + 'universal catalog' => '~\ball\s+(latest\s+)?prices\b|\ball\s+commodit~i', + 'real-time claim' => '~\breal[- ]time\b~i', + 'free-tier claim' => '~\bfree\s+tier\b|\bfree\s+api\s+key\b~i', + ]; + + foreach ($files as $file) { + $content = file_get_contents($root . '/' . $file); + self::assertIsString($content, sprintf('Unable to read %s', $file)); + foreach ($forbidden as $label => $pattern) { + self::assertSame( + 0, + preg_match($pattern, $content), + sprintf('%s contains %s; link to the reviewed product contract instead', $file, $label), + ); + } + } + } + + public function testCanonicalDeveloperContractIsDiscoverable(): void + { + $root = dirname(__DIR__); + $composer = json_decode( + (string) file_get_contents($root . '/composer.json'), + true, + flags: JSON_THROW_ON_ERROR, + ); + self::assertSame('oilpriceapi/oilpriceapi', $composer['name']); + self::assertSame('>=8.1', $composer['require']['php']); + self::assertSame('2.1.0', Client::VERSION); + self::assertSame('https://api.oilpriceapi.com', Client::DEFAULT_BASE_URL); + + $readme = (string) file_get_contents($root . '/README.md'); + foreach ([ + 'OILPRICEAPI_KEY', + 'Authorization: Token YOUR_API_KEY', + '/v1/prices/latest?by_code=BRENT_CRUDE_USD', + 'https://api.oilpriceapi.com/product-facts.json', + ] as $required) { + self::assertStringContainsString($required, $readme); + } + + $quickstart = (string) file_get_contents($root . '/examples/quickstart.php'); + foreach (['OILPRICEAPI_KEY', 'BRENT_CRUDE_USD', 'statusCode', 'retryAfter'] as $required) { + self::assertStringContainsString($required, $quickstart); + } + } +} diff --git a/tests/fixtures/router.php b/tests/fixtures/router.php new file mode 100644 index 0000000..7133c8d --- /dev/null +++ b/tests/fixtures/router.php @@ -0,0 +1,48 @@ + 'fixture route not found'], JSON_THROW_ON_ERROR); + return; +} + +$authorization = $_SERVER['HTTP_AUTHORIZATION'] ?? ''; +switch ($authorization) { + case 'Token valid-smoke-key': + echo json_encode([ + 'status' => 'success', + 'data' => [ + 'code' => 'BRENT_CRUDE_USD', + 'price' => 71.80, + 'currency' => 'USD', + 'unit' => 'barrel', + 'source' => 'market_reporting', + 'created_at' => '2026-07-19T12:00:00Z', + 'updated_at' => '2026-07-19T12:00:00Z', + ], + ], JSON_THROW_ON_ERROR); + break; + case 'Token invalid-smoke-key': + http_response_code(401); + echo json_encode(['error' => ['message' => 'invalid API key']], JSON_THROW_ON_ERROR); + break; + case 'Token locked-smoke-key': + http_response_code(403); + echo json_encode(['error' => ['message' => 'dataset not enabled']], JSON_THROW_ON_ERROR); + break; + case 'Token limited-smoke-key': + header('Retry-After: 3'); + http_response_code(429); + echo json_encode(['error' => ['message' => 'request limit reached']], JSON_THROW_ON_ERROR); + break; + default: + http_response_code(401); + echo json_encode(['error' => ['message' => 'missing API key']], JSON_THROW_ON_ERROR); +}