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
+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);
}
}
}