feat: Http curl wrapper and TokenStore with atomic write and flock
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -1 +1 @@
|
|||||||
{"version":2,"defects":{"Weerscherm\\Tests\\DashboardServiceTest::testForecastUsesStationLocationUnlessConfigured":7,"Weerscherm\\Tests\\DashboardServiceTest::testMeasureFailureFallsBackToSumRain24":7},"times":{"Weerscherm\\Tests\\ConfigTest::testDefaultsApplyWhenKeysAreMissing":0,"Weerscherm\\Tests\\ConfigTest::testFileValuesOverrideDefaults":0,"Weerscherm\\Tests\\ConfigTest::testUnknownKeyReturnsGivenDefault":0,"Weerscherm\\Tests\\ConfigTest::testFileMustReturnArray":0,"Weerscherm\\Tests\\StationMapperTest::testStationMetadata":0,"Weerscherm\\Tests\\StationMapperTest::testOutdoor":0,"Weerscherm\\Tests\\StationMapperTest::testIndoorDefaultOrderAndUnreachableModule":0,"Weerscherm\\Tests\\StationMapperTest::testIndoorConfigDeterminesOrderAndLabels":0,"Weerscherm\\Tests\\StationMapperTest::testRainModule":0,"Weerscherm\\Tests\\StationMapperTest::testWithoutRainModule":0,"Weerscherm\\Tests\\StationMapperTest::testUnreachableOutdoorModuleGivesNulls":0,"Weerscherm\\Tests\\StationMapperTest::testUnknownDeviceIdFallsBackToFirstStation":0,"Weerscherm\\Tests\\StationMapperTest::testEmptyDevicesGivesNullsWithoutCrashing":0,"Weerscherm\\Tests\\RainCalculatorTest::testNormalWeek":0,"Weerscherm\\Tests\\RainCalculatorTest::testMissingPreviousDayIsNullAndExcludedFromTotal":0,"Weerscherm\\Tests\\RainCalculatorTest::testMissingTodayFallsBackToSumRain24":0,"Weerscherm\\Tests\\RainCalculatorTest::testMonthBoundaryKeepsPreviousDaysButNotInMonthTotal":0,"Weerscherm\\Tests\\RainCalculatorTest::testDaylightSavingTransitionMapsTimestampsToLocalDates":0,"Weerscherm\\Tests\\RainCalculatorTest::testEmptyBodyGivesNulls":0,"Weerscherm\\Tests\\RainCalculatorTest::testDateBeginIsMinOfFirstOfMonthAndThreeDaysAgo":0,"Weerscherm\\Tests\\CacheTest::testWriteThenReadRoundTrip":0.002,"Weerscherm\\Tests\\CacheTest::testMissingKeyReadsNull":0.002,"Weerscherm\\Tests\\CacheTest::testCorruptFileReadsNull":0,"Weerscherm\\Tests\\CacheTest::testFreshness":0,"Weerscherm\\Tests\\CacheTest::testKeyIsSanitised":0,"Weerscherm\\Tests\\CacheTest::testWriteCreatesDirectory":0,"Weerscherm\\Tests\\ForecastMapperTest::testSkipsTodayAndReturnsFiveDays":0,"Weerscherm\\Tests\\ForecastMapperTest::testNullValuesStayNull":0,"Weerscherm\\Tests\\ForecastMapperTest::testShorterResponseGivesFewerDays":0,"Weerscherm\\Tests\\ForecastMapperTest::testMissingTimeArrayGivesEmptyList":0,"Weerscherm\\Tests\\DashboardServiceTest::testHappyPathMatchesContract":0.001,"Weerscherm\\Tests\\DashboardServiceTest::testMeasureIsRequestedForRainModuleFromMidnightOfDateBegin":0.001,"Weerscherm\\Tests\\DashboardServiceTest::testForecastUsesStationLocationUnlessConfigured":0.001,"Weerscherm\\Tests\\DashboardServiceTest::testStationsFailureWithoutCacheGivesNullsAndError":0,"Weerscherm\\Tests\\DashboardServiceTest::testStationsFailureWithCacheServesStaleData":0.001,"Weerscherm\\Tests\\DashboardServiceTest::testAuthFailureGivesNetatmoAuthError":0,"Weerscherm\\Tests\\DashboardServiceTest::testMeasureFailureFallsBackToSumRain24":0,"Weerscherm\\Tests\\DashboardServiceTest::testForecastFailureLeavesRestIntact":0.001,"Weerscherm\\Tests\\DashboardServiceTest::testSecondBuildWithinTtlUsesCache":0.001}}
|
{"version":2,"defects":{"Weerscherm\\Tests\\DashboardServiceTest::testForecastUsesStationLocationUnlessConfigured":7,"Weerscherm\\Tests\\DashboardServiceTest::testMeasureFailureFallsBackToSumRain24":7},"times":{"Weerscherm\\Tests\\ConfigTest::testDefaultsApplyWhenKeysAreMissing":0,"Weerscherm\\Tests\\ConfigTest::testFileValuesOverrideDefaults":0,"Weerscherm\\Tests\\ConfigTest::testUnknownKeyReturnsGivenDefault":0,"Weerscherm\\Tests\\ConfigTest::testFileMustReturnArray":0.001,"Weerscherm\\Tests\\StationMapperTest::testStationMetadata":0,"Weerscherm\\Tests\\StationMapperTest::testOutdoor":0,"Weerscherm\\Tests\\StationMapperTest::testIndoorDefaultOrderAndUnreachableModule":0,"Weerscherm\\Tests\\StationMapperTest::testIndoorConfigDeterminesOrderAndLabels":0,"Weerscherm\\Tests\\StationMapperTest::testRainModule":0,"Weerscherm\\Tests\\StationMapperTest::testWithoutRainModule":0,"Weerscherm\\Tests\\StationMapperTest::testUnreachableOutdoorModuleGivesNulls":0,"Weerscherm\\Tests\\StationMapperTest::testUnknownDeviceIdFallsBackToFirstStation":0,"Weerscherm\\Tests\\StationMapperTest::testEmptyDevicesGivesNullsWithoutCrashing":0,"Weerscherm\\Tests\\RainCalculatorTest::testNormalWeek":0,"Weerscherm\\Tests\\RainCalculatorTest::testMissingPreviousDayIsNullAndExcludedFromTotal":0,"Weerscherm\\Tests\\RainCalculatorTest::testMissingTodayFallsBackToSumRain24":0,"Weerscherm\\Tests\\RainCalculatorTest::testMonthBoundaryKeepsPreviousDaysButNotInMonthTotal":0,"Weerscherm\\Tests\\RainCalculatorTest::testDaylightSavingTransitionMapsTimestampsToLocalDates":0,"Weerscherm\\Tests\\RainCalculatorTest::testEmptyBodyGivesNulls":0,"Weerscherm\\Tests\\RainCalculatorTest::testDateBeginIsMinOfFirstOfMonthAndThreeDaysAgo":0,"Weerscherm\\Tests\\CacheTest::testWriteThenReadRoundTrip":0.003,"Weerscherm\\Tests\\CacheTest::testMissingKeyReadsNull":0.005,"Weerscherm\\Tests\\CacheTest::testCorruptFileReadsNull":0,"Weerscherm\\Tests\\CacheTest::testFreshness":0.001,"Weerscherm\\Tests\\CacheTest::testKeyIsSanitised":0,"Weerscherm\\Tests\\CacheTest::testWriteCreatesDirectory":0.001,"Weerscherm\\Tests\\ForecastMapperTest::testSkipsTodayAndReturnsFiveDays":0,"Weerscherm\\Tests\\ForecastMapperTest::testNullValuesStayNull":0,"Weerscherm\\Tests\\ForecastMapperTest::testShorterResponseGivesFewerDays":0,"Weerscherm\\Tests\\ForecastMapperTest::testMissingTimeArrayGivesEmptyList":0,"Weerscherm\\Tests\\DashboardServiceTest::testHappyPathMatchesContract":0.002,"Weerscherm\\Tests\\DashboardServiceTest::testMeasureIsRequestedForRainModuleFromMidnightOfDateBegin":0.001,"Weerscherm\\Tests\\DashboardServiceTest::testForecastUsesStationLocationUnlessConfigured":0.001,"Weerscherm\\Tests\\DashboardServiceTest::testStationsFailureWithoutCacheGivesNullsAndError":0,"Weerscherm\\Tests\\DashboardServiceTest::testStationsFailureWithCacheServesStaleData":0.001,"Weerscherm\\Tests\\DashboardServiceTest::testAuthFailureGivesNetatmoAuthError":0.001,"Weerscherm\\Tests\\DashboardServiceTest::testMeasureFailureFallsBackToSumRain24":0,"Weerscherm\\Tests\\DashboardServiceTest::testForecastFailureLeavesRestIntact":0,"Weerscherm\\Tests\\DashboardServiceTest::testSecondBuildWithinTtlUsesCache":0.001,"Weerscherm\\Tests\\TokenStoreTest::testWriteReadRoundTripAndMode":0,"Weerscherm\\Tests\\TokenStoreTest::testMissingOrInvalidFileReadsNull":0,"Weerscherm\\Tests\\TokenStoreTest::testWithLockReturnsValueAndReleasesAfterException":0,"Weerscherm\\Tests\\TokenStoreTest::testConcurrentProcessesAreSerialised":0.442}}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Weerscherm;
|
||||||
|
|
||||||
|
/** Dunne curl-wrapper met korte timeouts. Netwerkfouten worden UpstreamException. */
|
||||||
|
final class Http implements HttpClient
|
||||||
|
{
|
||||||
|
public function __construct(private readonly int $timeout = 8, private readonly int $connectTimeout = 5)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public function get(string $url, array $query = [], array $headers = []): HttpResponse
|
||||||
|
{
|
||||||
|
if ($query !== []) {
|
||||||
|
$url .= (str_contains($url, '?') ? '&' : '?') . http_build_query($query);
|
||||||
|
}
|
||||||
|
return $this->request($url, $headers, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function postForm(string $url, array $fields): HttpResponse
|
||||||
|
{
|
||||||
|
return $this->request($url, ['Content-Type: application/x-www-form-urlencoded'], http_build_query($fields));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param list<string> $headers */
|
||||||
|
private function request(string $url, array $headers, ?string $postBody): HttpResponse
|
||||||
|
{
|
||||||
|
$ch = curl_init($url);
|
||||||
|
if ($ch === false) {
|
||||||
|
throw new UpstreamException('curl_init mislukt');
|
||||||
|
}
|
||||||
|
curl_setopt_array($ch, [
|
||||||
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
|
CURLOPT_FOLLOWLOCATION => false,
|
||||||
|
CURLOPT_CONNECTTIMEOUT => $this->connectTimeout,
|
||||||
|
CURLOPT_TIMEOUT => $this->timeout,
|
||||||
|
CURLOPT_HTTPHEADER => array_merge(['Accept: application/json', 'User-Agent: weerscherm/1.0'], $headers),
|
||||||
|
CURLOPT_SSL_VERIFYPEER => true,
|
||||||
|
]);
|
||||||
|
if ($postBody !== null) {
|
||||||
|
curl_setopt($ch, CURLOPT_POST, true);
|
||||||
|
curl_setopt($ch, CURLOPT_POSTFIELDS, $postBody);
|
||||||
|
}
|
||||||
|
$body = curl_exec($ch);
|
||||||
|
$status = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
|
||||||
|
$error = curl_error($ch);
|
||||||
|
curl_close($ch);
|
||||||
|
|
||||||
|
if (!is_string($body)) {
|
||||||
|
throw new UpstreamException("Verzoek mislukt: $error");
|
||||||
|
}
|
||||||
|
return new HttpResponse($status, $body);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Weerscherm;
|
||||||
|
|
||||||
|
interface HttpClient
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param array<string, string|int|float> $query
|
||||||
|
* @param list<string> $headers
|
||||||
|
*/
|
||||||
|
public function get(string $url, array $query = [], array $headers = []): HttpResponse;
|
||||||
|
|
||||||
|
/** @param array<string, string|int|float> $fields */
|
||||||
|
public function postForm(string $url, array $fields): HttpResponse;
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Weerscherm;
|
||||||
|
|
||||||
|
final class HttpResponse
|
||||||
|
{
|
||||||
|
public function __construct(public readonly int $status, public readonly string $body)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string|int, mixed> */
|
||||||
|
public function json(): array
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$decoded = json_decode($this->body, true, 512, JSON_THROW_ON_ERROR);
|
||||||
|
} catch (\JsonException $e) {
|
||||||
|
throw new UpstreamException('Ongeldige JSON in antwoord: ' . $e->getMessage(), 0, $e);
|
||||||
|
}
|
||||||
|
if (!is_array($decoded)) {
|
||||||
|
throw new UpstreamException('Antwoord is geen JSON-object');
|
||||||
|
}
|
||||||
|
return $decoded;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Weerscherm;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bewaart de Netatmo-tokens in één JSON-bestand. Schrijft atomisch (tijdelijk bestand + rename, mode 0600)
|
||||||
|
* en biedt een flock zodat gelijktijdige requests niet dubbel verversen.
|
||||||
|
*/
|
||||||
|
final class TokenStore
|
||||||
|
{
|
||||||
|
public function __construct(private readonly string $file)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public function path(): string
|
||||||
|
{
|
||||||
|
return $this->file;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array{access_token: string, refresh_token: string, expires_at: int}|null */
|
||||||
|
public function read(): ?array
|
||||||
|
{
|
||||||
|
if (!is_file($this->file)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$raw = json_decode((string) file_get_contents($this->file), true);
|
||||||
|
if (!is_array($raw) || !is_string($raw['refresh_token'] ?? null) || $raw['refresh_token'] === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
'access_token' => is_string($raw['access_token'] ?? null) ? $raw['access_token'] : '',
|
||||||
|
'refresh_token' => $raw['refresh_token'],
|
||||||
|
'expires_at' => is_int($raw['expires_at'] ?? null) ? $raw['expires_at'] : 0,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array{access_token: string, refresh_token: string, expires_at: int} $token */
|
||||||
|
public function write(array $token): void
|
||||||
|
{
|
||||||
|
$dir = dirname($this->file);
|
||||||
|
if (!is_dir($dir) && !mkdir($dir, 0750, true) && !is_dir($dir)) {
|
||||||
|
throw new \RuntimeException("Map kan niet worden aangemaakt: $dir");
|
||||||
|
}
|
||||||
|
$tmp = $this->file . '.tmp.' . uniqid('', true);
|
||||||
|
$json = json_encode([
|
||||||
|
'access_token' => $token['access_token'],
|
||||||
|
'refresh_token' => $token['refresh_token'],
|
||||||
|
'expires_at' => $token['expires_at'],
|
||||||
|
], JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT);
|
||||||
|
if (file_put_contents($tmp, $json) === false) {
|
||||||
|
throw new \RuntimeException("Tokenbestand kan niet worden geschreven: $tmp");
|
||||||
|
}
|
||||||
|
chmod($tmp, 0600);
|
||||||
|
if (!rename($tmp, $this->file)) {
|
||||||
|
@unlink($tmp);
|
||||||
|
throw new \RuntimeException("Tokenbestand kan niet worden vervangen: {$this->file}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Voert $fn uit onder een exclusieve lock. Ook bij een exception wordt de lock losgelaten.
|
||||||
|
*
|
||||||
|
* @template T
|
||||||
|
* @param callable(): T $fn
|
||||||
|
* @return T
|
||||||
|
*/
|
||||||
|
public function withLock(callable $fn): mixed
|
||||||
|
{
|
||||||
|
$dir = dirname($this->file);
|
||||||
|
if (!is_dir($dir) && !mkdir($dir, 0750, true) && !is_dir($dir)) {
|
||||||
|
throw new \RuntimeException("Map kan niet worden aangemaakt: $dir");
|
||||||
|
}
|
||||||
|
$handle = fopen($this->file . '.lock', 'c');
|
||||||
|
if ($handle === false) {
|
||||||
|
throw new \RuntimeException("Lockbestand kan niet worden geopend: {$this->file}.lock");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (!flock($handle, LOCK_EX)) {
|
||||||
|
throw new \RuntimeException('Lock kan niet worden verkregen');
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return $fn();
|
||||||
|
} finally {
|
||||||
|
flock($handle, LOCK_UN);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
fclose($handle);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Weerscherm\Tests;
|
||||||
|
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Weerscherm\TokenStore;
|
||||||
|
|
||||||
|
final class TokenStoreTest extends TestCase
|
||||||
|
{
|
||||||
|
private string $dir;
|
||||||
|
private string $file;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
$this->dir = sys_get_temp_dir() . '/weerscherm-token-' . uniqid();
|
||||||
|
$this->file = $this->dir . '/netatmo-token.json';
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function tearDown(): void
|
||||||
|
{
|
||||||
|
array_map('unlink', glob($this->dir . '/*') ?: []);
|
||||||
|
@rmdir($this->dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testWriteReadRoundTripAndMode(): void
|
||||||
|
{
|
||||||
|
$store = new TokenStore($this->file);
|
||||||
|
$store->write(['access_token' => 'a', 'refresh_token' => 'r', 'expires_at' => 123]);
|
||||||
|
|
||||||
|
self::assertSame(['access_token' => 'a', 'refresh_token' => 'r', 'expires_at' => 123], $store->read());
|
||||||
|
self::assertSame('0600', substr(sprintf('%o', fileperms($this->file)), -4));
|
||||||
|
self::assertSame([], glob($this->dir . '/*.tmp.*') ?: [], 'geen tijdelijke bestanden achtergelaten');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testMissingOrInvalidFileReadsNull(): void
|
||||||
|
{
|
||||||
|
self::assertNull((new TokenStore($this->file))->read());
|
||||||
|
|
||||||
|
mkdir($this->dir, 0700, true);
|
||||||
|
file_put_contents($this->file, '{"access_token":"a"}');
|
||||||
|
self::assertNull((new TokenStore($this->file))->read(), 'zonder refresh token is het bestand onbruikbaar');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testWithLockReturnsValueAndReleasesAfterException(): void
|
||||||
|
{
|
||||||
|
$store = new TokenStore($this->file);
|
||||||
|
|
||||||
|
self::assertSame(42, $store->withLock(fn() => 42));
|
||||||
|
|
||||||
|
try {
|
||||||
|
$store->withLock(function (): void {
|
||||||
|
throw new \RuntimeException('boem');
|
||||||
|
});
|
||||||
|
self::fail('exception verwacht');
|
||||||
|
} catch (\RuntimeException) {
|
||||||
|
}
|
||||||
|
|
||||||
|
$handle = fopen($this->file . '.lock', 'c');
|
||||||
|
self::assertTrue(flock($handle, LOCK_EX | LOCK_NB), 'lock is weer vrij');
|
||||||
|
fclose($handle);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testConcurrentProcessesAreSerialised(): void
|
||||||
|
{
|
||||||
|
$store = new TokenStore($this->file);
|
||||||
|
$script = sprintf(
|
||||||
|
'<?php require %s; $s = new Weerscherm\TokenStore(%s); $s->withLock(function () { usleep(400000); });',
|
||||||
|
var_export(__DIR__ . '/../vendor/autoload.php', true),
|
||||||
|
var_export($this->file, true),
|
||||||
|
);
|
||||||
|
$scriptFile = $this->dir . '/hold.php';
|
||||||
|
mkdir($this->dir, 0700, true);
|
||||||
|
file_put_contents($scriptFile, $script);
|
||||||
|
|
||||||
|
$proc = proc_open([PHP_BINARY, $scriptFile], [], $pipes);
|
||||||
|
usleep(150000); // geef het andere proces tijd om de lock te pakken
|
||||||
|
|
||||||
|
$start = microtime(true);
|
||||||
|
$store->withLock(fn() => null);
|
||||||
|
$waited = microtime(true) - $start;
|
||||||
|
proc_close($proc);
|
||||||
|
|
||||||
|
self::assertGreaterThan(0.15, $waited, 'tweede proces moest wachten op de lock');
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user