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