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
67 changes: 66 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,71 @@
# Changelog

## Unreleased
## 3.0.0 (unreleased)

### Breaking changes

- `Price::fromArray()` is no longer total. It is public, documented, and
callable directly, and it now throws `ApiException` for payloads the 2.x
line accepted silently.

**What throws now that did not before:**

| Row | 2.x behaviour | 3.0 behaviour |
| --- | --- | --- |
| no `code`, or a blank or non-string `code` | `code` became `''` | `ApiException` |
| no `price`, or a non-numeric `price` | `price` became `0.0` | `ApiException` |
| no `currency` | `currency` became `'USD'` | `ApiException` |
| a non-string `currency` | `['EUR']` became `'Array'`, `978` became `'978'` | `ApiException` |
| a non-string `unit`, `name`, `source`, `type` or `formatted` | cast, so `true` became `'1'` | `ApiException` |
| a `created_at`/`updated_at` PHP had to repair | `2026-13-45T99:99:99Z` became `2027-02-18T04:40:39Z` | `ApiException` |
| a relative `created_at`/`updated_at` | `now`, `next friday`, `+1 week` all became dates | `ApiException` |

A legitimate zero or negative price is still preserved; a genuinely empty
`prices` list still returns an empty array; a row with no timestamp field at
all is still valid and leaves `updatedAt` null; and an absent or explicitly
null `unit`, `name`, `source`, `type` or `formatted` still means "not
provided" rather than an error.

Two deliberate calls inside the timestamp rule, called out because they
reject input a reader might expect to pass: a leap second (`23:59:60`) is
rejected rather than rolled silently into the next minute, and a naive
timestamp is read as UTC rather than as the host's local timezone.

**What callers should do:** the methods on `Client` (`latest()`, the
historical period methods, `demoPrices()`) already surfaced malformed data
as `ApiException`, so a caller that catches `ApiException` around API calls
needs no change. A caller that invokes `Price::fromArray()` on its own
payloads must now wrap it:

```php
try {
$price = \OilPriceAPI\Price::fromArray($row);
} catch (\OilPriceAPI\Exception\ApiException $e) {
// The row was malformed. Previously you received a Price carrying
// manufactured values - $0.00, an empty code, or an invented date.
// Handle or skip the row; do not retry, the payload will not change.
}
```

A caller that relied on the manufactured values - reading `$price->price`
as `0.0` to mean "no data", say - must switch to catching the exception.
There is no opt-out, by design: the manufactured values were
indistinguishable from real quotes once they left the SDK.

- **Redirects are no longer followed.** A `$baseUrl` whose host answers with a
3xx now raises `TransportException` instead of being followed silently.
`https://api.oilpriceapi.com` does not redirect, so this affects only a
custom base URL; point it at the final URL. Details under Security below.

- **New platform requirement: `lib-curl >= 7.58.0`.** `composer.json` accepted
any libcurl. 7.58.0 is where libcurl began stripping `Authorization` across
an origin change, which the SDK depended on without saying so. Composer will
now refuse to install on an older host rather than leaving the credential
exposed.

- The version jumps 2.1.2 to 3.0.0 with no 2.2.0 in between. The changes above
shipped to `main` labelled as patches; this corrects the label rather than
re-releasing the code.

### Security

Expand Down
27 changes: 27 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,33 @@ 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.

## Upgrading to 3.0

`Price::fromArray()` is public and documented, and in 3.0 it throws
`ApiException` for payloads 2.x accepted silently: a missing, blank or
non-string `code`, a missing or non-numeric `price`, and an unparseable
timestamp. 2.x manufactured a value in each case — `$0.00`, an empty code, an
invented date — and a manufactured value is indistinguishable from a real
quote once it leaves the SDK.

If you call `Client` methods (`latest()`, the historical period methods,
`demoPrices()`) and already catch `ApiException`, nothing changes: those
methods surfaced malformed data as `ApiException` before. If you call
`Price::fromArray()` on your own payloads, wrap it:

