Skip to content
Open
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion src/Config.php
Original file line number Diff line number Diff line change
Expand Up @@ -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']);
Expand Down
92 changes: 92 additions & 0 deletions src/Event/EventsApi.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
<?php

namespace Helioviewer\Api\Event;

use DateTimeInterface;
use GuzzleHttp\Client;
use GuzzleHttp\ClientInterface;
use Helioviewer\Api\Sentry\Sentry;

class EventsApi {

private ClientInterface $client;

/**
* EventsApi constructor.
*
* @param ClientInterface|null $client Optional Guzzle client; if not provided, a new Client is created.
*/
public function __construct(ClientInterface $client = null)
{
$this->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;
}
}
8 changes: 8 additions & 0 deletions src/Event/EventsApiException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<?php

namespace Helioviewer\Api\Event;

class EventsApiException extends \Exception
{

}
19 changes: 19 additions & 0 deletions src/Module/SolarEvents.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 {

Expand Down Expand Up @@ -230,6 +232,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"));

Expand All @@ -238,6 +241,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']);
Expand Down
86 changes: 86 additions & 0 deletions tests/unit_tests/events/EventsApiTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
<?php declare(strict_types=1);

/**
* @author Kasim Necdet Percinel <kasim.n.percinel@nasa.gov>
*/

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'
);
}
}
Loading