diff --git a/settings/Config.Example.ini b/settings/Config.Example.ini index 414f0869d..e46025af9 100644 --- a/settings/Config.Example.ini +++ b/settings/Config.Example.ini @@ -82,6 +82,9 @@ db_events = true ; Leave blank for no password, otherwise choose a long and sufficiently random string import_events_auth = "" +; This is the URL of the eventsapi, you can set to your development one if needed +events_api_url = "https://events.helioviewer.org" + [movie_params] ; FFmpeg location ffmpeg = ffmpeg diff --git a/src/Config.php b/src/Config.php index 16e46f457..6fbe26b26 100644 --- a/src/Config.php +++ b/src/Config.php @@ -35,7 +35,15 @@ public function __construct($file) { if ( in_array('acao_url', array_keys($this->config)) ) { - if ( in_array('HTTP_ORIGIN', array_keys($_SERVER)) + // In dev environments, allow CORS from all origins + if ( in_array('app_env', array_keys($this->config)) + && str_starts_with($this->config['app_env'], 'dev') ) { + + header("Access-Control-Allow-Origin: *"); + header("Access-Control-Allow-Methods: ".$this->config['acam']); + header("Access-Control-Allow-Headers: Content-Type"); + + } elseif ( in_array('HTTP_ORIGIN', array_keys($_SERVER)) && in_array($_SERVER['HTTP_ORIGIN'], $this->config['acao_url']) ) { header("Access-Control-Allow-Origin: ".$_SERVER['HTTP_ORIGIN']); diff --git a/src/Event/EventsApi.php b/src/Event/EventsApi.php new file mode 100644 index 000000000..b9d899017 --- /dev/null +++ b/src/Event/EventsApi.php @@ -0,0 +1,92 @@ +client = $client ?? new Client([ + 'timeout' => 4, + 'headers' => [ + 'Accept' => 'application/json', + 'User-Agent' => 'Helioviewer-API/2.0' + ] + ]); + } + + /** + * Get events for a specific source + * + * @param DateTimeInterface $observationTime The observation time + * @param string $source The data source (e.g. "CCMC") + * @return array Array of event data + * @throws EventsApiException on API errors or unexpected responses + */ + public function getEventsForSource(DateTimeInterface $observationTime, string $source): array + { + // Build the API URL: /api/v1/events/{source}/observation/{datetime} + $formattedTime = $observationTime->format('Y-m-d H:i:s'); + $encodedTime = urlencode($formattedTime); + + $baseUrl = defined('HV_EVENTS_API_URL') ? HV_EVENTS_API_URL : 'https://events.helioviewer.org'; + $url = $baseUrl . "/api/v1/events/{$source}/observation/{$encodedTime}"; + + Sentry::setContext('EventsApi', [ + 'url' => $url, + 'source' => $source, + 'observation_time' => $observationTime->format('Y-m-d\TH:i:s\Z') + ]); + + $response = $this->client->request('GET', $url); + + return $this->parseResponse($response); + } + + /** + * Parse the HTTP response and decode JSON + * + * @param \Psr\Http\Message\ResponseInterface $response + * @return array + * @throws EventsApiException if JSON decoding fails or response format is unexpected + */ + private function parseResponse($response): array + { + $body = (string)$response->getBody(); + $data = json_decode($body, true); + + if (json_last_error() !== JSON_ERROR_NONE) { + Sentry::setContext('EventsApi', [ + 'raw_response' => $body, + 'json_error' => json_last_error_msg(), + 'response_status' => $response->getStatusCode() + ]); + + throw new EventsApiException("Failed to decode JSON response: " . json_last_error_msg()); + } + + if (!is_array($data)) { + Sentry::setContext('EventsApi', [ + 'unexpected_response_type' => gettype($data), + 'raw_response' => $body, + 'response_status' => $response->getStatusCode() + ]); + + throw new EventsApiException("Unexpected response format: expected array, got " . gettype($data)); + } + + return $data; + } +} diff --git a/src/Event/EventsApiException.php b/src/Event/EventsApiException.php new file mode 100644 index 000000000..db5796780 --- /dev/null +++ b/src/Event/EventsApiException.php @@ -0,0 +1,8 @@ +getNormalizedEvents($this->date, Array()); + $events_api_sources = ["CCMC", "RHESSI"]; + $observationTime = new DateTimeImmutable($this->date); $startDate = $observationTime->sub(new DateInterval("PT12H")); $length = new DateInterval("P1D"); - $event_categories = array_merge($event_categories, Helper_EventInterface::GetEvents($startDate, $length, $observationTime)); - // Lay down all relevant event REGIONS first + // Collect CCMC events if any + try { + + $eventsApi = new EventsApi(); + $event_categories = array_merge($event_categories, $eventsApi->getEventsForSource($observationTime, "CCMC")); + // if there is no error only left is RHESSI to collect + $events_api_sources = ["RHESSI"]; + } catch (EventsApiException $e) { + Sentry::capture($e); + } + + // Collect RHESSI events + $event_categories = array_merge($event_categories, Helper_EventInterface::GetEvents($startDate, $length, $observationTime, $events_api_sources)); + + // Lay down all relevant event REGIONS first $events_to_render = []; $events_manager = $this->eventsManager; $add_label_visibility_and_concept = function($events_data, $event_cat_pin, $event_group_name) use ($events_manager) { diff --git a/src/Module/SolarEvents.php b/src/Module/SolarEvents.php index f4e96af95..42d6d0852 100644 --- a/src/Module/SolarEvents.php +++ b/src/Module/SolarEvents.php @@ -16,6 +16,8 @@ require_once HV_ROOT_DIR . "/../src/Helper/EventInterface.php"; use Helioviewer\Api\Sentry\Sentry; +use Helioviewer\Api\Event\EventsApi; +use Helioviewer\Api\Event\EventsApiException; class Module_SolarEvents implements Module { @@ -216,6 +218,7 @@ private function getHekEvents() { public function events() { // The given time is the observation time. $observationTime = new DateTimeImmutable($this->_params['startTime']); + // The query start time is 12 hours earlier. $start = $observationTime->sub(new DateInterval("PT12H")); @@ -224,6 +227,22 @@ public function events() { // at the center. $length = new DateInterval('P1D'); + // Handle CCMC source using new Events API + // This provides direct access to CCMC event data without going through the standard EventInterface + if (array_key_exists('sources', $this->_options) && $this->_options['sources'] === 'CCMC') { + + try { + $eventsApi = new EventsApi(); + $data = $eventsApi->getEventsForSource($observationTime, "CCMC"); + + header("Content-Type: application/json"); + echo json_encode($data); + return; + } catch (EventsApiException $e) { + Sentry::capture($e); + } + } + // Check if any specific datasources were requested if (array_key_exists('sources', $this->_options)) { $sources = explode(',', $this->_options['sources']); diff --git a/tests/unit_tests/events/EventsApiTest.php b/tests/unit_tests/events/EventsApiTest.php new file mode 100644 index 000000000..a7b47a40d --- /dev/null +++ b/tests/unit_tests/events/EventsApiTest.php @@ -0,0 +1,86 @@ + + */ + +use PHPUnit\Framework\TestCase; +use GuzzleHttp\ClientInterface; +use GuzzleHttp\Psr7\Response; +use Helioviewer\Api\Event\EventsApi; +use Helioviewer\Api\Event\EventsApiException; + +final class EventsApiTest extends TestCase +{ + private $mockClient; + private $eventsApi; + + protected function setUp(): void + { + $this->mockClient = $this->createMock(ClientInterface::class); + $this->eventsApi = new EventsApi($this->mockClient); + } + + public function testItShouldGetEventsSuccessfully(): void + { + $responseData = [ + ['id' => 1, 'type' => 'event'.rand()], + ['id' => 2, 'type' => 'event'.rand()] + ]; + + $this->mockClient->expects($this->once()) + ->method('request') + ->with('GET', $this->stringContains('/api/v1/events/CCMC/observation/')) + ->willReturn(new Response(200, [], json_encode($responseData))); + + $result = $this->eventsApi->getEventsForSource( + new DateTimeImmutable('2024-01-15 12:00:00'), + 'CCMC' + ); + + $this->assertEquals($responseData, $result); + } + + public function testItShouldUrlEncodeObservationTime(): void + { + $this->mockClient->expects($this->once()) + ->method('request') + ->with('GET', $this->stringContains('2024-01-15+12%3A30%3A45')) + ->willReturn(new Response(200, [], json_encode([]))); + + $this->eventsApi->getEventsForSource( + new DateTimeImmutable('2024-01-15 12:30:45'), + 'CCMC' + ); + } + + public function testItShouldThrowExceptionOnInvalidJson(): void + { + $this->mockClient->expects($this->once()) + ->method('request') + ->willReturn(new Response(200, [], 'invalid json {')); + + $this->expectException(EventsApiException::class); + $this->expectExceptionMessage('Failed to decode JSON response'); + + $this->eventsApi->getEventsForSource( + new DateTimeImmutable('2024-01-15 12:00:00'), + 'CCMC' + ); + } + + public function testItShouldThrowExceptionWhenResponseIsNotArray(): void + { + $this->mockClient->expects($this->once()) + ->method('request') + ->willReturn(new Response(200, [], '"just a string"')); + + $this->expectException(EventsApiException::class); + $this->expectExceptionMessage('Unexpected response format: expected array, got string'); + + $this->eventsApi->getEventsForSource( + new DateTimeImmutable('2024-01-15 12:00:00'), + 'CCMC' + ); + } +}