Skip to content

Commit 6deef2a

Browse files
committed
Issue #37 - Adding Fetcher to pull API data. Adding Hook to add the federated_footer to the page render array.
1 parent 66efeaf commit 6deef2a

File tree

4 files changed

+342
-0
lines changed

4 files changed

+342
-0
lines changed

psulib_base_helper.services.yml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,12 @@ services:
33
class: Drupal\psulib_base_helper\TwigExtension\StripHtmlComments
44
tags:
55
- { name: twig.extension }
6+
Drupal\psulib_base_helper\PsuFederatedDataFetcher:
7+
class: Drupal\psulib_base_helper\PsuFederatedDataFetcher
8+
arguments:
9+
- '@cache.default'
10+
- '@logger.channel.default'
11+
- '@http_client'
12+
Drupal\psulib_base_helper\Hook\ThemeHooks:
13+
class: \Drupal\psulib_base_helper\Hook\ThemeHooks
14+
autowire: true

src/Hook/ThemeHooks.php

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Drupal\psulib_base_helper\Hook;
6+
7+
use Drupal\Core\Config\ConfigFactoryInterface;
8+
use Drupal\Core\Hook\Attribute\Hook;
9+
use Drupal\psulib_base_helper\PsuFederatedDataFetcher;
10+
11+
/**
12+
* Hooks for altering the Dynamic Hero.
13+
*/
14+
class ThemeHooks {
15+
16+
/**
17+
* Constructs the plugin instance.
18+
*/
19+
public function __construct(
20+
private readonly PsuFederatedDataFetcher $dataFetcher,
21+
private readonly ConfigFactoryInterface $configFactory,
22+
) {}
23+
24+
/**
25+
* Implements hook_preprocess_HOOK() for page.
26+
*
27+
* Add the federated footer to all pages.
28+
*/
29+
#[Hook('preprocess_page')]
30+
public function preprocessPage(&$variables): void {
31+
$show_federated_footer = $this->configFactory
32+
->get('psulib_base.settings')
33+
->get('show_federated_footer');
34+
35+
if (!$show_federated_footer) {
36+
return;
37+
}
38+
39+
$data = $this->dataFetcher->getFederatedData('brandFooter');
40+
41+
if (!$data || empty($data['linkContentCollection']['items'])) {
42+
return;
43+
}
44+
45+
$variables['page']['federated_footer'] = [
46+
'#type' => 'component',
47+
'#component' => 'psulib_base:federated_footer',
48+
'#props' => [
49+
'links' => $data['linkContentCollection']['items'],
50+
],
51+
];
52+
}
53+
54+
}

