diff --git a/.env b/.env index 114aa336..697751b0 100644 --- a/.env +++ b/.env @@ -93,7 +93,7 @@ REDIS_CACHE_DSN=redis://redis:6379/0 ###< redis ### ###> Calendar Api Feed Source ### -# See docs/calendar-api-feed.md for variable explainations. +# See docs/feed/calendar-api-feed.md for variable explainations. CALENDAR_API_FEED_SOURCE_LOCATION_ENDPOINT= CALENDAR_API_FEED_SOURCE_RESOURCE_ENDPOINT= CALENDAR_API_FEED_SOURCE_EVENT_ENDPOINT= diff --git a/CHANGELOG.md b/CHANGELOG.md index e6badc10..e328e452 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +- [#226](https://github.com/os2display/display-api-service/pull/226) + - Added Colibo feed type. - [#225](https://github.com/os2display/display-api-service/pull/225) - Added ADRs. - [#215](https://github.com/os2display/display-api-service/pull/215) diff --git a/composer.json b/composer.json index f2425bda..e4ff3420 100644 --- a/composer.json +++ b/composer.json @@ -27,6 +27,7 @@ "rlanvin/php-rrule": "^2.2", "symfony/asset": "~6.4.0", "symfony/console": "~6.4.0", + "symfony/dom-crawler": "~6.4.0", "symfony/dotenv": "~6.4.0", "symfony/expression-language": "~6.4.0", "symfony/flex": "^2.0", diff --git a/composer.lock b/composer.lock index fa7f11b5..ac9344e1 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "bed3fa646c40854e6982154f552fcae6", + "content-hash": "67233a09d452101515a4003cb8eee218", "packages": [ { "name": "api-platform/core", @@ -3611,6 +3611,73 @@ }, "time": "2024-09-04T12:55:26+00:00" }, + { + "name": "masterminds/html5", + "version": "2.9.0", + "source": { + "type": "git", + "url": "https://github.com/Masterminds/html5-php.git", + "reference": "f5ac2c0b0a2eefca70b2ce32a5809992227e75a6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Masterminds/html5-php/zipball/f5ac2c0b0a2eefca70b2ce32a5809992227e75a6", + "reference": "f5ac2c0b0a2eefca70b2ce32a5809992227e75a6", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "php": ">=5.3.0" + }, + "require-dev": { + "phpunit/phpunit": "^4.8.35 || ^5.7.21 || ^6 || ^7 || ^8 || ^9" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.7-dev" + } + }, + "autoload": { + "psr-4": { + "Masterminds\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Matt Butcher", + "email": "technosophos@gmail.com" + }, + { + "name": "Matt Farina", + "email": "matt@mattfarina.com" + }, + { + "name": "Asmir Mustafic", + "email": "goetas@gmail.com" + } + ], + "description": "An HTML5 parser and serializer.", + "homepage": "http://masterminds.github.io/html5-php", + "keywords": [ + "HTML5", + "dom", + "html", + "parser", + "querypath", + "serializer", + "xml" + ], + "support": { + "issues": "https://github.com/Masterminds/html5-php/issues", + "source": "https://github.com/Masterminds/html5-php/tree/2.9.0" + }, + "time": "2024-03-31T07:05:07+00:00" + }, { "name": "monolog/monolog", "version": "3.7.0", @@ -6316,6 +6383,73 @@ ], "time": "2024-09-08T12:31:10+00:00" }, + { + "name": "symfony/dom-crawler", + "version": "v6.4.13", + "source": { + "type": "git", + "url": "https://github.com/symfony/dom-crawler.git", + "reference": "ae074dffb018c37a57071990d16e6152728dd972" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/dom-crawler/zipball/ae074dffb018c37a57071990d16e6152728dd972", + "reference": "ae074dffb018c37a57071990d16e6152728dd972", + "shasum": "" + }, + "require": { + "masterminds/html5": "^2.6", + "php": ">=8.1", + "symfony/polyfill-ctype": "~1.8", + "symfony/polyfill-mbstring": "~1.0" + }, + "require-dev": { + "symfony/css-selector": "^5.4|^6.0|^7.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\DomCrawler\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Eases DOM navigation for HTML and XML documents", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/dom-crawler/tree/v6.4.13" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-10-25T15:07:50+00:00" + }, { "name": "symfony/dotenv", "version": "v6.4.12", @@ -11666,73 +11800,6 @@ ], "time": "2020-07-06T04:49:32+00:00" }, - { - "name": "masterminds/html5", - "version": "2.9.0", - "source": { - "type": "git", - "url": "https://github.com/Masterminds/html5-php.git", - "reference": "f5ac2c0b0a2eefca70b2ce32a5809992227e75a6" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/Masterminds/html5-php/zipball/f5ac2c0b0a2eefca70b2ce32a5809992227e75a6", - "reference": "f5ac2c0b0a2eefca70b2ce32a5809992227e75a6", - "shasum": "" - }, - "require": { - "ext-dom": "*", - "php": ">=5.3.0" - }, - "require-dev": { - "phpunit/phpunit": "^4.8.35 || ^5.7.21 || ^6 || ^7 || ^8 || ^9" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "2.7-dev" - } - }, - "autoload": { - "psr-4": { - "Masterminds\\": "src" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Matt Butcher", - "email": "technosophos@gmail.com" - }, - { - "name": "Matt Farina", - "email": "matt@mattfarina.com" - }, - { - "name": "Asmir Mustafic", - "email": "goetas@gmail.com" - } - ], - "description": "An HTML5 parser and serializer.", - "homepage": "http://masterminds.github.io/html5-php", - "keywords": [ - "HTML5", - "dom", - "html", - "parser", - "querypath", - "serializer", - "xml" - ], - "support": { - "issues": "https://github.com/Masterminds/html5-php/issues", - "source": "https://github.com/Masterminds/html5-php/tree/2.9.0" - }, - "time": "2024-03-31T07:05:07+00:00" - }, { "name": "myclabs/deep-copy", "version": "1.12.0", @@ -13889,73 +13956,6 @@ ], "time": "2024-05-31T14:49:08+00:00" }, - { - "name": "symfony/dom-crawler", - "version": "v6.4.12", - "source": { - "type": "git", - "url": "https://github.com/symfony/dom-crawler.git", - "reference": "9d307ecbcb917001692be333cdc58f474fdb37f0" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/dom-crawler/zipball/9d307ecbcb917001692be333cdc58f474fdb37f0", - "reference": "9d307ecbcb917001692be333cdc58f474fdb37f0", - "shasum": "" - }, - "require": { - "masterminds/html5": "^2.6", - "php": ">=8.1", - "symfony/polyfill-ctype": "~1.8", - "symfony/polyfill-mbstring": "~1.0" - }, - "require-dev": { - "symfony/css-selector": "^5.4|^6.0|^7.0" - }, - "type": "library", - "autoload": { - "psr-4": { - "Symfony\\Component\\DomCrawler\\": "" - }, - "exclude-from-classmap": [ - "/Tests/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Fabien Potencier", - "email": "fabien@symfony.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - } - ], - "description": "Eases DOM navigation for HTML and XML documents", - "homepage": "https://symfony.com", - "support": { - "source": "https://github.com/symfony/dom-crawler/tree/v6.4.12" - }, - "funding": [ - { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, - { - "url": "https://github.com/fabpot", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", - "type": "tidelift" - } - ], - "time": "2024-09-15T06:35:36+00:00" - }, { "name": "symfony/maker-bundle", "version": "v1.61.0", diff --git a/docs/calender-api-feed.md b/docs/feed/calender-api-feed.md similarity index 100% rename from docs/calender-api-feed.md rename to docs/feed/calender-api-feed.md diff --git a/docs/feed/feed-overview.md b/docs/feed/feed-overview.md new file mode 100644 index 00000000..adb2c0af --- /dev/null +++ b/docs/feed/feed-overview.md @@ -0,0 +1,27 @@ +# Feed Overview + +"Feeds" in OS2display are external data sources that can provide up-to-data to slides. The idea is that if you can set +up slide based on a feed and publish it. The Screen Client will then fetch new data from the feed whenever the Slide is +shown on screen. + +The simplest example is a classic RSS news feed. You can set up a slide based on the RSS slide template, configure the +RSS source URL, and whenever the slide is on screen it will show the latest entries from the RSS feed. + +This means that administrators can set up slides and playlists that stays up to date automatically. + +## Architecture + +The "Feed" architecture is designed to enable both generic and custom feed types. To enable this all feed based screen +templates are designed to support a given "feed output model". These are normalized data sets from a given feed type. + +Each feed implementation defines which output model it supports. Thereby multiple feed implementations can support the +same output model. This is done to enable decoupling of the screen templates from the feed implementation. + +For example: + +* If you have a news source that is not a RSS feed you can implement a "FeedSource" that fetches data from your source + then normalizes the data and outputs it as the RSS output model. When setting up RSS slides this feed source can then + be selected as the source for the slide. +* OS2display has calendar templates that can show bookings or meetings. To show data from your specific calendar or + booking system you can implement a "FeedSource" that fetches booking data from your source and normalizes it to match + the calendar output model. diff --git a/psalm-baseline.xml b/psalm-baseline.xml index ef8d120f..e2174f7b 100644 --- a/psalm-baseline.xml +++ b/psalm-baseline.xml @@ -285,33 +285,6 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/Feed/CalendarApiFeedType.php b/src/Feed/CalendarApiFeedType.php index 37210cf5..7f4c820d 100644 --- a/src/Feed/CalendarApiFeedType.php +++ b/src/Feed/CalendarApiFeedType.php @@ -6,11 +6,10 @@ use App\Entity\Tenant\Feed; use App\Entity\Tenant\FeedSource; -use App\Model\CalendarEvent; -use App\Model\CalendarLocation; -use App\Model\CalendarResource; +use App\Feed\OutputModel\Calendar\Event; +use App\Feed\OutputModel\Calendar\Location; +use App\Feed\OutputModel\Calendar\Resource; use App\Service\FeedService; -use Faker\Core\DateTime; use Psr\Cache\CacheItemPoolInterface; use Psr\Log\LoggerInterface; use Symfony\Component\HttpFoundation\Request; @@ -29,7 +28,7 @@ */ class CalendarApiFeedType implements FeedTypeInterface { - final public const string SUPPORTED_FEED_TYPE = SupportedFeedOutputs::CALENDAR_OUTPUT; + final public const string SUPPORTED_FEED_TYPE = FeedOutputModels::CALENDAR_OUTPUT; final public const string EXCLUDE_IF_TITLE_NOT_CONTAINS = 'EXCLUDE_IF_TITLE_NOT_CONTAINS'; final public const string REPLACE_TITLE_IF_CONTAINS = 'REPLACE_TITLE_IF_CONTAINS'; @@ -79,7 +78,7 @@ public function getData(Feed $feed): array foreach ($resources as $resource) { $events = $this->getResourceEvents($resource); - /** @var CalendarEvent $event */ + /** @var Event $event */ foreach ($events as $event) { $title = $event->title; @@ -204,7 +203,7 @@ public function getConfigOptions(Request $request, FeedSource $feedSource, strin $resources = array_merge($resources, $locationResources); } - $resourceOptions = array_map(fn (CalendarResource $resource) => [ + $resourceOptions = array_map(fn (Resource $resource) => [ 'id' => Ulid::generate(), 'title' => $resource->displayName, 'value' => $resource->id, @@ -215,7 +214,7 @@ public function getConfigOptions(Request $request, FeedSource $feedSource, strin return $resourceOptions; } elseif ('locations' === $name) { - $locationOptions = array_map(fn (CalendarLocation $location) => [ + $locationOptions = array_map(fn (Location $location) => [ 'id' => Ulid::generate(), 'title' => $location->displayName, 'value' => $location->id, @@ -260,7 +259,7 @@ private function getLocationOptions(): array { $locations = $this->loadLocations(); - return array_reduce($locations, function (array $carry, CalendarLocation $location) { + return array_reduce($locations, function (array $carry, Location $location) { $carry[] = $location->id; return $carry; @@ -274,7 +273,7 @@ private function getResourceEvents(string $resourceId): array if (!$cacheItem->isHit()) { $allEvents = $this->loadEvents(); - $items = array_filter($allEvents, fn (CalendarEvent $item) => $item->resourceId === $resourceId); + $items = array_filter($allEvents, fn (Event $item) => $item->resourceId === $resourceId); $cacheItem->set($items); $cacheItem->expiresAfter($this->cacheExpireSeconds); @@ -291,7 +290,7 @@ private function getLocationResources(string $locationId): array if (!$cacheItem->isHit()) { $allResources = $this->loadResources(); - $items = array_filter($allResources, fn (CalendarResource $item) => $item->locationId === $locationId); + $items = array_filter($allResources, fn (Resource $item) => $item->locationId === $locationId); $cacheItem->set($items); $cacheItem->expiresAfter($this->cacheExpireSeconds); @@ -311,7 +310,7 @@ private function loadLocations(): array $LocationEntries = $response->toArray(); - $locations = array_map(fn (array $entry) => new CalendarLocation( + $locations = array_map(fn (array $entry) => new Location( $entry[$this->getMapping('locationId')], $entry[$this->getMapping('locationDisplayName')], ), $LocationEntries); @@ -346,7 +345,7 @@ private function loadResources(): array // Only include resources that are included in events endpoint. if ($includeValue) { - $resource = new CalendarResource( + $resource = new Resource( $resourceEntry[$this->getMapping('resourceId')], $resourceEntry[$this->getMapping('resourceLocationId')], $resourceEntry[$this->getMapping('resourceDisplayName')], @@ -377,7 +376,7 @@ private function loadEvents(): array $eventEntries = $response->toArray(); $events = array_reduce($eventEntries, function (array $carry, array $entry) { - $newEntry = new CalendarEvent( + $newEntry = new Event( Ulid::generate(), $entry[$this->getMapping('eventTitle')], $this->stringToUnixTimestamp($entry[$this->getMapping('eventStartTime')]), diff --git a/src/Feed/ColiboFeedType.php b/src/Feed/ColiboFeedType.php new file mode 100644 index 00000000..65526ab6 --- /dev/null +++ b/src/Feed/ColiboFeedType.php @@ -0,0 +1,265 @@ +feedService->getFeedSourceConfigUrl($feedSource, 'allowed-recipients'); + + return [ + [ + 'key' => 'colibo-feed-type-recipient-selector', + 'input' => 'multiselect-from-endpoint', + 'endpoint' => $feedEntryRecipients, + 'name' => 'recipients', + 'label' => 'Grupper', + 'helpText' => 'Vælg hvilke grupper, der skal hentes nyheder fra.', + 'formGroupClasses' => 'mb-3', + ], + [ + 'key' => 'colibo-feed-type-page-size', + 'input' => 'input', + 'type' => 'number', + 'name' => 'page_size', + 'label' => 'Antal nyheder', + 'defaultValue' => '5', + 'helpText' => 'Vælg hvor mange nyheder der maksimalt skal hentes.', + 'formGroupClasses' => 'mb-3', + ], + ]; + } + + public function getData(Feed $feed): array + { + $configuration = $feed->getConfiguration(); + $secrets = $feed->getFeedSource()?->getSecrets() ?? []; + + $result = [ + 'title' => 'Intranet', + 'entries' => [], + ]; + + $baseUri = $secrets['api_base_uri']; + $recipients = $configuration['recipients'] ?? []; + $publishers = $configuration['publishers'] ?? []; + $pageSize = isset($configuration['page_size']) ? (int) $configuration['page_size'] : 10; + + if (empty($baseUri) || 0 === count($recipients)) { + return $result; + } + + $feedSource = $feed->getFeedSource(); + + if (null === $feedSource) { + return $result; + } + + $entries = $this->apiClient->getFeedEntriesNews($feedSource, $recipients, $publishers, $pageSize); + + foreach ($entries as $entry) { + $item = new Item(); + $item->setTitle($entry->fields->title); + + $crawler = new Crawler($entry->fields->description); + $summary = ''; + foreach ($crawler as $domElement) { + $summary .= $domElement->textContent; + } + $item->setSummary($summary); + + $item->setPublicId((string) $entry->id); + + $link = sprintf('%s/feedentry/%s', $baseUri, $entry->id); + $item->setLink($link); + + if (null !== $entry->fields->body) { + $crawler = new Crawler($entry->fields->body); + $content = ''; + foreach ($crawler as $domElement) { + $content .= $domElement->textContent; + } + } else { + $content = $item->getSummary(); + } + $item->setContent($content); + + $updated = $entry->updated ?? $entry->publishDate; + $item->setLastModified(new \DateTime($updated)); + + $author = new Item\Author(); + $author->setName($entry->publisher->name); + $item->setAuthor($author); + + if (null !== $entry->fields->galleryItems) { + try { + $galleryItems = json_decode($entry->fields->galleryItems, true, 512, JSON_THROW_ON_ERROR); + } catch (\JsonException) { + $galleryItems = []; + } + + foreach ($galleryItems as $galleryItem) { + $media = new Item\Media(); + + $large = sprintf('%s/api/files/%s/thumbnail/large', $baseUri, $galleryItem['id']); + $media->setUrl($large); + + $small = sprintf('%s/api/files/%s/thumbnail/small', $baseUri, $galleryItem['id']); + $media->setThumbnail($small); + + $item->addMedia($media); + } + } + + foreach ($entry->recipients as $recipient) { + $category = new Category(); + $category->setLabel($recipient->name); + + $item->addCategory($category); + } + + $result['entries'][] = $item->toArray(); + } + + return $result; + } + + public function getConfigOptions(Request $request, FeedSource $feedSource, string $name): ?array + { + switch ($name) { + case 'allowed-recipients': + $allowedIds = $feedSource->getSecrets()['allowed_recipients'] ?? []; + $allGroupOptions = $this->getConfigOptions($request, $feedSource, 'recipients'); + + if (null === $allGroupOptions) { + return []; + } + + return array_values(array_filter($allGroupOptions, fn (ConfigOption $group) => in_array($group->value, $allowedIds))); + case 'recipients': + $id = self::getIdKey($feedSource); + + /** @var CacheItemInterface $cacheItem */ + $cacheItem = $this->feedsCache->getItem('colibo_feed_entry_groups_'.$id); + + if ($cacheItem->isHit()) { + $groups = $cacheItem->get(); + } else { + $groups = $this->apiClient->getSearchGroups($feedSource); + + $groups = array_map(fn (array $item) => new ConfigOption( + Ulid::generate(), + sprintf('%s (%d)', $item['model']['title'], $item['model']['id']), + (string) $item['model']['id'] + ), $groups); + + usort($groups, fn ($a, $b) => strcmp($a->title, $b->title)); + + $cacheItem->set($groups); + $cacheItem->expiresAfter(self::CACHE_TTL); + $this->feedsCache->save($cacheItem->set($groups)); + } + + return $groups; + default: + return null; + } + } + + public function getRequiredSecrets(): array + { + return [ + 'api_base_uri' => [ + 'type' => 'string', + 'exposeValue' => true, + ], + 'client_id' => [ + 'type' => 'string', + ], + 'client_secret' => [ + 'type' => 'string', + ], + 'allowed_recipients' => [ + 'type' => 'string_array', + 'exposeValue' => true, + ], + ]; + } + + public function getRequiredConfiguration(): array + { + return ['recipients', 'page_size']; + } + + public function getSupportedFeedOutputType(): string + { + return self::SUPPORTED_FEED_TYPE; + } + + public function getSchema(): array + { + return [ + '$schema' => 'http://json-schema.org/draft-04/schema#', + 'type' => 'object', + 'properties' => [ + 'api_base_uri' => [ + 'type' => 'string', + 'format' => 'uri', + ], + 'client_id' => [ + 'type' => 'string', + ], + 'client_secret' => [ + 'type' => 'string', + ], + 'allowed_recipients' => [ + 'type' => 'array', + 'items' => [ + 'type' => 'string', + ], + ], + ], + 'required' => ['api_base_uri', 'client_id', 'client_secret'], + ]; + } + + public static function getIdKey(FeedSource $feedSource): string + { + $ulid = $feedSource->getId(); + assert(null !== $ulid); + + return $ulid->toBase32(); + } +} diff --git a/src/Feed/EventDatabaseApiFeedType.php b/src/Feed/EventDatabaseApiFeedType.php index fb660ef7..250c2e40 100644 --- a/src/Feed/EventDatabaseApiFeedType.php +++ b/src/Feed/EventDatabaseApiFeedType.php @@ -19,7 +19,7 @@ */ class EventDatabaseApiFeedType implements FeedTypeInterface { - final public const string SUPPORTED_FEED_TYPE = SupportedFeedOutputs::POSTER_OUTPUT; + final public const string SUPPORTED_FEED_TYPE = FeedOutputModels::POSTER_OUTPUT; final public const int REQUEST_TIMEOUT = 10; public function __construct( @@ -50,12 +50,21 @@ public function getData(Feed $feed): array $tags = $configuration['subscriptionTagValue'] ?? null; $numberOfItems = $configuration['subscriptionNumberValue'] ?? 5; - $queryParams = array_filter([ + $queryParams = [ 'items_per_page' => $numberOfItems, - 'occurrences.place.id' => array_map(static fn ($place) => str_replace('/api/places/', '', (string) $place['value']), $places), - 'organizer.id' => array_map(static fn ($organizer) => str_replace('/api/organizers/', '', (string) $organizer['value']), $organizers), - 'tags' => array_map(static fn ($tag) => str_replace('/api/tags/', '', (string) $tag['value']), $tags), - ]); + ]; + + if (null !== $places) { + $queryParams['occurrences.place.id'] = array_map(static fn (array $place) => str_replace('/api/places/', '', (string) $place['value']), $places); + } + + if (null !== $organizers) { + $queryParams['organizer.id'] = array_map(static fn (array $organizer) => str_replace('/api/organizers/', '', (string) $organizer['value']), $organizers); + } + + if (null !== $tags) { + $queryParams['tags'] = array_map(static fn (array $tag) => str_replace('/api/tags/', '', (string) $tag['value']), $tags); + } $response = $this->client->request( 'GET', @@ -122,12 +131,13 @@ public function getData(Feed $feed): array if ($throwable instanceof ClientException && Response::HTTP_NOT_FOUND == $throwable->getCode()) { try { // Slide publishedTo is set to now. This will make the slide unpublished from this point on. - $feed->getSlide()->setPublishedTo(new \DateTime('now', new \DateTimeZone('UTC'))); + $slide = $feed->getSlide()?->setPublishedTo(new \DateTime('now', new \DateTimeZone('UTC'))); + $this->entityManager->flush(); $this->logger->info('Feed with id: {feedId} depends on an item that does not exist in Event Database. Unpublished slide with id: {slideId}', [ 'feedId' => $feed->getId(), - 'slideId' => $feed->getSlide()->getId(), + 'slideId' => $slide?->getId(), ]); } catch (\Exception $exception) { $this->logger->error('{code}: {message}', [ diff --git a/src/Feed/FeedException.php b/src/Feed/FeedException.php new file mode 100644 index 00000000..e164db7b --- /dev/null +++ b/src/Feed/FeedException.php @@ -0,0 +1,9 @@ +getFeed() as $item) { - $result['entries'][] = $item->toArray(); + $entry = $item->toArray(); + + if (empty($entry['author'])) { + $entry['author'] = [ + 'name' => $feedResult->getFeed()->getTitle(), + ]; + } + + $result['entries'][] = $entry; if (!is_null($numberOfEntries) && count($result['entries']) >= $numberOfEntries) { break; diff --git a/src/Feed/SourceType/Colibo/ApiClient.php b/src/Feed/SourceType/Colibo/ApiClient.php new file mode 100644 index 00000000..ae98dab1 --- /dev/null +++ b/src/Feed/SourceType/Colibo/ApiClient.php @@ -0,0 +1,231 @@ + */ + private array $apiClients = []; + + public function __construct( + private readonly CacheItemPoolInterface $feedsCache, + private readonly LoggerInterface $logger, + ) {} + + /** + * Get Feed News Entries for a given FeedSource. + * + * @param FeedSource $feedSource + * The FeedSource to scope by + * @param array $recipients + * An array of recipient ID's to filter by + * @param array $publishers + * An array of publisher ID's to filter by + * @param int $pageSize + * Number of elements to retrieve + * + * @return mixed + */ + public function getFeedEntriesNews(FeedSource $feedSource, array $recipients = [], array $publishers = [], int $pageSize = 10): mixed + { + try { + $client = $this->getApiClient($feedSource); + + $options = [ + 'headers' => [ + 'Content-Type' => 'application/json', + ], + 'query' => [ + 'recipients' => array_map(fn ($recipient) => (object) [ + 'Id' => $recipient, + 'Type' => 'Group', + ], $recipients), + 'publishers' => array_map(fn ($publisher) => (object) [ + 'Id' => $publisher, + 'Type' => 'Group', + ], $publishers), + 'pageSize' => $pageSize, + ], + ]; + + $response = $client->request('GET', '/api/feedentries/news', $options); + + return json_decode($response->getContent(), false, 512, JSON_THROW_ON_ERROR); + } catch (\Throwable $throwable) { + $this->logger->error('{code}: {message}', [ + 'code' => $throwable->getCode(), + 'message' => $throwable->getMessage(), + ]); + + return []; + } + } + + /** + * Retrieve search groups based on the given feed source and type. + * + * @param FeedSource $feedSource + * @param string $type + * + * @return array + */ + public function getSearchGroups(FeedSource $feedSource, string $type = 'WorkGroup'): array + { + try { + $responseData = $this->getSearchGroupsPage($feedSource, $type)->toArray(); + + $groups = $responseData['results']; + + $total = $responseData['total']; + $pages = (int) ceil($total / self::BATCH_SIZE); + + /** @var ResponseInterface[] $responses */ + $responses = []; + for ($page = 1; $page < $pages; ++$page) { + $responses[] = $this->getSearchGroupsPage($feedSource, $type, $page); + } + + foreach ($responses as $response) { + $responseData = $response->toArray(); + $groups = array_merge($groups, $responseData['results']); + } + + return $groups; + } catch (\Throwable $throwable) { + $this->logger->error('{code}: {message}', [ + 'code' => $throwable->getCode(), + 'message' => $throwable->getMessage(), + ]); + + return []; + } + } + + /** + * @param FeedSource $feedSource + * @param string $type + * @param int $pageIndex + * @param int $pageSize + * + * @return ResponseInterface + * + * @throws ColiboException + */ + private function getSearchGroupsPage(FeedSource $feedSource, string $type, int $pageIndex = 0, int $pageSize = self::BATCH_SIZE): ResponseInterface + { + try { + $client = $this->getApiClient($feedSource); + + return $client->request('GET', '/api/search/groups', [ + 'query' => [ + 'groupSearchQuery.groupTypes' => $type, + 'groupSearchQuery.pageIndex' => $pageIndex, + 'groupSearchQuery.pageSize' => $pageSize, + ], + ]); + } catch (ColiboException $exception) { + throw $exception; + } catch (\Throwable $throwable) { + throw new ColiboException($throwable->getMessage(), (int) $throwable->getCode(), $throwable); + } + } + + /** + * Get an authenticated scoped API client for the given FeedSource. + * + * @param FeedSource $feedSource + * + * @return HttpClientInterface + * + * @throws ColiboException + */ + private function getApiClient(FeedSource $feedSource): HttpClientInterface + { + $id = ColiboFeedType::getIdKey($feedSource); + + if (array_key_exists($id, $this->apiClients)) { + return $this->apiClients[$id]; + } + + $secrets = new SecretsDTO($feedSource); + $this->apiClients[$id] = HttpClient::createForBaseUri($secrets->apiBaseUri)->withOptions([ + 'headers' => [ + 'Authorization' => 'Bearer '.$this->fetchToken($feedSource), + 'Accept' => 'application/json', + ], + ]); + + return $this->apiClients[$id]; + } + + /** + * Get the auth token for the given FeedSource. + * + * @param FeedSource $feedSource + * + * @return string + * + * @throws ColiboException + */ + private function fetchToken(FeedSource $feedSource): string + { + $id = ColiboFeedType::getIdKey($feedSource); + + /** @var CacheItemInterface $cacheItem */ + $cacheItem = $this->feedsCache->getItem('colibo_token_'.$id); + + if ($cacheItem->isHit()) { + /** @var string $token */ + $token = $cacheItem->get(); + } else { + try { + $secrets = new SecretsDTO($feedSource); + $client = HttpClient::createForBaseUri($secrets->apiBaseUri); + + $response = $client->request('POST', '/auth/oauth2/connect/token', [ + 'headers' => [ + 'Content-Type' => 'application/x-www-form-urlencoded', + 'Accept' => 'application/json', + ], + 'body' => [ + 'grant_type' => self::GRANT_TYPE, + 'scope' => self::SCOPE, + 'client_id' => $secrets->clientId, + 'client_secret' => $secrets->clientSecret, + ], + ]); + + $content = $response->getContent(); + $contentDecoded = json_decode($content, false, 512, JSON_THROW_ON_ERROR); + + $token = $contentDecoded->access_token; + + // Expire cache 5 min before token expire + $expireSeconds = intval($contentDecoded->expires_in - 300); + + $cacheItem->set($token); + $cacheItem->expiresAfter($expireSeconds); + $this->feedsCache->save($cacheItem); + } catch (\Throwable $throwable) { + throw new ColiboException($throwable->getMessage(), (int) $throwable->getCode(), $throwable); + } + } + + return $token; + } +} diff --git a/src/Feed/SourceType/Colibo/ColiboException.php b/src/Feed/SourceType/Colibo/ColiboException.php new file mode 100644 index 00000000..6ca7f2c5 --- /dev/null +++ b/src/Feed/SourceType/Colibo/ColiboException.php @@ -0,0 +1,11 @@ +getSecrets(); + + if (null === $secrets) { + throw new \RuntimeException('No secrets found for feed source.'); + } + + if (!isset($secrets['api_base_uri'], $secrets['client_id'], $secrets['client_secret'])) { + throw new \RuntimeException('Missing required secrets for feed source.'); + } + + if (false === filter_var($secrets['api_base_uri'], FILTER_VALIDATE_URL)) { + throw new \RuntimeException('Invalid api_endpoint.'); + } + + $this->apiBaseUri = rtrim((string) $secrets['api_base_uri'], '/'); + $this->clientId = $secrets['client_id']; + $this->clientSecret = $secrets['client_secret']; + } +} diff --git a/src/Feed/SparkleIOFeedType.php b/src/Feed/SparkleIOFeedType.php index 64daed7b..67787a84 100644 --- a/src/Feed/SparkleIOFeedType.php +++ b/src/Feed/SparkleIOFeedType.php @@ -8,11 +8,11 @@ use App\Entity\Tenant\FeedSource; use App\Service\FeedService; use Psr\Cache\CacheItemInterface; +use Psr\Cache\CacheItemPoolInterface; use Psr\Cache\InvalidArgumentException; use Psr\Log\LoggerInterface; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\Uid\Ulid; -use Symfony\Contracts\Cache\CacheInterface; use Symfony\Contracts\HttpClient\Exception\ClientExceptionInterface; use Symfony\Contracts\HttpClient\Exception\RedirectionExceptionInterface; use Symfony\Contracts\HttpClient\Exception\ServerExceptionInterface; @@ -22,14 +22,14 @@ /** @deprecated The SparkleIO service is discontinued. */ class SparkleIOFeedType implements FeedTypeInterface { - final public const string SUPPORTED_FEED_TYPE = SupportedFeedOutputs::INSTAGRAM_OUTPUT; + final public const string SUPPORTED_FEED_TYPE = FeedOutputModels::INSTAGRAM_OUTPUT; final public const int REQUEST_TIMEOUT = 10; public function __construct( private readonly FeedService $feedService, private readonly HttpClientInterface $client, - private readonly CacheInterface $feedsCache, + private readonly CacheItemPoolInterface $feedsCache, private readonly LoggerInterface $logger, ) {}