Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 22 additions & 13 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ on:
tags: ["v*"]
pull_request:

permissions:
contents: read

jobs:
test:
name: PHP ${{ matrix.php }}
Expand All @@ -15,10 +18,12 @@ jobs:
matrix:
php: ["8.1", "8.2", "8.3", "8.4", "8.5"]
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
persist-credentials: false

- name: Setup PHP ${{ matrix.php }}
uses: shivammathur/setup-php@v2
uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2
with:
php-version: ${{ matrix.php }}
extensions: curl, json
Expand All @@ -37,28 +42,33 @@ jobs:
name: Packaged Composer example
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
persist-credentials: false

- name: Setup PHP
uses: shivammathur/setup-php@v2
uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2
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' }}
SDK_VERSION: ${{ startsWith(github.ref, 'refs/tags/v') && github.ref_name || '2.1.1' }}
run: ./scripts/clean-install-smoke.sh

live:
name: Live canonical first request
if: github.ref == format('refs/heads/{0}', github.event.repository.default_branch)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
persist-credentials: false

- name: Setup PHP
uses: shivammathur/setup-php@v2
uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2
with:
php-version: "8.3"
extensions: curl, json
Expand All @@ -67,17 +77,16 @@ jobs:
- name: Install dependencies
run: composer update --no-interaction --prefer-dist --no-progress

# Optional live smoke test. IMPORTANT: do NOT gate this step with
# Required default-branch 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)
# Validate inside the shell so a missing production credential fails closed.
- name: Live smoke test
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
echo "OILPRICEAPI_TEST_KEY is required for the default-branch live smoke test." >&2
exit 1
fi
OILPRICEAPI_KEY="$OILPRICEAPI_TEST_KEY" php examples/smoke.php
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
# Changelog

## 2.1.1 (2026-08-11)

### Fixed

- Use the instrument-generic Brent futures path in every packaged raw-client
example and guard future Composer surfaces against venue-path regressions.
- Pin CI actions, disable persisted checkout credentials, and keep
authenticated production smoke credentials out of pull-request jobs.

## 2.1.0 (2026-07-19)

### Fixed
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,7 @@ private compatible endpoint is required.
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');
$curve = $client->raw()->get('/v1/futures/brent/curve');
```

Availability varies by dataset, plan, source, and account entitlement. Review
Expand Down
2 changes: 1 addition & 1 deletion scripts/clean-install-smoke.sh
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ set -euo pipefail
root_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
tmp_dir="$(mktemp -d)"
server_pid=""
sdk_version="${SDK_VERSION:-2.1.0}"
sdk_version="${SDK_VERSION:-2.1.1}"

cleanup() {
if [[ -n "$server_pid" ]]; then
Expand Down
91 changes: 85 additions & 6 deletions scripts/validate-public-claims.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,52 @@
/**
* @return list<string>
*/
function oilpriceapiPublicTextFiles(string $root): array
function oilpriceapiPublicTextFiles(string $root, array $excludedPaths = []): array
{
$root = realpath($root) ?: $root;
if (!is_dir($root)) {
throw new InvalidArgumentException(sprintf('Package root does not exist: %s', $root));
}

$excludedPaths = array_map(
static function (string $path): string {
$path = trim(str_replace(DIRECTORY_SEPARATOR, '/', $path), '/');
if ($path === '' || strpbrk($path, '*?[]!') !== false) {
throw new InvalidArgumentException(sprintf('Unsupported Composer archive exclusion: %s', $path));
}

return $path;
},
$excludedPaths,
);
$isExcluded = static function (string $relative) use ($excludedPaths): bool {
foreach ($excludedPaths as $excludedPath) {
if ($relative === $excludedPath || str_starts_with($relative, $excludedPath . '/')) {
return true;
}
}

return false;
};

$files = [];
$iterator = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($root, FilesystemIterator::SKIP_DOTS),
$directory = new RecursiveDirectoryIterator($root, FilesystemIterator::SKIP_DOTS);
$filter = new RecursiveCallbackFilterIterator(
$directory,
static function (SplFileInfo $file) use ($root, $isExcluded): bool {
if ($file->isLink()) {
return false;
}
$relative = str_replace(
DIRECTORY_SEPARATOR,
'/',
substr($file->getPathname(), strlen($root) + 1),
);

return !$isExcluded($relative);
},
);
$iterator = new RecursiveIteratorIterator($filter);
foreach ($iterator as $file) {
if (!$file instanceof SplFileInfo || !$file->isFile() || $file->isLink()) {
continue;
Expand Down Expand Up @@ -51,6 +86,7 @@ function oilpriceapiClaimFailures(string $root, array $files): array
'real-time claim' => '~\breal[- ]time\b~i',
'free-tier claim' => '~\bfree\s+tier\b|\bfree\s+api\s+key\b~i',
'fixed demo rate' => '~\b\d+\s+(requests?|reqs?\.?)\s*((per|an?)\s+|/\s*)(minutes?|mins?|hours?|hrs?|days?)\b~i',
'venue-specific futures path' => '~/(?:ice-(?:brent|wti|gasoil)|eua-carbon)(?:/|[\'"`])~i',
];