```php
try {
$price = \OilPriceAPI\Price::fromArray($row);
} catch (\OilPriceAPI\Exception\ApiException $e) {
// Malformed row. Previously you got a Price carrying manufactured values.
// Handle or skip it; retrying will not change the payload.
}
```

A legitimate zero or negative price is still preserved, an empty `prices` list
still returns an empty array, and a row with no timestamp is still valid. See
the CHANGELOG for the full table.

## Reviewed Product Facts

The versioned, reviewed contract is
Expand Down
2 changes: 1 addition & 1 deletion 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.2';
public const VERSION = '3.0.0';
public const DEFAULT_BASE_URL = 'https://api.oilpriceapi.com';
public const DEFAULT_TIMEOUT = 10.0;
public const DEFAULT_MAX_RETRIES = 3;
Expand Down
2 changes: 1 addition & 1 deletion tests/PublicClaimsTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -312,7 +312,7 @@ public function testCanonicalDeveloperContractIsDiscoverable(): void
);
self::assertSame('oilpriceapi/oilpriceapi', $composer['name']);
self::assertSame('>=8.1', $composer['require']['php']);
self::assertSame('2.1.2', Client::VERSION);
self::assertSame('3.0.0', Client::VERSION);
self::assertSame('https://api.oilpriceapi.com', Client::DEFAULT_BASE_URL);

$readme = (string) file_get_contents($root . '/README.md');
Expand Down
223 changes: 223 additions & 0 deletions tests/VersioningTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,223 @@
<?php

declare(strict_types=1);

namespace OilPriceAPI\Tests;

use OilPriceAPI\Client;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;