src/PsuFederatedDataFetcher.php

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Drupal\psulib_base_helper;
6+
7+
use Drupal\Core\Cache\CacheBackendInterface;
8+
use GuzzleHttp\ClientInterface;
9+
use GuzzleHttp\Exception\GuzzleException;
10+
use Psr\Log\LoggerInterface;
11+
12+
/**
13+
* Fetches data from PSU API endpoints.
14+
*/
15+
class PsuFederatedDataFetcher {
16+
17+
/**
18+
* URL ot the fetchallfederateddata endpoint.
19+
*
20+
* This is currently a Vercel serverless function that proxies requests to
21+
* the various PSU API endpoints.
22+
*/
23+
const API_ENDPOINT = "https://psu-flex-endpoints.vercel.app/api/fetchAllFederatedData";
24+
25+
/**
26+
* Data fetched from the PSU API endpoint.
27+
*
28+
* @var array
29+
*/
30+
protected $data = [];
31+
32+
/**
33+
* Constructs a PsuDataFetcher object.
34+
*/
35+
public function __construct(
36+
protected readonly CacheBackendInterface $cache,
37+
protected readonly LoggerInterface $logger,
38+
protected readonly ClientInterface $httpClient,
39+
) {
40+
// Pull the Federated data on construct so it's available for all
41+
// subsequent calls to getData.
42+
$this->fetchFederatedData();
43+
}
44+
45+
/**
46+
* Fetches data from the All data endpoint and save the data to cache.
47+
*/
48+
protected function fetchFederatedData(): void {
49+
// Implementation for fetching data from the PSU API.
50+
// This is a placeholder for the actual API call logic.
51+
if (isset($this->data['data'])) {
52+
return;
53+
}
54+
55+
$cache_id = "psulib_base_helper:psu_data:all";
56+
if ($cache = $this->cache->get($cache_id)) {
57+
$this->data['data'] = $cache->data;
58+
return;
59+
}
60+
61+
try {
62+
$response = $this->httpClient->request('GET', self::API_ENDPOINT, [
63+
'verify' => FALSE,
64+
'headers' => [
65+
'accept' => 'application/json',
66+
],
67+
]);
68+
$data = $response->getBody()->getContents();
69+
$data = json_decode($data, TRUE);
70+
$this->cache->set(
71+
$cache_id,
72+
$data,
73+
// Cache for 24 hours.
74+
time() + 86400
75+
);
76+
77+
$this->data['data'] = $data;
78+
}
79+
catch (GuzzleException | \Exception $e) {
80+
if ($e->getCode() === 404 && $e->getMessage() === '') {
81+
$this->cache->set(
82+
$cache_id,
83+
['data' => []],
84+
// Cache for 10 minutes.
85+
time() + 600
86+
);
87+
return;
88+
}
89+
$this->data = ['data' => []];
90+
$this->logger->error($e->getMessage());
91+
}
92+
}
93+
94+
/**
95+
* Returns the data for a specific name.
96+
*
97+
* @param string $name
98+
* The name of the data to retrieve.
99+
*
100+
* @return array
101+
* The data associated with the given name.
102+
*/
103+
public function getFederatedData(string $name): array {
104+
105+
if (isset($this->data['data'][$name])) {
106+
return $this->data['data'][$name];
107+
}
108+
// Implementation for retrieving data, potentially using caching.
109+
return [];
110+
}
111+
112+
}
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Drupal\Tests\psulib_base_helper\Unit\Hook;
6+
7+
use Drupal\Core\Config\ConfigFactoryInterface;
8+
use Drupal\Core\Config\ImmutableConfig;
9+
use Drupal\psulib_base_helper\Hook\ThemeHooks;
10+
use Drupal\psulib_base_helper\PsuFederatedDataFetcher;
11+
use Drupal\Tests\UnitTestCase;
12+
use PHPUnit\Framework\Attributes\CoversClass;
13+
use PHPUnit\Framework\Attributes\DataProvider;
14+
use PHPUnit\Framework\Attributes\Group;
15+
use PHPUnit\Framework\Attributes\Test;
16+
17+
/**
18+
* Tests for ThemeHooks.
19+
*/
20+
#[Group('psulib_base_helper')]
21+
#[CoversClass(ThemeHooks::class)]
22+
class ThemeHooksTest extends UnitTestCase {
23+
24+
/**
25+
* The mocked data fetcher.
26+
*
27+
* @var \Drupal\psulib_base_helper\PsuFederatedDataFetcher|\PHPUnit\Framework\MockObject\MockObject
28+
*/
29+
protected $dataFetcher;
30+
31+
/**
32+
* The mocked config factory.
33+
*
34+
* @var \Drupal\Core\Config\ConfigFactoryInterface|\PHPUnit\Framework\MockObject\MockObject
35+
*/
36+
protected $configFactory;
37+
38+
/**
39+
* The mocked config.
40+
*
41+
* @var \Drupal\Core\Config\ImmutableConfig|\PHPUnit\Framework\MockObject\MockObject
42+
*/
43+
protected $config;
44+
45+
/**
46+
* The ThemeHooks instance.
47+
*
48+
* @var \Drupal\psulib_base_helper\Hook\ThemeHooks
49+
*/
50+
protected ThemeHooks $hooks;
51+
52+
/**
53+
* {@inheritdoc}
54+
*/
55+
protected function setUp(): void {
56+
parent::setUp();
57+
58+
$this->dataFetcher = $this->createMock(PsuFederatedDataFetcher::class);
59+
$this->configFactory = $this->createMock(ConfigFactoryInterface::class);
60+
$this->config = $this->createMock(ImmutableConfig::class);
61+
62+
$this->configFactory->method('get')
63+
->with('psulib_base.settings')
64+
->willReturn($this->config);
65+
66+
$this->hooks = new ThemeHooks(
67+
$this->dataFetcher,
68+
$this->configFactory
69+
);
70+
}
71+
72+
/**
73+
* Test that the federated footer render array is added when enabled.
74+
*/
75+
#[Test]
76+
public function testFederatedFooterAddedWhenSettingsAndDataPresent(): void {
77+
$this->config->method('get')
78+
->with('show_federated_footer')
79+
->willReturn(TRUE);
80+
81+
$expectedLinks = [
82+
['title' => 'Libraries', 'url' => 'https://example.org/libraries'],
83+
['title' => 'Research', 'url' => 'https://example.org/research'],
84+
];
85+
86+
$this->dataFetcher->expects($this->once())
87+
->method('getFederatedData')
88+
->with('brandFooter')
89+
->willReturn([
90+
'linkContentCollection' => [
91+
'items' => $expectedLinks,
92+
],
93+
]);
94+
95+
$variables = ['page' => []];
96+
97+
$this->hooks->preprocessPage($variables);
98+
99+
$this->assertArrayHasKey('federated_footer', $variables['page']);
100+
$this->assertSame('component', $variables['page']['federated_footer']['#type']);
101+
$this->assertSame('psulib_base:federated_footer', $variables['page']['federated_footer']['#component']);
102+
$this->assertSame(
103+
['links' => $expectedLinks],
104+
$variables['page']['federated_footer']['#props']
105+
);
106+
}
107+
108+
/**
109+
* Test that the federated footer is not added when disabled or missing data.
110+
*
111+
* @param bool|null $showSetting
112+
* The show_federated_footer setting value.
113+
* @param array $data
114+
* The data returned by the federated data fetcher.
115+
* @param bool $expectFetcherCall
116+
* Whether the data fetcher should be called.
117+
*/
118+
#[Test]
119+
#[DataProvider('federatedFooterNotAddedProvider')]
120+
public function testFederatedFooterNotAddedWhenNoDataOrDisabled(?bool $showSetting, array $data, bool $expectFetcherCall): void {
121+
$this->config->method('get')
122+
->with('show_federated_footer')
123+
->willReturn($showSetting);
124+
125+
if ($expectFetcherCall) {
126+
$this->dataFetcher->expects($this->once())
127+
->method('getFederatedData')
128+
->with('brandFooter')
129+
->willReturn($data);
130+
}
131+
else {
132+
$this->dataFetcher->expects($this->never())
133+
->method('getFederatedData');
134+
}
135+
136+
$variables = ['page' => []];
137+
138+
$this->hooks->preprocessPage($variables);
139+
140+
$this->assertArrayNotHasKey('federated_footer', $variables['page']);
141+
}
142+
143+
/**
144+
* Data provider for disabled or empty federated footer scenarios.
145+
*
146+
* @return array
147+
* The data sets for the test.
148+
*/
149+
public static function federatedFooterNotAddedProvider(): array {
150+
$validData = [
151+
'linkContentCollection' => [
152+
'items' => [
153+
['label' => 'Libraries', 'url' => 'https://example.org/libraries'],
154+
],
155+
],
156+
];
157+
158+
return [
159+
'disabled via setting' => [FALSE, $validData, FALSE],
160+
'missing setting' => [NULL, $validData, FALSE],
161+
'no data' => [TRUE, [], TRUE],
162+
'missing link collection' => [TRUE, ['linkContentCollection' => []], TRUE],
163+
'empty items' => [TRUE, ['linkContentCollection' => ['items' => []]], TRUE],
164+
];
165+
}
166+
167+
}

0 commit comments

Comments
 (0)