diff --git a/.env b/.env index c62c7b33..114aa336 100644 --- a/.env +++ b/.env @@ -92,3 +92,14 @@ REDIS_CACHE_PREFIX=DisplayApiService REDIS_CACHE_DSN=redis://redis:6379/0 ###< redis ### +###> Calendar Api Feed Source ### +# See docs/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= +CALENDAR_API_FEED_SOURCE_CUSTOM_MAPPINGS='{}' +CALENDAR_API_FEED_SOURCE_EVENT_MODIFIERS='{}' +CALENDAR_API_FEED_SOURCE_DATE_FORMAT= +CALENDAR_API_FEED_SOURCE_DATE_TIMEZONE= +CALENDAR_API_FEED_SOURCE_CACHE_EXPIRE_SECONDS=300 +###< Calendar Api Feed Source ### diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f031846..1b0eac97 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,11 +4,12 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +- [#215](https://github.com/os2display/display-api-service/pull/215) + - Added calendar api feed type. - [#223](https://github.com/os2display/display-api-service/pull/223) - Added explicit fixtures to avoid false negatives in the test suite - [#219](https://github.com/os2display/display-api-service/pull/219) - Fixed psalm, test, coding standards and updated api spec. - - [#222](https://github.com/os2display/display-api-service/pull/222) - Adds create, update, delete operations to feed-source endpoint. - Adds data validation for feed source. diff --git a/config/api_platform/feed_source.yaml b/config/api_platform/feed_source.yaml index 6375b101..36db139b 100644 --- a/config/api_platform/feed_source.yaml +++ b/config/api_platform/feed_source.yaml @@ -33,7 +33,7 @@ resources: _api_Feed_get_source_config: class: ApiPlatform\Metadata\Get method: GET - uriTemplate: '/feed_sources/{id}/config/{name}' + uriTemplate: '/feed-sources/{id}/config/{name}' read: false controller: App\Controller\FeedSourceConfigGetController openapiContext: diff --git a/config/packages/cache.yaml b/config/packages/cache.yaml index c3f359eb..c4c0fd61 100644 --- a/config/packages/cache.yaml +++ b/config/packages/cache.yaml @@ -17,13 +17,17 @@ framework: # Default expire set to 5 minutes default_lifetime: 300 + # Creates a "calendar.api.cache" service + calendar.api.cache: + adapter: cache.adapter.redis + # Creates a "auth.screen.cache" service auth.screen.cache: adapter: cache.adapter.redis # Default expire set to 1 day default_lifetime: 86400 - # Creates a "interactive_slide.cache" service + # Creates an "interactive_slide.cache" service interactive_slide.cache: adapter: cache.adapter.redis # Default expire set to 12 hours diff --git a/config/services.yaml b/config/services.yaml index 72293eb9..6f1a0b8c 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -51,6 +51,17 @@ services: Symfony\Component\Security\Http\Authentication\AuthenticationFailureHandlerInterface: '@Lexik\Bundle\JWTAuthenticationBundle\Security\Http\Authentication\AuthenticationFailureHandler' Symfony\Component\Security\Http\Authentication\AuthenticationSuccessHandlerInterface: '@Lexik\Bundle\JWTAuthenticationBundle\Security\Http\Authentication\AuthenticationSuccessHandler' + App\Feed\CalendarApiFeedType: + arguments: + $locationEndpoint: '%env(string:CALENDAR_API_FEED_SOURCE_LOCATION_ENDPOINT)%' + $resourceEndpoint: '%env(string:CALENDAR_API_FEED_SOURCE_RESOURCE_ENDPOINT)%' + $eventEndpoint: '%env(string:CALENDAR_API_FEED_SOURCE_EVENT_ENDPOINT)%' + $customMappings: '%env(json:CALENDAR_API_FEED_SOURCE_CUSTOM_MAPPINGS)%' + $eventModifiers: '%env(json:CALENDAR_API_FEED_SOURCE_EVENT_MODIFIERS)%' + $dateFormat: '%env(string:CALENDAR_API_FEED_SOURCE_DATE_FORMAT)%' + $timezone: '%env(string:CALENDAR_API_FEED_SOURCE_DATE_TIMEZONE)%' + $cacheExpireSeconds: '%env(int:CALENDAR_API_FEED_SOURCE_CACHE_EXPIRE_SECONDS)%' + App\Service\KeyVaultService: arguments: $keyVaultSource: '%env(string:APP_KEY_VAULT_SOURCE)%' diff --git a/docs/calender-api-feed.md b/docs/calender-api-feed.md new file mode 100644 index 00000000..e6a1ca01 --- /dev/null +++ b/docs/calender-api-feed.md @@ -0,0 +1,189 @@ +# Calendar API Feed + +The CalendarApiFeedType retrieves locations, resources and events from 3 JSON endpoints +set in the environment variables: + +```dotenv +CALENDAR_API_FEED_SOURCE_LOCATION_ENDPOINT= +CALENDAR_API_FEED_SOURCE_RESOURCE_ENDPOINT= +CALENDAR_API_FEED_SOURCE_EVENT_ENDPOINT= +``` + +## Mapping the json data + +By default, the three endpoints should return data as follows: + +### Locations + +```json +[ + { + "id": "Location Id 2", + "displayName": "Location display name 1" + }, + { + "id": "Location Id 2", + "displayName": "Location display name 2" + } +] +``` + +* The `id` (Mapping key: LOCATION_ID) should be unique for the location and is used to identify it in the resource relation. +* The `displayName` (Mapping key: LOCATION_DISPLAY_NAME) is the name of the location in the admin. + +### Resources + +```json +[ + { + "id": "Resource Id 1", + "locationId": "Location Id 1", + "displayName": "Resource 1", + "includedInEvents": true + }, + { + "id": "Resource Id 2", + "locationId": "Location Id 1", + "displayName": "Resource 2", + "includedInEvents": false + } +] +``` + +* The `id` (Mapping key: RESOURCE_ID) should be unique for the resource. +* The `locationId` (Mapping key: RESOURCE_LOCATION_ID) is the id of the location the resource belongs to. +* The `displayName` (Mapping key: RESOURCE_DISPLAY_NAME) is the name the resource is presented by in templates and admin. +* The `includedInEvents` (Mapping key: RESOURCE_INCLUDED_IN_EVENTS) determines if the resource is included in the events +endpoint. + This property can be excluded in the data. If this is the case, it defaults to `true`. + +### Events + +```json +[ + { + "title": "Event Title 1", + "startTime": "2025-02-15T13:00:00+02:00", + "endTime": "2025-02-15T13:30:00+02:00", + "resourceDisplayName": "Resource 1", + "resourceId": "Resource Id 1" + }, + { + "title": "Event Title 2", + "startTime": "2025-02-15T15:00:00+02:00", + "endTime": "2025-02-15T15:30:00+02:00", + "resourceDisplayName": "Resource 1", + "resourceId": "Resource Id 1" + } +] +``` + +* The `title` (Mapping key: EVENT_TITLE) is the title of the event. +* The `startTime` (Mapping key: EVENT_START_TIME) is the start time of the event. +Should be formatted as an `ISO 8601 date`, e.g. `2004-02-15T15:00:00+02:00`. +* The `endTime` (Mapping key: EVENT_END_TIME) is the end time of the event. +Should be formatted as an `ISO 8601 date`, e.g. `2004-02-15T15:30:00+02:00`. +* The `resourceDisplayName` (Mapping key: EVENT_RESOURCE_ID) is display name of the resource the event belongs to. +* The `resourceId` (Mapping key: EVENT_RESOURCE_DISPLAY_NAME) is the id of the resource the event belongs to. + +## Overriding mappings + +Mappings can be overridden changing the following environment variable: + +```dotenv +CALENDAR_API_FEED_SOURCE_CUSTOM_MAPPINGS='{}' +``` + +E.g. + +```dotenv +CALENDAR_API_FEED_SOURCE_CUSTOM_MAPPINGS='{ + "LOCATION_ID": "Example1", + "LOCATION_DISPLAY_NAME": "Example2", + "RESOURCE_ID": "Example3", + "RESOURCE_LOCATION_ID": "Example4", + "RESOURCE_DISPLAY_NAME": "Example5", + "RESOURCE_INCLUDED_IN_EVENTS": "Example6", + "EVENT_TITLE": "Example7", + "EVENT_START_TIME": "Example8", + "EVENT_END_TIME": "Example9", + "EVENT_RESOURCE_ID": "Example10", + "EVENT_RESOURCE_DISPLAY_NAME": "Example11" +}' +``` + +## Dates + +By default, dates are assumed to be `Y-m-d\TH:i:sP` e.g. `2004-02-15T15:00:00+02:00`. + +If another date format is supplied for the date fields, these can be set with: + +```dotenv +CALENDAR_API_FEED_SOURCE_DATE_FORMAT= +CALENDAR_API_FEED_SOURCE_DATE_TIMEZONE= +``` + +E.g. + +```dotenv +CALENDAR_API_FEED_SOURCE_DATE_FORMAT="m/d/YH:i:s" +CALENDAR_API_FEED_SOURCE_DATE_TIMEZONE="Europe/Copenhagen" +``` + +## Modifiers + +Modifiers can be set up to modify the output of the feed. + +Two types of modifiers are available: + +* EXCLUDE_IF_TITLE_NOT_CONTAINS: Removes entries from the feed if the title not contain the trigger word. +* REPLACE_TITLE_IF_CONTAINS: Changes the title if it contains the trigger word. + +Parameters: + +* type: EXCLUDE_IF_TITLE_NOT_CONTAINS or REPLACE_TITLE_IF_CONTAINS +* id: Unique identifier for the modifier. +* title: Display name when showing the modifier in the admin. +* description: Help text for the modifier. +* activateInFeed: Should this filter be optional? If false the rule will always apply. +* trigger: The string that should trigger the modifier. +* replacement: The string to replace the title with. +* removeTrigger: Should the trigger word be filtered from the title? +* caseSensitive: Should the trigger word be case-sensitive? + +Examples of modifiers: + +```json +[ + { + "type": "EXCLUDE_IF_TITLE_NOT_CONTAINS", + "id": "excludeIfNotContainsListe", + "title": "Vis kun begivenheder med (liste) i titlen.", + "description": "Denne mulighed fjerner begivenheder, der IKKE har (liste) i titlen. Den fjerner også (liste) fra titlen.", + "activateInFeed": true, + "trigger": "(liste)", + "removeTrigger": true, + "caseSensitive": false + }, + { + "type": "REPLACE_TITLE_IF_CONTAINS", + "id": "replaceIfContainsOptaget", + "activateInFeed": false, + "trigger": "(optaget)", + "replacement": "Optaget", + "removeTrigger": true, + "caseSensitive": false + }, + { + "type": "REPLACE_TITLE_IF_CONTAINS", + "id": "onlyShowAsOptaget", + "activateInFeed": true, + "title": "Overskriv alle titler med Optaget", + "description": "Denne mulighed viser alle titler som Optaget.", + "trigger": "", + "replacement": "Optaget", + "removeTrigger": false, + "caseSensitive": false + } +] +``` diff --git a/infrastructure/itkdev/display-api-service/etc/confd/templates/env.local.tmpl b/infrastructure/itkdev/display-api-service/etc/confd/templates/env.local.tmpl index f9ce1ef7..3814736a 100644 --- a/infrastructure/itkdev/display-api-service/etc/confd/templates/env.local.tmpl +++ b/infrastructure/itkdev/display-api-service/etc/confd/templates/env.local.tmpl @@ -51,3 +51,14 @@ CLI_REDIRECT={{ getenv "APP_CLI_REDIRECT" "" }} REDIS_CACHE_PREFIX={{ getenv "APP_CLI_REDIRECT" "DisplayApiService" }} REDIS_CACHE_DSN={{ getenv "APP_CLI_REDIRECT" "redis://redis:6379/0" }} ###< redis ### + +###> Calendar Api Feed Source ### +CALENDAR_API_FEED_SOURCE_LOCATION_ENDPOINT={{ getenv "APP_CALENDAR_API_FEED_SOURCE_LOCATION_ENDPOINT" "" }} +CALENDAR_API_FEED_SOURCE_RESOURCE_ENDPOINT={{ getenv "APP_CALENDAR_API_FEED_SOURCE_RESOURCE_ENDPOINT" "" }} +CALENDAR_API_FEED_SOURCE_EVENT_ENDPOINT={{ getenv "APP_CALENDAR_API_FEED_SOURCE_EVENT_ENDPOINT" "" }} +CALENDAR_API_FEED_SOURCE_CUSTOM_MAPPINGS={{ getenv "APP_CALENDAR_API_FEED_SOURCE_CUSTOM_MAPPINGS" "'{}'" }} +CALENDAR_API_FEED_SOURCE_EVENT_MODIFIERS={{ getenv "APP_CALENDAR_API_FEED_SOURCE_EVENT_MODIFIERS" "'{}'" }} +CALENDAR_API_FEED_SOURCE_DATE_FORMAT={{ getenv "APP_CALENDAR_API_FEED_SOURCE_DATE_FORMAT" "" }} +CALENDAR_API_FEED_SOURCE_DATE_TIMEZONE={{ getenv "APP_CALENDAR_API_FEED_SOURCE_DATE_TIMEZONE" "" }} +CALENDAR_API_FEED_SOURCE_CACHE_EXPIRE_SECONDS={{ getenv "CALENDAR_API_FEED_SOURCE_CACHE_EXPIRE_SECONDS" "300" }} +###< Calendar Api Feed Source ### diff --git a/infrastructure/os2display/display-api-service/etc/confd/templates/env.local.tmpl b/infrastructure/os2display/display-api-service/etc/confd/templates/env.local.tmpl index f9ce1ef7..3814736a 100644 --- a/infrastructure/os2display/display-api-service/etc/confd/templates/env.local.tmpl +++ b/infrastructure/os2display/display-api-service/etc/confd/templates/env.local.tmpl @@ -51,3 +51,14 @@ CLI_REDIRECT={{ getenv "APP_CLI_REDIRECT" "" }} REDIS_CACHE_PREFIX={{ getenv "APP_CLI_REDIRECT" "DisplayApiService" }} REDIS_CACHE_DSN={{ getenv "APP_CLI_REDIRECT" "redis://redis:6379/0" }} ###< redis ### + +###> Calendar Api Feed Source ### +CALENDAR_API_FEED_SOURCE_LOCATION_ENDPOINT={{ getenv "APP_CALENDAR_API_FEED_SOURCE_LOCATION_ENDPOINT" "" }} +CALENDAR_API_FEED_SOURCE_RESOURCE_ENDPOINT={{ getenv "APP_CALENDAR_API_FEED_SOURCE_RESOURCE_ENDPOINT" "" }} +CALENDAR_API_FEED_SOURCE_EVENT_ENDPOINT={{ getenv "APP_CALENDAR_API_FEED_SOURCE_EVENT_ENDPOINT" "" }} +CALENDAR_API_FEED_SOURCE_CUSTOM_MAPPINGS={{ getenv "APP_CALENDAR_API_FEED_SOURCE_CUSTOM_MAPPINGS" "'{}'" }} +CALENDAR_API_FEED_SOURCE_EVENT_MODIFIERS={{ getenv "APP_CALENDAR_API_FEED_SOURCE_EVENT_MODIFIERS" "'{}'" }} +CALENDAR_API_FEED_SOURCE_DATE_FORMAT={{ getenv "APP_CALENDAR_API_FEED_SOURCE_DATE_FORMAT" "" }} +CALENDAR_API_FEED_SOURCE_DATE_TIMEZONE={{ getenv "APP_CALENDAR_API_FEED_SOURCE_DATE_TIMEZONE" "" }} +CALENDAR_API_FEED_SOURCE_CACHE_EXPIRE_SECONDS={{ getenv "CALENDAR_API_FEED_SOURCE_CACHE_EXPIRE_SECONDS" "300" }} +###< Calendar Api Feed Source ### diff --git a/public/api-spec-v2.json b/public/api-spec-v2.json index 08816da0..4668e98f 100644 --- a/public/api-spec-v2.json +++ b/public/api-spec-v2.json @@ -623,6 +623,70 @@ "deprecated": false } }, + "/v2/feed-sources/{id}/config/{name}": { + "get": { + "operationId": "get-v2-feed-source-id-config-name", + "tags": [ + "FeedSources" + ], + "responses": { + "200": { + "content": { + "application/ld+json": { + "examples": { + "example1": { + "value": [ + { + "key": "key1", + "id": "id1", + "value": "value1" + } + ] + } + } + } + }, + "headers": [] + } + }, + "summary": "Get config for name from a feed source.", + "description": "Get config for name from a feed source.", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "", + "required": true, + "deprecated": false, + "allowEmptyValue": false, + "schema": { + "type": "string", + "format": "ulid", + "pattern": "^[A-Za-z0-9]{26}$" + }, + "style": "simple", + "explode": false, + "allowReserved": false + }, + { + "name": "name", + "in": "path", + "description": "", + "required": true, + "deprecated": false, + "allowEmptyValue": false, + "schema": { + "type": "string", + "pattern": "^[A-Za-z0-9]*$" + }, + "style": "simple", + "explode": false, + "allowReserved": false + } + ], + "deprecated": false + } + }, "/v2/feed-sources/{id}/slides": { "get": { "operationId": "get-v2-feed-source-slide-id", @@ -875,70 +939,6 @@ "deprecated": false } }, - "/v2/feed_sources/{id}/config/{name}": { - "get": { - "operationId": "get-v2-feed-source-id-config-name", - "tags": [ - "FeedSources" - ], - "responses": { - "200": { - "content": { - "application/ld+json": { - "examples": { - "example1": { - "value": [ - { - "key": "key1", - "id": "id1", - "value": "value1" - } - ] - } - } - } - }, - "headers": [] - } - }, - "summary": "Get config for name from a feed source.", - "description": "Get config for name from a feed source.", - "parameters": [ - { - "name": "id", - "in": "path", - "description": "", - "required": true, - "deprecated": false, - "allowEmptyValue": false, - "schema": { - "type": "string", - "format": "ulid", - "pattern": "^[A-Za-z0-9]{26}$" - }, - "style": "simple", - "explode": false, - "allowReserved": false - }, - { - "name": "name", - "in": "path", - "description": "", - "required": true, - "deprecated": false, - "allowEmptyValue": false, - "schema": { - "type": "string", - "pattern": "^[A-Za-z0-9]*$" - }, - "style": "simple", - "explode": false, - "allowReserved": false - } - ], - "deprecated": false - } - }, "/v2/feeds": { "get": { "operationId": "get-v2-feeds", @@ -8144,6 +8144,12 @@ "feedType": { "type": "string" }, + "secrets": { + "type": "array", + "items": { + "type": "string" + } + }, "feeds": { "type": "array", "items": { @@ -8201,6 +8207,12 @@ "feedType": { "type": "string" }, + "secrets": { + "type": "array", + "items": { + "type": "string" + } + }, "feeds": { "type": "array", "items": { @@ -8293,6 +8305,12 @@ "feedType": { "type": "string" }, + "secrets": { + "type": "array", + "items": { + "type": "string" + } + }, "feeds": { "type": "array", "items": { @@ -8463,6 +8481,12 @@ "feedType": { "type": "string" }, + "secrets": { + "type": "array", + "items": { + "type": "string" + } + }, "feeds": { "type": "array", "items": { diff --git a/public/api-spec-v2.yaml b/public/api-spec-v2.yaml index 98c1364d..fddf4aef 100644 --- a/public/api-spec-v2.yaml +++ b/public/api-spec-v2.yaml @@ -463,6 +463,50 @@ paths: explode: false allowReserved: false deprecated: false + '/v2/feed-sources/{id}/config/{name}': + get: + operationId: get-v2-feed-source-id-config-name + tags: + - FeedSources + responses: + '200': + content: + application/ld+json: + examples: + example1: + value: [{ key: key1, id: id1, value: value1 }] + headers: [] + summary: 'Get config for name from a feed source.' + description: 'Get config for name from a feed source.' + parameters: + - + name: id + in: path + description: '' + required: true + deprecated: false + allowEmptyValue: false + schema: + type: string + format: ulid + pattern: '^[A-Za-z0-9]{26}$' + style: simple + explode: false + allowReserved: false + - + name: name + in: path + description: '' + required: true + deprecated: false + allowEmptyValue: false + schema: + type: string + pattern: '^[A-Za-z0-9]*$' + style: simple + explode: false + allowReserved: false + deprecated: false '/v2/feed-sources/{id}/slides': get: operationId: get-v2-feed-source-slide-id @@ -673,50 +717,6 @@ paths: explode: false allowReserved: false deprecated: false - '/v2/feed_sources/{id}/config/{name}': - get: - operationId: get-v2-feed-source-id-config-name - tags: - - FeedSources - responses: - '200': - content: - application/ld+json: - examples: - example1: - value: [{ key: key1, id: id1, value: value1 }] - headers: [] - summary: 'Get config for name from a feed source.' - description: 'Get config for name from a feed source.' - parameters: - - - name: id - in: path - description: '' - required: true - deprecated: false - allowEmptyValue: false - schema: - type: string - format: ulid - pattern: '^[A-Za-z0-9]{26}$' - style: simple - explode: false - allowReserved: false - - - name: name - in: path - description: '' - required: true - deprecated: false - allowEmptyValue: false - schema: - type: string - pattern: '^[A-Za-z0-9]*$' - style: simple - explode: false - allowReserved: false - deprecated: false /v2/feeds: get: operationId: get-v2-feeds @@ -5766,6 +5766,10 @@ components: type: string feedType: type: string + secrets: + type: array + items: + type: string feeds: type: array items: @@ -5806,6 +5810,10 @@ components: type: string feedType: type: string + secrets: + type: array + items: + type: string feeds: type: array items: @@ -5869,6 +5877,10 @@ components: type: string feedType: type: string + secrets: + type: array + items: + type: string feeds: type: array items: @@ -5985,6 +5997,10 @@ components: type: string feedType: type: string + secrets: + type: array + items: + type: string feeds: type: array items: diff --git a/src/Command/Feed/CreateFeedSourceCommand.php b/src/Command/Feed/CreateFeedSourceCommand.php index 1e4f2ce2..15e450c5 100644 --- a/src/Command/Feed/CreateFeedSourceCommand.php +++ b/src/Command/Feed/CreateFeedSourceCommand.php @@ -97,20 +97,41 @@ final protected function execute(InputInterface $input, OutputInterface $output) $secrets = []; $io->info('Set required secrets.'); $requiredSecrets = $feedType->getRequiredSecrets(); - foreach ($requiredSecrets as $requiredSecret) { + foreach ($requiredSecrets as $requiredSecret => $configuration) { + $io->info("--- \"$requiredSecret\" ---"); $value = null; - do { - $value = $io->ask("Enter \"$requiredSecret\": "); - - if ('' == $value) { - $io->warning('Value cannot be empty'); - } - } while ('' == $value); + $type = $configuration['type'] ?? 'string'; + switch ($type) { + case 'string': + do { + $inputString = $io->ask("Enter \"$requiredSecret\": "); + + if ('' == $inputString) { + $io->warning('Value cannot be empty'); + } else { + $value = $inputString; + } + } while (null == $value); + break; + case 'string_array': + $value = []; + $question = new Question('Add entry (autocompletes)'); + $question->setAutocompleterValues($configuration['options'] ?? []); + + do { + $locationId = $io->askQuestion($question); + + if (null !== $locationId) { + $value[] = $locationId; + } + } while (null !== $locationId); + break; + } $secrets[$requiredSecret] = $value; } - if (array_keys($secrets) != $requiredSecrets) { + if (array_keys($secrets) != array_keys($requiredSecrets)) { $io->error('Not all secrets set'); return Command::INVALID; @@ -138,6 +159,10 @@ final protected function execute(InputInterface $input, OutputInterface $output) $secretsString = implode('', array_map(function ($key) use ($secrets) { $value = $secrets[$key]; + if (is_array($value)) { + return " - key: Array\n"; + } + return " - $key: $value\n"; }, array_keys($secrets))); $confirmed = $io->confirm("\n--------------\n". diff --git a/src/Controller/FeedSourceConfigGetController.php b/src/Controller/FeedSourceConfigGetController.php index 99f88023..b6905b78 100644 --- a/src/Controller/FeedSourceConfigGetController.php +++ b/src/Controller/FeedSourceConfigGetController.php @@ -10,6 +10,7 @@ use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\Attribute\AsController; #[AsController] @@ -23,18 +24,18 @@ public function __construct( public function __invoke(Request $request, string $id, string $name): JsonResponse { - $feedUlid = $this->validationUtils->validateUlid($id); - $feedSource = $this->feedSourceRepository->find($feedUlid); + $feedSourceUlid = $this->validationUtils->validateUlid($id); + $feedSource = $this->feedSourceRepository->find($feedSourceUlid); if (!$feedSource) { - return new JsonResponse([], \Symfony\Component\HttpFoundation\Response::HTTP_NOT_FOUND); + return new JsonResponse([], Response::HTTP_NOT_FOUND); } $config = $this->feedService->getConfigOptions($request, $feedSource, $name); if (is_null($config)) { - return new JsonResponse($config, \Symfony\Component\HttpFoundation\Response::HTTP_NOT_FOUND); + return new JsonResponse($config, Response::HTTP_NOT_FOUND); } - return new JsonResponse($config, \Symfony\Component\HttpFoundation\Response::HTTP_OK); + return new JsonResponse($config, Response::HTTP_OK); } } diff --git a/src/Dto/FeedSource.php b/src/Dto/FeedSource.php index 8ce3da9e..a738598d 100644 --- a/src/Dto/FeedSource.php +++ b/src/Dto/FeedSource.php @@ -18,6 +18,7 @@ class FeedSource public string $description = ''; public string $outputType = ''; public string $feedType = ''; + public array $secrets = []; public array $feeds = []; public array $admin = []; public string $supportedFeedOutputType = ''; diff --git a/src/Feed/CalendarApiFeedType.php b/src/Feed/CalendarApiFeedType.php new file mode 100644 index 00000000..37210cf5 --- /dev/null +++ b/src/Feed/CalendarApiFeedType.php @@ -0,0 +1,490 @@ +mappings = $this->createMappings($this->customMappings); + } + + /** + * {@inheritDoc} + */ + public function getData(Feed $feed): array + { + try { + $results = []; + + $configuration = $feed->getConfiguration(); + + $enabledModifiers = $configuration['enabledModifiers'] ?? []; + + if (!isset($configuration['resources'])) { + $this->logger->error('CalendarApiFeedType: Resources not set.'); + + return []; + } + + $resources = $configuration['resources']; + foreach ($resources as $resource) { + $events = $this->getResourceEvents($resource); + + /** @var CalendarEvent $event */ + foreach ($events as $event) { + $title = $event->title; + + // Modify title according to event modifiers. + foreach ($this->eventModifiers as $modifier) { + // Make it configurable in the Feed if the modifiers should be enabled. + if ($modifier['activateInFeed'] && !in_array($modifier['id'], $enabledModifiers)) { + continue; + } + + if (self::EXCLUDE_IF_TITLE_NOT_CONTAINS == $modifier['type']) { + $match = preg_match('/'.$modifier['trigger'].'/'.(!$modifier['caseSensitive'] ? 'i' : ''), $title); + + if ($modifier['removeTrigger']) { + $title = str_replace($modifier['trigger'], '', $title); + } + + if (!$match) { + continue; + } + } + + if (self::REPLACE_TITLE_IF_CONTAINS == $modifier['type']) { + $match = preg_match('/'.$modifier['trigger'].'/'.(!$modifier['caseSensitive'] ? 'i' : ''), $title); + + if ($modifier['removeTrigger']) { + $title = str_replace($modifier['trigger'], '', $title); + } + + if ($match) { + $title = $modifier['replacement']; + } + } + } + + $title = trim($title); + + $results[] = [ + 'id' => Ulid::generate(), + 'title' => $title, + 'startTime' => $event->startTimeTimestamp, + 'endTime' => $event->endTimeTimestamp, + 'resourceTitle' => $event->resourceDisplayName, + 'resourceId' => $event->resourceId, + ]; + } + } + + // Sort bookings by start time. + usort($results, fn (array $a, array $b) => $a['startTime'] > $b['startTime'] ? 1 : -1); + + return $results; + } catch (\Throwable $throwable) { + $this->logger->error('{code}: {message}', [ + 'code' => $throwable->getCode(), + 'message' => $throwable->getMessage(), + ]); + } + + return []; + } + + /** + * {@inheritDoc} + */ + public function getAdminFormOptions(FeedSource $feedSource): array + { + $endpoint = $this->feedService->getFeedSourceConfigUrl($feedSource, 'resources'); + + $result = [ + [ + 'key' => 'calendar-api-resource-selector', + 'input' => 'multiselect-from-endpoint', + 'endpoint' => $endpoint, + 'name' => 'resources', + 'label' => 'Vælg resurser', + 'helpText' => 'Her vælger du hvilke resurser, der skal hentes indgange fra.', + 'formGroupClasses' => 'mb-3', + ], + ]; + + $enableModifierOptions = []; + foreach ($this->eventModifiers as $modifier) { + if (isset($modifier['activateInFeed']) && true === $modifier['activateInFeed']) { + $enableModifierOptions[] = [ + 'title' => $modifier['title'] ?? $modifier['id'], + 'description' => $modifier['description'] ?? '', + 'value' => $modifier['id'], + ]; + } + } + + if (count($enableModifierOptions) > 0) { + $result[] = [ + 'key' => 'calendar-api-modifiers', + 'input' => 'checkbox-options', + 'name' => 'enabledModifiers', + 'label' => 'Vælg justeringer af begivenheder', + 'helpText' => 'Her kan du aktivere forskellige justeringer af begivenhederne.', + 'formGroupClasses' => 'mb-3', + 'options' => $enableModifierOptions, + ]; + } + + return $result; + } + + /** + * {@inheritDoc} + */ + public function getConfigOptions(Request $request, FeedSource $feedSource, string $name): ?array + { + try { + if ('resources' === $name) { + $secrets = $feedSource->getSecrets(); + $locationIds = $secrets['locations'] ?? []; + + $resources = []; + + foreach ($locationIds as $locationId) { + $locationResources = $this->getLocationResources($locationId); + $resources = array_merge($resources, $locationResources); + } + + $resourceOptions = array_map(fn (CalendarResource $resource) => [ + 'id' => Ulid::generate(), + 'title' => $resource->displayName, + 'value' => $resource->id, + ], $resources); + + // Sort resource options by title. + usort($resourceOptions, fn ($a, $b) => strcmp((string) $a['title'], (string) $b['title'])); + + return $resourceOptions; + } elseif ('locations' === $name) { + $locationOptions = array_map(fn (CalendarLocation $location) => [ + 'id' => Ulid::generate(), + 'title' => $location->displayName, + 'value' => $location->id, + ], $this->loadLocations()); + + usort($locationOptions, fn ($a, $b) => strcmp((string) $a['title'], (string) $b['title'])); + + return $locationOptions; + } + } catch (\Throwable $throwable) { + $this->logger->error('{code}: {message}', [ + 'code' => $throwable->getCode(), + 'message' => $throwable->getMessage(), + ]); + } + + return null; + } + + public function getRequiredSecrets(): array + { + return [ + 'locations' => [ + 'type' => 'string_array', + 'options' => $this->getLocationOptions(), + 'exposeValue' => true, + ], + ]; + } + + public function getRequiredConfiguration(): array + { + return ['resources']; + } + + public function getSupportedFeedOutputType(): string + { + return self::SUPPORTED_FEED_TYPE; + } + + private function getLocationOptions(): array + { + $locations = $this->loadLocations(); + + return array_reduce($locations, function (array $carry, CalendarLocation $location) { + $carry[] = $location->id; + + return $carry; + }, []); + } + + private function getResourceEvents(string $resourceId): array + { + $cacheItem = $this->calendarApiCache->getItem(self::CACHE_KEY_EVENTS.'-'.$resourceId); + + if (!$cacheItem->isHit()) { + $allEvents = $this->loadEvents(); + + $items = array_filter($allEvents, fn (CalendarEvent $item) => $item->resourceId === $resourceId); + + $cacheItem->set($items); + $cacheItem->expiresAfter($this->cacheExpireSeconds); + $this->calendarApiCache->save($cacheItem); + } + + return $cacheItem->get() ?? []; + } + + private function getLocationResources(string $locationId): array + { + $cacheItem = $this->calendarApiCache->getItem(self::CACHE_KEY_RESOURCES.'-'.$locationId); + + if (!$cacheItem->isHit()) { + $allResources = $this->loadResources(); + + $items = array_filter($allResources, fn (CalendarResource $item) => $item->locationId === $locationId); + + $cacheItem->set($items); + $cacheItem->expiresAfter($this->cacheExpireSeconds); + $this->calendarApiCache->save($cacheItem); + } + + return $cacheItem->get() ?? []; + } + + private function loadLocations(): array + { + $cacheItem = $this->calendarApiCache->getItem(self::CACHE_KEY_LOCATIONS); + + if (!$cacheItem->isHit() || $this->shouldFetchNewData(self::CACHE_KEY_LOCATIONS)) { + try { + $response = $this->client->request('GET', $this->locationEndpoint); + + $LocationEntries = $response->toArray(); + + $locations = array_map(fn (array $entry) => new CalendarLocation( + $entry[$this->getMapping('locationId')], + $entry[$this->getMapping('locationDisplayName')], + ), $LocationEntries); + + $cacheItem->set($locations); + $this->calendarApiCache->save($cacheItem); + } catch (\Throwable $throwable) { + $this->logger->error('Error fetching locations data. {code}: {message}', ['code' => $throwable->getCode(), 'message' => $throwable->getMessage()]); + } + } + + return $cacheItem->get() ?? []; + } + + private function loadResources(): array + { + $cacheItem = $this->calendarApiCache->getItem(self::CACHE_KEY_RESOURCES); + + if (!$cacheItem->isHit() || $this->shouldFetchNewData(self::CACHE_KEY_RESOURCES)) { + try { + $response = $this->client->request('GET', $this->resourceEndpoint); + + $resourceEntries = $response->toArray(); + + $resources = []; + + foreach ($resourceEntries as $resourceEntry) { + // Only include resources that are marked as included in events. Defaults to true, if the resourceEntry + // does not have the property defined by the mapping resourceIncludedInEvents. + $resourceIncludedInEvents = $resourceEntry[$this->getMapping('resourceIncludedInEvents')] ?? true; + $includeValue = $this->parseBool($resourceIncludedInEvents); + + // Only include resources that are included in events endpoint. + if ($includeValue) { + $resource = new CalendarResource( + $resourceEntry[$this->getMapping('resourceId')], + $resourceEntry[$this->getMapping('resourceLocationId')], + $resourceEntry[$this->getMapping('resourceDisplayName')], + ); + + $resources[] = $resource; + } + } + + $cacheItem->set($resources); + $this->calendarApiCache->save($cacheItem); + } catch (\Throwable $throwable) { + $this->logger->error('Error fetching resources data. {code}: {message}', ['code' => $throwable->getCode(), 'message' => $throwable->getMessage()]); + } + } + + return $cacheItem->get() ?? []; + } + + private function loadEvents(): array + { + $cacheItem = $this->calendarApiCache->getItem(self::CACHE_KEY_EVENTS); + + if (!$cacheItem->isHit() || $this->shouldFetchNewData(self::CACHE_KEY_EVENTS)) { + try { + $response = $this->client->request('GET', $this->eventEndpoint); + + $eventEntries = $response->toArray(); + + $events = array_reduce($eventEntries, function (array $carry, array $entry) { + $newEntry = new CalendarEvent( + Ulid::generate(), + $entry[$this->getMapping('eventTitle')], + $this->stringToUnixTimestamp($entry[$this->getMapping('eventStartTime')]), + $this->stringToUnixTimestamp($entry[$this->getMapping('eventEndTime')]), + $entry[$this->getMapping('eventResourceId')], + $entry[$this->getMapping('eventResourceDisplayName')], + ); + + // Filter out entries if they do not supply required data. + if ( + !empty($newEntry->startTimeTimestamp) + && !empty($newEntry->endTimeTimestamp) + && !empty($newEntry->resourceId) + && !empty($newEntry->resourceDisplayName) + ) { + $carry[] = $newEntry; + } + + return $carry; + }, []); + + $cacheItem->set($events); + $this->calendarApiCache->save($cacheItem); + } catch (\Throwable $throwable) { + $this->logger->error('Error fetching events data. {code}: {message}', ['code' => $throwable->getCode(), 'message' => $throwable->getMessage()]); + } + } + + return $cacheItem->get() ?? []; + } + + private function stringToUnixTimestamp(string $dateTimeString): int + { + // Default dateformat is: 'Y-m-d\TH:i:sP'. Example: 2004-02-15T15:19:21+00:00 + // See: https://www.php.net/manual/en/datetime.format.php for available formats. + $dateFormat = '' !== $this->dateFormat ? $this->dateFormat : \DateTimeInterface::ATOM; + // Default is no timezone since the difference from UTC is in the dateformat (+00:00). + // For timezone options see: https://www.php.net/manual/en/timezones.php + $timezone = !empty($this->timezone) ? new \DateTimeZone($this->timezone) : null; + + $datetime = \DateTime::createFromFormat($dateFormat, $dateTimeString, $timezone); + + if (false === $datetime) { + $this->logger->warning('Date {date} could not be parsed by format {format}', [ + 'date' => $dateTimeString, + 'format' => $dateFormat, + ]); + + return 0; + } + + return $datetime->getTimestamp(); + } + + private function parseBool(string|bool $value): bool + { + if (is_bool($value)) { + return $value; + } else { + return 'true' == strtolower($value); + } + } + + private function getMapping(string $key): string + { + return $this->mappings[$key]; + } + + private function shouldFetchNewData(string $cacheKey): bool + { + $latestRequestCacheItem = $this->calendarApiCache->getItem($cacheKey.self::CACHE_LATEST_REQUEST_SUFFIX); + $latestRequest = $latestRequestCacheItem->get(); + + return null === $latestRequest || $latestRequest <= time() - $this->cacheExpireSeconds; + } + + private function createMappings(array $customMappings): array + { + return [ + 'locationId' => $customMappings['LOCATION_ID'] ?? 'id', + 'locationDisplayName' => $customMappings['LOCATION_DISPLAY_NAME'] ?? 'displayName', + 'resourceId' => $customMappings['RESOURCE_ID'] ?? 'id', + 'resourceLocationId' => $customMappings['RESOURCE_LOCATION_ID'] ?? 'locationId', + 'resourceDisplayName' => $customMappings['RESOURCE_DISPLAY_NAME'] ?? 'displayName', + 'resourceIncludedInEvents' => $customMappings['RESOURCE_INCLUDED_IN_EVENTS'] ?? 'includedInEvents', + 'eventTitle' => $customMappings['EVENT_TITLE'] ?? 'title', + 'eventStartTime' => $customMappings['EVENT_START_TIME'] ?? 'startTime', + 'eventEndTime' => $customMappings['EVENT_END_TIME'] ?? 'endTime', + 'eventResourceId' => $customMappings['EVENT_RESOURCE_ID'] ?? 'resourceId', + 'eventResourceDisplayName' => $customMappings['EVENT_RESOURCE_DISPLAY_NAME'] ?? 'displayName', + ]; + } + + public function getSchema(): array + { + return [ + '$schema' => 'http://json-schema.org/draft-04/schema#', + 'type' => 'object', + 'properties' => [ + 'locations' => [ + 'type' => 'array', + 'items' => [ + 'type' => 'string', + ], + ], + ], + 'required' => ['locations'], + ]; + } +} diff --git a/src/Feed/EventDatabaseApiFeedType.php b/src/Feed/EventDatabaseApiFeedType.php index 741a202e..fb660ef7 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 = 'poster'; + final public const string SUPPORTED_FEED_TYPE = SupportedFeedOutputs::POSTER_OUTPUT; final public const int REQUEST_TIMEOUT = 10; public function __construct( @@ -29,11 +29,6 @@ public function __construct( private readonly EntityManagerInterface $entityManager, ) {} - /** - * @param Feed $feed - * - * @return array - */ public function getData(Feed $feed): array { try { @@ -286,7 +281,12 @@ public function getConfigOptions(Request $request, FeedSource $feedSource, strin */ public function getRequiredSecrets(): array { - return ['host']; + return [ + 'host' => [ + 'type' => 'string', + 'exposeValue' => true, + ], + ]; } /** diff --git a/src/Feed/KobaFeedType.php b/src/Feed/KobaFeedType.php index 1d37cf42..2bf319b6 100644 --- a/src/Feed/KobaFeedType.php +++ b/src/Feed/KobaFeedType.php @@ -12,9 +12,10 @@ use Symfony\Component\Uid\Ulid; use Symfony\Contracts\HttpClient\HttpClientInterface; +/** @deprecated */ class KobaFeedType implements FeedTypeInterface { - final public const string SUPPORTED_FEED_TYPE = 'calendar'; + final public const string SUPPORTED_FEED_TYPE = SupportedFeedOutputs::CALENDAR_OUTPUT; public function __construct( private readonly FeedService $feedService, @@ -227,7 +228,15 @@ public function getConfigOptions(Request $request, FeedSource $feedSource, strin public function getRequiredSecrets(): array { - return ['kobaHost', 'kobaApiKey']; + return [ + 'kobaHost' => [ + 'type' => 'string', + 'exposeValue' => true, + ], + 'kobaApiKey' => [ + 'type' => 'string', + ], + ]; } public function getRequiredConfiguration(): array @@ -263,6 +272,15 @@ public function getSchema(): array return [ '$schema' => 'http://json-schema.org/draft-04/schema#', 'type' => 'object', + 'properties' => [ + 'kobaHost' => [ + 'type' => 'string', + ], + 'kobaApiKey' => [ + 'type' => 'string', + ], + ], + 'required' => ['kobaHost', 'kobaApiKey'], ]; } } diff --git a/src/Feed/NotifiedFeedType.php b/src/Feed/NotifiedFeedType.php index 5bf387df..75ac8b9c 100644 --- a/src/Feed/NotifiedFeedType.php +++ b/src/Feed/NotifiedFeedType.php @@ -17,7 +17,7 @@ */ class NotifiedFeedType implements FeedTypeInterface { - final public const string SUPPORTED_FEED_TYPE = 'instagram'; + final public const string SUPPORTED_FEED_TYPE = SupportedFeedOutputs::INSTAGRAM_OUTPUT; final public const int REQUEST_TIMEOUT = 10; private const string BASE_URL = 'https://api.listen.notified.com'; @@ -177,7 +177,11 @@ public function getSearchProfiles(string $token): array */ public function getRequiredSecrets(): array { - return ['token']; + return [ + 'token' => [ + 'type' => 'string', + ], + ]; } /** diff --git a/src/Feed/RssFeedType.php b/src/Feed/RssFeedType.php index 7d3de686..4e395da7 100644 --- a/src/Feed/RssFeedType.php +++ b/src/Feed/RssFeedType.php @@ -15,7 +15,8 @@ class RssFeedType implements FeedTypeInterface { - final public const string SUPPORTED_FEED_TYPE = 'rss'; + final public const string SUPPORTED_FEED_TYPE = SupportedFeedOutputs::RSS_OUTPUT; + private readonly FeedIo $feedIo; public function __construct( diff --git a/src/Feed/SparkleIOFeedType.php b/src/Feed/SparkleIOFeedType.php index 430d1bf5..64daed7b 100644 --- a/src/Feed/SparkleIOFeedType.php +++ b/src/Feed/SparkleIOFeedType.php @@ -22,7 +22,8 @@ /** @deprecated The SparkleIO service is discontinued. */ class SparkleIOFeedType implements FeedTypeInterface { - final public const string SUPPORTED_FEED_TYPE = 'instagram'; + final public const string SUPPORTED_FEED_TYPE = SupportedFeedOutputs::INSTAGRAM_OUTPUT; + final public const int REQUEST_TIMEOUT = 10; public function __construct( @@ -167,7 +168,18 @@ public function getConfigOptions(Request $request, FeedSource $feedSource, strin */ public function getRequiredSecrets(): array { - return ['baseUrl', 'clientId', 'clientSecret']; + return [ + 'baseUrl' => [ + 'type' => 'string', + 'exposeValue' => true, + ], + 'clientId' => [ + 'type' => 'string', + ], + 'clientSecret' => [ + 'type' => 'string', + ], + ]; } /** @@ -290,6 +302,18 @@ public function getSchema(): array return [ '$schema' => 'http://json-schema.org/draft-04/schema#', 'type' => 'object', + 'properties' => [ + 'baseUrl' => [ + 'type' => 'string', + ], + 'clientId' => [ + 'type' => 'string', + ], + 'clientSecret' => [ + 'type' => 'string', + ], + ], + 'required' => ['baseUrl', 'clientId', 'clientSecret'], ]; } } diff --git a/src/Feed/SupportedFeedOutputs.php b/src/Feed/SupportedFeedOutputs.php new file mode 100644 index 00000000..d080b97a --- /dev/null +++ b/src/Feed/SupportedFeedOutputs.php @@ -0,0 +1,64 @@ +Sed nulla lorem, varius sodales justo ac, ultrices placerat nunc.\n
", + * "mediaUrl": "https://raw.githubusercontent.com/os2display/display-templates/refs/heads/develop/src/fixtures/images/mountain1.jpeg", + * "videoUrl": null, + * "username": "username", + * "createdTime": "2022-02-03T08:50:07", + * }, + * { + * "textMarkup": "