radix([ * 'birth' => [ * 'datetime' => '1980-05-17T14:32:00', * 'timezone' => 'Europe/Berlin', * 'location' => ['latitude' => 52.52, 'longitude' => 13.405], * ], * 'options' => ['house_system' => 'koch'], * ]); * * Beispiel — mehrere Endpunkte parallel (curl_multi, ein TLS-Handshake je Host): * * $results = $api->parallel([ * 'radix' => ['POST', '/v1/radix', $radixBody], * 'transits' => ['POST', '/v1/transits', $transitsBody], * ]); * // $results['radix'] und $results['transits'] sind entweder das * // decodierte Response-Array ODER eine AstroApiException — pruefen * // mit `instanceof AstroApiException`. * * ETag-Cache: Ab v1.2 fuehrt die Klasse einen internen In-Process-Cache * fuer ETag-Antworten (max. 256 Eintraege, LRU). Bei wiederholten * Anfragen mit identischer Eingabe sendet der Helper `If-None-Match`; * die API antwortet bei deterministischen Endpunkten mit 304 (kein * Response-Body, spart Bandbreite). Aufrufer merkt nichts — er bekommt * das gleiche decodierte Array wie beim ersten Call. Per Konstruktor- * Flag deaktivierbar (`new AstroApiClient($key, $url, 30, false)`). * * Lizenz: MIT. */ declare(strict_types=1); final class AstroApiException extends RuntimeException { /** @var array|null Fehler-Body, sofern die API einen lieferte. */ public ?array $body; public int $status; public function __construct(string $message, int $status, ?array $body = null) { parent::__construct($message, $status); $this->status = $status; $this->body = $body; } } final class AstroApiClient { public const BASE_LIVE = 'https://astroapi.services'; public const BASE_TEST = 'http://astroapi.test'; private string $apiKey; private string $baseUrl; private int $timeout; private bool $cacheEnabled; /** * Wiederverwendetes cURL-Handle. Spart pro Folgerequest gegen denselben * Host den TLS-Handshake (~30–80 ms) und den TCP-Connect (~30 ms). * Lazy initialisiert beim ersten Aufruf, in __destruct() geschlossen. */ private ?\CurlHandle $ch = null; /** * ETag-Cache fuer 304-Antworten. Schluessel = sha1(method|path|body|query), * Wert = ['etag' => '""', 'body' => decoded-array]. * Insertion-Order = LRU; bei voller Tabelle wird der aelteste Eintrag * verworfen. * * @var array}> */ private array $cache = []; private int $cacheMaxEntries = 256; public function __construct( string $apiKey = '', string $baseUrl = self::BASE_LIVE, int $timeout = 30, bool $cacheEnabled = true ) { // Leerer Key ist erlaubt fuer oeffentliche Endpunkte (z.B. /v1/health, // /v1/planets) — der X-API-Key-Header wird dann weggelassen. $this->apiKey = $apiKey; $this->baseUrl = rtrim($baseUrl, '/'); $this->timeout = $timeout; $this->cacheEnabled = $cacheEnabled; } public function __destruct() { if ($this->ch !== null) { curl_close($this->ch); $this->ch = null; } } /** Leert den internen ETag-Cache (z.B. fuer Tests). */ public function clearCache(): void { $this->cache = []; } /** @return array{entries:int,maxEntries:int,enabled:bool} */ public function cacheStats(): array { return [ 'entries' => count($this->cache), 'maxEntries' => $this->cacheMaxEntries, 'enabled' => $this->cacheEnabled, ]; } // ---------- Allgemeine Endpunkte ------------------------------------ /** GET /v1/health — kein Auth noetig. */ public function health(): array { return $this->request('GET', '/v1/health'); } /** GET /v1/me — Plan-Info zum API-Key. */ public function me(): array { return $this->request('GET', '/v1/me'); } /** GET /v1/planets — statischer Planeten-Katalog. */ public function planets(): array { return $this->request('GET', '/v1/planets'); } /** GET /v1/houses — Hausspitzen fuer (datetime, lat, lon, system?). */ public function houses(array $query): array { return $this->request('GET', '/v1/houses', null, $query); } // ---------- Berechnungs-Endpunkte (POST) ---------------------------- public function radix(array $body): array { return $this->request('POST', '/v1/radix', $body); } public function aspects(array $body): array { return $this->request('POST', '/v1/aspects', $body); } public function transits(array $body): array { return $this->request('POST', '/v1/transits', $body); } public function progressions(array $body): array { return $this->request('POST', '/v1/progressions', $body); } public function returns(array $body): array { return $this->request('POST', '/v1/returns', $body); } public function mercuryReturn(array $body): array { return $this->request('POST', '/v1/mercury-return', $body); } public function venusReturn(array $body): array { return $this->request('POST', '/v1/venus-return', $body); } public function marsReturn(array $body): array { return $this->request('POST', '/v1/mars-return', $body); } public function jupiterReturn(array $body): array { return $this->request('POST', '/v1/jupiter-return', $body); } public function saturnReturn(array $body): array { return $this->request('POST', '/v1/saturn-return', $body); } public function ephemeris(array $body): array { return $this->request('POST', '/v1/ephemeris', $body); } public function asteroids(array $body): array { return $this->request('POST', '/v1/asteroids', $body); } public function fixedStars(array $body): array { return $this->request('POST', '/v1/fixed-stars', $body); } public function declinations(array $body): array { return $this->request('POST', '/v1/declinations', $body); } public function antiscia(array $body): array { return $this->request('POST', '/v1/antiscia', $body); } public function harmonics(array $body): array { return $this->request('POST', '/v1/harmonics', $body); } public function draconic(array $body): array { return $this->request('POST', '/v1/draconic', $body); } public function midpoints(array $body): array { return $this->request('POST', '/v1/midpoints', $body); } public function lunarPhases(array $body): array { return $this->request('POST', '/v1/lunar-phases', $body); } public function ingresses(array $body): array { return $this->request('POST', '/v1/ingresses', $body); } public function stations(array $body): array { return $this->request('POST', '/v1/stations', $body); } public function eclipses(array $body): array { return $this->request('POST', '/v1/eclipses', $body); } public function heliacal(array $body): array { return $this->request('POST', '/v1/heliacal', $body); } public function riseTransitSet(array $body): array{ return $this->request('POST', '/v1/rise-transit-set', $body); } public function planetaryHours(array $body): array{ return $this->request('POST', '/v1/planetary-hours', $body); } public function patterns(array $body): array { return $this->request('POST', '/v1/patterns', $body); } public function voidOfCourse(array $body): array { return $this->request('POST', '/v1/void-of-course', $body); } public function relationship(array $body): array { return $this->request('POST', '/v1/relationship', $body); } // Hellenistische Endpunkte public function lots(array $body): array { return $this->request('POST', '/v1/lots', $body); } public function dignities(array $body): array { return $this->request('POST', '/v1/dignities', $body); } public function profections(array $body): array { return $this->request('POST', '/v1/profections', $body); } public function firdaria(array $body): array { return $this->request('POST', '/v1/firdaria', $body); } public function zodiacalReleasing(array $body): array { return $this->request('POST', '/v1/zodiacal-releasing', $body); } // ---------- Generischer Aufruf -------------------------------------- /** * Eigene/neue Endpunkte ohne Convenience-Methode aufrufen. * * @param 'GET'|'POST' $method * @param string $path z.B. "/v1/radix" * @param array|null $body POST-Body als Array (wird JSON-kodiert). * @param array $query Optionale Query-Parameter. * @return array * @throws AstroApiException bei HTTP-Status != 2xx oder Netzwerkfehler. */ public function request(string $method, string $path, ?array $body = null, array $query = []): array { // Handle einmal anlegen, dann pro Folgerequest mit curl_reset // wiederverwenden — spart TCP-Connect + TLS-Handshake. if ($this->ch === null) { $this->ch = curl_init(); } else { curl_reset($this->ch); } $cacheKey = $this->cacheEnabled ? $this->cacheKey($method, $path, $body, $query) : null; $ifNoneMatch = ($cacheKey !== null && isset($this->cache[$cacheKey])) ? $this->cache[$cacheKey]['etag'] : null; $responseEtag = null; $this->applyOptions($this->ch, $method, $path, $body, $query, $ifNoneMatch); curl_setopt($this->ch, CURLOPT_HEADERFUNCTION, $this->etagCapture($responseEtag)); $raw = curl_exec($this->ch); $errno = curl_errno($this->ch); $errMsg = curl_error($this->ch); $status = (int)curl_getinfo($this->ch, CURLINFO_HTTP_CODE); if ($errno !== 0 || $raw === false) { throw new AstroApiException("Network error: $errMsg", 0); } // 304: Server bestaetigt, dass sich nichts geaendert hat — gecachten // Body zurueckgeben und Eintrag als zuletzt benutzt markieren. if ($status === 304 && $cacheKey !== null && isset($this->cache[$cacheKey])) { return $this->cacheTouch($cacheKey); } $decoded = $this->decode((string)$raw, $status); // Erfolgreiche Antwort mit ETag im Cache ablegen (oder vorhandenen // Eintrag ueberschreiben, falls der Server einen neuen ETag schickt). if ($cacheKey !== null && $responseEtag !== null && $status >= 200 && $status < 300) { $this->cacheStore($cacheKey, $responseEtag, $decoded); } return $decoded; } /** * Fuehrt mehrere Requests parallel aus (curl_multi). * * Pro Host bleibt nur **ein** TLS-Handshake noetig, alle Requests laufen * gleichzeitig. Bei zwei Endpunkten halbiert das die wahrgenommene * Latenz, weil Radix und Transits nicht mehr seriell aufeinander warten. * * @param array $requests * Assoziatives Array: key => [method, path, body?, query?] * @return array * Gleiche Keys; Wert ist entweder das decodierte Response-Array * ODER eine AstroApiException, falls dieser einzelne Request * fehlschlug. Pruefe pro Eintrag mit `instanceof AstroApiException`. */ public function parallel(array $requests): array { if ($requests === []) { return []; } $mh = curl_multi_init(); $handles = []; $meta = []; // key => ['cacheKey' => ?string] $etagSlots = []; // key => ?string (per-handle Capture, geteilte Map) foreach ($requests as $key => $req) { $method = (string)($req[0] ?? 'GET'); $path = (string)($req[1] ?? '/'); $body = $req[2] ?? null; $query = (array)($req[3] ?? []); $cacheKey = $this->cacheEnabled ? $this->cacheKey($method, $path, $body, $query) : null; $ifNoneMatch = ($cacheKey !== null && isset($this->cache[$cacheKey])) ? $this->cache[$cacheKey]['etag'] : null; $h = curl_init(); $this->applyOptions($h, $method, $path, $body, $query, $ifNoneMatch); $etagSlots[$key] = null; // Closure haelt eigene Kopie von $key und schreibt direkt in die // gemeinsame Map — keine Referenz-Tricks auf Array-Slots noetig. $captureKey = $key; curl_setopt($h, CURLOPT_HEADERFUNCTION, static function ($ch, string $line) use (&$etagSlots, $captureKey): int { $colon = strpos($line, ':'); if ($colon !== false) { $name = strtolower(rtrim(substr($line, 0, $colon))); if ($name === 'etag') { $etagSlots[$captureKey] = trim(substr($line, $colon + 1)); } } return strlen($line); }); curl_multi_add_handle($mh, $h); $handles[$key] = $h; $meta[$key] = ['cacheKey' => $cacheKey]; } // Ausfuehren bis alle Handles fertig sind. $active = null; do { $mrc = curl_multi_exec($mh, $active); if ($active) { curl_multi_select($mh, 1.0); } } while ($active && $mrc === CURLM_OK); $results = []; foreach ($handles as $key => $h) { $raw = curl_multi_getcontent($h); $errno = curl_errno($h); $errMsg = curl_error($h); $status = (int)curl_getinfo($h, CURLINFO_HTTP_CODE); $cacheKey = $meta[$key]['cacheKey']; $etag = $etagSlots[$key]; curl_multi_remove_handle($mh, $h); curl_close($h); if ($errno !== 0 || $raw === null || $raw === false) { $results[$key] = new AstroApiException("Network error: $errMsg", 0); continue; } if ($status === 304 && $cacheKey !== null && isset($this->cache[$cacheKey])) { $results[$key] = $this->cacheTouch($cacheKey); continue; } try { $decoded = $this->decode((string)$raw, $status); if ($cacheKey !== null && $etag !== null && $status >= 200 && $status < 300) { $this->cacheStore($cacheKey, $etag, $decoded); } $results[$key] = $decoded; } catch (AstroApiException $e) { $results[$key] = $e; } } curl_multi_close($mh); return $results; } // ---------- Interne Helfer ------------------------------------------ /** Belegt ein cURL-Handle mit URL, Headers, Method und Body. */ private function applyOptions( \CurlHandle $ch, string $method, string $path, ?array $body, array $query, ?string $ifNoneMatch = null ): void { $url = $this->baseUrl . $path; if ($query !== []) { $url .= (str_contains($path, '?') ? '&' : '?') . http_build_query($query); } $headers = ['Accept: application/json']; if ($this->apiKey !== '') { $headers[] = 'X-API-Key: ' . $this->apiKey; } if ($ifNoneMatch !== null) { $headers[] = 'If-None-Match: ' . $ifNoneMatch; } $opts = [ CURLOPT_URL => $url, CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => $this->timeout, CURLOPT_USERAGENT => 'astroAPI-PHP-Helper/1.2', ]; if ($method === 'POST') { $opts[CURLOPT_POST] = true; $opts[CURLOPT_POSTFIELDS] = json_encode($body ?? new stdClass(), JSON_UNESCAPED_SLASHES); $headers[] = 'Content-Type: application/json'; } elseif ($method !== 'GET') { $opts[CURLOPT_CUSTOMREQUEST] = $method; } $opts[CURLOPT_HTTPHEADER] = $headers; curl_setopt_array($ch, $opts); } /** * Liefert eine Closure, die jeden Response-Header begutachtet und den * ETag-Wert (inkl. der Quotes) in $slot ablegt. Per-Handle einsetzbar. * * @param-out string|null $slot */ private function etagCapture(?string &$slot): \Closure { return static function ($ch, string $line) use (&$slot): int { $colon = strpos($line, ':'); if ($colon !== false) { $name = strtolower(rtrim(substr($line, 0, $colon))); if ($name === 'etag') { $slot = trim(substr($line, $colon + 1)); } } return strlen($line); }; } /** * Cache-Key aus Method, Path, Body und Query. Body und Query werden * JSON-kodiert, damit verschachtelte Strukturen stabil hashen. */ private function cacheKey(string $method, string $path, ?array $body, array $query): string { $bodyJson = $body === null ? 'null' : (string)json_encode($body, JSON_UNESCAPED_SLASHES); $queryJson = $query === [] ? '{}' : (string)json_encode($query, JSON_UNESCAPED_SLASHES); return sha1($method . '|' . $path . '|' . $bodyJson . '|' . $queryJson); } /** Markiert einen Cache-Eintrag als zuletzt benutzt (move-to-end). */ private function cacheTouch(string $key): array { $entry = $this->cache[$key]; unset($this->cache[$key]); $this->cache[$key] = $entry; return $entry['body']; } /** Speichert eine Antwort und verdraengt ggf. den aeltesten Eintrag. */ private function cacheStore(string $key, string $etag, array $body): void { unset($this->cache[$key]); $this->cache[$key] = ['etag' => $etag, 'body' => $body]; if (count($this->cache) > $this->cacheMaxEntries) { array_shift($this->cache); } } /** * @return array * @throws AstroApiException */ private function decode(string $raw, int $status): array { $decoded = json_decode($raw, true); if (!is_array($decoded)) { throw new AstroApiException("Invalid JSON response (status $status).", $status); } if ($status >= 400) { $errCode = (string)($decoded['error']['code'] ?? 'http_error'); $errText = (string)($decoded['error']['message'] ?? "HTTP $status"); throw new AstroApiException("$errCode: $errText", $status, $decoded); } return $decoded; } }