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:
2026-09-11 14:45:18 +02:00
co-authored by Claude Fable 5.1
parent c65bc1d957
commit dababbd1f2
6 changed files with 279 additions and 1 deletions
+1 -1
View File
@@ -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}}
+56
View File
@@ -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);
}
}
+17
View File
@@ -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;
}
+26
View File
@@ -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;
}
}
+92
View File
@@ -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);
}
}
}
+87
View File
@@ -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');
}
}