$failures = [];
Expand All @@ -63,17 +99,60 @@ function oilpriceapiClaimFailures(string $root, array $files): array
foreach ($patterns as $label => $pattern) {
preg_match_all($pattern, $content, $matches);
foreach ($matches[0] as $match) {
if ($label === 'fixed demo rate' && strtolower($match) === '50 requests/day') {
continue;
}
$failures[] = sprintf('%s: %s matched %s', $file, $label, $match);
}
}
foreach (oilpriceapiFixedCadenceClaims($content) as $claim) {
$failures[] = sprintf('%s: fixed request cadence matched %s', $file, $claim);
}
}

return $failures;
}

/**
* Find mutable count/action/cadence claims in any word order within a bounded
* paragraph or sentence. Non-request counts such as tests or records per page
* are intentionally outside the invariant.
*
* @return list<string>
*/
function oilpriceapiFixedCadenceClaims(string $content): array
{
$segments = preg_split('~(?:\R\s*\R)|(?<=[.!?])\s+~u', $content) ?: [];
$patterns = [
'~\b\d[\d,]*\b~u',
'~\b(?:api\s+)?(?:requests?|calls?|queries|hits?|credits?)\b~iu',
'~(?:\b(?:daily|hourly|minutely|monthly)\b|(?:/|\bper\b|\bevery\b|\beach\b|\bin\b)\s*(?:(?:a|an|one|1|24)\s+)?(?:minutes?|mins?|hours?|hrs?|days?|months?)\b)~iu',
];

$claims = [];
foreach ($segments as $segment) {
$positions = [];
foreach ($patterns as $pattern) {
preg_match_all($pattern, $segment, $matches, PREG_OFFSET_CAPTURE);
$positions[] = array_map(static fn (array $match): int => $match[1], $matches[0]);
}
if (in_array([], $positions, true)) {
continue;
}

foreach ($positions[0] as $countPosition) {
foreach ($positions[1] as $actionPosition) {
foreach ($positions[2] as $cadencePosition) {
if (max($countPosition, $actionPosition, $cadencePosition)
- min($countPosition, $actionPosition, $cadencePosition) <= 200) {
$claims[] = trim((string) preg_replace('~\s+~u', ' ', $segment));
continue 4;
}
}
}
}
}

return $claims;
}

if (PHP_SAPI === 'cli' && realpath((string) ($_SERVER['SCRIPT_FILENAME'] ?? '')) === __FILE__) {
$root = $argv[1] ?? '';
$files = oilpriceapiPublicTextFiles($root);
Expand Down
4 changes: 2 additions & 2 deletions src/Client.php
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@
*/
final class Client
{
public const VERSION = '2.1.0';
public const VERSION = '2.1.1';
public const DEFAULT_BASE_URL = 'https://api.oilpriceapi.com';
public const DEFAULT_TIMEOUT = 10.0;
public const DEFAULT_MAX_RETRIES = 3;
Expand Down Expand Up @@ -170,7 +170,7 @@ public function demoPrices(): array
/**
* Escape hatch: call a versioned GET endpoint and get its decoded envelope.
*
* $curve = $client->raw()->get('/v1/futures/ice-brent/curve');
* $curve = $client->raw()->get('/v1/futures/brent/curve');
*/
public function raw(): RawClient
{
Expand Down
4 changes: 2 additions & 2 deletions src/RawClient.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
* Returns the full decoded JSON envelope as an associative array, so ANY
* OilPriceAPI endpoint is reachable without waiting for an SDK release:
*
* $curve = $client->raw()->get('/v1/futures/ice-brent/curve');
* $curve = $client->raw()->get('/v1/futures/brent/curve');
* foreach ($curve['data']['contracts'] ?? [] as $contract) {
* // ...
* }
Expand All @@ -31,7 +31,7 @@ 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 string $path API path, e.g. '/v1/futures/brent/curve'
* @param array<string, scalar> $params Query string parameters
*
* @return array<string, mixed> Full decoded JSON response (envelope included)
Expand Down
6 changes: 3 additions & 3 deletions tests/ClientTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -365,7 +365,7 @@ public function testRawEscapeHatchReachesAnyEndpoint(): void
$envelope = [
'status' => 'success',
'data' => [
'contract' => 'ice-brent',
'contract' => 'brent',
'curve' => [
['month' => '2026-08', 'price' => 71.50],
['month' => '2026-09', 'price' => 71.10],
Expand All @@ -374,11 +374,11 @@ public function testRawEscapeHatchReachesAnyEndpoint(): void
];
$this->transport->queue(200, $envelope);

$result = $this->client()->raw()->get('/v1/futures/ice-brent/curve', ['unit' => 'usd']);
$result = $this->client()->raw()->get('/v1/futures/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',
'https://api.oilpriceapi.com/v1/futures/brent/curve?unit=usd',
$this->transport->requests[0]['url'],
);
$this->assertSame('Token test_key', $this->transport->requests[0]['headers']['Authorization']);
Expand Down
Loading