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:
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user