/**
* The advertised version has to match what actually shipped.
*
* #18 narrowed `Price::fromArray()` - a public, documented, total function -
* into a partial one that throws. That is a major version, and it went out
* behind `Client::VERSION = '2.1.2'` and a changelog entry with no version
* heading at all. An integrator reading either would conclude nothing in their
* code could break.
*/
final class VersioningTest extends TestCase
{
private const LAST_BC_COMPATIBLE_MAJOR = 2;

public function testVersionIsSemver(): void
{
$this->assertMatchesRegularExpression(
'/^\d+\.\d+\.\d+$/',
Client::VERSION,
'Client::VERSION must be a plain MAJOR.MINOR.PATCH string.',
);
}

/**
* `Price::fromArray()` throws for input the 2.x line accepted. That is not
* a patch.
*/
public function testVersionRecordsTheBreakingChangeAsAMajor(): void
{
[$major] = array_map('intval', explode('.', Client::VERSION));

$this->assertGreaterThan(
self::LAST_BC_COMPATIBLE_MAJOR,
$major,
sprintf(
'Price::fromArray() went from total to partial, so %s is not a valid version '
. 'for this line: a caller reading it would expect no breakage.',
Client::VERSION,
),
);
}

/**
* The topmost changelog entry must name a version. "## Unreleased" tells a
* reader nothing about whether upgrading is safe.
*/
public function testTopmostChangelogEntryCarriesAVersionHeading(): void
{
$heading = self::firstChangelogHeading();

$this->assertMatchesRegularExpression(
'/^\d+\.\d+\.\d+/',
$heading,
sprintf('The topmost CHANGELOG entry is "## %s" and names no version.', $heading),
);
}

public function testTopmostChangelogEntryMatchesTheShippedVersion(): void
{
$this->assertStringStartsWith(
Client::VERSION,
self::firstChangelogHeading(),
'The CHANGELOG and Client::VERSION disagree about what is shipping.',
);
}

/**
* The break has to be written down in words a caller can act on: what
* throws now that did not before.
*/
public function testChangelogDocumentsTheBreakingChange(): void
{
$entry = self::firstChangelogEntry();

$this->assertMatchesRegularExpression(
'/###\s+Breaking changes/i',
$entry,
'The major entry has no "### Breaking changes" section.',
);
$this->assertStringContainsString(
'Price::fromArray',
$entry,
'The breaking-change note does not name the method that broke.',
);
$this->assertMatchesRegularExpression(
'/ApiException/',
$entry,
'The breaking-change note does not say what is thrown.',
);
}

/**
* The breaking-change section must enumerate EVERY break in the release,
* not just the first one found. A reader checking whether it is safe to
* upgrade reads this section and nothing else; a break documented only
* under "Fixed" or "Security" is a break they will meet in production.
*
* @return array<string, array{string}>
*/
public static function breaksThatMustBeListed(): array
{
return [
'fromArray is partial' => ['Price::fromArray'],
'currency is required' => ['no `currency`'],
'non-string currency' => ['a non-string `currency`'],
'non-string label fields' => ['non-string `unit`'],
'repaired timestamps' => ['PHP had to repair'],
'relative timestamps' => ['a relative `created_at`'],
'redirects not followed' => ['Redirects are no longer followed'],
'libcurl floor' => ['lib-curl >= 7.58.0'],
];
}

#[DataProvider('breaksThatMustBeListed')]
public function testEveryBreakIsListedUnderBreakingChanges(string $needle): void
{
$this->assertStringContainsString(
$needle,
self::breakingChangesSection(),
sprintf('"%s" is not documented under "### Breaking changes".', $needle),
);
}

/**
* The rejections a reader would not predict have to be named, or they
* arrive as a surprise on a row that looks fine.
*
* @return array<string, array{string}>
*/
public static function surprisingRejections(): array
{
return [
'leap second' => ['leap second'],
'naive timestamp timezone' => ['read as UTC'],
];
}

#[DataProvider('surprisingRejections')]
public function testSurprisingRejectionsAreCalledOut(string $needle): void
{
$this->assertStringContainsString($needle, self::breakingChangesSection());
}

/**
* What stays working matters as much as what breaks: without it a reader
* cannot tell whether a legitimate zero price still survives.
*/
public function testBreakingChangesSectionSaysWhatStillWorks(): void
{
$section = self::breakingChangesSection();

$this->assertMatchesRegularExpression('/zero or negative price is still preserved/', $section);
$this->assertMatchesRegularExpression('/empty\s+`prices`\s+list still returns an empty array/', $section);
$this->assertStringContainsString('no timestamp field', $section);
}

/**
* The version a customer sees in our access logs must be the version we
* think we shipped.
*/
public function testUserAgentCarriesTheSameVersion(): void
{
$transport = new MockTransport();
$transport->queue(200, [
'status' => 'success',
'data' => ['code' => 'BRENT_CRUDE_USD', 'price' => 71.8, 'currency' => 'USD'],
]);
$client = new Client('fixture_key_NOT_REAL_0123456789', Client::DEFAULT_BASE_URL, 10.0, 0, $transport);

$client->latest('BRENT_CRUDE_USD');

$this->assertSame(
'oilpriceapi-php/' . Client::VERSION,
$transport->requests[0]['headers']['User-Agent'],
);
}

/**
* The text between "### Breaking changes" and the next "###" heading.
*/
private static function breakingChangesSection(): string
{
$entry = self::firstChangelogEntry();
$start = strpos($entry, '### Breaking changes');
self::assertIsInt($start, 'The topmost entry has no "### Breaking changes" section.');

$rest = substr($entry, $start + strlen('### Breaking changes'));
$next = strpos($rest, '###');

return $next === false ? $rest : substr($rest, 0, $next);
}

private static function changelog(): string
{
return (string) file_get_contents(dirname(__DIR__) . '/CHANGELOG.md');
}

private static function firstChangelogHeading(): string
{
preg_match('/^## (.+)$/m', self::changelog(), $matches);
self::assertNotEmpty($matches, 'CHANGELOG.md has no "## " heading.');

return trim($matches[1]);
}

private static function firstChangelogEntry(): string
{
$parts = preg_split('/^## /m', self::changelog());
self::assertIsArray($parts);
self::assertArrayHasKey(1, $parts, 'CHANGELOG.md has no entries.');

return $parts[1];
}
}